From 083892f62ccd2927d8afd45bf593f49dafdea2d5 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Sat, 12 Sep 2026 21:20:57 +0300 Subject: [PATCH 001/205] feat: up afm version --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index e820c358..bcec1b95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG AFM_VERSION=0.5.67 +ARG AFM_VERSION=1.0.1 ARG RALPHEX_VERSION=1.6 ARG PYTHON_VERSION=3.12 ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 From 3e350814bb384298a4bb51e37f4316289bc12416 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Sun, 13 Sep 2026 18:21:03 +0000 Subject: [PATCH 002/205] feat(pipeline): declare the afm file-manager roots contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipeline cell gains the AFM_DOCKER_FILE_ROOTS payload: the CODEMANIFEST declares FileRoot, collect_file_roots, and encode_file_roots (file_roots.py) and layers the encoded roots into the run-form env-file — an explicit user -e entry still wins. The afm practice documents the base64 JSON schema and the roots scope, the Dockerfile carries the static image default (project root only), and the pipeline usage file explains exposing extra directories via home.docker.run volume tokens. The run/info container modules take blank-line-only formatting. --- .goga/usages/cooks/afm.md | 37 ++++ Dockerfile | 1 + .../pipeline/.usages/pipeline-command.md | 35 ++++ goga/commands/pipeline/CODEMANIFEST | 159 +++++++++++++++++- .../pipeline/run_pipeline_container.py | 20 +++ .../pipeline/run_pipeline_info_container.py | 1 + 6 files changed, 244 insertions(+), 9 deletions(-) diff --git a/.goga/usages/cooks/afm.md b/.goga/usages/cooks/afm.md index 10a61958..03653cb2 100644 --- a/.goga/usages/cooks/afm.md +++ b/.goga/usages/cooks/afm.md @@ -275,3 +275,40 @@ supplies the `client.command` overlay; the persistent directory mounted at - Do NOT derive the `client.command` tmpfile mount target from `AFM_DIR` — `config.yaml` is always read from `~/.afm/config.yaml` (= `/home/goga/.afm/config.yaml` in the container), regardless of where `AFM_DIR` points. + +## File manager roots via AFM_DOCKER_FILE_ROOTS + +afm's dashboard file manager displays the directories the user may browse. The set is +delivered through the `AFM_DOCKER_FILE_ROOTS` environment variable — a standard base64 +string (with padding) of a compact UTF-8 JSON object: + + {"version":1,"roots":[{"id":"...","label":"...","container_path":"...","mount_read_only":false,"kind":"project"}]} + +- `version` — always `1` +- `id` — a stable identifier, unique within the list (`"project"` for the project root) +- `label` — display name: `"project"` for the project root; the full `container_path` + for extra roots +- `container_path` — the in-container mount target +- `mount_read_only` — whether the mount is read-only +- `kind` — `"project"` (the project root) or `"extra"` (a directory mount resolved + from `home.docker.run` `-v` tokens) + +The producer is the host-side run launcher: it writes the variable into the run +env-file from the ACTUAL mounts of the launch, so the payload and the `-v` mounts never +diverge. The Dockerfile carries only a static image default; the run env-file overrides +it on every launch, and an explicit user `-e AFM_DOCKER_FILE_ROOTS=...` wins over both +(env-file last-write-wins semantics). + +Scope of roots: every mounted volumes-directory except afm state — the project root +`/workspace` plus extra directories from `home.docker.run` `-v` tokens whose host part +exists as a directory. Named volumes, file mounts, credentials, the afm-config tmpfile, +and the persistent afm state directory are not user file roots and never appear. + +### Constraints + +- The info forms (list/overview/card) receive no `AFM_DOCKER_FILE_ROOTS` — no dashboard + is started there; the image default stays in effect by design. +- Do not add new goga config surface for extra roots — `home.docker.run` is the single + source. +- goga is the producer of the contract; how afm parses and renders the variable is + afm's own concern (external binary, separate repository). diff --git a/Dockerfile b/Dockerfile index bcec1b95..d6f5fcaf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,6 +56,7 @@ ENV PATH="/opt/goga/bin:/srv:/home/goga/bin:${PATH}" ENV GOGA_DOCKER=1 ENV RALPHEX_DOCKER=1 ENV AFM_IN_DOCKER=1 +ENV AFM_DOCKER_FILE_ROOTS=eyJ2ZXJzaW9uIjoxLCJyb290cyI6W3siaWQiOiJwcm9qZWN0IiwibGFiZWwiOiJteXByb2oiLCJjb250YWluZXJfcGF0aCI6Ii93b3Jrc3BhY2UiLCJtb3VudF9yZWFkX29ubHkiOmZhbHNlLCJraW5kIjoicHJvamVjdCJ9XX0= USER goga diff --git a/goga/commands/pipeline/.usages/pipeline-command.md b/goga/commands/pipeline/.usages/pipeline-command.md index 88b0909c..e3de84c0 100644 --- a/goga/commands/pipeline/.usages/pipeline-command.md +++ b/goga/commands/pipeline/.usages/pipeline-command.md @@ -83,6 +83,41 @@ silently. decision travels in the subcommand argv: `-m goga.pipeline list [--info]` or `-m goga.pipeline run NAME --info [-w WORKFLOW | --no-workflow]`. +## File manager roots (run form) + +The afm dashboard file manager shows the directories the user may browse. +The run form (`goga pipeline NAME`) delivers that set to afm through the +AFM_DOCKER_FILE_ROOTS container environment variable; the listing and info +forms never produce it. + +| Root | Source | Presence | +|---|---|---| +| project | the mounted project at `/workspace` | always — listed first, read-write | +| extra | a directory mount from a `home.docker.run` `-v`/`--volume` token | when the token's host part exists as a directory | + +File mounts, named volumes, missing host paths, credential files, the afm +config overlay, and the persistent afm state directory never become roots. + +Exposing an extra directory — add a volume token to ~/.goga/config.yml: + + docker: + run: + - "-v /home/me/data:/home/goga/data" + - "-v /home/me/readonly-stuff:/home/goga/ro:ro" + +- the host part must exist as a directory at launch time +- append `:ro` to expose the directory read-only (`mount_read_only: true`) +- the label shown in the dashboard is the full container path +- roots appear in token order, after the project root + +An explicit user entry wins over the launcher-produced value: + + goga pipeline myflow -e AFM_DOCKER_FILE_ROOTS= + +With unchanged mounts, every launch writes the same value: the payload is a +compact JSON (`{"version":1,"roots":[...]}`) encoded as standard base64 +with padding. + ## -p vs docker -p The user-facing -p/--parallel is a Click option. The Docker port-publish diff --git a/goga/commands/pipeline/CODEMANIFEST b/goga/commands/pipeline/CODEMANIFEST index 8e40ac35..6d538d26 100644 --- a/goga/commands/pipeline/CODEMANIFEST +++ b/goga/commands/pipeline/CODEMANIFEST @@ -135,6 +135,13 @@ Annotations: | Use the `click` practice for the -t/--topic option: a long form and a short alias sharing a single Option, and exit-code propagation. + The run form produces the AFM_DOCKER_FILE_ROOTS environment variable — + the file-manager roots of the afm dashboard. The value is composed from + the actual directory mounts of the launch — the payload contract lives + in the `afm` practice — and written into the container env-file through + `collect_file_roots` and `encode_file_roots`. The listing and info + forms produce no such variable. + --- "pipeline(ctx: click.Context, name: str | None, list_requested: bool, info: bool, topic: str | None, todo: bool, extra_env: tuple[str, ...], proxy: str | None, add_host: tuple[str, ...], clean: bool, update: bool, workflow: str | None, no_workflow: bool, skip: tuple[str, ...], parallel: int | None)": @@ -451,13 +458,19 @@ Annotations: | 11. Create a private env-file layering home.env as the BASE (lowest-priority) layer, then config.pipeline.env, git identity, the `extra_env` KEY=VALUE strings (forwarded as-is, no validation), - AFM_DIR set to the in-container persistent state path, the - workflow_env entries (GOGA_WORKFLOW_NAME and/or GOGA_WORKFLOW_DISABLED - per step 9), the GOGA_SKIP_STAGES entry when `skip` is non-empty + AFM_DIR set to the in-container persistent state path, + AFM_DOCKER_FILE_ROOTS set to the encoded file-manager roots of the + launch — composed via `collect_file_roots` from the home.docker.run + tokens and encoded via `encode_file_roots`; the roots never diverge + from the actual mounts of the launch — then the workflow_env + entries (GOGA_WORKFLOW_NAME and/or GOGA_WORKFLOW_DISABLED per + step 9), the GOGA_SKIP_STAGES entry when `skip` is non-empty (joined comma-separated via ",".join(skip); omitted when `skip` is empty), and — when `proxy` is non-None — HTTP_PROXY, HTTPS_PROXY, and NO_PROXY (fixed at localhost,127.0.0.1). Project config and CLI - override home.env on key conflict. + override home.env on key conflict; an explicit `extra_env` + AFM_DOCKER_FILE_ROOTS entry overrides the launcher value (the + `extra_env` lines follow every launcher layer in the file). 12. Assemble the docker-run inputs to hand to `DockerRunner`: - args: -m goga.pipeline run --port [--parallel ] (--parallel appended ONLY when @@ -530,6 +543,9 @@ Annotations: | lifecycle. Apply the `afm` practice for the in-container afm config.yaml contract, including the prompts_dir field written in step 6. + Apply the `afm` practice for the file-manager roots payload delivered + through AFM_DOCKER_FILE_ROOTS in step 11 — the schema, the encoding, + and the roots scope live there. Apply the `run-pipeline` practice for the in-container prompt materialization contract that populates the prompts_dir directory, and for the workflow environment contract (GOGA_WORKFLOW_NAME / @@ -556,8 +572,13 @@ Annotations: | ProjectConfig or CLI - env-file content: home.env (base) + config.pipeline.env + git identity + `extra_env` KEY=VALUE strings (forwarded as-is, no validation) + - AFM_DIR=/home/goga/pipeline + (when proxy is non-None) - HTTP_PROXY/HTTPS_PROXY/NO_PROXY + AFM_DIR=/home/goga/pipeline + AFM_DOCKER_FILE_ROOTS= + + (when proxy is non-None) HTTP_PROXY/HTTPS_PROXY/NO_PROXY + - AFM_DOCKER_FILE_ROOTS is written into the env-file on every run + launch: the value comes from `collect_file_roots` and + `encode_file_roots` over the home.docker.run tokens; repeated + launches with unchanged mounts produce the identical value; an + explicit user -e AFM_DOCKER_FILE_ROOTS=... entry wins - home.env is the lowest-priority env layer; project config (config.pipeline.env) and CLI extra_env override home.env on key conflict. home.docker.run tokens are appended to the docker run @@ -626,6 +647,10 @@ Annotations: | - Do not mount anything under /workspace other than the project directory — in-container afm state belongs in /home/goga/.afm/ (config.yaml) and /home/goga/pipeline (state) + - Do not compose file-manager roots from any mount other than the + project mount and the home.docker.run directory mounts — engine + mounts (the persistent afm state, the config overlay, credentials) + never become roots - Do not write the afm config into the project directory — use a tmpfile and a read-only mount - Do not invoke the in-container entrypoint other than via "docker run @@ -794,6 +819,121 @@ Annotations: | creates it - Do not selectively preserve any files — the wipe is total +"FileRoot(id: str, label: str, container_path: str, mount_read_only: bool, kind: str)": + location: file_roots.py + annotations: | + Record of one file-manager root — a container directory the user may + browse in the afm dashboard. Carries the stable data model of the roots + payload; the field set and meanings follow the schema in the `afm` + practice. + + `id`: stable identifier, unique within the list; the fixed string + "project" identifies the project root + `label`: display name shown to the user + `container_path`: in-container mount target of the browsable directory + `mount_read_only`: whether the mount is read-only + `kind`: "project" for the project root, "extra" for a directory mount + resolved from home.docker.run volume tokens + + Apply the `convention` practice for the data-model rules of this record. + + Requirements: + - Immutable data record per the `convention` practice + - Field order is stable and matches the payload schema in the `afm` + practice + + Constraints: + - No behavior — the type carries data only + properties: + "id -> str": | + Stable identifier of the root, unique within the list; the fixed + string "project" for the project root. + "label -> str": | + Display name: "project" for the project root; the full container path + for extra roots. + "container_path -> str": | + In-container mount target of the browsable directory. + "mount_read_only -> bool": | + True when the mount is read-only. + "kind -> str": | + Root kind: "project" or "extra". + +"collect_file_roots(tokens: list[str]) -> roots: list[FileRoot]": + location: file_roots.py + annotations: | + Compose the file-manager roots of a run launch from the docker volume + tokens of the home configuration: the project root first, then one root + per directory mount declared by the tokens. + + `tokens`: docker run tokens of home.docker.run — already + shell-tokenized; consumed verbatim per the + `home-configuration` practice + `roots`: the ordered root list — the project root first, extra roots in + token order + + Algorithm: + 1. Start the list with the project root: id "project", label "project", + container path /workspace, read-write, kind "project" + 2. Scan `tokens` for volume declarations — the short -v and long + --volume flag forms; unrecognized tokens are skipped without failing + 3. For each volume declaration whose host part exists as a directory: + derive one extra root — kind "extra", label equal to the container + path, the read-only flag from the presence of the read-only mode in + the token, the id derived deterministically from the container path; + a later declaration targeting an already-rooted container path + supersedes the earlier root — the surviving root carries the later + declaration's fields and position (its mount shadows the earlier) + 4. Skip declarations whose host part is a named volume, a file, or a + missing path — only directories become roots + 5. Return the list: the project root first, extra roots in token order + + Requirements: + - Deterministic — unchanged tokens produce an identical list on every + call + - Every extra-root id is unique within the list and never collides with + "project" + - At most one root per container path — when several volume + declarations target the same container path, only the last + declaration in token order yields the root; earlier declarations + targeting an already-rooted container path are superseded (their + mount is shadowed by the later one) + - Only existing host directories become extra roots + + Constraints: + - Do not re-split or re-quote `tokens` — consume the already-tokenized + list + - Do not fail on unrecognized or malformed tokens — skip them + - Do not include engine mounts — afm state, the config overlay, and + credentials never become roots + +"encode_file_roots(roots: list[FileRoot]) -> value: str": + location: file_roots.py + annotations: | + Encode the roots list into the value of the AFM_DOCKER_FILE_ROOTS + environment variable: a compact JSON payload with a fixed field order, + carried as one standard-base64 string with padding, per the `afm` + practice. + + `roots`: the ordered root list + `value`: the base64 string written as the variable value + + Algorithm: + 1. Compose the payload object — the fixed schema version and the roots + list, each root carrying its fields in the stable schema order + 2. Serialize as compact UTF-8 JSON without whitespace + 3. Encode with standard base64, padding kept, as a single line + 4. Return the encoded string + + Requirements: + - Deterministic — the same list always produces the identical string + - The output decodes with standard base64 (padding) into the compact + JSON of the schema in the `afm` practice + + Constraints: + - Do not vary separators, field order, or padding — the canonical form + is fixed + - Do not wrap or split the output — one line, no whitespace + --- Author: Goga @@ -803,6 +943,7 @@ Description: | the flat list, the overview, the card, and the run — launches the goga Docker container and invokes the in-container pipeline entrypoint inside it; the run form can first bring the repository onto the requested work - (the -t/--topic switch-or-create) on the host. The runtime boundary to - the in-container pipeline is docker — this cell has no Python Type - Imports from it. + (the -t/--topic switch-or-create) on the host and describes the afm + dashboard file-manager roots to the container env-file. The runtime + boundary to the in-container pipeline is docker — this cell has no Python + Type Imports from it. diff --git a/goga/commands/pipeline/run_pipeline_container.py b/goga/commands/pipeline/run_pipeline_container.py index fb313965..19ff0404 100644 --- a/goga/commands/pipeline/run_pipeline_container.py +++ b/goga/commands/pipeline/run_pipeline_container.py @@ -117,12 +117,15 @@ def _write_env_file( Path to the written temporary file. """ fd, path = tempfile.mkstemp(prefix="goga-pipeline-env-") + with os.fdopen(fd, "w") as f: Path(path).chmod(stat.S_IRUSR | stat.S_IWUSR) + for k, v in env.items(): f.write(f"{k}={v}\n") for pair in extra_env: f.write(f"{pair}\n") + return Path(path) @@ -137,6 +140,7 @@ def _allocate_port() -> int: The allocated port number. """ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: sock.bind(("", 0)) return int(sock.getsockname()[1]) @@ -191,16 +195,20 @@ def _write_afm_config_tmpfile(wrapper_path: str | None) -> Path: Path to the written temporary file. """ fd, path = tempfile.mkstemp(prefix="goga-afm-config-") + with os.fdopen(fd, "w") as f: Path(path).chmod(stat.S_IRUSR | stat.S_IWUSR) + if wrapper_path is not None: f.write("client:\n") f.write(f" command: {wrapper_path}\n") + f.write("theme: goga\n") f.write("open_browser: false\n") f.write("proxy:\n") f.write(" enabled: false\n") f.write(f"prompts_dir: {_IN_CONTAINER_AFM_DIR}/prompts\n") + return Path(path) @@ -256,6 +264,7 @@ def clean_pipeline_runtime_dir(pipeline_runtime_dir: Path) -> None: # concurrent --clean); any other failure propagates — the wipe is total. with contextlib.suppress(FileNotFoundError): shutil.rmtree(pipeline_runtime_dir) + pipeline_runtime_dir.mkdir(parents=True, exist_ok=True) @@ -312,12 +321,15 @@ def _resolve_workflow_env( # honest. workflows_root = (Path.cwd() / ".goga" / "workflows").resolve() auto_match_path = workflows_root / f"{name}.yml" + try: auto_match_path.resolve().relative_to(workflows_root) except ValueError: return {}, None + if auto_match_path.exists(): return {}, name + return {}, None @@ -378,10 +390,12 @@ def _build_env_file( # noqa: PLR0913, PLR0917 # directory at /home/goga/pipeline; ~/.afm/config.yaml stays the config # source regardless (see the `afm` practice). env["AFM_DIR"] = _IN_CONTAINER_AFM_DIR + if proxy is not None: env["HTTP_PROXY"] = proxy env["HTTPS_PROXY"] = proxy env["NO_PROXY"] = "localhost,127.0.0.1" + # Step 9 — workflow env-file decision matrix (host-side, BEFORE launch). The # log name is set only when a workflow will actually be applied (step 10). workflow_env, workflow_log_name = _resolve_workflow_env(workflow, no_workflow, name) @@ -392,12 +406,14 @@ def _build_env_file( # noqa: PLR0913, PLR0917 # the env-file skip-free. if skip: env["GOGA_SKIP_STAGES"] = ",".join(skip) + env_file = _write_env_file(env, extra_env) # Step 10 — the workflow log line. Emitted ONLY when a workflow will # actually be applied (explicit --workflow, or basename auto-match file # present on the host). if workflow_log_name is not None: click.echo(f'Pipeline running with workflow "{workflow_log_name}"') + return env_file @@ -486,6 +502,7 @@ def _run_named( # noqa: PLR0913, PLR0917 # optional --clean wipe happens here — strictly before launch, never after. runtime_dir = resolve_pipeline_runtime_dir(name) runtime_dir.mkdir(parents=True, exist_ok=True) + if clean: clean_pipeline_runtime_dir(runtime_dir) @@ -505,6 +522,7 @@ def _on_signal(signum: int, _frame: object) -> None: prev_int = signal.signal(signal.SIGINT, _on_signal) afm_config: Path | None = None env_file: Path | None = None + try: # pipeline.agent is OPTIONAL: the agent may be supplied per-stage by the # workflow instead. Resolve the wrapper path only when an agent is @@ -542,6 +560,7 @@ def _on_signal(signum: int, _frame: object) -> None: # its port); params = the docker-run options the runner translates to # flags via the shared param→flag rule. args = ["-m", "goga.pipeline", "run", name, "--port", str(port)] + # --parallel is appended to the in-container run argv ONLY when # not None (backward compatible — absent ⇒ no flag ⇒ afm unbounded). It is # appended after --port and before the container launch; the in-container @@ -595,6 +614,7 @@ def _on_signal(signum: int, _frame: object) -> None: afm_config.unlink(missing_ok=True) if env_file is not None: env_file.unlink(missing_ok=True) + signal.signal(signal.SIGTERM, prev_term) signal.signal(signal.SIGINT, prev_int) diff --git a/goga/commands/pipeline/run_pipeline_info_container.py b/goga/commands/pipeline/run_pipeline_info_container.py index 1170e6b9..39f376fb 100644 --- a/goga/commands/pipeline/run_pipeline_info_container.py +++ b/goga/commands/pipeline/run_pipeline_info_container.py @@ -75,6 +75,7 @@ def _compose_argv( argv += ["-w", workflow] elif no_workflow: argv += ["--no-workflow"] + return argv From 8cdfc9c1ade1f828a652b81f04f181ae96651706 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Sun, 13 Sep 2026 18:27:36 +0000 Subject: [PATCH 003/205] feat: add file_roots module with FileRoot, collect_file_roots, encode_file_roots --- goga/commands/pipeline/file_roots.py | 281 +++++++++++++++++ tests/commands/pipeline/test_file_roots.py | 349 +++++++++++++++++++++ 2 files changed, 630 insertions(+) create mode 100644 goga/commands/pipeline/file_roots.py create mode 100644 tests/commands/pipeline/test_file_roots.py diff --git a/goga/commands/pipeline/file_roots.py b/goga/commands/pipeline/file_roots.py new file mode 100644 index 00000000..91c6378d --- /dev/null +++ b/goga/commands/pipeline/file_roots.py @@ -0,0 +1,281 @@ +"""Producer of the afm file-manager roots (the ``AFM_DOCKER_FILE_ROOTS`` payload). + +The run-mode launcher composes the ordered list of browsable directory roots of +a launch — the project root (always first) plus one extra root per +``home.docker.run`` directory mount — and encodes it into the value of the +``AFM_DOCKER_FILE_ROOTS`` environment variable written into the run env-file. +goga is only the PRODUCER of this contract: the payload schema (``version``, +``roots`` with ``id``/``label``/``container_path``/``mount_read_only``/``kind``) +and the decoding side belong to the ``afm`` practice (the external Go binary +mounted into the container). + +Both routines are pure and total: unrecognized or malformed tokens are skipped +(never re-split, never re-quoted — the ``home.docker.run`` tokens arrive already +shell-tokenized per the ``home-configuration`` contract), and the host-directory +probe swallows ``OSError`` so an inaccessible path degrades to a skipped root +rather than a launcher traceback. Only the project mount and the +``home.docker.run`` directory mounts become roots — engine mounts (persistent +afm state, config overlay, credentials) never enter the token stream in the +first place, so the constraint holds constructively. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path + +# Recognized volume-declaration flag forms: ``-v VALUE`` / ``--volume VALUE`` +# (two tokens) and ``--volume=VALUE`` (one token). The ``-v=VALUE`` shape is +# not documented by the docker CLI and is deliberately NOT recognized. +_VOLUME_FLAGS = ("-v", "--volume") +_VOLUME_EQ_PREFIX = "--volume=" + +# A ``source:target[:mode]`` declaration splits into at most 3 colon-separated +# parts; anything longer is an unrecognized form and is skipped. +_MAX_VOLUME_PARTS = 3 + + +@dataclass(frozen=True, kw_only=True) +class FileRoot: + """One browsable directory root surfaced to the afm dashboard. + + An immutable data record: the field set, order, and meanings follow the + payload schema of the ``afm`` practice. All five fields are required — + default empty values (e.g. ``id=""``) would violate the payload contract, + so no field carries a default. + + Args: + id: Stable identifier, unique within the roots list; ``"project"`` + for the project root. + label: Display name: ``"project"`` for the project root; the full + container path for extra roots. + container_path: In-container mount point of the browsable directory. + mount_read_only: True when the mount is read-only (a ``ro`` mode + segment on the declaration). + kind: ``"project"`` or ``"extra"``. + """ + + id: str + label: str + container_path: str + mount_read_only: bool + kind: str + + +def _host_is_dir(host: str) -> bool: + """Probe whether the host side of a mount declaration is an existing directory. + + ``Path.is_dir()`` returns ``False`` only for ENOENT/ENOTDIR — every other + ``OSError`` (e.g. ``PermissionError``/EACCES on a ``-v /root/...`` mount + probed by a non-root launcher) PROPAGATES. Catching ``OSError`` here keeps + the composition total: an inaccessible host path degrades to a skipped + root instead of crashing the launch. + + Args: + host: Host-side path of a volume declaration, consumed verbatim — + ``~`` and ``$VAR`` are NOT expanded (a literal non-existent path + simply fails the existence check). + + Returns: + True when the host path resolves to an existing directory (symlinks + are followed, mirroring docker's resolved bind mounts); False on any + other outcome. + """ + try: + return Path(host).is_dir() + except OSError: + return False + + +def _next_volume_value(tokens: list[str], i: int) -> tuple[str | None, int]: + """Recognize the volume declaration starting at ``tokens[i]`` (scanner step). + + Args: + tokens: The already-tokenized ``home.docker.run`` list — never re-split + or re-quoted (``home-configuration`` contract). + i: Index of the candidate token. + + Returns: + ``(value, next_i)`` — the declaration's value string and the index to + resume scanning from, or ``(None, i + 1)`` when the token is not a + recognized volume declaration. A dangling flag at the end of the list + (``i + 1`` out of range) also yields ``None``. + """ + token = tokens[i] + if token in _VOLUME_FLAGS and i + 1 < len(tokens): + return tokens[i + 1], i + 2 + if token.startswith(_VOLUME_EQ_PREFIX): + return token[len(_VOLUME_EQ_PREFIX) :], i + 1 + return None, i + 1 + + +def _parse_volume(value: str) -> tuple[str, str, str | None] | None: + """Split a volume declaration value into ``(host, container, mode)``. + + Args: + value: The declaration value, e.g. ``/host/dir:/ctr:ro``. + + Returns: + The three-way split with ``mode`` present only for the 3-part form, + or ``None`` for forms outside the ``source:target[:mode]`` contract: + a single part (anonymous volume ``/ctr``) or more than three parts. + """ + parts = value.split(":") + if len(parts) == 1 or len(parts) > _MAX_VOLUME_PARTS: + return None + mode = parts[2] if len(parts) == _MAX_VOLUME_PARTS else None + return parts[0], parts[1], mode + + +def _mode_is_read_only(mode: str | None) -> bool: + """Whether the declaration's mode list contains an exact ``ro`` segment. + + Args: + mode: The optional third part of a volume declaration (e.g. ``ro``, + ``ro,z``, ``rw``), already split away from the paths. + + Returns: + True only when a comma-separated segment strips to exactly ``"ro"`` + (``"ro"`` in ``"ro,z"`` → True; ``"rw"`` → False). False when the + declaration carries no mode at all. + """ + if mode is None: + return False + return "ro" in [segment.strip() for segment in mode.split(",")] + + +def _root_id_for(container: str, taken_ids: set[str]) -> str: + """Derive a list-unique id for an extra root from its container path. + + The base id strips the leading ``/`` and maps every remaining ``/`` to + ``-`` (``/home/goga/data`` → ``home-goga-data``). On collision — with the + reserved ``"project"`` id or with an id already taken by an earlier root — + the id is suffixed with ``-`` plus the first 8 hex characters of the + sha256 of the EXACT container path, so the suffix depends only on the path + itself while the need for it depends on token order. Distinct container + paths never collide in the list, so same-base paths always receive + distinct suffixes. + + Args: + container: The in-container mount point (unique within a launch). + taken_ids: Ids already claimed by earlier roots (always contains + ``"project"``). + + Returns: + The id for the new extra root; the caller adds it to ``taken_ids``. + """ + base = container.lstrip("/").replace("/", "-") + if base == "project" or base in taken_ids: + suffix = hashlib.sha256(container.encode("utf-8")).hexdigest()[:8] + return f"{base}-{suffix}" + return base + + +def collect_file_roots(tokens: list[str]) -> list[FileRoot]: + """Compose the ordered file-manager roots of a launch. + + The project root (``/workspace``) always comes first; each surviving + ``home.docker.run`` directory mount contributes one extra root. A mount + becomes an extra root only when its host side contains ``/`` (named + volumes and the ambiguous Windows ``C:`` prefix do not) AND resolves to an + existing host directory (``~``/``$VAR`` never expand — literal + non-existent paths are skipped). Supersede semantics: a later declaration + into an already-rooted container path REPLACES the earlier record and its + position — including when the later declaration yields no root (a named + volume shadowing a bind), which removes the path's root entirely; + unreachable in practice (docker rejects duplicate mount points) but kept + deterministic for any input. + + Determinism: identical tokens produce an identical list (order, fields, + and ids) on every call. Engine mounts (persistent afm state, the + config-overlay tmpfile, credentials) never appear in the token stream, so + they can never become roots. Raises nothing on any input. + + Args: + tokens: The ``home.docker.run`` token list, consumed verbatim — + already shell-tokenized at config load (``home-configuration``); + never re-split, re-quoted, or validated here. + + Returns: + The roots list: the project root first, then one extra root per + surviving directory mount in declaration order (deduplicated by + container path — only the last declaration of a path survives). + """ + project = FileRoot( + id="project", + label="project", + container_path="/workspace", + mount_read_only=False, + kind="project", + ) + roots: dict[str, FileRoot] = {} + taken_ids = {"project"} + i = 0 + while i < len(tokens): + value, i = _next_volume_value(tokens, i) + # An unrecognized token, a dangling flag at the end, or an empty value + # (``-v ""`` / ``--volume=``) — skip without failing. + if value is None or value == "": + continue + parsed = _parse_volume(value) + if parsed is None: + continue + host, container, mode = parsed + # A host side without "/" is a named volume (or the ambiguous Windows + # drive-letter form); only existing host directories become roots. + eligible = "/" in host and _host_is_dir(host) + read_only = _mode_is_read_only(mode) + # Supersede: a later declaration into a rooted path removes the earlier + # record first — only the LAST declaration of a container path survives + # (with its fields, re-inserted at the end of the order). + roots.pop(container, None) + if eligible: + root_id = _root_id_for(container, taken_ids) + roots[container] = FileRoot( + id=root_id, + label=container, + container_path=container, + mount_read_only=read_only, + kind="extra", + ) + taken_ids.add(root_id) + return [project, *roots.values()] + + +def encode_file_roots(roots: list[FileRoot]) -> str: + """Encode the roots list into the ``AFM_DOCKER_FILE_ROOTS`` value. + + Canonical form (fixed by the ``afm`` practice): a compact UTF-8 JSON + payload — ``,``/``:`` separators without spaces, key order ``version``, + ``roots``, and ``id``, ``label``, ``container_path``, ``mount_read_only``, + ``kind`` inside each root, non-ASCII kept literal + (``ensure_ascii=False``) — encoded as one standard-base64 line with + padding preserved. Deterministic and total: identical roots give the + identical string, and nothing is raised. + + Args: + roots: The composed roots list (e.g. the ``collect_file_roots`` + output; the empty list is a valid degenerate payload). + + Returns: + The single-line base64 value for the environment variable — never + wrapped or split. + """ + payload = { + "version": 1, + "roots": [ + { + "id": root.id, + "label": root.label, + "container_path": root.container_path, + "mount_read_only": root.mount_read_only, + "kind": root.kind, + } + for root in roots + ], + } + compact = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + return base64.b64encode(compact.encode("utf-8")).decode("ascii") diff --git a/tests/commands/pipeline/test_file_roots.py b/tests/commands/pipeline/test_file_roots.py new file mode 100644 index 00000000..6dd0f676 --- /dev/null +++ b/tests/commands/pipeline/test_file_roots.py @@ -0,0 +1,349 @@ +"""Unit tests for the afm file-manager roots producer (``file_roots`` module). + +Mirrors the structure of ``goga/commands/pipeline/file_roots.py``: the contract +class pins the signatures, the frozen record shape, and the kw-only required +fields; ``TestCollectFileRoots`` and ``TestEncodeFileRoots`` cover the pure +composition and canonical encoding routines with filesystem fixtures under +``tmp_path`` (no mocks — the single monkeypatched ``Path`` seam exists only for +the inaccessible-host-path case, where chmod 0000 would not reliably produce +EACCES under a root CI user). The byte-exact base64 fixtures pin the canonical +form: ``,``/``:`` separators without spaces, the fixed key order, literal UTF-8 +(``ensure_ascii=False``), and preserved standard-base64 padding. +""" + +from __future__ import annotations + +import base64 +import dataclasses +import inspect +import json +import sys +import typing +from pathlib import Path + +import pytest +from goga.commands.pipeline.file_roots import FileRoot, collect_file_roots, encode_file_roots + +# Resolve the real submodule via sys.modules — `goga.commands.pipeline` binds a +# click Command of the same name on its parent package, so string-based +# monkeypatch paths walking through the package do not resolve (same idiom as +# test_run_pipeline_container.py). +_fr_mod = sys.modules["goga.commands.pipeline.file_roots"] + +_PROJECT_ROOT = FileRoot( + id="project", + label="project", + container_path="/workspace", + mount_read_only=False, + kind="project", +) + +# Byte-exact canonical fixtures (verified against design.md at plan-compile +# time): compact UTF-8 JSON -> one standard-base64 line with padding. +_PROJECT_ONLY_VALUE = ( + "eyJ2ZXJzaW9uIjoxLCJyb290cyI6W3siaWQiOiJwcm9qZWN0IiwibGFiZWwiOiJwcm9qZWN0IiwiY29udGFpbmVyX3Bh" + "dGgiOiIvd29ya3NwYWNlIiwibW91bnRfcmVhZF9vbmx5IjpmYWxzZSwia2luZCI6InByb2plY3QifV19" +) +_TWO_ROOTS_VALUE = ( + "eyJ2ZXJzaW9uIjoxLCJyb290cyI6W3siaWQiOiJwcm9qZWN0IiwibGFiZWwiOiJwcm9qZWN0IiwiY29udGFpbmVyX3Bh" + "dGgiOiIvd29ya3NwYWNlIiwibW91bnRfcmVhZF9vbmx5IjpmYWxzZSwia2luZCI6InByb2plY3QifSx7ImlkIjoiaG9t" + "ZS1nb2dhLWRhdGEiLCJsYWJlbCI6Ii9ob21lL2dvZ2EvZGF0YSIsImNvbnRhaW5lcl9wYXRoIjoiL2hvbWUvZ29nYS9k" + "YXRhIiwibW91bnRfcmVhZF9vbmx5Ijp0cnVlLCJraW5kIjoiZXh0cmEifV19" +) +_EMPTY_VALUE = "eyJ2ZXJzaW9uIjoxLCJyb290cyI6W119" +_NON_ASCII_VALUE = ( + "eyJ2ZXJzaW9uIjoxLCJyb290cyI6W3siaWQiOiJwcm9qZWN0IiwibGFiZWwiOiJwcm9qZWN0IiwiY29udGFpbmVyX3Bh" + "dGgiOiIvd29ya3NwYWNlIiwibW91bnRfcmVhZF9vbmx5IjpmYWxzZSwia2luZCI6InByb2plY3QifSx7ImlkIjoiaG9t" + "ZS1nb2dhLdC00LDQvdC90YvQtSIsImxhYmVsIjoiL2hvbWUvZ29nYS/QtNCw0L3QvdGL0LUiLCJjb250YWluZXJfcGF0" + "aCI6Ii9ob21lL2dvZ2Ev0LTQsNC90L3Ri9C1IiwibW91bnRfcmVhZF9vbmx5IjpmYWxzZSwia2luZCI6ImV4dHJhIn1d" + "fQ==" +) + + +def _decode(value: str) -> dict: + """Decode an AFM_DOCKER_FILE_ROOTS value into its payload dict.""" + return json.loads(base64.b64decode(value)) + + +# --- Contract tests --- + + +class TestFileRootsContract: + def test_signatures_and_frozen_record_match_contract(self) -> None: + """Signatures, field set/order, kw-only required fields, and immutability match the contract.""" + assert list(inspect.signature(collect_file_roots).parameters) == ["tokens"] + collect_hints = typing.get_type_hints(collect_file_roots) + assert collect_hints["tokens"] == list[str] + assert collect_hints["return"] == list[FileRoot] + + assert list(inspect.signature(encode_file_roots).parameters) == ["roots"] + encode_hints = typing.get_type_hints(encode_file_roots) + assert encode_hints["roots"] == list[FileRoot] + assert encode_hints["return"] is str + + assert [f.name for f in dataclasses.fields(FileRoot)] == [ + "id", + "label", + "container_path", + "mount_read_only", + "kind", + ] + assert all(f.kw_only and f.default is dataclasses.MISSING for f in dataclasses.fields(FileRoot)) + + root = FileRoot( + id="home-goga-data", + label="/home/goga/data", + container_path="/home/goga/data", + mount_read_only=True, + kind="extra", + ) + with pytest.raises(dataclasses.FrozenInstanceError): + root.id = "x" + + +# --- collect_file_roots --- + + +class TestCollectFileRoots: + def test_collect_file_roots_with_directory_mount(self, tmp_path: Path) -> None: + """An existing host directory mounted via -v becomes one extra root after the project root.""" + (tmp_path / "data").mkdir() + + roots = collect_file_roots(["-v", f"{tmp_path}/data:/home/goga/data"]) + + assert len(roots) == 2 + assert roots[0] == FileRoot( + id="project", + label="project", + container_path="/workspace", + mount_read_only=False, + kind="project", + ) + assert roots[1].id == "home-goga-data" + assert roots[1].label == "/home/goga/data" + assert roots[1].container_path == "/home/goga/data" + assert roots[1].mount_read_only is False + assert roots[1].kind == "extra" + + def test_collect_file_roots_read_only_mode(self, tmp_path: Path) -> None: + """The third :ro/:rw mode segment maps onto mount_read_only (exact segment match).""" + (tmp_path / "ro").mkdir() + (tmp_path / "rw").mkdir() + + roots = collect_file_roots( + ["-v", f"{tmp_path}/ro:/mnt/ro:ro", "-v", f"{tmp_path}/rw:/mnt/rw:rw"], + ) + + assert {r.container_path: r.mount_read_only for r in roots[1:]} == { + "/mnt/ro": True, + "/mnt/rw": False, + } + + def test_collect_file_roots_long_volume_forms(self, tmp_path: Path) -> None: + """--volume VALUE and --volume=VALUE are recognized like the short -v form.""" + (tmp_path / "d").mkdir() + + roots = collect_file_roots( + ["--volume", f"{tmp_path}/d:/a", f"--volume={tmp_path}/d:/b"], + ) + + assert [r.container_path for r in roots[1:]] == ["/a", "/b"] + + def test_collect_file_roots_empty_tokens(self) -> None: + """No docker.run tokens still yield the project-only list — the variable is written on EVERY run launch.""" + roots = collect_file_roots([]) + + assert roots == [ + FileRoot( + id="project", + label="project", + container_path="/workspace", + mount_read_only=False, + kind="project", + ) + ] + + def test_collect_file_roots_skips_named_volume_file_missing( + self, tmp_path: Path + ) -> None: + """Named volumes, file mounts, missing paths, and unrelated flags never become roots.""" + (tmp_path / "file.txt").write_text("x") + + roots = collect_file_roots( + [ + "-v", "mydata:/mnt/named", + "-v", f"{tmp_path}/file.txt:/mnt/file", + "-v", f"{tmp_path}/missing:/mnt/missing", + "--network=host", + "-e", "X=Y", + ] + ) + + assert [r.container_path for r in roots] == ["/workspace"] + + def test_collect_file_roots_dangling_and_malformed(self) -> None: + """Dangling flags, anonymous volumes, >3-part values, and empty values are skipped without exceptions.""" + roots = collect_file_roots(["-v", "--volume", "-v", "/ctr", "-v", "a:b:c:d", "-v", ""]) + + assert [r.container_path for r in roots] == ["/workspace"] + + def test_collect_file_roots_supersedes_duplicate_container_path(self, tmp_path: Path) -> None: + """A later declaration into the same container path replaces the record AND its position/fields.""" + (tmp_path / "one").mkdir() + (tmp_path / "two").mkdir() + + roots = collect_file_roots( + ["-v", f"{tmp_path}/one:/mnt/x:ro", "-v", f"{tmp_path}/two:/mnt/x"], + ) + + assert [r.container_path for r in roots] == ["/workspace", "/mnt/x"] + assert roots[1].mount_read_only is False + + def test_collect_file_roots_non_directory_supersedes_root(self, tmp_path: Path) -> None: + """A later non-root-yielding declaration (named volume) shadows an earlier root on the same path.""" + (tmp_path / "d").mkdir() + + roots = collect_file_roots(["-v", f"{tmp_path}/d:/mnt/x", "-v", "vol:/mnt/x"]) + + assert [r.container_path for r in roots] == ["/workspace"] + + def test_collect_file_roots_id_never_collides_with_project(self, tmp_path: Path) -> None: + """An extra root whose sanitized base is "project" gets the sha256-suffixed id.""" + (tmp_path / "p").mkdir() + + roots = collect_file_roots(["-v", f"{tmp_path}/p:/project"]) + + ids = [r.id for r in roots] + assert len(set(ids)) == len(ids) + assert roots[1].id == "project-ea0135bc" + assert roots[1].id != "project" + + def test_collect_file_roots_id_unique_on_sanitization_collision(self, tmp_path: Path) -> None: + """Two container paths sanitizing to the same base get distinct ids (second gets the sha256 suffix).""" + (tmp_path / "goga" / "data").mkdir(parents=True) + (tmp_path / "goga-data").mkdir() + + roots = collect_file_roots( + [ + "-v", f"{tmp_path}/goga/data:/home/goga/data", + "-v", f"{tmp_path}/goga-data:/home/goga-data", + ] + ) + + ids = [r.id for r in roots[1:]] + assert ids == ["home-goga-data", "home-goga-data-96b6889c"] + assert len(set(ids)) == 2 + + def test_collect_file_roots_deterministic(self, tmp_path: Path) -> None: + """Identical tokens produce an identical list — including order and ids — on every call.""" + (tmp_path / "a").mkdir() + (tmp_path / "b").mkdir() + tokens = ["-v", f"{tmp_path}/a:/mnt/a", "-v", f"{tmp_path}/b:/mnt/b"] + + assert collect_file_roots(tokens) == collect_file_roots(tokens) + + def test_collect_file_roots_relative_host_path(self, tmp_path: Path, monkeypatch) -> None: + """A relative host path is resolved from the launcher CWD — the same directory docker CLI resolves from.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "rel").mkdir() + + roots = collect_file_roots(["-v", "./rel:/mnt/rel"]) + + assert roots[1].container_path == "/mnt/rel" + + def test_collect_file_roots_inaccessible_host_path_is_skipped(self, monkeypatch) -> None: + """An OSError from the host probe is swallowed by _host_is_dir — the launcher never tracebacks on -v /root/... . + + chmod 0000 is not a reliable EACCES source under a root CI user, so the + module-level ``Path`` seam is replaced with a stub whose ``is_dir`` + raises ``PermissionError`` (an ``OSError`` subclass). + """ + + class UnreachablePath: + def __init__(self, path: str) -> None: + self._path = path + + def is_dir(self) -> bool: + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(_fr_mod, "Path", UnreachablePath) + + roots = collect_file_roots(["-v", "/root/secrets:/mnt/secret"]) + + assert [r.container_path for r in roots] == ["/workspace"] + + def test_collect_file_roots_symlink_to_directory(self, tmp_path: Path) -> None: + """Path.is_dir() follows symlinks — a symlink to a directory counts as one (docker resolves it too).""" + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "link" + link.symlink_to(real) + + roots = collect_file_roots(["-v", f"{link}:/mnt/link"]) + + assert roots[1].container_path == "/mnt/link" + + +# --- encode_file_roots --- + + +class TestEncodeFileRoots: + def test_encode_file_roots_project_only_canonical(self) -> None: + """The project-only list encodes to the byte-exact canonical fixture with a round-trippable payload.""" + value = encode_file_roots([_PROJECT_ROOT]) + + assert value == _PROJECT_ONLY_VALUE + payload = _decode(value) + assert payload == { + "version": 1, + "roots": [ + { + "id": "project", + "label": "project", + "container_path": "/workspace", + "mount_read_only": False, + "kind": "project", + } + ], + } + assert list(payload["roots"][0]) == ["id", "label", "container_path", "mount_read_only", "kind"] + + def test_encode_file_roots_deterministic_and_two_roots(self) -> None: + """Repeated calls give the identical string, and the two-roots list matches the byte-exact fixture.""" + roots = [ + _PROJECT_ROOT, + FileRoot( + id="home-goga-data", + label="/home/goga/data", + container_path="/home/goga/data", + mount_read_only=True, + kind="extra", + ), + ] + + first = encode_file_roots(roots) + second = encode_file_roots(roots) + + assert first == second + assert first == _TWO_ROOTS_VALUE + + def test_encode_file_roots_empty_list(self) -> None: + """The empty list still encodes a valid payload (collect always returns the project root in practice).""" + assert encode_file_roots([]) == _EMPTY_VALUE + + def test_encode_file_roots_non_ascii_literal_utf8(self) -> None: + """Non-ASCII field values stay literal UTF-8 (ensure_ascii=False) — pinned byte-exact.""" + roots = [ + _PROJECT_ROOT, + FileRoot( + id="home-goga-данные", + label="/home/goga/данные", + container_path="/home/goga/данные", + mount_read_only=False, + kind="extra", + ), + ] + + value = encode_file_roots(roots) + + assert value == _NON_ASCII_VALUE From 192fd047cc1106ca50250072a701efb12c1bb1f5 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Sun, 13 Sep 2026 18:30:32 +0000 Subject: [PATCH 004/205] feat: re-export FileRoot, collect_file_roots, encode_file_roots from the pipeline facade --- goga/commands/pipeline/__init__.py | 4 +++ tests/commands/pipeline/test_file_roots.py | 13 ++++++++ .../pipeline/test_pipeline_command.py | 32 ++++++++++++------- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/goga/commands/pipeline/__init__.py b/goga/commands/pipeline/__init__.py index 8b46e1bf..8b9a785c 100644 --- a/goga/commands/pipeline/__init__.py +++ b/goga/commands/pipeline/__init__.py @@ -1,5 +1,6 @@ """Pipeline command cell — host-side launcher for the single goga pipeline command.""" +from .file_roots import FileRoot, collect_file_roots, encode_file_roots from .pipeline import pipeline from .run_pipeline_container import ( clean_pipeline_runtime_dir, @@ -9,7 +10,10 @@ from .run_pipeline_info_container import run_pipeline_info_container __all__: list[str] = [ + "FileRoot", "clean_pipeline_runtime_dir", + "collect_file_roots", + "encode_file_roots", "pipeline", "resolve_pipeline_runtime_dir", "run_pipeline_container", diff --git a/tests/commands/pipeline/test_file_roots.py b/tests/commands/pipeline/test_file_roots.py index 6dd0f676..e39e0926 100644 --- a/tests/commands/pipeline/test_file_roots.py +++ b/tests/commands/pipeline/test_file_roots.py @@ -100,6 +100,19 @@ def test_signatures_and_frozen_record_match_contract(self) -> None: with pytest.raises(dataclasses.FrozenInstanceError): root.id = "x" + def test_importable_from_facade_and_declared_location(self) -> None: + """All three names are importable from the package facade; the module lives at its declared location.""" + from goga.commands.pipeline import FileRoot as facade_FileRoot + from goga.commands.pipeline import collect_file_roots as facade_collect_file_roots + from goga.commands.pipeline import encode_file_roots as facade_encode_file_roots + + assert facade_FileRoot is FileRoot + assert facade_collect_file_roots is collect_file_roots + assert facade_encode_file_roots is encode_file_roots + assert sys.modules["goga.commands.pipeline.file_roots"].__file__.endswith( + "goga/commands/pipeline/file_roots.py" + ) + # --- collect_file_roots --- diff --git a/tests/commands/pipeline/test_pipeline_command.py b/tests/commands/pipeline/test_pipeline_command.py index d9e5f85e..f482ae08 100644 --- a/tests/commands/pipeline/test_pipeline_command.py +++ b/tests/commands/pipeline/test_pipeline_command.py @@ -771,15 +771,19 @@ def test_pipeline_todo_non_tty_aborts_before_docker(self, tmp_path: Path, monkey # --- Facade contract: goga/commands/pipeline exports the full contract API --- -# The five names declared in the cell CODEMANIFEST — the pipeline command, the -# two container launchers, and the two runtime-dir helpers (declared since the -# cell existed, exported since release 1.3.0; the slug transformer and the -# current-branch reader belong to goga.history, and the topic procedure -# delegates to goga.topics.ensure_topic — neither is re-exported from this -# facade; the former branch routines moved to the topics domain in release -# 1.4.0 and are gone from this cell entirely). +# The eight names declared in the cell CODEMANIFEST — the pipeline command, the +# two container launchers, the two runtime-dir helpers, and the file-roots +# trio added by the afm file-manager roots producer (declared since the cell +# existed, exported since release 1.3.0 — the trio since the file-manager +# support change; the slug transformer and the current-branch reader belong to +# goga.history, and the topic procedure delegates to goga.topics.ensure_topic +# — neither is re-exported from this facade; the former branch routines moved +# to the topics domain in release 1.4.0 and are gone from this cell entirely). _PIPELINE_FACADE_ALL = [ + "FileRoot", "clean_pipeline_runtime_dir", + "collect_file_roots", + "encode_file_roots", "pipeline", "resolve_pipeline_runtime_dir", "run_pipeline_container", @@ -789,7 +793,7 @@ def test_pipeline_todo_non_tty_aborts_before_docker(self, tmp_path: Path, monkey class TestCommandsFacadeExportsInfoLauncher: def test_commands_facade_exports_info_launcher(self) -> None: - """The package facade defines all five public names and lists them in ``__all__``. + """The package facade defines all eight public names and lists them in ``__all__``. ``goga.commands.pipeline`` is shadowed on the ``goga.commands`` package by the pipeline Click command (see the module-level note above), so the @@ -803,7 +807,7 @@ def test_commands_facade_exports_info_launcher(self) -> None: assert name in commands_facade.__all__, f"{name} is missing from goga.commands.pipeline.__all__" def test_commands_facade_all_is_alphabetical_and_complete(self) -> None: - """``__all__`` holds exactly the five names in alphabetical order.""" + """``__all__`` holds exactly the eight names in ASCII order (classes first).""" commands_facade = sys.modules["goga.commands.pipeline"] assert commands_facade.__all__ == _PIPELINE_FACADE_ALL @@ -811,11 +815,14 @@ def test_cell_facades_export_full_contract_api(self) -> None: """Every declared contract name is importable from the cell facade root. The Python facade rule obliges ``goga.commands.pipeline`` to expose the - full contract API: the command, both launchers, and the two - runtime-dir helpers. + full contract API: the command, both launchers, the two runtime-dir + helpers, and the file-roots trio. """ from goga.commands.pipeline import ( + FileRoot, clean_pipeline_runtime_dir, + collect_file_roots, + encode_file_roots, resolve_pipeline_runtime_dir, run_pipeline_container, run_pipeline_info_container, @@ -829,6 +836,9 @@ def test_cell_facades_export_full_contract_api(self) -> None: assert run_pipeline_info_container is not None assert resolve_pipeline_runtime_dir is not None assert clean_pipeline_runtime_dir is not None + assert FileRoot is not None + assert collect_file_roots is not None + assert encode_file_roots is not None assert sys.modules["goga.commands.pipeline"].__all__ == _PIPELINE_FACADE_ALL def test_cell_facade_holds_no_branch_machinery(self) -> None: From b8771c07afcc181c28dd9abae8e7c5482e62c503 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Sun, 13 Sep 2026 18:34:50 +0000 Subject: [PATCH 005/205] feat: write AFM_DOCKER_FILE_ROOTS env layer in _build_env_file from home.docker.run tokens --- .../pipeline/run_pipeline_container.py | 49 ++++++-- .../pipeline/test_run_pipeline_container.py | 109 +++++++++++++++++- 2 files changed, 146 insertions(+), 12 deletions(-) diff --git a/goga/commands/pipeline/run_pipeline_container.py b/goga/commands/pipeline/run_pipeline_container.py index 19ff0404..3b7f7e34 100644 --- a/goga/commands/pipeline/run_pipeline_container.py +++ b/goga/commands/pipeline/run_pipeline_container.py @@ -35,6 +35,7 @@ from ...config import HomeConfig, ProjectConfig, load_home_config from ...docker import DockerRunner, docker_build_if_not_exist, docker_update from ...runtime import resolve_runtime_dir +from .file_roots import collect_file_roots, encode_file_roots logger = logging.getLogger(__name__) @@ -335,6 +336,7 @@ def _resolve_workflow_env( def _build_env_file( # noqa: PLR0913, PLR0917 home_env: dict[str, str], + docker_run_tokens: list[str], extra_env: tuple[str, ...], pipeline_env: dict[str, str], proxy: str | None, @@ -348,20 +350,30 @@ def _build_env_file( # noqa: PLR0913, PLR0917 Layers ``home_env`` (``home.env``) as the BASE (lowest-priority) env layer, then git identity, then ``pipeline_env`` (config.pipeline.env — project config wins over both git and home on key conflict), then ``AFM_DIR``, the - proxy env vars (when ``proxy`` is set), and the workflow env vars per the - decision matrix (``_resolve_workflow_env`` — step 9), writes them to a - private env-file alongside the raw ``extra_env`` KEY=VALUE strings (step 11). - The ``GOGA_SKIP_STAGES`` entry is layered in after the workflow env vars - when ``skip`` is non-empty (joined comma-separated). This cell surfaces - and emits the ``Pipeline running with workflow "NAME"`` log line to stdout - ONLY when a workflow will actually be applied (step 10). This cell surfaces - NO dashboard URL line — this is the only host-side stdout besides the docker - output stream. + ``AFM_DOCKER_FILE_ROOTS`` afm file-manager roots layer (composed from + ``docker_run_tokens`` — the project root plus one extra root per directory + mount), the proxy env vars (when ``proxy`` is set), and the workflow env + vars per the decision matrix (``_resolve_workflow_env`` — step 9), writes + them to a private env-file alongside the raw ``extra_env`` KEY=VALUE + strings (step 11). The ``GOGA_SKIP_STAGES`` entry is layered in after the + workflow env vars when ``skip`` is non-empty (joined comma-separated). This + cell surfaces and emits the ``Pipeline running with workflow "NAME"`` log + line to stdout ONLY when a workflow will actually be applied (step 10). + This cell surfaces NO dashboard URL line — this is the only host-side + stdout besides the docker output stream. Args: home_env: ``home.env`` from the machine-wide home config — the lowest-priority env layer (project config and CLI win on key conflict). Survives where unconflicted. + docker_run_tokens: ``home.docker.run`` tokens (already shell-tokenized, + consumed verbatim per the ``home-configuration`` contract) from + which the ``AFM_DOCKER_FILE_ROOTS`` file-manager roots are + composed — the project root plus one extra root per directory + mount. Written on EVERY run launch; an explicit ``-e + AFM_DOCKER_FILE_ROOTS=...`` entry in ``extra_env`` is appended + after this dict layer and wins via docker ``--env-file`` + last-write-wins. extra_env: Additional raw KEY=VALUE strings appended verbatim (a SEPARATE channel appended last, winning on key conflict). pipeline_env: ``config.pipeline.env`` merged on top of git identity and @@ -390,6 +402,13 @@ def _build_env_file( # noqa: PLR0913, PLR0917 # directory at /home/goga/pipeline; ~/.afm/config.yaml stays the config # source regardless (see the `afm` practice). env["AFM_DIR"] = _IN_CONTAINER_AFM_DIR + # The afm file-manager roots layer (the `afm` practice): the ordered roots + # of this launch — the project root first, then one extra root per + # home.docker.run directory mount — canonically encoded as base64 compact + # JSON. goga is only the PRODUCER of the payload; afm (in-container) + # decodes it. Written on EVERY run launch; repeated launches with + # unchanged mounts produce the identical value. + env["AFM_DOCKER_FILE_ROOTS"] = encode_file_roots(collect_file_roots(docker_run_tokens)) if proxy is not None: env["HTTP_PROXY"] = proxy @@ -438,7 +457,9 @@ def _run_named( # noqa: PLR0913, PLR0917 the persistent afm state host directory exists (wiping it first when ``clean`` is set), writes a private env-file layering ``home.env`` as the BASE layer under ``config.pipeline.env``, git identity, ``extra_env``, - ``AFM_DIR``, the workflow env vars (per the workflow decision matrix), and — + ``AFM_DIR``, ``AFM_DOCKER_FILE_ROOTS`` (the afm file-manager roots composed + from the ``home.docker.run`` directory mounts), the workflow env vars (per + the workflow decision matrix), and — when ``proxy`` is set — the proxy env vars, emits the workflow log line when a workflow will actually be applied, optionally refreshes the image via ``docker_update`` (forwarding ``home.docker.build`` to image build in the @@ -533,6 +554,7 @@ def _on_signal(signum: int, _frame: object) -> None: afm_config = _write_afm_config_tmpfile(wrapper_path) env_file = _build_env_file( home_env=home.env, + docker_run_tokens=home.docker.run, extra_env=extra_env, pipeline_env=config.pipeline.env, proxy=proxy, @@ -653,7 +675,12 @@ def run_pipeline_container( # noqa: PLR0913, PLR0917 directory exists (wiping it first when ``clean`` is set), writes a private env-file layering ``home.env`` as the BASE layer under ``config.pipeline.env``, git identity, ``extra_env`` (raw KEY=VALUE strings), - ``AFM_DIR=/home/goga/pipeline``, the workflow env vars (per the workflow + ``AFM_DIR=/home/goga/pipeline``, + ``AFM_DOCKER_FILE_ROOTS`` (the base64 afm file-manager-roots payload + composed from the ``home.docker.run`` directory mounts — the project root + plus one extra root each, per the ``afm`` practice; an explicit ``-e + AFM_DOCKER_FILE_ROOTS=...`` entry wins via docker ``--env-file`` + last-write-wins), the workflow env vars (per the workflow decision matrix), the ``GOGA_SKIP_STAGES`` entry (when ``skip`` is non-empty), and — when ``proxy`` is set — the proxy env vars, mounts the persistent directory read-write at ``/home/goga/pipeline`` (it survives diff --git a/tests/commands/pipeline/test_run_pipeline_container.py b/tests/commands/pipeline/test_run_pipeline_container.py index 34a5c0ea..634e0af1 100644 --- a/tests/commands/pipeline/test_run_pipeline_container.py +++ b/tests/commands/pipeline/test_run_pipeline_container.py @@ -1,5 +1,7 @@ from __future__ import annotations +import base64 +import json import signal import subprocess import sys @@ -10,7 +12,14 @@ import pytest from goga.commands.pipeline import run_pipeline_container from goga.commands.pipeline.run_pipeline_container import run_pipeline_container as rpc -from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import ( + BuildConfig, + DockerArgsConfig, + HomeConfig, + PipelineConfig, + ProjectConfig, + TaskExecutorConfig, +) # Resolve the real submodule via sys.modules (the package __init__ binds the # function name `run_pipeline_container`, which would shadow string-based @@ -408,6 +417,104 @@ def capture(env: dict[str, str], extra_env: tuple[str, ...] = ()) -> Path: assert captured["extra_env"] == ("ANTHROPIC_API_KEY=sk-xxx", "MODEL=claude-sonnet-4-6") +# --- afm file-manager roots (AFM_DOCKER_FILE_ROOTS layer) --- + + +class TestPipelineFileRoots: + """Run mode produces the afm file-manager roots env layer (the `afm` practice). + + The launcher is the PRODUCER of the ``AFM_DOCKER_FILE_ROOTS`` payload: the + value is composed from the ACTUAL launch mounts — the project root plus one + extra root per ``home.docker.run`` directory mount — and written into the + env-file on EVERY run launch, immediately after ``AFM_DIR``. A raw + ``-e AFM_DOCKER_FILE_ROOTS=...`` entry is a separate channel appended after + the dict layers, so docker ``--env-file`` last-write-wins gives the user + value the final say. + """ + + def test_env_file_writes_file_roots_after_afm_dir(self, tmp_path: Path, monkeypatch) -> None: + """AFM_DOCKER_FILE_ROOTS lands right after AFM_DIR, composed from the launch tokens.""" + (tmp_path / "data").mkdir() + config = _make_config() + monkeypatch.setattr(_rpc_mod, "_check_docker", lambda: True) + monkeypatch.setattr(_rpc_mod, "_allocate_port", lambda: 50321) + monkeypatch.setattr(_rpc_mod, "_read_git_config", lambda: {}) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + _rpc_mod, + "load_home_config", + lambda: HomeConfig(env={}, docker=DockerArgsConfig(run=["-v", f"{tmp_path}/data:/home/goga/data"])), + ) + + captured_env: dict[str, str] = {} + real_write = _rpc_mod._write_env_file + + def capture(env: dict[str, str], extra_env: tuple[str, ...] = ()) -> Path: + captured_env.update(env) + return real_write(env, extra_env) + + monkeypatch.setattr(_rpc_mod, "_write_env_file", capture) + + mock_proc = mock.Mock() + mock_proc.wait.return_value = 0 + with ( + mock.patch.object(subprocess, "Popen", return_value=mock_proc), + mock.patch.object(subprocess, "run"), + ): + run_pipeline_container("deploy", config) + + assert "AFM_DOCKER_FILE_ROOTS" in captured_env + # dict key order: the roots layer is written immediately after AFM_DIR + assert list(captured_env).index("AFM_DOCKER_FILE_ROOTS") > list(captured_env).index("AFM_DIR") + # the value decodes to the roots composed from the actual launch tokens + payload = json.loads(base64.b64decode(captured_env["AFM_DOCKER_FILE_ROOTS"])) + assert payload["roots"][0]["container_path"] == "/workspace" + assert payload["roots"][1]["container_path"] == "/home/goga/data" + + def test_extra_env_file_roots_override_wins(self, tmp_path: Path, monkeypatch) -> None: + """A raw -e AFM_DOCKER_FILE_ROOTS line is written after the launcher line (last-write-wins).""" + (tmp_path / "data").mkdir() + config = _make_config() + monkeypatch.setattr(_rpc_mod, "_check_docker", lambda: True) + monkeypatch.setattr(_rpc_mod, "_allocate_port", lambda: 50321) + monkeypatch.setattr(_rpc_mod, "_read_git_config", lambda: {}) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + _rpc_mod, + "load_home_config", + lambda: HomeConfig(env={}, docker=DockerArgsConfig(run=["-v", f"{tmp_path}/data:/home/goga/data"])), + ) + + captured_lines: list[str] = [] + real_write = _rpc_mod._write_env_file + + def capture(env: dict[str, str], extra_env: tuple[str, ...] = ()) -> Path: + path = real_write(env, extra_env) + captured_lines.extend(path.read_text().splitlines()) + return path + + monkeypatch.setattr(_rpc_mod, "_write_env_file", capture) + + mock_proc = mock.Mock() + mock_proc.wait.return_value = 0 + with ( + mock.patch.object(subprocess, "Popen", return_value=mock_proc), + mock.patch.object(subprocess, "run"), + ): + run_pipeline_container("deploy", config, extra_env=("AFM_DOCKER_FILE_ROOTS=custom",)) + + override_idx = captured_lines.index("AFM_DOCKER_FILE_ROOTS=custom") + launcher_idxs = [ + i + for i, line in enumerate(captured_lines) + if line.startswith("AFM_DOCKER_FILE_ROOTS=") and line != "AFM_DOCKER_FILE_ROOTS=custom" + ] + # the launcher layer was written exactly once, and the raw -e line comes + # AFTER it — docker --env-file last-write-wins → the user value wins + assert len(launcher_idxs) == 1 + assert override_idx > max(launcher_idxs) + + # --- parallel cap (run mode only) --- From addbe23df31f387ea606a0eaa93ae112d6b04599 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Sun, 13 Sep 2026 18:37:52 +0000 Subject: [PATCH 006/205] feat: pin info launcher produces no AFM_DOCKER_FILE_ROOTS (structural no-env-file test) --- .../test_run_pipeline_info_container.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/commands/pipeline/test_run_pipeline_info_container.py b/tests/commands/pipeline/test_run_pipeline_info_container.py index 210d9bc5..327227aa 100644 --- a/tests/commands/pipeline/test_run_pipeline_info_container.py +++ b/tests/commands/pipeline/test_run_pipeline_info_container.py @@ -34,6 +34,7 @@ run_pipeline_info_container as rpic, ) from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.docker._flags import translate_params # Resolve the real submodule via sys.modules (the package __init__ will bind the # function name `run_pipeline_info_container`, which would shadow string-based @@ -149,6 +150,45 @@ def test_run_pipeline_info_container_composes_flat_list_argv(self, tmp_path: Pat mocks["update"].assert_not_called() assert mocks["build"].called + def test_info_launcher_produces_no_file_roots(self, tmp_path: Path, monkeypatch) -> None: + """No AFM_DOCKER_FILE_ROOTS — the minimal shape composes no env-file at all.""" + from goga.config import DockerArgsConfig, HomeConfig + + mocks = _install_happy_path(monkeypatch) + home = HomeConfig(env={}, docker=DockerArgsConfig(run=[])) + monkeypatch.setattr(_rpic_mod, "load_home_config", mock.Mock(return_value=home)) + monkeypatch.chdir(tmp_path) + + result = rpic( + name=None, + info=False, + config=_make_config(), + hosts={}, + update=False, + workflow=None, + no_workflow=False, + ) + + assert result == 0 + args, kwargs = mocks["runner_instance"].run.call_args + # The guarantee is structural: no env-file parameter exists to filter — + # the variable cannot be produced, so the image's static ENV default + # holds in the container. + assert "env_file" not in kwargs + # Rebuild the docker-run argv exactly as DockerRunner.run would: + # params → flags via the shared rule, then extra_args, image, args. + params = {key: value for key, value in kwargs.items() if key != "extra_args"} + argv = [ + "docker", + "run", + *translate_params(params), + *list(kwargs.get("extra_args") or []), + mocks["runner_cls"].call_args.args[0], + *args, + ] + assert "--env-file" not in argv + assert not any("AFM_DOCKER_FILE_ROOTS" in token for token in argv) + class TestOverviewAndCardArgv: @pytest.mark.parametrize( From 3623d46751c410f4989a8314328a1c18b7701203 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Sun, 13 Sep 2026 18:48:48 +0000 Subject: [PATCH 007/205] fix: address code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs: document AFM_DOCKER_FILE_ROOTS (runtime.md) and the docker.run file-manager side effect (home.md) — user-facing behavior shipped with zero human documentation - tests: pin the launcher-composed roots winning over home.env / config.pipeline.env keys (the payload must mirror actual mounts) - tests: cover the ro,z comma-list mode segment and the trailing dangling -v flag branch --- docs/configuration/home.md | 19 ++++++++ docs/features/pipelines/runtime.md | 27 +++++++++++ tests/commands/pipeline/test_file_roots.py | 12 ++++- .../pipeline/test_run_pipeline_container.py | 47 +++++++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) diff --git a/docs/configuration/home.md b/docs/configuration/home.md index 04da77fa..c235c1c4 100644 --- a/docs/configuration/home.md +++ b/docs/configuration/home.md @@ -30,6 +30,25 @@ malformed entry (an unterminated quote) fails to load with a clean error. | `docker.run` | list of strings | Shell fragments appended to every `docker run` invocation in both `goga build` and `goga pipeline`. Each entry is shell-tokenized (e.g. `-v /host:/container` → `-v` + volume spec) | | `docker.build` | list of strings | Shell fragments appended to image builds only — forwarded by both `goga build` and `goga pipeline` (`docker_build_if_not_exist` / `docker_update`, build branch only; ignored on image pull). Each entry is shell-tokenized like `docker.run` | +### `docker.run` volume mounts and the dashboard file manager + +In the run form of `goga pipeline `, every `docker.run` directory-mount +entry additionally becomes a browsable root of the pipeline web UI's file +manager (delivered to the container as `AFM_DOCKER_FILE_ROOTS` — see +[Runtime](../features/pipelines/runtime.md)): + +```yaml +docker: + run: + - "-v /home/me/data:/home/goga/data" # browsable in the file manager + - "-v /home/me/refs:/home/goga/refs:ro" # read-only root +``` + +The host part must exist as a directory at launch time; `~` and `$VAR` are not +expanded (a literal `~` path yields no root). Roots appear in token order +after the project root; an explicit `-e AFM_DOCKER_FILE_ROOTS=...` overrides +the composed set. + ## Env layering The env layering formula is `{**home.env, **project_env, **cli_env}` — `home.env` diff --git a/docs/features/pipelines/runtime.md b/docs/features/pipelines/runtime.md index ba4fdd1a..be4ae134 100644 --- a/docs/features/pipelines/runtime.md +++ b/docs/features/pipelines/runtime.md @@ -66,6 +66,33 @@ container env-file: `HTTP_PROXY`, `HTTPS_PROXY`, and flags; CLI entries merge on top of config with the CLI winning on key conflict. +## File manager roots + +The run form writes `AFM_DOCKER_FILE_ROOTS` into the container env-file on +**every** launch — the set of directories the file manager of the pipeline +web UI (served on the published port) lets you browse. The value is standard +base64 (with padding) of a compact UTF-8 JSON payload: + +```json +{"version":1,"roots":[{"id":"project","label":"project","container_path":"/workspace","mount_read_only":false,"kind":"project"}]} +``` + +| Root | Source | Presence | +|---|---|---| +| `project` | the project directory mounted at `/workspace` | always — listed first, read-write | +| `extra` | a directory mount from a [home configuration](../../configuration/home.md) `docker.run` `-v`/`--volume` token | when the token's host part exists as a directory at launch time | + +Extra roots are labeled with their full container path, appear in token order +after the project root, and a `:ro` mount is flagged read-only. Named volumes, +file mounts, missing host paths, credential mounts, and the persistent +pipeline state directory never become roots. The value is deterministic — +unchanged mounts produce the identical value on every launch. + +An explicit `-e AFM_DOCKER_FILE_ROOTS=...` entry wins over the +launcher-composed value (docker `--env-file` last-write-wins). The list/info +forms launch no env-file, so they produce no variable — the image's static +default applies there. + ## Credential mounts Credential files for claude (`~/.claude/.credentials.json`), codex diff --git a/tests/commands/pipeline/test_file_roots.py b/tests/commands/pipeline/test_file_roots.py index e39e0926..30d153b7 100644 --- a/tests/commands/pipeline/test_file_roots.py +++ b/tests/commands/pipeline/test_file_roots.py @@ -142,14 +142,20 @@ def test_collect_file_roots_read_only_mode(self, tmp_path: Path) -> None: """The third :ro/:rw mode segment maps onto mount_read_only (exact segment match).""" (tmp_path / "ro").mkdir() (tmp_path / "rw").mkdir() + (tmp_path / "roz").mkdir() roots = collect_file_roots( - ["-v", f"{tmp_path}/ro:/mnt/ro:ro", "-v", f"{tmp_path}/rw:/mnt/rw:rw"], + [ + "-v", f"{tmp_path}/ro:/mnt/ro:ro", + "-v", f"{tmp_path}/rw:/mnt/rw:rw", + "-v", f"{tmp_path}/roz:/mnt/roz:ro,z", + ], ) assert {r.container_path: r.mount_read_only for r in roots[1:]} == { "/mnt/ro": True, "/mnt/rw": False, + "/mnt/roz": True, } def test_collect_file_roots_long_volume_forms(self, tmp_path: Path) -> None: @@ -196,7 +202,9 @@ def test_collect_file_roots_skips_named_volume_file_missing( def test_collect_file_roots_dangling_and_malformed(self) -> None: """Dangling flags, anonymous volumes, >3-part values, and empty values are skipped without exceptions.""" - roots = collect_file_roots(["-v", "--volume", "-v", "/ctr", "-v", "a:b:c:d", "-v", ""]) + roots = collect_file_roots( + ["-v", "--volume", "-v", "/ctr", "-v", "a:b:c:d", "-v", "", "-v"], + ) assert [r.container_path for r in roots] == ["/workspace"] diff --git a/tests/commands/pipeline/test_run_pipeline_container.py b/tests/commands/pipeline/test_run_pipeline_container.py index 634e0af1..3e05653c 100644 --- a/tests/commands/pipeline/test_run_pipeline_container.py +++ b/tests/commands/pipeline/test_run_pipeline_container.py @@ -514,6 +514,53 @@ def capture(env: dict[str, str], extra_env: tuple[str, ...] = ()) -> Path: assert len(launcher_idxs) == 1 assert override_idx > max(launcher_idxs) + def test_home_and_pipeline_env_keys_do_not_override_composed_roots( + self, tmp_path: Path, monkeypatch + ) -> None: + """AFM_DOCKER_FILE_ROOTS keys in home.env / config.pipeline.env lose to the composed value. + + The roots layer is written after the {**home_env, **git, **pipeline_env} + merge, so the payload always mirrors the launch's actual mounts — a + stale config-layer value must never diverge from them (only the raw -e + channel wins, per docker --env-file last-write-wins). + """ + (tmp_path / "data").mkdir() + config = _make_config(pipeline_env={"AFM_DOCKER_FILE_ROOTS": "stale-from-config"}) + monkeypatch.setattr(_rpc_mod, "_check_docker", lambda: True) + monkeypatch.setattr(_rpc_mod, "_allocate_port", lambda: 50321) + monkeypatch.setattr(_rpc_mod, "_read_git_config", lambda: {}) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + _rpc_mod, + "load_home_config", + lambda: HomeConfig( + env={"AFM_DOCKER_FILE_ROOTS": "stale-from-home"}, + docker=DockerArgsConfig(run=["-v", f"{tmp_path}/data:/home/goga/data"]), + ), + ) + + captured_env: dict[str, str] = {} + real_write = _rpc_mod._write_env_file + + def capture(env: dict[str, str], extra_env: tuple[str, ...] = ()) -> Path: + captured_env.update(env) + return real_write(env, extra_env) + + monkeypatch.setattr(_rpc_mod, "_write_env_file", capture) + + mock_proc = mock.Mock() + mock_proc.wait.return_value = 0 + with ( + mock.patch.object(subprocess, "Popen", return_value=mock_proc), + mock.patch.object(subprocess, "run"), + ): + run_pipeline_container("deploy", config) + + # neither stale key survives: the value decodes to the roots composed + # from the actual launch tokens + payload = json.loads(base64.b64decode(captured_env["AFM_DOCKER_FILE_ROOTS"])) + assert [r["container_path"] for r in payload["roots"]] == ["/workspace", "/home/goga/data"] + # --- parallel cap (run mode only) --- From 09f84882b269dd9a0fd5f2b7b79c188f0f8551ba Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 00:09:47 +0300 Subject: [PATCH 008/205] fix: delete old env from dockerfile --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index d6f5fcaf..bcec1b95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,7 +56,6 @@ ENV PATH="/opt/goga/bin:/srv:/home/goga/bin:${PATH}" ENV GOGA_DOCKER=1 ENV RALPHEX_DOCKER=1 ENV AFM_IN_DOCKER=1 -ENV AFM_DOCKER_FILE_ROOTS=eyJ2ZXJzaW9uIjoxLCJyb290cyI6W3siaWQiOiJwcm9qZWN0IiwibGFiZWwiOiJteXByb2oiLCJjb250YWluZXJfcGF0aCI6Ii93b3Jrc3BhY2UiLCJtb3VudF9yZWFkX29ubHkiOmZhbHNlLCJraW5kIjoicHJvamVjdCJ9XX0= USER goga From c22ab0934b501191dbd2023e86fb938745380733 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 20:45:05 +0000 Subject: [PATCH 009/205] feat: split onboarding cell into questions, survey, participation and generator sub-cells Refactor the onboarding architecture: introduce four sub-cells (questions, survey, participation, generator) with their own CODEMANIFESTs and usage files, and rewire the onboarding facade manifest to import from them instead of declaring everything inline. - add goga/onboarding/{questions,survey,participation,generator} cells with .usages (question-records, survey-run, session-participation, tool-contexts, artifact-generation) - add hooks usage per-tool-delivery and version usage minor-line; update hooks, hooks/catalog, version and commands/init manifests - record the staged-guarantees-over-fire-and-forget ADR in .goga/memory/architecture.md - bump AFM_VERSION to 1.0.3 in the Dockerfile --- .goga/memory/architecture.md | 10 + Dockerfile | 2 +- goga/commands/init/.usages/init.md | 30 +- goga/commands/init/CODEMANIFEST | 78 ++-- goga/hooks/.usages/per-tool-delivery.md | 63 ++++ goga/hooks/CODEMANIFEST | 12 + goga/hooks/catalog/CODEMANIFEST | 8 + goga/onboarding/.usages/onboarding-usage.md | 134 +++---- goga/onboarding/CODEMANIFEST | 340 ++++++------------ .../generator/.usages/artifact-generation.md | 51 +++ goga/onboarding/generator/CODEMANIFEST | 149 ++++++++ .../.usages/session-participation.md | 49 +++ .../participation/.usages/tool-contexts.md | 81 +++++ goga/onboarding/participation/CODEMANIFEST | 229 ++++++++++++ .../questions/.usages/question-records.md | 66 ++++ goga/onboarding/questions/CODEMANIFEST | 178 +++++++++ goga/onboarding/survey/.usages/survey-run.md | 56 +++ goga/onboarding/survey/CODEMANIFEST | 241 +++++++++++++ goga/version/.usages/minor-line.md | 44 +++ goga/version/CODEMANIFEST | 31 ++ 20 files changed, 1483 insertions(+), 369 deletions(-) create mode 100644 goga/hooks/.usages/per-tool-delivery.md create mode 100644 goga/onboarding/generator/.usages/artifact-generation.md create mode 100644 goga/onboarding/generator/CODEMANIFEST create mode 100644 goga/onboarding/participation/.usages/session-participation.md create mode 100644 goga/onboarding/participation/.usages/tool-contexts.md create mode 100644 goga/onboarding/participation/CODEMANIFEST create mode 100644 goga/onboarding/questions/.usages/question-records.md create mode 100644 goga/onboarding/questions/CODEMANIFEST create mode 100644 goga/onboarding/survey/.usages/survey-run.md create mode 100644 goga/onboarding/survey/CODEMANIFEST create mode 100644 goga/version/.usages/minor-line.md diff --git a/.goga/memory/architecture.md b/.goga/memory/architecture.md index 2b97c85b..763f15b1 100644 --- a/.goga/memory/architecture.md +++ b/.goga/memory/architecture.md @@ -121,3 +121,13 @@ documents to touch is decided by this rule, not by the task's original list. Defects surfaced by verification are repaired in the artifact itself, and the complete check suite is re-run to green before approval. Approving with known breakage and deferring the repair to a later stage is rejected. + +## Staged guarantees over fire-and-forget delivery + +When a domain must condition its own state on the outcome of delivered hooks — staged contributions, all-or-nothing +commits per tool — a fire-and-forget emission is insufficient by construction: it collects nothing after the event, so +per-hook outcomes are out of reach. The domain then drives the delivery itself over the platform's public primitives +(registry subscriptions, per-tool contexts, the context wrapping, the argument projection), grouping subscriptions by +tool and committing a tool's contribution only after all of its hooks succeed. The platform facade re-exports the +primitives for that purpose; the platform itself is never reworked to return outcomes, delivery is never filtered, and +a tool's eligibility stays expressed in its delivered context (a marker), never in the delivery loop. diff --git a/Dockerfile b/Dockerfile index bcec1b95..158db586 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG AFM_VERSION=1.0.1 +ARG AFM_VERSION=1.0.3 ARG RALPHEX_VERSION=1.6 ARG PYTHON_VERSION=3.12 ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 diff --git a/goga/commands/init/.usages/init.md b/goga/commands/init/.usages/init.md index 842d16ff..5d14be26 100644 --- a/goga/commands/init/.usages/init.md +++ b/goga/commands/init/.usages/init.md @@ -4,13 +4,14 @@ CLI wrapper for the goga project initialization command. Integrates interactive onboarding with template scaffolding (copier). Routes between -onboarding-only, scaffold-then-onboarding, and upgrade modes, and guards -against re-initializing an existing project. +onboarding-only, scaffold-then-onboarding, and upgrade modes, guards +against re-initializing an existing project, and carries the tool +invitation flag into the onboarding session. ## Syntax ``` -goga init [] [--upgrade] [--ref ] +goga init [] [-t ]... [--upgrade] [--ref ] ``` ## Modes @@ -23,11 +24,19 @@ goga init [] [--upgrade] [--ref ] | `goga init` (when `.goga/` already exists, no ``) | non-zero exit: "Project already initialized" | | `goga init --upgrade` | non-zero exit: " and --upgrade are mutually exclusive" (`--upgrade` updates existing state tied to a specific repository) | | `goga init --ref ` (no ``, no `--upgrade`) | non-zero exit: "--ref requires or --upgrade" (ref is meaningful only with a template source) | +| `goga init -t --upgrade` | non-zero exit: `-t/--tool` requires an onboarding session and `--upgrade` runs none | ## Arguments and options - `` — optional positional. Git URL of a copier template, optionally with a ref fragment (`url.git#v1.0`). +- `-t `, `--tool ` — repeatable. Invites the named tool package + into the onboarding session. Acts in both modes that run onboarding (bare + and ``-given); a repeated name deduplicates into one invitation and + one block, preserving the flag order. Rejected with a nonzero exit when + combined with `--upgrade`. The command passes the names through as opaque + data — installation checks, warnings, and the invitation semantics belong + to the onboarding domain (see `onboarding-usage`). - `--upgrade` — boolean. Run template migration (`Scaffold.upgrade`) from `.goga/scaffold.yml`. No onboarding. Mutually exclusive with `` (`--upgrade` updates existing state tied to a specific repository). Requires a git-tracked destination (a git repo, @@ -50,13 +59,15 @@ asking twice. `--upgrade`). Must not be git-ignored. - `.goga/config.yml`, `.goga/usages/conventions.md`, `Dockerfile` — produced by onboarding (skipped when already present). +- `.goga/tools//` — config files contributed by the invited + tools, written by the onboarding engine. ## Exit code - `0` — success - non-zero — error, or already-initialized (bare `init` in a project where `.goga/` exists), or missing scaffold state file on `--upgrade`, or invalid argument - combination (`` with `--upgrade`) + combination (`` with `--upgrade`; `-t/--tool` with `--upgrade`; a bare `--ref`) ## Examples @@ -64,9 +75,15 @@ asking twice. # Interactive onboarding only (clean directory) goga init +# Interactive onboarding with invited tool packages +goga init -t my-tool -t viewer + # Scaffold a project from a template, then conditional onboarding goga init https://github.com/example/goga-py-template.git +# Scaffold from a template and invite tools into the session +goga init https://github.com/example/goga-py-template.git -t my-tool + # Scaffold at a pinned version goga init https://github.com/example/goga-py-template.git#v1.0 @@ -82,8 +99,11 @@ goga init --upgrade --ref v2.0 ## Anti-patterns -- Do not expect onboarding in `--upgrade` mode — it is scaffold-only. +- Do not expect onboarding in `--upgrade` mode — it is scaffold-only, and + `-t/--tool` is rejected there. - Do not expect the already-initialized guard to fire when `` is given. - Do not combine `` with `--upgrade` — the combination is rejected (`--upgrade` updates existing state tied to a specific repository). - Do not git-ignore `.goga/scaffold.yml` — `--upgrade` depends on it. +- Do not check tool installation in the command — the names are opaque data; + the onboarding domain warns for invited-but-not-installed names. diff --git a/goga/commands/init/CODEMANIFEST b/goga/commands/init/CODEMANIFEST index 3b09420b..ed3c4b41 100644 --- a/goga/commands/init/CODEMANIFEST +++ b/goga/commands/init/CODEMANIFEST @@ -3,6 +3,7 @@ Imports: - InitLogic - Questionnaire - FileGenerator + - ToolParticipation Usages: - onboarding-usage From: goga/onboarding @@ -31,54 +32,63 @@ Annotations: | --- -"init(tpl: str | None, upgrade: bool, ref: str | None) -> exit_code: int": +"init(tpl: str | None, upgrade: bool, ref: str | None, tools: tuple[str, ...]) -> exit_code: int": location: init.py annotations: | - CLI wrapper for the initialization command. Integrates two independent domains — onboarding - and scaffold — and owns the mode routing, execution order, and already-initialized guard. - Delegates execution to `InitLogic` (onboarding) and `Scaffold` (scaffold). + CLI wrapper for the initialization command. Integrates two independent + domains — onboarding and scaffold — and owns the mode routing, + execution order, already-initialized guard, and the tool invitation + flag. Delegates execution to `InitLogic` (onboarding) and `Scaffold` + (scaffold). - `tpl`: optional positional — git URL of a copier template, optionally with a ref fragment - (url.git#ref) + `tpl`: optional positional — git URL of a copier template, optionally with a ref fragment (url.git#ref) `upgrade`: when True, run template migration only (no onboarding) - `ref`: explicit git ref overriding the URL fragment (`tpl`) or the migration target ref - (`upgrade`); rejected with a nonzero exit when given without `tpl` and without `upgrade` - (meaningless with no template source) - `exit_code`: 0 on success, nonzero on error, already-initialized, or invalid argument - combination + `ref`: explicit git ref overriding the URL fragment (`tpl`) or the migration target ref (`upgrade`) + `tools`: the invited tool names from the repeated -t/--tool flag; an empty tuple when absent + `exit_code`: 0 on success, nonzero on error, already-initialized, or invalid argument combination + + Use the `onboarding-usage` practice for the session API and the + invitation semantics. + Use the `scaffold-usage` practice for the Scaffold API. Algorithm: 1. Validate `ref` placement: if `ref` is not None and `tpl` is None and not `upgrade` -> emit - "--ref requires or --upgrade" and return nonzero (ref is meaningful only with a - template source — primary generation or migration target) - 2. Determine mode: if `upgrade` and `tpl` are both given -> emit " and --upgrade are - mutually exclusive (--upgrade updates existing state tied to a specific repository)" and - return nonzero; otherwise `upgrade` -> UPGRADE; `tpl` is not None -> - SCAFFOLD_THEN_ONBOARDING; otherwise BARE_ONBOARDING - 3. Already-initialized guard: if BARE_ONBOARDING and the .goga/ directory exists -> emit - "Project already initialized" and return nonzero (the guard does NOT fire when `tpl` is - given) - 4. Dispatch: + "--ref requires or --upgrade" and return nonzero (ref is meaningful only with a template + source — primary generation or migration target) + 2. Determine mode: if `upgrade` and `tpl` are both given -> emit " and --upgrade are mutually + exclusive (--upgrade updates existing state tied to a specific repository)" and return nonzero; + otherwise `upgrade` -> UPGRADE; `tpl` is not None -> SCAFFOLD_THEN_ONBOARDING; otherwise + BARE_ONBOARDING + 3. Validate the invitation flag: if `tools` is non-empty and the mode is UPGRADE -> emit a message + stating that -t/--tool requires an onboarding session and --upgrade runs none; return nonzero + 4. Deduplicate `tools` preserving the flag order — one invitation per name, one block per tool + 5. Already-initialized guard: if BARE_ONBOARDING and the .goga/ directory exists -> emit + "Project already initialized" and return nonzero (the guard does NOT fire when `tpl` is given) + 6. Dispatch: - UPGRADE: construct `Scaffold`; return Scaffold.upgrade(`ref`) - - SCAFFOLD_THEN_ONBOARDING: construct `Scaffold`; sc = Scaffold.generate(`tpl`, `ref`); if - sc nonzero return sc; otherwise construct `InitLogic`(`Questionnaire`, `FileGenerator`) and - return its run() - - BARE_ONBOARDING: construct `InitLogic`(`Questionnaire`, `FileGenerator`) and return its run() + - SCAFFOLD_THEN_ONBOARDING: construct `Scaffold`; sc = Scaffold.generate(`tpl`, `ref`); if sc + nonzero return sc; otherwise construct `InitLogic`(`Questionnaire`, `FileGenerator`, + `ToolParticipation`(`tools`)) and return its run() + - BARE_ONBOARDING: construct `InitLogic`(`Questionnaire`, `FileGenerator`, + `ToolParticipation`(`tools`)) and return its run() Requirements: - - scaffold runs before onboarding when `tpl` is given (template may bring .goga/ artefacts - that onboarding then skips) + - The invitation acts in both modes that run onboarding (bare and template-given); a repeated name + deduplicates into one block + - The command passes the names through as opaque data — installation checks and warnings belong + to the onboarding domain + - scaffold runs before onboarding when `tpl` is given (template may bring .goga/ artefacts that + onboarding then skips) - the already-initialized marker is the .goga/ directory, not a specific file Constraints: - - Do not combine --upgrade with — --upgrade updates state tied to a specific - repository; the combination is rejected with a nonzero exit + - Do not combine --upgrade with or with a non-empty `tools` — both combinations are rejected + with a nonzero exit and a clear message - Do not run onboarding in UPGRADE mode - - Do not fire the already-initialized guard when `tpl` is given (scaffold is meaningful in an - existing project) - - Do not accept a bare --ref (no , no --upgrade) — ref is only meaningful with a template - source; a bare --ref is rejected with a nonzero exit - - The command delegates execution — it does not implement onboarding or copier logic itself + - Do not fire the already-initialized guard when `tpl` is given + - Do not accept a bare --ref (no , no --upgrade) + - The command delegates execution — it does not implement onboarding, invitation, or copier logic + itself --- diff --git a/goga/hooks/.usages/per-tool-delivery.md b/goga/hooks/.usages/per-tool-delivery.md new file mode 100644 index 00000000..1d00184d --- /dev/null +++ b/goga/hooks/.usages/per-tool-delivery.md @@ -0,0 +1,63 @@ +# hooks — delivering per tool with staged control + +How a goga domain delivers an action to its subscribed hooks per tool, when +the plain emission is not enough — the domain must know each tool's outcome +(staged contributions, compensating rollback). For domain maintainers inside +goga. + +## When to use + +Use `emit_hook_event` when the domain only hands the context over — the +emission is fire-and-forget and collects nothing after the event. Use this +pattern when a tool's contribution is committed only after its hooks succeed; +per-hook outcomes are out of reach through the emission, so the domain drives +the delivery loop itself over the public primitives. + +## The public primitives + + from goga.hooks import HookRegistry, wrap_context, build_hook_arguments + +- `HookRegistry()` — the run registry; `build_once()` assembles it once per + run. +- `registry.subscriptions_for(domain, action)` — the address's + subscriptions, in enumeration order. +- `registry.self_context(tool)` — the isolated context of one tool. +- `wrap_context(view)` — the delivery view of your context: reads and calls + pass through, attribute assignment is blocked. +- `build_hook_arguments(hook, proxy, self_context)` — the keyword arguments + for the call; only names the hook declared receive values. + +## The pattern + +```python +registry = HookRegistry() +registry.build_once() + +groups: dict[str, list] = {} +for sub in registry.subscriptions_for("", ""): + groups.setdefault(sub.tool, []).append(sub) + +for tool, subs in groups.items(): + proxy = wrap_context(build_the_context_for(tool)) # your per-tool view + try: + for sub in subs: + sub.hook(**build_hook_arguments(sub.hook, proxy, registry.self_context(tool))) + except Exception as reason: + logger.warning("tool skipped", extra={"tool": tool, "action": "", "reason": reason}) + discard(tool) # the tool's whole contribution + continue + commit(tool) # only after every hook of the tool succeeded +``` + +## Rules the pattern keeps + +- Deliver to every subscriber of the address — never filter delivery by + invitation or any other criterion; a tool's eligibility lives in its + context (a marker the hook checks), not in delivery. +- Treat a failure per the action's error class recorded in the catalog — + soft: warn naming the tool, the action, and the reason, then continue with + the next tool. The single fatal case (a broken package import) surfaces at + `build_once`. +- One registry per run — build it once and share it across your checkpoints. +- Do not deliver a hook any value it did not declare — + `build_hook_arguments` is the single projection. diff --git a/goga/hooks/CODEMANIFEST b/goga/hooks/CODEMANIFEST index 17e467ba..bac9e05d 100644 --- a/goga/hooks/CODEMANIFEST +++ b/goga/hooks/CODEMANIFEST @@ -8,7 +8,12 @@ Imports: From: goga/hooks/registry - Types: - emit_hook_event + - wrap_context + - build_hook_arguments From: goga/hooks/dispatch + - Types: + - enumerate_tool_packages + From: goga/hooks/tools Usages: convention: .goga/usages/conventions.md @@ -28,6 +33,10 @@ Annotations: | assembles the registry on first use. Consumers address the platform through this facade only. Apply the `convention` practice for the code style and intra-package imports. Use relative imports. + It additionally re-exports the delivery primitives and the + installed-package enumeration — for domains that orchestrate per-tool + delivery themselves and need each hook's outcome or the installed + identities. --- @@ -35,6 +44,9 @@ Annotations: | ->HookRegistry: {} ->ToolHooks: {} ->emit_hook_event: {} +->wrap_context: {} +->build_hook_arguments: {} +->enumerate_tool_packages: {} --- diff --git a/goga/hooks/catalog/CODEMANIFEST b/goga/hooks/catalog/CODEMANIFEST index aebdf3dd..b319df44 100644 --- a/goga/hooks/catalog/CODEMANIFEST +++ b/goga/hooks/catalog/CODEMANIFEST @@ -67,6 +67,14 @@ Annotations: | domain="statuses", name="register_statuses", error_class="soft": a failing hook of the action is skipped with a warning and the command continues + - The catalog carries the onboarding session-declaration action — the + record domain="onboarding", name="declare_session", error_class="soft": + a failing hook of the action is skipped with a warning and the sequence + continues + - The catalog carries the onboarding config-amendment action — the + record domain="onboarding", name="amend_config", error_class="soft": a + failing hook of the action is skipped with a warning and the sequence + continues Constraints: - Do not derive records from installed packages or imports — the diff --git a/goga/onboarding/.usages/onboarding-usage.md b/goga/onboarding/.usages/onboarding-usage.md index 51ddd73e..2f68ae46 100644 --- a/goga/onboarding/.usages/onboarding-usage.md +++ b/goga/onboarding/.usages/onboarding-usage.md @@ -1,108 +1,64 @@ # Project Onboarding — goga/onboarding -## Overview +## Domain -The `goga.onboarding` package provides interactive goga project onboarding — -collecting user input and generating configuration files. Onboarding is -filesystem-conditional: sections whose artefacts already exist are skipped -(relevant when an external template generator has run first). +Interactive initialization of a goga project: one session that surveys the +core configuration and the invited tool questions, applies the tool +amendments, and writes the project artifacts. Target audience: the init +command and embedding code. ## Facade Import all types directly from `goga.onboarding`: ```python -from goga.onboarding import InitLogic, Questionnaire, FileGenerator, InitAnswers, GogaConfigAnswers +from goga.onboarding import ( + CreatedFile, FileGenerator, InitLogic, Question, QuestionGroup, + Questionnaire, SessionAnswers, SessionPlan, ToolParticipation, + apply_skips, assemble_session_plan, core_questions, +) ``` ## Usage -### InitLogic — orchestrator +### Run a session with invited tools ```python -from goga.onboarding import InitLogic, Questionnaire, FileGenerator - -questionnaire = Questionnaire() -generator = FileGenerator() -logic = InitLogic(questionnaire=questionnaire, generator=generator) +from goga.onboarding import FileGenerator, InitLogic, Questionnaire, ToolParticipation +logic = InitLogic( + questionnaire=Questionnaire(), + generator=FileGenerator(), + participation=ToolParticipation(invited=["my-tool", "viewer"]), +) exit_code = logic.run() ``` -### InitLogic.run() - -Run an interactive user survey and generate project files. - -**Returns:** exit_code (0 — success, 1 — error) - -**Behavior:** -- Create the .goga/ directory if it does not exist -- Generate .goga/config.yml with minimal configuration — SKIPPED when goga_config is None (the file already exists) -- If the user opted in — download .goga/usages/conventions.md -- If the user requested a custom Dockerfile — create it with `FROM {dockerfile_base_image}`; - the top-level `image` field holds the name of the image built from it (the `docker build -t` tag) - -### Data - -`InitAnswers` — response container holding `GogaConfigAnswers`. -`goga_config`: `GogaConfigAnswers | None`. `None` = do not write .goga/config.yml -(used when the file already exists). - -`GogaConfigAnswers` fields: `language`, `agent`, `image`, `pipeline_agent`, -`pipeline_env`, `env`, `codemanifest_usages`, `codemanifest_annotations`, -`dockerfile_path`, `dockerfile_base_image` (see CODEMANIFEST). - -## Survey flow - -`Questionnaire.ask_goga_config()` returns `GogaConfigAnswers | None`: If -.goga/config.yml already exists -> returns None (the whole config survey is -skipped). Otherwise proceeds: language → convention → -codemanifest_usages → codemanifest_annotations → agent → dockerfile → (image branch) -→ env → pipeline_agent → pipeline_env. - -The image branch depends on the Dockerfile decision: -- **With a Dockerfile** (`dockerfile_path` set): - - `ask_base_image(language)` — the `FROM` baseline (language hints, default = last entry). - - `ask_image_name(language=None, default=:latest)` — the name/tag for - the image built from the Dockerfile. The default is resolved from the git project name - (falling back to no default when the name is unavailable): - `:latest` when the git remote is available; **when the git name is unavailable, - no default is offered and `image` is required**. Passing `language` uses the - `{language}-image:latest` default. -- **Without a Dockerfile** (`dockerfile_path` None): `ask_image(language)` — pre-built - image to PULL. - -## Conditional onboarding - -When onboarding runs after an external template generator has produced -`.goga/config.yml` and/or `.goga/usages/conventions.md`, onboarding detects -the existing artefacts and skips the corresponding survey sections instead -of asking twice. This is composition, not arbitration: an existing -`.goga/config.yml` is not rewritten — whoever created it first wins. - -## Per-field survey methods - -- `ask_language() -> str` -- `ask_base_convention() -> (codemanifest_usages, codemanifest_annotations)` — skipped when .goga/usages/conventions.md exists -- `ask_codemanifest_usages(prefill: dict | None = None) -> dict | None` -- `ask_codemanifest_annotations(prefill: str | None = None) -> str | None` -- `ask_agent() -> str | None` -- `ask_dockerfile_path() -> str | None` -- `ask_image(language: str) -> str` — pre-built image to PULL (no-Dockerfile branch) -- `ask_base_image(language: str) -> str` — FROM baseline (Dockerfile branch) -- `ask_image_name(language: str | None = None, default: str | None = None) -> str` — name/tag for the built image (Dockerfile branch) -- `ask_env(agent: str | None) -> dict | None` -- `ask_pipeline_agent() -> str | None` -- `ask_pipeline_env(pipeline_agent: str | None) -> dict | None` - -## Generated .goga/config.yml structure - -(`language`, `image`, `dockerfile`, `build`, `pipeline`, `codemanifest` -field order; see CODEMANIFEST `FileGenerator`.) - -## Anti-patterns - -- Do not write `build.image` — the Docker image is the top-level `image` field. -- Do not force-emit empty `build:`/`pipeline:` blocks. -- Do not inherit `agent` into `pipeline_agent`. -- Do not regenerate .goga/config.yml when goga_config is None — None means skip. +**Returns:** exit code (0 — success, nonzero — a session error). + +**Session flow:** an existing `.goga/config.yml` ends the session +immediately — no questions, no tool events, no artifacts; otherwise the +session reads the installed version (clean error when unreadable), +collects the tool declarations, surveys the core tree and the tool blocks +with attribution, collects and commits the tool contributions, generates +`.goga/config.yml`, the Dockerfile, and the tool configs, and reports the +created files with tool attribution. + +### Behavior guarantees + +- A failing tool is soft: its contribution is discarded with a warning + naming the tool and the reason; the session continues and returns 0. +- An invited but not installed tool name is a warning; the session + continues. +- Session errors are single clean messages without a traceback: a broken + package import (named), an unreadable installed version, an empty + required `language` at generation (named). +- The image hints carry the minor tag of the installed goga version. + +## Notes for the consumer + +- Onboarding is filesystem-conditional: an existing `.goga/config.yml` is + never rewritten — whoever created it first wins. +- Pass the deduplicated invited names in flag order to + `ToolParticipation`; without invitations the session contains no tool + blocks and matches the plain behavior. diff --git a/goga/onboarding/CODEMANIFEST b/goga/onboarding/CODEMANIFEST index a23f16be..eb09b499 100644 --- a/goga/onboarding/CODEMANIFEST +++ b/goga/onboarding/CODEMANIFEST @@ -1,38 +1,43 @@ Imports: + - Types: + - Question + - QuestionGroup + - SessionAnswers + From: goga/onboarding/questions + - Types: + - Questionnaire + - SessionPlan + - core_questions + - assemble_session_plan + - apply_skips + Usages: + - survey-run + From: goga/onboarding/survey + - Types: + - ToolParticipation + - ToolDeclaration + - ToolContribution + Usages: + - session-participation + From: goga/onboarding/participation + - Types: + - FileGenerator + - CreatedFile + Usages: + - artifact-generation + From: goga/onboarding/generator + - Types: + - minor_version + - host_goga_version + Usages: + - minor-line + From: goga/version - Types: - resolve_project_name From: goga/config Usages: convention: .goga/usages/conventions.md - click: .goga/usages/cooks/click.md - yaml: | - Use yaml.dump() to generate .goga/config.yml. - PyYAML library. Set default_flow_style=False for human-readable output. - lang_conventions: | - Download base language conventions from the qarium/goga-lang-conventions repository (branch 0.0.x). - URL template: https://raw.githubusercontent.com/qarium/goga-lang-conventions/refs/heads/0.0.x/{language}/project.md - The language identifier maps directly to the URL path segment (no mapping layer). - Save the downloaded file to .goga/usages/conventions.md. - image_defaults: | - The default Docker image depends on the selected language. - For languages with predefined images, display a list of suggestions; default to the last entry. - Accept arbitrary user input for the image name. - Language → available image mapping: - - python: qarium/goga-python-{3.10-3.14}:1.3 - - golang: qarium/goga-golang-{1.23, 1.24, 1.25, 1.26}:1.3 - - javascript: qarium/goga-node-{22, 24}:1.3 - - kotlin: qarium/goga-kotlin-{2.0, 2.1, 2.2, 2.3}:1.3 - - swift: qarium/goga-swift-{6.0, 6.1, 6.2}:1.3 - agent_env_defaults: | - Map each agent to a list of environment variable keys for prompting. - Display the keys to the user; collect corresponding values. - Agent → env key mapping: - - claude: ANTHROPIC_BASE_URL, ANTHROPIC_DEFAULT_HAIKU_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_MODEL - - codex: CODEX_MODEL - - cursor: CURSOR_MODEL - - opencode: OPENCODE_MODEL, OPENCODE_VARIANT - - qwen: OPENAI_BASE_URL, OPENAI_MODEL Annotations: | The `convention` practice is used for: @@ -41,230 +46,85 @@ Annotations: | - Debugging and testing - Organizing the test infrastructure - Understanding the general principles and rules of development and testing in the project - Use `click` for interactive user input (Questionnaire). - Use `yaml` for YAML file generation (FileGenerator). - Use `lang_conventions` to download the base convention for the selected language (Questionnaire, FileGenerator). - Use `image_defaults` to list available Docker images by language (Questionnaire). - Use `agent_env_defaults` to suggest env keys for the selected agent (Questionnaire). - All types must be immutable dataclasses (frozen=True, kw_only=True). - Onboarding is filesystem-conditional: it skips sections whose artefacts already exist (see - Questionnaire, FileGenerator). An existing .goga/config.yml is never rewritten — - whoever created it first wins. - ---- -"InitAnswers(goga_config: GogaConfigAnswers | None = None)": - location: answers.py - annotations: | - User response container. Extend with new properties for additional config files - as needed. + This cell is the facade of the onboarding domain: it owns the session + orchestration and re-exports the public session API of the leaf cells — + the question-and-answer model, the survey, the tool participation, and + the artifact generation. Consumers address the domain through this + facade only. Use relative imports. - `goga_config`: responses for generating .goga/config.yml, or None when config.yml is not - written (the artifact already exists); defaults to None - properties: - "goga_config -> GogaConfigAnswers | None": | - Responses for generating .goga/config.yml. None signals 'do not write the file' to - FileGenerator (used when .goga/config.yml already exists). Defaults to None. - -"GogaConfigAnswers(language: str, image: str, agent: str | None, pipeline_agent: str | None, pipeline_env: dict | None, env: dict | None, codemanifest_usages: dict | None, codemanifest_annotations: str | None, dockerfile_path: str | None, dockerfile_base_image: str | None)": - location: answers.py - annotations: | - Input data for .goga/config.yml and Dockerfile generation. - - `language`: selected project language - `agent`: selected AI executor (used as build.task_executor.agent); None when the user declines to configure a build agent - `image`: Docker image (drives the top-level image field, NOT build.image). With a - Dockerfile it is the NAME of the image built from it (the docker build -t tag); - without a Dockerfile it is the pre-built image to pull. - `pipeline_agent`: AI executor used as pipeline.agent / afm client.command; None when the user declines to configure a pipeline agent - `pipeline_env`: environment variables for the pipeline block - `env`: environment variables for task_executor - `codemanifest_usages`: codemanifest practice mappings - `codemanifest_annotations`: codemanifest annotation block - `dockerfile_path`: path to custom Dockerfile (None to skip Dockerfile creation) - `dockerfile_base_image`: base image for the Dockerfile FROM line; set only when - dockerfile_path is set, None otherwise. Never emitted to config.yml. - properties: - "language -> str": | - Selected project language. - "image -> str": | - Docker image. Drives the top-level image field (NOT build.image). With a Dockerfile - it is the name/tag of the image built from it; without a Dockerfile it is the - pre-built image to pull. - "agent -> str | None": | - Selected AI executor. Used as build.task_executor.agent. None — no build agent - configured (the agent key is omitted from the generated config). - "pipeline_agent -> str | None": | - AI executor used as pipeline.agent (afm client.command inside the container). None - — no pipeline agent configured. Does NOT inherit the build agent. - "pipeline_env -> dict | None": | - Environment variables for the pipeline block. None — omit pipeline.env. - "env -> dict | None": | - Environment variables for task_executor. - "codemanifest_usages -> dict | None": | - Codemanifest practice mappings. - "codemanifest_annotations -> str | None": | - Codemanifest annotation block. - "dockerfile_path -> str | None": | - Path to custom Dockerfile. None — skip Dockerfile creation. - "dockerfile_base_image -> str | None": | - Base image for the Dockerfile FROM line. Set only when dockerfile_path is set - (None otherwise). Consumed solely by Dockerfile generation; never emitted to - config.yml. +--- -"Questionnaire()": - location: questionnaire.py +"InitLogic(questionnaire: Questionnaire, generator: FileGenerator, participation: ToolParticipation)": + location: logic.py annotations: | - Interactive user survey driven by `click`. - - Use `image_defaults` to list available Docker images for the chosen language. - Use `lang_conventions` to offer downloading the base convention after language selection. - Use `agent_env_defaults` to prompt for env keys after agent selection. - - The survey is decomposed into per-field ask_* methods; ask_goga_config orchestrates them. + The orchestrator of one initialization session. + + `questionnaire`: the survey engine + `generator`: the artifact generator + `participation`: the tool participation mediator + + Apply the `convention` practice for the code style and intra-package + imports. + Use the `minor-line` practice for reading the installed version and + deriving the image tag. + Use the `session-participation` practice for the two tool moments. + Use the `survey-run` practice for the plan assembly and the survey. + Use the `artifact-generation` practice for the generation and the file + report. methods: - "ask() -> answers:InitAnswers": | - Display the header "=== Goga Project Initialization ===" and wizard description. - Delegate to ask_goga_config() (which may return None when .goga/config.yml already exists) - and wrap the result in InitAnswers (goga_config may be None). - "ask_goga_config() -> config:GogaConfigAnswers | None": | - Survey for .goga/config.yml. + "run() -> exit_code: int": | + Run the whole session. - Returns None when .goga/config.yml already exists (skip the whole config survey). - - `config`: assembled responses, or None when .goga/config.yml already exists + `exit_code`: 0 on success, nonzero on a session error Algorithm: - 1. If .goga/config.yml exists -> return None (skip the entire config survey) - 2. Display "Collecting .goga/config.yml settings...". Orchestrate the per-field ask_* methods in - survey order and assemble GogaConfigAnswers: - ask_language → ask_base_convention (SKIPPED when .goga/usages/conventions.md exists) - → ask_codemanifest_usages → ask_codemanifest_annotations → ask_agent → ask_dockerfile_path - → (image branch) → ask_env → ask_pipeline_agent → ask_pipeline_env. - The image branch depends on the Dockerfile decision: when ask_dockerfile_path returns a path, - ask_base_image (FROM) is surveyed, then `resolve_project_name` is - called — if it returns a name the proposed default is :latest, if it returns None no - default is set — then ask_image_name(language=None, default=) is surveyed (built - image tag), and dockerfile_base_image is set; otherwise ask_image (pre-built image to pull) is - surveyed and dockerfile_base_image is None. - Each per-field method emits a section header (---) and explanatory text. - "ask_language() -> language: str": | - Select from (python, golang, kotlin, swift, javascript). - "ask_base_convention() -> prefill: tuple[dict | None, str | None]": | - Offer to download the base convention via `lang_conventions` for the selected language. - On acceptance: return ({"conventions": ".goga/usages/conventions.md"}, conventions directive text), - pre-filling codemanifest_usages and codemanifest_annotations. - On decline: return (None, None). - "ask_codemanifest_usages(prefill: dict | None = None) -> codemanifest_usages: dict | None": | - Optional additional usages appended onto `prefill` (the base convention usages). - Collect name→path pairs in a loop; duplicate names are skipped. - Returns None when neither prefill nor input exists. - "ask_codemanifest_annotations(prefill: str | None = None) -> codemanifest_annotations: str | None": | - Optional custom annotations appended to `prefill` (the base convention annotations). - Returns None when neither prefill nor input exists. - "ask_agent() -> agent: str | None": | - Confirm-gated (default No). On decline: return None (no build agent configured). - On acceptance: select from (claude, codex, cursor, opencode, qwen). Used as build.task_executor.agent. - "ask_dockerfile_path() -> dockerfile_path: str | None": | - Optional; prompt to create a custom Dockerfile. - On acceptance: request path (default ".goga/Dockerfile"). None to skip Dockerfile creation. - "ask_image(language: str) -> image: str": | - Survey the pre-built Docker image to PULL (no-Dockerfile branch). Display hints from - `image_defaults` for `language`; default to the last entry; accept free-form input. - Captures the top-level image field (NOT build.image). - "ask_base_image(language: str) -> base_image: str": | - Survey the BASE image for the Dockerfile FROM line (Dockerfile branch). Display hints - from `image_defaults` for `language`; default to the last entry; accept free-form input. - The result populates dockerfile_base_image and is never emitted to config.yml. - "ask_image_name(language: str | None = None, default: str | None = None) -> image: str": | - Survey the NAME (tag) for the image built from the Dockerfile (Dockerfile branch), since - goga build runs docker build -t . Free-form input. When `language` is provided - (not None): default `{language}-image:latest` (used by consumers that pass - `language`). When `language` is None: offer `default` as the default; when - `default` is None → no default is offered and the `image` field is required. Captures the - top-level image field (NOT build.image). + 1. An existing .goga/config.yml ends the session — no question is + asked, no tool event is delivered, no artifact is written + 2. Read the installed goga version — `host_goga_version` — and derive + its minor line — `minor_version` — for the image hints; an + unreadable version is a clean session error without a traceback + 3. Deliver the declaration moment via `ToolParticipation` — it warns + for every invited identity that is not among the installed tool + packages + 4. Build the core tree — `core_questions`, with the image tag from + step 2, the project name from `resolve_project_name`, and + convention_exists read from the existence of + .goga/usages/conventions.md; assemble the plan with the collected + declarations; flatten every declaration's buffered skips into + (tool identity, raw path) pairs and apply them via `apply_skips` + 5. Run the survey into the answer space via `Questionnaire` + 6. Deliver the amendment moment and commit the surviving tool + contributions via `ToolParticipation` + 7. Generate the artifacts via `FileGenerator` and render the final + file report with the attribution + 8. Return 0 Requirements: - - The offered default depends on `language`/`default` as above; a None default makes the - `image` field required (no suggestion). - "ask_env(agent: str | None) -> env: dict | None": | - Propose env keys from `agent_env_defaults` for the selected `agent`; collect values for each, - then optionally collect arbitrary key-value pairs. Drives build.task_executor.env. A None `agent` - skips the suggested-keys block and only offers arbitrary key-value pairs. - Returns None when nothing is collected. - "ask_pipeline_agent() -> pipeline_agent: str | None": | - Confirm-gated (default No). On decline: return None (no pipeline agent configured). Does NOT - inherit the build agent. On acceptance: select from (claude, codex, cursor, opencode, qwen). - Drives pipeline.agent. - "ask_pipeline_env(pipeline_agent: str | None) -> pipeline_env: dict | None": | - Propose env keys from `agent_env_defaults` for `pipeline_agent`; collect values for each, - then optionally collect arbitrary key-value pairs. Drives pipeline.env. A None `pipeline_agent` - skips the suggested-keys block and only offers arbitrary key-value pairs. - Returns None when nothing is collected. - -"FileGenerator()": - location: generator.py - annotations: | - Project file generator. Use `yaml` for YAML serialization. - Use `lang_conventions` to download the convention file when present in codemanifest_usages. - methods: - "generate(answers: InitAnswers) -> _:None": | - Generate all project files from the provided answers. - - Algorithm: - 1. If answers.goga_config is None -> skip config.yml and Dockerfile generation; return - (the artefacts already exist). - 2. Otherwise: if dockerfile_path is set — create a Dockerfile with - FROM {dockerfile_base_image}; the top-level image field holds the name of the image - built from it. - 3. Delegate to generate_goga_config() with answers.goga_config. - "generate_goga_config(config: GogaConfigAnswers) -> _:None": | - Algorithm: - 1. If codemanifest_usages contains the key "conventions" — download the - convention file from the URL defined by `lang_conventions` (based on - language) and save it to .goga/usages/conventions.md. - On download failure — raise RuntimeError with the URL and cause. - 2. Create the .goga/ directory if it does not exist - 3. Serialize a YAML document per the `yaml` practice, preserving field - order and rendering codemanifest_annotations as a literal block scalar - - GogaConfigAnswers → YAML field mapping: - - language → language (top-level) - - image → image (top-level) - - dockerfile_path → dockerfile (top-level, omit when None) - - agent → build.task_executor.agent (omit when None) - - env → build.task_executor.env (omit when None or empty) - - pipeline_agent → pipeline.agent (omit when None) - - pipeline_env → pipeline.env (omit when None or empty) - - codemanifest_usages → codemanifest.usages (omit when None or empty) - - codemanifest_annotations → codemanifest.annotations (omit when None) - - Requirements: - - Emit image as a top-level field - - Emit the build: block only when it carries content (a non-None agent - and/or a non-empty env); an empty build block adds no value and is omitted - - Emit the pipeline: block only when it carries content (a non-None - pipeline_agent and/or a non-empty pipeline_env); by default no agent is - configured, so a freshly-initialized project with no agent/env omits - pipeline entirely - - Field order in the generated YAML: language, image, dockerfile, build, pipeline, codemanifest - -"InitLogic(questionnaire: Questionnaire, generator: FileGenerator)": - location: logic.py - annotations: | - Orchestrator for the init command business logic. - - `questionnaire`: user survey provider - `generator`: file generation provider - methods: - "run() -> exit_code:int": | - Run the questionnaire via questionnaire.ask(). - Generate files via generator.generate(). - Return 0 on success, 1 on error. + - A tool failure never changes the exit code — the softness of the + tool moments is theirs + - A session error is one clean message — a broken package import, an + unreadable version, an empty required field at generation — never + a traceback + +->Question: {} +->QuestionGroup: {} +->SessionAnswers: {} +->SessionPlan: {} +->Questionnaire: {} +->core_questions: {} +->assemble_session_plan: {} +->apply_skips: {} +->ToolParticipation: {} +->ToolDeclaration: {} +->ToolContribution: {} +->FileGenerator: {} +->CreatedFile: {} --- Author: Goga -CreatedAt: 03/06/26 +CreatedAt: 14/09/26 Description: | - Interactive goga project initialization — user survey and configuration file generation + Facade of the onboarding domain — the initialization session + orchestration and the public session API. diff --git a/goga/onboarding/generator/.usages/artifact-generation.md b/goga/onboarding/generator/.usages/artifact-generation.md new file mode 100644 index 00000000..f492e3d4 --- /dev/null +++ b/goga/onboarding/generator/.usages/artifact-generation.md @@ -0,0 +1,51 @@ +# Artifact generation — goga/onboarding/generator + +## Domain + +Writing the onboarding session artifacts from the committed answer space +and the committed tool contributions: `.goga/config.yml`, the Dockerfile, +the base conventions file, and the tool config files. Target audience: the +session orchestrator. + +## Public API + + from goga.onboarding import FileGenerator + +- `FileGenerator().generate(answers, contributions) -> list[CreatedFile]` — + every artifact in generation order with attribution (`CreatedFile.tool` + is None for engine files, the tool identity for tool files). An existing + `.goga/config.yml` skips the config and Dockerfile generation — never + rewritten, whoever created it first wins. +- `generate_goga_config(answers)` — the project config from the answer + snapshot: field order language, image, dockerfile, build, pipeline, + codemanifest, tools, usages; empty build/pipeline blocks omitted; + annotations rendered as a literal block. An empty required `language` is + a clean session error naming the field; the conventions download failure + is a clean error with the URL and the cause. +- `generate_tool_configs(contributions)` — every buffered tool file + serialized as YAML into `.goga/tools//`; the same file name + written again replaces the file. + +## Ready-to-use pattern + +### Generate after the survey and the committed contributions + +```python +from goga.onboarding import FileGenerator + +files = FileGenerator().generate(answers, contributions) +for entry in files: + if entry.tool is None: + print(f"created {entry.path}") + else: + print(f"created {entry.path} (tool: {entry.tool})") +``` + +## Notes for the consumer + +- Call `generate` once, after the tool contributions are committed — the + snapshot is read at that moment. +- The return value is the single source of the final file report — render + it with the tool attribution. +- The written config must pass the project config loader — the mapping + above is normative. diff --git a/goga/onboarding/generator/CODEMANIFEST b/goga/onboarding/generator/CODEMANIFEST new file mode 100644 index 00000000..0e555c73 --- /dev/null +++ b/goga/onboarding/generator/CODEMANIFEST @@ -0,0 +1,149 @@ +Imports: + - Types: + - SessionAnswers + Usages: + - question-records + From: goga/onboarding/questions + - Types: + - ToolContribution + Usages: + - session-participation + From: goga/onboarding/participation + +Usages: + convention: .goga/usages/conventions.md + yaml: | + Use yaml.dump() to generate .goga/config.yml and the tool config files. + PyYAML library. Set default_flow_style=False for human-readable output. + lang_conventions: | + Download base language conventions from the qarium/goga-lang-conventions repository (branch 0.0.x). + URL template: https://raw.githubusercontent.com/qarium/goga-lang-conventions/refs/heads/0.0.x/{language}/project.md + The language identifier maps directly to the URL path segment (no mapping layer). + Save the downloaded file to .goga/usages/conventions.md. + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the artifact generation of the session: the project + config, the Dockerfile, the base conventions download, and the tool + config files — written from the committed answer space and the committed + tool contributions. An existing .goga/config.yml is never rewritten — + whoever created it first wins; the guarantee lives here, not only at the + caller. Use relative imports. + +--- + +"FileGenerator()": + location: generator.py + annotations: | + The artifact generator of the session. + + Apply the `convention` practice for the code style and intra-package + imports. + Use the `yaml` practice for every YAML serialization. + Use the `question-records` practice for the answer space structure. + Use the `session-participation` practice for the committed + contributions. + methods: + "generate(answers: SessionAnswers, contributions: list[ToolContribution]) -> files: list[CreatedFile]": | + Generate every artifact of the session and report the created files. + + `answers`: the committed answer space of the session + `contributions`: the committed contributions, in enumeration order + `files`: the created files with attribution — an engine file carries + a None tool, a tool file carries the tool identity + + Algorithm: + 1. An existing .goga/config.yml skips the config and the Dockerfile + generation — the file is never rewritten + 2. Otherwise: create the Dockerfile when the answers carry a + Dockerfile path — FROM its base image; then generate the project + config + 3. Generate the tool config files of `contributions` + 4. Return the created files with attribution, in generation order + + Requirements: + - The return value is the single source of the final file report + "generate_goga_config(answers: SessionAnswers) -> _: None": | + Generate .goga/config.yml from the answer snapshot. + + `answers`: the committed answer space + + Algorithm: + 1. Take the snapshot of `answers` + 2. An empty required language field is a clean error of the session + naming the field + 3. Download the base conventions file per the `lang_conventions` + practice when the codemanifest usages carry the conventions entry + — a download failure is a clean error with the URL and the cause + 4. Create the .goga/ directory when missing + 5. Serialize the YAML document per the `yaml` practice, preserving + the field order and rendering the annotations as a literal block + + Snapshot → YAML field mapping: + - language → language (top level) + - docker_image.image → image (top level) + - docker_image.dockerfile → dockerfile (top level, omitted when absent) + - docker_image.base_image → the Dockerfile FROM line only — never + emitted to the config + - build.task_executor.agent → build.task_executor.agent (omitted when absent) + - build.task_executor.env → build.task_executor.env (omitted when absent or empty) + - pipeline.agent / pipeline.env → the pipeline block (same omission rules) + - codemanifest.usages / codemanifest.annotations → the codemanifest block + - tools → tools (omitted when absent or empty) + - usages → usages (omitted when absent or empty) + - convention and the confirm gates are presentational — their answers + are never carried into the config (the convention acceptance + pre-fills codemanifest; a gate only gates its collection) + + Requirements: + - Field order in the document: language, image, dockerfile, build, + pipeline, codemanifest, tools, usages + - The build and pipeline blocks appear only when they carry content + - The written file passes the core schema loader of the project + config + "generate_tool_configs(contributions: list[ToolContribution]) -> _: None": | + Generate the tool config files from the committed contributions. + + `contributions`: the committed contributions, in enumeration order + + Algorithm: + 1. For every contribution, for every buffered file in call order: + serialize the data per the `yaml` practice and write it into the + tool's config directory under the buffered file name + 2. Writing the same file name again replaces the file + + Requirements: + - The engine is the single write path of the tool configs — the + buffered data is written verbatim, without interpretation + +"CreatedFile(path: str, tool: str | None)": + location: generator.py + annotations: | + One entry of the final file report — a created file with its + attribution. + + `path`: the created file path relative to the project root + `tool`: the tool identity for a tool file; None for an engine file + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "path -> str": | + The created file path relative to the project root. + "tool -> str | None": | + The tool identity of the file; None for an engine file. + +--- + +Author: Goga +CreatedAt: 14/09/26 +Description: | + Artifact generation of the onboarding session — the project config, the + Dockerfile, the base conventions, the tool configs, and the final file + report. diff --git a/goga/onboarding/participation/.usages/session-participation.md b/goga/onboarding/participation/.usages/session-participation.md new file mode 100644 index 00000000..02854dc6 --- /dev/null +++ b/goga/onboarding/participation/.usages/session-participation.md @@ -0,0 +1,49 @@ +# Session participation — goga/onboarding/participation + +## Domain + +Mediating the tool participation in one onboarding session: the invitation +set, the declaration moment before the survey, the amendment moment after +it, and the staged commit of the surviving contributions. Target +audience: the session orchestrator. + +## Public API + + from goga.onboarding import ToolParticipation + +- `ToolParticipation(invited)` — the mediator of one session; `invited` is + the deduplicated list of tool names from the command line, in flag + order. +- `collect_declarations() -> list[ToolDeclaration]` — deliver + `onboarding/declare_session` per tool. Warns for every invited name not + among the installed tool packages; a failing hook of a tool drops that + tool's whole declaration; an invited tool without a subscription + participates silently. +- `collect_contributions(answers) -> list[ToolContribution]` — deliver + `onboarding/amend_config` per tool after the survey. A failing hook + discards the tool's whole contribution (amendments and files); the + surviving contributions are committed: amendments apply to `answers` in + delivery order, files are collected for generation. + +## Ready-to-use pattern + +### Run both moments around the survey + +```python +from goga.onboarding import SessionAnswers, ToolParticipation + +participation = ToolParticipation(invited=["my-tool", "viewer"]) +declarations = participation.collect_declarations() # moment one — before the survey +# ... assemble the plan, run the survey into answers ... +contributions = participation.collect_contributions(answers) # moment two — after +``` + +## Notes for the consumer + +- One registry per session — build and both deliveries share it; a broken + package import is the single fatal case (a clean error naming the + package). +- Tool failures are soft — every warning names the tool, the action, and + the reason; the session and the other tools continue. +- The returned contributions carry the committed file buffers — hand them + to the artifact generation. diff --git a/goga/onboarding/participation/.usages/tool-contexts.md b/goga/onboarding/participation/.usages/tool-contexts.md new file mode 100644 index 00000000..86bb0ed7 --- /dev/null +++ b/goga/onboarding/participation/.usages/tool-contexts.md @@ -0,0 +1,81 @@ +# Onboarding contexts — goga/onboarding/participation + +## Domain + +What a tool package receives inside a `goga init` session and the member +contract of the two onboarding actions. Target audience: authors of +`goga_tool_*` packages that need project configuration. + +## Subscribing + +Register hooks for the two actions in the package facade — the tool +identity is assigned by goga from the package name: + +```python +def register_hooks(hooks): + hooks.subscribe("onboarding", "declare_session", "declare", declare_session) + hooks.subscribe("onboarding", "amend_config", "amend", amend_config) +``` + +A hook declares `context` (and optionally `self`) by name; values land by +name. Subscribing to one action only is fine — the moments are +independent. + +## Moment one — declare_session(context) + +Declare the tool's questions and skips as data; the engine asks them +itself after the core questions, under a heading with the tool's name. + +```python +from goga.onboarding import Question, QuestionGroup + +def declare_session(context): + if not context.invited: + return # contract rule: return immediately + context.declare(Question(id="token", kind="input", prompt="Service token")) + context.declare(QuestionGroup(id="reporting", prompt="Reporting", + children=[Question(id="enabled", kind="confirm", + prompt="Enable reporting?", default=False)])) + context.skip("docker_image.base_image") # unprefixed — core tree or own block +``` + +- `context.invited` — False means the session did not invite this tool: + return immediately, call nothing. +- `context.declare(item)` — a `Question` or a one-level `QuestionGroup`; + local names, the engine qualifies them with the tool identity. A + repeated local name is rejected with a warning; the rest of the + declaration stands. +- `context.skip(path)` — unprefixed for the core tree or the tool's own + block, `.`-prefixed for another tool's block. A skip removes the + whole subtree; unknown paths are a no-op with a warning. + +## Moment two — amend_config(context) + +Read the isolated answers and buffer the contribution. + +```python +def amend_config(context): + if not context.invited: + return + if context.answers.get("reporting", {}).get("enabled"): + context.answer("pipeline.env", {"REPORT_URL": "https://example.com"}) + context.answer("tools", {"my-tool": "latest"}) + context.write_config("service.yml", {"token_source": "env", "interval": 60}) +``` + +- `context.answers` — the core answers plus this tool's own answers under + local names; other tools' answers are never visible. +- `context.answer(id, value)` — buffer an amendment: mappings merge + recursively, scalars and lists replace; substituting a user's answer is + silent; registrations in `tools`/`usages` are ordinary amendments. +- `context.write_config(file, data)` — buffer a config file; the engine + serializes YAML and writes `.goga/tools//`; the same file + written again is replaced. + +## Failure behavior + +- An exception in a hook drops the tool's whole contribution with a + warning naming the tool and the reason; `goga init` continues and exits + 0. +- A broken package import is the single fatal case — a clean session + error naming the package. diff --git a/goga/onboarding/participation/CODEMANIFEST b/goga/onboarding/participation/CODEMANIFEST new file mode 100644 index 00000000..d968fe4d --- /dev/null +++ b/goga/onboarding/participation/CODEMANIFEST @@ -0,0 +1,229 @@ +Imports: + - Types: + - Question + - QuestionGroup + - SessionAnswers + Usages: + - question-records + From: goga/onboarding/questions + - Types: + - HookRegistry + - wrap_context + - build_hook_arguments + - enumerate_tool_packages + Usages: + - per-tool-delivery + - registering-hooks + From: goga/hooks + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the tool participation in the onboarding session: the + invitation, the two onboarding action moments delivered per tool with + staged control, the tool declaration and contribution surfaces, and the + isolated answer views. The per-tool delivery composes `wrap_context` + and `build_hook_arguments` per the `per-tool-delivery` practice. A + failure of one tool never cancels another tool or the session; the + single fatal case is a broken package import. Every warning names the + tool, the action, and the reason. Use relative imports. + +--- + +"ToolDeclaration(tool: str, invited: bool)": + location: declaration.py + annotations: | + The moment-one surface delivered to one tool — the session declaration + context and its buffer. + + `tool`: the tool identity assigned by the platform + `invited`: the invitation marker — False marks a subscribed tool the + session did not invite + + Apply the `convention` practice for the code style and intra-package + imports. + Use the `question-records` practice for the declaration records. + Use the `registering-hooks` practice for the hook signature and the + failure handling behind the action. + + Requirements: + - A hook of a non-invited tool returns immediately — the False marker + is the contract rule; no member is called + - The declared questions and skips are buffered; the engine reads them + after the delivery of the moment completes + - A hook is never called to survey — the engine asks the declared + questions itself + properties: + "tool -> str": | + The tool identity of the owning tool. + "invited -> bool": | + The invitation marker of the session. + "questions -> list[Question | QuestionGroup]": | + The declared questions and groups, in declaration order. + "skips -> list[str]": | + The declared skip paths, in declaration order. + methods: + "declare(item: Question | QuestionGroup) -> _: None": | + Declare one question or one group of the tool's block. + + `item`: the question record or the one-level group + + Requirements: + - A group of a tool is limited to one nesting level with simple + children + - The local names are the tool's own — the engine qualifies them + with the tool identity + "skip(path: str) -> _: None": | + Declare one skip request. + + `path`: the raw path — unprefixed for the core tree or the tool's + own block, prefixed with a tool identity for another tool's block + + Requirements: + - The engine resolves and applies every declared skip as one set + after the whole declaration; individual pairs inside a pairs + question are not addressable + +"ToolContribution(tool: str, invited: bool, answers: dict)": + location: contribution.py + annotations: | + The moment-two surface delivered to one tool and the staged buffer of + its contribution — the amendments and the config files. + + `tool`: the tool identity assigned by the platform + `invited`: the invitation marker of the session + `answers`: the isolated answer view of the tool — the core answers plus + its own under local names + + Apply the `convention` practice for the code style and intra-package + imports. + Use the `registering-hooks` practice for the hook signature and the + failure handling behind the action. + + Requirements: + - A hook of a non-invited tool returns immediately + - The contribution is staged — the engine applies the buffered + amendments and writes the buffered files only after every hook of + the tool of this moment completed without failure + - The view carries nothing of the other tools — coordination goes + through amendments of shared sections + properties: + "tool -> str": | + The tool identity of the owning tool. + "invited -> bool": | + The invitation marker of the session. + "answers -> dict": | + The isolated answer view — the core and the tool's own answers. + "amendments -> list[tuple[str, str | bool | dict]]": | + The buffered amendments — the path and the value, in call order. + "files -> list[tuple[str, dict]]": | + The buffered config files — the file name and the data, in call + order. + methods: + "answer(id: str, value: str | bool | dict) -> _: None": | + Buffer one amendment of the collected configuration. + + `id`: the dot-path of the addressed entry + `value`: the amendment value + + Requirements: + - Merge semantics apply at the addressed location — mappings merge + recursively, scalars and lists replace + - Substituting a user's answer is silent — a lawful right of the + tool + - The registrations in the tools and usages sections are ordinary + amendments at their ids + "write_config(file: str, data: dict) -> _: None": | + Buffer one config file of the tool. + + `file`: the file name inside the tool's config directory + `data`: the serializable mapping of the file + + Requirements: + - The engine serializes and writes the file into the tool's config + directory; writing the same file again replaces it + - A tool never writes its config files itself — the engine API is + the single write path + +"ToolParticipation(invited: list[str])": + location: participation.py + annotations: | + The mediator of the tool participation — both onboarding action moments + delivered per tool with staged control. + + `invited`: the invited tool identities, deduplicated, in flag order + + Apply the `convention` practice for the code style and intra-package + imports. + Use the `per-tool-delivery` practice for the delivery loop. + Use the `registering-hooks` practice for the registration contract + behind the actions. + properties: + "invited -> list[str]": | + The invited tool identities, deduplicated, in flag order. + methods: + "collect_declarations() -> declarations: list[ToolDeclaration]": | + Deliver the moment one — the session declaration action. + + `declarations`: the declarations of the surviving tools, in + enumeration order + + Algorithm: + 1. Build the run registry — `HookRegistry` — once for the whole + session; a broken package import is a clean error naming the + package — the single fatal case + 2. Warn for every invited identity that is not among the installed + tool packages — resolved via `enumerate_tool_packages` — naming + it; the session continues without its block + 3. Deliver the declaration action to every subscriber per tool, in + enumeration order: an invited tool receives an active surface, a + subscribed tool without an invitation receives the not-invited + marker + 4. A failing hook of a tool drops that tool's whole declaration — a + warning, the session continues; the other tools stand + 5. Return the declarations of the surviving tools + + Requirements: + - An invited tool without a subscription to the action participates + silently — no block, no warning + "collect_contributions(answers: SessionAnswers) -> contributions: list[ToolContribution]": | + Deliver the moment two — the config amendment action — and commit the + surviving contributions. + + `answers`: the session answer space after the survey + `contributions`: the committed contributions, in enumeration order + + Algorithm: + 1. Deliver the amendment action to every subscriber per tool, in + enumeration order, each tool with its isolated answer view + 2. A failing hook of a tool discards its whole contribution — the + amendments and the files together — with a warning; the session + continues; the other tools stand + 3. Commit every surviving contribution: apply its buffered + amendments to `answers` in delivery order; collect its buffered + files + 4. Return the committed contributions + + Requirements: + - The committed amendments apply in delivery order — the enumeration + order of the tool identities; a conflicting leaf is won by the + last applied amendment + - The file buffer of a failed tool is discarded together with its + amendments + +--- + +Author: Goga +CreatedAt: 14/09/26 +Description: | + Tool participation in the onboarding session — the invitation, the two + onboarding action moments delivered per tool, the staged contributions, + and the isolated answer views. diff --git a/goga/onboarding/questions/.usages/question-records.md b/goga/onboarding/questions/.usages/question-records.md new file mode 100644 index 00000000..43145d68 --- /dev/null +++ b/goga/onboarding/questions/.usages/question-records.md @@ -0,0 +1,66 @@ +# Question records — goga/onboarding/questions + +## Domain + +The declarative question-and-answer model of the onboarding session: +question records of every kind, nesting groups, and the session answer +space. Target audience: cells that build or survey a question tree, and +tool package authors whose session questions are declared as these +records. + +## Public API + + from goga.onboarding import Question, QuestionGroup, SessionAnswers + +- `Question(id, kind, prompt, choices=None, default=None, keys=None)` — one + simple question. Kinds: `choice` (answer — a string from `choices`), + `input` (answer — a free-form string), `confirm` (answer — a bool), + `pairs` (answer — a mapping of strings; `keys` proposes the keys). +- `QuestionGroup(id, prompt=None, children=None)` — one nesting level; the + group's answer is the mapping of its children's answers. +- `SessionAnswers(tools=None)` — the answer space of one run: `record`, + `amend`, `view_for`, `snapshot`. `tools` reserves the top-level keys of + the tool sections (the plan block names) — with it, `view_for(tool)` + isolates the core plus the tool's own answers; without it the space is + core-only. + +## Ready-to-use patterns + +### Declare a question of each kind + +```python +from goga.onboarding import Question, QuestionGroup + +language = Question(id="language", kind="choice", prompt="Project language", + choices=["python", "golang"], default="python") +image = Question(id="image", kind="input", prompt="Image name") +setup = Question(id="setup", kind="confirm", prompt="Configure the tool?", default=False) +env = Question(id="env", kind="pairs", prompt="Environment variables", + keys=["API_URL", "TOKEN"]) +``` + +### Declare a group + +```python +block = QuestionGroup(id="reporting", prompt="Reporting settings", + children=[setup, env]) +``` + +A group carries one nesting level with simple children; its answer is a +mapping keyed by child ids. + +### Address answers + +Paths join ids with dots (`reporting.env`); stored answers are nested +mappings — groups hold mappings, no dotted keys. `record` replaces at the +path; `amend` merges mappings recursively and replaces scalars and lists +(last applied wins); `view_for(tool)` returns the core plus the tool's own +answers under local names — other tools' answers are never visible; +`snapshot` returns the committed whole for generation. + +## Notes for the consumer + +- Question records are immutable value objects — build them fresh, never + mutate. +- The `id` is local to its parent; uniqueness matters among siblings of the + same tree position. diff --git a/goga/onboarding/questions/CODEMANIFEST b/goga/onboarding/questions/CODEMANIFEST new file mode 100644 index 00000000..e2dfbafd --- /dev/null +++ b/goga/onboarding/questions/CODEMANIFEST @@ -0,0 +1,178 @@ +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the declarative question-and-answer model of the + onboarding session: the question records of every kind, the nesting + groups, and the session answer space. Data and pure answer operations + only — no interactivity, no filesystem, no tool delivery. The question + records are immutable; the answer space is the single mutable + accumulator of one run. Use relative imports. + +--- + +"Question(id: str, kind: str, prompt: str, choices: list[str] | None = None, default: str | bool | None = None, keys: list[str] | None = None)": + location: questions.py + annotations: | + One declarative question record — the survey unit of the session. + + `id`: the local name of the question within its parent — unique among + the siblings of its tree position + `kind`: the question kind — one of choice, input, confirm, pairs + `prompt`: the user-facing prompt text + `choices`: the offered values; set for the choice kind + `default`: the preselected value or the input default; a bool for the + confirm kind + `keys`: the proposed keys of the repeated key-value collection; set for + the pairs kind + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The record carries data only — rendering the question and validating + the answer value belong to the survey engine + - The kind fixes the parameterization: `choices` for choice, `default` + for input and confirm, `keys` for pairs + - The answer value of each kind: choice and input — a string, confirm — + a boolean, pairs — a mapping of strings + properties: + "id -> str": | + The local name of the question within its parent. + "kind -> str": | + The question kind — choice, input, confirm, or pairs. + "prompt -> str": | + The user-facing prompt text. + "choices -> list[str] | None": | + The offered values of the choice kind. + "default -> str | bool | None": | + The preselected value or the input default; a bool for confirm. + "keys -> list[str] | None": | + The proposed keys of the pairs kind. + +"QuestionGroup(id: str, prompt: str | None = None, children: list[Question | QuestionGroup] | None = None)": + location: questions.py + annotations: | + One nesting node of the question tree — a section whose answer is the + mapping of its children's answers. + + `id`: the local name of the group within its parent — unique among the + siblings of its tree position + `prompt`: the optional section heading; a purely structural node carries + none + `children`: the nested questions and groups, in survey order + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The tree path of a node — the ids from the root to the node joined by + dots — addresses the node in skip requests and answer paths + - The answer value of a group is a nested mapping keyed by child ids — + never a flat dotted key + - A group declared by a tool is limited to one nesting level with + simple children; deeper nesting belongs to the core survey structure + properties: + "id -> str": | + The local name of the group within its parent. + "prompt -> str | None": | + The optional section heading of the group. + "children -> list[Question | QuestionGroup] | None": | + The nested questions and groups, in survey order. + +"SessionAnswers(tools: list[str] | None = None)": + location: answers.py + annotations: | + The answer space of one session — the question-to-value mapping shared + by the survey, the tool participation, and the file generation. + + `tools`: the reserved tool-section names — the identities of the tools + whose blocks the plan carries; None when the space holds no tool + sections + + Apply the `convention` practice for the code style and intra-package + imports. + + Requirements: + - Created empty — the space holds no answers; `tools` reserves the + top-level keys of the tool sections without creating them; the + structure is nested mappings keyed by question ids — groups hold + mappings, no dotted keys are ever stored + - The single mutable accumulator of the run — every answer, core and + tool, lands here exactly once + methods: + "record(id: str, value: str | bool | dict) -> _: None": | + Record the user's answer collected by the survey. + + `id`: the dot-path of the answered question in the plan tree + `value`: the answer value of the question kind + + Algorithm: + 1. Resolve `id` segment by segment, creating the intermediate + mappings of the traversed groups + 2. Set the value at the leaf name + + Requirements: + - Recording replaces — a later record at the same path overwrites the + earlier value; merging belongs to amendments + "amend(id: str, value: str | bool | dict) -> _: None": | + Apply one amendment of a tool contribution at the addressed location. + + `id`: the dot-path of the addressed entry + `value`: the amendment value + + Algorithm: + 1. Resolve `id` segment by segment, creating the intermediate + mappings of the traversed groups + 2. An existing mapping at the leaf merges recursively with `value`; + a scalar or a list replaces; an absent leaf is created + + Requirements: + - Mappings merge recursively; scalars and lists replace + - Amendments apply in delivery order — a later amendment wins at + every conflicting leaf + - Substituting a user's answer is a tool's lawful right — the + amendment applies silently + "view_for(tool: str) -> view: dict": | + The isolated answer view of one tool. + + `tool`: the tool identity + `view`: the core answers plus the tool's own answers under their + local names + + Algorithm: + 1. Take the core section of the space — every top-level key except + the reserved tool-section names of the space + 2. Add the tool's own section re-keyed by local names, without the + tool prefix + + Requirements: + - The answers of other tools are never present — coordination goes + through amendments of shared sections, not through reading foreign + data + - The view is a snapshot — amendments applied after the call do not + appear in it + "snapshot() -> view: dict": | + The full answer space for generation. + + `view`: the complete nested structure — the core and every committed + tool section + + Requirements: + - Reflects the committed state at the call moment — generation runs + after the tool contributions are committed + +--- + +Author: Goga +CreatedAt: 14/09/26 +Description: | + Declarative question-and-answer model of the onboarding session — + question records, nesting groups, and the session answer space. diff --git a/goga/onboarding/survey/.usages/survey-run.md b/goga/onboarding/survey/.usages/survey-run.md new file mode 100644 index 00000000..3864b0bd --- /dev/null +++ b/goga/onboarding/survey/.usages/survey-run.md @@ -0,0 +1,56 @@ +# Survey run — goga/onboarding/survey + +## Domain + +Assembling the onboarding survey plan and running it interactively: the +core question tree, the tool question blocks, the skip requests, and the +click-driven survey. Target audience: the session orchestrator that builds +a plan and collects answers into the answer space. + +## Public API + + from goga.onboarding import core_questions, assemble_session_plan, apply_skips, Questionnaire + +- `core_questions(image_tag, project_name, convention_exists) -> QuestionGroup` + — the eight core sections in survey order; image hints carry + `image_tag`; the convention section is omitted when the file exists. +- `assemble_session_plan(core, declarations) -> SessionPlan` — one root: + core children, then one group per declaring tool in enumeration order. + A repeated local name within a tool drops that element with a warning; + the rest of the declaration stands. The core section names are reserved — + a tool identity colliding with one drops the tool's whole block with a + warning; the tool keeps its amendment rights. +- `apply_skips(plan, skips) -> SessionPlan` — removes the addressed + subtrees; unprefixed paths address the core tree or the declaring + tool's own block, `.`-prefixed paths address that tool's block; + unknown paths are a no-op with a warning; pairs are addressed only as a + whole. +- `Questionnaire().run(plan, answers)` — the interactive survey: session + header, core sections, tool blocks with attribution headings; values are + recorded into the answer space at their plan paths. + +## Ready-to-use pattern + +### Build the plan and run the survey + +```python +from goga.onboarding import ( + SessionAnswers, Questionnaire, apply_skips, assemble_session_plan, core_questions, +) + +core = core_questions(image_tag="1.3", project_name="my-app", convention_exists=False) +plan = assemble_session_plan(core, declarations) # declarations: from tool participation +plan = apply_skips(plan, skips) # skips: (tool, raw path) pairs +answers = SessionAnswers() +Questionnaire().run(plan, answers) +``` + +## Notes for the consumer + +- Skips are applied to the assembled plan as one set — order-independent; + run the survey only after `apply_skips`. +- The core survey keeps its conditional patterns: the Dockerfile branch + decides the image questions; a confirm-gated collection asks its gate + first. +- Questions are declarative data — the engine asks them; nothing calls a + tool hook to survey. diff --git a/goga/onboarding/survey/CODEMANIFEST b/goga/onboarding/survey/CODEMANIFEST new file mode 100644 index 00000000..e9fc5701 --- /dev/null +++ b/goga/onboarding/survey/CODEMANIFEST @@ -0,0 +1,241 @@ +Imports: + - Types: + - Question + - QuestionGroup + - SessionAnswers + Usages: + - question-records + From: goga/onboarding/questions + - Types: + - ToolDeclaration + From: goga/onboarding/participation + +Usages: + convention: .goga/usages/conventions.md + click: .goga/usages/cooks/click.md + image_defaults: | + The default Docker image hints depend on the selected language and carry + the current minor tag supplied at runtime (the image-tag input of + the core tree) — never a hardcoded tag. For languages with predefined + images, display the suggestions; default to the last entry. Accept + arbitrary user input for the image name. Language → image family + mapping (the tag completes the name): + - python: qarium/goga-python-{3.10-3.14}:{tag} + - golang: qarium/goga-golang-{1.23, 1.24, 1.25, 1.26}:{tag} + - javascript: qarium/goga-node-{22, 24}:{tag} + - kotlin: qarium/goga-kotlin-{2.0, 2.1, 2.2, 2.3}:{tag} + - swift: qarium/goga-swift-{6.0, 6.1, 6.2}:{tag} + agent_env_defaults: | + Map each agent to a list of environment variable keys for prompting. + Display the keys to the user; collect corresponding values. + Agent → env key mapping: + - claude: ANTHROPIC_BASE_URL, ANTHROPIC_DEFAULT_HAIKU_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_MODEL + - codex: CODEX_MODEL + - cursor: CURSOR_MODEL + - opencode: OPENCODE_MODEL, OPENCODE_VARIANT + - qwen: OPENAI_BASE_URL, OPENAI_MODEL + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the survey of the onboarding session: the core question + tree, the assembly of the session plan with the tool question blocks, + the application of skip requests, and the interactive run. The survey is + interactive on the host through the `click` practice; core sections are + conditional on the filesystem state. Questions are declarative data — + the engine asks them itself; a tool hook is never called to survey. Use + relative imports. + +--- + +"core_questions(image_tag: str, project_name: str | None, convention_exists: bool) -> tree: QuestionGroup": + location: core.py + annotations: | + Build the core question tree of the session — the eight core sections + in survey order. + + `image_tag`: the current minor tag for image hints (the `image_defaults` + practice completes image names with it) + `project_name`: the git-derived project name for the built-image name + default; None offers no default + `convention_exists`: True when the base conventions file already exists + `tree`: the core tree + + Apply the `convention` practice for docstring style and intra-package + imports. + Use the `image_defaults` practice for image hints. + Use the `agent_env_defaults` practice for the env key suggestions. + + Algorithm: + 1. Compose the sections in order: language, convention, codemanifest, + build, docker_image, pipeline, tools, usages + 2. Omit the convention section when `convention_exists` is True + 3. Set the image hints from the `image_defaults` practice completed + with `image_tag`; the built-image name default follows + `project_name` + + Requirements: + - language — a choice of the supported languages + - convention — an offer to adopt the base convention; acceptance + pre-fills the codemanifest section + - codemanifest — practice usages and annotations entries + - build — the task executor: agent and env (suggested keys per + `agent_env_defaults`) + - docker_image — the Dockerfile decision, the base image of the FROM + line, and the image name; the base image applies only when a + Dockerfile path is given; the children carry the local names + image, dockerfile, base_image — the mapping into the top-level + config fields belongs to the generation + - pipeline — agent and env (suggested keys per `agent_env_defaults`) + - tools — a confirm-gated repeated collection of name and version + pairs; version values follow the four-form version grammar, an + absent version reads as latest + - usages — a confirm-gated repeated record collection: group, + dependency name, git repository, optional ref and root; the answer + nests as {group: {dep: {git, ref, root}}} + - Answer values nest as mappings mirroring the project config schema + - The review section is not part of the tree — a future additive core + section + +"assemble_session_plan(core: QuestionGroup, declarations: list[ToolDeclaration]) -> plan: SessionPlan": + location: plan.py + annotations: | + Assemble the session plan — the core tree plus the tool question + blocks in one root. + + `core`: the core tree built by `core_questions` + `declarations`: the collected declarations of the run, in enumeration + order + `plan`: the assembled plan + + Apply the `convention` practice for docstring style and intra-package + imports. + Use the `question-records` practice for the record structure. + + Algorithm: + 1. Start from the children of `core` + 2. For each declaration in enumeration order: wrap its declared + questions into one group named by the tool identity and append it + after the core children + 3. A repeated local name within one tool's declaration is rejected — + the element of the declaration is dropped with a warning naming the + tool and the reason; the surviving elements of the same declaration + stand + 4. A tool identity colliding with a section name of the `core` tree + drops the tool's whole block — a warning naming the tool and the + reserved name; the tool stands in the other moment + + Requirements: + - Deterministic — the plan children order is the core order followed by + the enumeration order of the tools + - A tool without declarations contributes no block + - Rejections never cancel the surviving elements of the same tool + - The core section names are reserved — a colliding tool identity + contributes no block; the tool keeps its amendment rights + +"apply_skips(plan: SessionPlan, skips: list[tuple[str, str]]) -> plan: SessionPlan": + location: plan.py + annotations: | + Apply the declared skip requests to the plan. + + `plan`: the assembled plan + `skips`: the declared skips — the declaring tool identity and the raw + path + `plan`: the plan with the skipped subtrees removed + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Resolve every path against the plan root: an unprefixed path + addresses the core tree or the declaring tool's own block; a path + prefixed with a tool identity addresses that tool's block + 2. Remove every resolved node — the whole subtree under it + 3. Apply all skips as one set — the result does not depend on the + order of application + 4. A path resolving to nothing is a no-op announced with a warning + + Requirements: + - A skipped question is never asked; the tool whose question was + skipped tolerates the missing answer + - Inside a pairs question the individual pairs are not addressable — + only the node as a whole + +"SessionPlan(root: QuestionGroup, tools: list[str])": + location: plan.py + annotations: | + The assembled survey plan — one tree with the tool blocks as groups + named by tool identity. + + `root`: the plan root — the core children followed by the tool blocks + `tools`: the participating tools in block order + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "root -> QuestionGroup": | + The plan root — the core sections followed by the tool blocks. + "tools -> list[str]": | + The participating tools in block order. + +"Questionnaire()": + location: questionnaire.py + annotations: | + The interactive survey engine of the session. + + Apply the `convention` practice for the code style and intra-package + imports. + Use the `click` practice for prompting, confirmation, choices, and the + repeated collections. + Use the `image_defaults` practice to render image hints. + Use the `agent_env_defaults` practice to render env key suggestions. + methods: + "run(plan: SessionPlan, answers: SessionAnswers) -> _: None": | + Run the whole survey of one plan into the answer space. + + `plan`: the assembled plan with skips applied + `answers`: the session answer space receiving the collected values + + Algorithm: + 1. Display the session header and the wizard description + 2. Survey the core sections of `plan` in order + 3. Survey every tool block after the core, in block order, under an + attribution heading naming the tool + 4. Record every collected value into `answers` at its plan path + + Requirements: + - The core survey keeps its conditional patterns: the base image + applies only when a Dockerfile path was given; the image defaults + follow the Dockerfile branch + - A skipped subtree is never asked + "ask_question(question: Question) -> value: str | bool | dict[str, str]": | + Ask one simple question of its kind. + + `question`: the question record + `value`: the answer value of the kind + + Requirements: + - Render the prompt, the offered choices or keys, and the default of + the record; a free-form input is accepted where the kind allows it + "ask_group(group: QuestionGroup) -> value: dict": | + Ask one group — its children in order. + + `group`: the group node + `value`: the mapping of the children's answers keyed by child ids + + Requirements: + - Section headings and explanatory text precede the children; the + confirm-gated collections ask their gate first + +--- + +Author: Goga +CreatedAt: 14/09/26 +Description: | + Survey of the onboarding session — the core question tree, the session + plan assembly with skip requests, and the interactive run. diff --git a/goga/version/.usages/minor-line.md b/goga/version/.usages/minor-line.md new file mode 100644 index 00000000..59ecc6ee --- /dev/null +++ b/goga/version/.usages/minor-line.md @@ -0,0 +1,44 @@ +# Minor line of a version — goga/version + +## Domain + +Deriving the minor line (N.M) of a version string. Target audience: features +that present values matching the installed minor — image tag hints, +compatibility labels — and need the same minor the host↔image comparison +uses. + +## Public API + + from goga.version import minor_version, host_goga_version + +- `minor_version(version: str) -> str` — the N.M line of `version`. A missing + minor segment reads as 0; dev/pre/post/local tails are discarded; an + undeterminable major segment raises ValueError. +- `host_goga_version() -> str` — the installed goga version; the single + reading point. Propagates the metadata exception when undeterminable. + +## Ready-to-use pattern + +### Offer a hint matching the installed minor + +Read once, derive, format at the consumer: + +```python +from goga.version import host_goga_version, minor_version + +version = host_goga_version() # may raise when metadata is unreadable — handle at the caller +tag = minor_version(version) # "1.3.2" -> "1.3" +image_hint = f"qarium/goga-python-3.12:{tag}" +``` + +- `minor_version` is pure — the caller owns the metadata boundary and passes + the string; the routine never reads, prints, or exits. +- A hint built from the returned line agrees with the (major, minor) + host↔image check by construction. + +## Notes for the consumer + +- Do not parse the version string at the call site — this routine owns the + reduction. +- An unreadable installed version is the caller's error to translate into a + clean message. diff --git a/goga/version/CODEMANIFEST b/goga/version/CODEMANIFEST index d658c237..9906c7a4 100644 --- a/goga/version/CODEMANIFEST +++ b/goga/version/CODEMANIFEST @@ -185,6 +185,37 @@ Annotations: | Apply the `convention` practice for docstring style and intra-package imports. +"minor_version(version: str) -> minor: str": + location: version.py + annotations: | + Derive the minor line of a version string — the N.M form consumers use + to present values that must match the installed minor (image tag hints). + + `version`: version string (release segments, possibly with + dev/pre/post/local tails) + `minor`: the minor line N.M + + Apply the `convention` practice for docstring style and the + pure-function discipline. + + Algorithm: + 1. Reduce `version` to its leading release segments: the first numeric + segment is the major, the optional second numeric segment is the minor + 2. Treat a missing minor segment as 0 + 3. Return the two segments joined by a dot — the minor line + 4. An argument with no leading numeric major segment raises ValueError + + Requirements: + - Pure function — deterministic, no I/O, no logging + - Richer tails reduce silently: 1.2.1.dev3, 1.2.0rc1, 1.2.0.post1, + 1.2.0+local all reduce to the 1.2 line + + Constraints: + - Do not read the installed version here — the caller owns the metadata + boundary; this routine receives it as `version` + - Do not validate that segments form a real released version — shape + recognition only, mirroring `resolve_version` + "version_check_enabled() -> enabled: bool": location: version.py annotations: | From 2c90cd606d6d58b60cfcf65b0ed402e6ce2352b6 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 20:48:55 +0000 Subject: [PATCH 010/205] feat: add minor_version routine to the version cell with facade re-export --- goga/version/__init__.py | 2 ++ goga/version/version.py | 30 ++++++++++++++++ tests/version/test_version.py | 66 +++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) diff --git a/goga/version/__init__.py b/goga/version/__init__.py index 960e8973..19f70a1c 100644 --- a/goga/version/__init__.py +++ b/goga/version/__init__.py @@ -4,6 +4,7 @@ compare_versions, ensure_version_match, host_goga_version, + minor_version, resolve_relative_spec, resolve_version, version_check_enabled, @@ -13,6 +14,7 @@ "compare_versions", "ensure_version_match", "host_goga_version", + "minor_version", "resolve_relative_spec", "resolve_version", "version_check_enabled", diff --git a/goga/version/version.py b/goga/version/version.py index ac6b41b8..53a46fad 100644 --- a/goga/version/version.py +++ b/goga/version/version.py @@ -274,6 +274,36 @@ def host_goga_version() -> str: return importlib.metadata.version("goga") +def minor_version(version: str) -> str: + """Reduce a version string to its minor line — the ``N.M`` form. + + Derives the two-segment minor line consumers use to present values that + must match the installed minor (the onboarding image-tag hints). The + argument is reduced to its leading release segments: the first numeric + segment is the major, the optional second numeric segment is the minor; + anything after them (pre-release, post-release, local, dev tails) is + discarded — rich versions are truncated, never rejected. A missing minor + segment counts as ``0`` (``"2"`` → ``"2.0"``), mirroring + ``compare_versions``' tolerance. Shape recognition only: no PEP 440 + existence check, no metadata reads (the caller owns the metadata boundary + and passes the installed version as ``version``), no logging. + + Args: + version: Version string to reduce (release segments, possibly with + dev/pre/post/local tails). + + Returns: + The minor line ``N.M`` of ``version``. + + Raises: + ValueError: If ``version`` has no leading numeric major segment. + """ + major, minor_seg = _release_segments(version) + minor = minor_seg if minor_seg is not None else "0" + + return f"{major}.{minor}" + + def version_check_enabled() -> bool: """Decide whether the host-side version check must run. diff --git a/tests/version/test_version.py b/tests/version/test_version.py index 5e150ab7..43eb5cb2 100644 --- a/tests/version/test_version.py +++ b/tests/version/test_version.py @@ -12,6 +12,7 @@ compare_versions, ensure_version_match, host_goga_version, + minor_version, resolve_relative_spec, resolve_version, version_check_enabled, @@ -31,6 +32,7 @@ def test_version_facade_all(self) -> None: "compare_versions", "ensure_version_match", "host_goga_version", + "minor_version", "resolve_relative_spec", "resolve_version", "version_check_enabled", @@ -117,6 +119,26 @@ def test_host_goga_version_signature(self) -> None: assert sig.return_annotation is str or sig.return_annotation == "str" +class TestMinorVersionFacade: + """Contract tests — verify minor_version is exposed and shaped per CODEMANIFEST.""" + + def test_minor_version_importable_from_facade(self) -> None: + assert minor_version is not None + assert callable(minor_version) + + def test_minor_version_in_facade_all(self) -> None: + facade = importlib.import_module("goga.version") + assert "minor_version" in facade.__all__ + assert callable(facade.minor_version) + + def test_minor_version_signature(self) -> None: + sig = inspect.signature(minor_version) + params = sig.parameters + assert list(params) == ["version"] + assert params["version"].annotation is str or params["version"].annotation == "str" + assert sig.return_annotation is str or sig.return_annotation == "str" + + class TestVersionCheckEnabledFacade: """Contract tests — verify version_check_enabled is exposed and shaped per CODEMANIFEST.""" @@ -477,6 +499,50 @@ def test_host_goga_version_propagates_metadata_failure( assert captured.err == "" +# --------------------------------------------------------------------------- +# Logic tests — minor_version (N.M line derivation) +# --------------------------------------------------------------------------- + + +class TestMinorVersionLogic: + """Behavioral scenarios — minor-line reduction with tail truncation.""" + + def test_minor_version_reduces_to_minor_line(self) -> None: + # Richer tails reduce silently to the N.M line; a missing minor + # segment counts as 0 (same tolerance as compare_versions). + assert minor_version("1.3.2") == "1.3" + assert minor_version("1.2.1.dev3") == "1.2" + assert minor_version("1.2.0rc1") == "1.2" + assert minor_version("1.2.0.post1") == "1.2" + assert minor_version("1.2.0+local") == "1.2" + assert minor_version("2") == "2.0" + + def test_minor_version_no_major_raises(self) -> None: + with pytest.raises(ValueError, match="cannot determine version line"): + minor_version("latest") + + def test_minor_version_purity(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Pure transformer — no file I/O, and the metadata reading point is + # never touched (the caller owns the metadata boundary). Same spy + # pattern as resolve_version. + import builtins + + opened: list[tuple] = [] + real_open = builtins.open + + def spy(*args, **kwargs): + opened.append(args) + return real_open(*args, **kwargs) + + metadata_read = mock.Mock(side_effect=AssertionError("metadata read")) + monkeypatch.setattr(builtins, "open", spy) + monkeypatch.setattr("goga.version.version.host_goga_version", metadata_read) + assert minor_version("1.3.2") == "1.3" + with pytest.raises(ValueError, match="cannot determine version line"): + minor_version("abc") + assert opened == [] + + # --------------------------------------------------------------------------- # Logic tests — ensure_version_match (outcome matrix) # --------------------------------------------------------------------------- From 2bb59db9756f2fe3c6060a6f8daacd45c4c2895c Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 20:51:10 +0000 Subject: [PATCH 011/205] feat: add onboarding action records to the hooks catalog --- goga/hooks/catalog/catalog.py | 2 ++ tests/hooks/catalog/test_catalog.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/goga/hooks/catalog/catalog.py b/goga/hooks/catalog/catalog.py index 0f498cfd..5c11ade4 100644 --- a/goga/hooks/catalog/catalog.py +++ b/goga/hooks/catalog/catalog.py @@ -38,6 +38,8 @@ class Action: _DECLARED_ACTIONS: list[Action] = [ # supported data, not discovery + Action(domain="onboarding", name="amend_config", error_class="soft"), + Action(domain="onboarding", name="declare_session", error_class="soft"), Action(domain="statuses", name="register_statuses", error_class="soft"), ] diff --git a/tests/hooks/catalog/test_catalog.py b/tests/hooks/catalog/test_catalog.py index 52d52ffe..e8b7f37e 100644 --- a/tests/hooks/catalog/test_catalog.py +++ b/tests/hooks/catalog/test_catalog.py @@ -81,6 +81,20 @@ def test_declared_actions_carries_the_statuses_action(self) -> None: assert ("statuses", "register_statuses") in records assert records[("statuses", "register_statuses")].error_class == "soft" + def test_catalog_carries_onboarding_actions(self) -> None: + """Both onboarding session actions are declared addresses, soft failures. + + The published statuses record stays untouched and the domain-then-name + ordering holds with the new records in place. + """ + records = declared_actions() + triples = {(r.domain, r.name, r.error_class) for r in records} + + assert ("onboarding", "declare_session", "soft") in triples + assert ("onboarding", "amend_config", "soft") in triples + assert [(r.domain, r.name) for r in records] == sorted((r.domain, r.name) for r in records) + assert ("statuses", "register_statuses") in {(r.domain, r.name) for r in records} + def test_declared_actions_is_deterministic_and_complete(self) -> None: """Same records in ``(domain, name)`` order on every call, unfiltered. From 5b0a71269f63bcec096c67c7e698e1d77d471e09 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 20:54:08 +0000 Subject: [PATCH 012/205] feat: re-export hooks delivery primitives through the hooks facade --- goga/hooks/__init__.py | 15 +++++++++++---- tests/hooks/test_facade.py | 32 +++++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/goga/hooks/__init__.py b/goga/hooks/__init__.py index 5539eb40..77ebbd83 100644 --- a/goga/hooks/__init__.py +++ b/goga/hooks/__init__.py @@ -3,18 +3,25 @@ The single consumer entry point of the platform for installed tool packages and for the domains: it re-exports the declared action catalog, the run registry with its per-tool inspection view, and the emission of an action at -a domain checkpoint — the emission assembles the registry on first use. The -facade declares no type of its own. Importing the package imports no tool -package and enumerates nothing. +a domain checkpoint — the emission assembles the registry on first use. It +additionally re-exports the delivery primitives and the installed-package +enumeration — for domains that orchestrate per-tool delivery themselves and +need each hook's outcome or the installed identities. The facade declares no +type of its own. Importing the package imports no tool package and +enumerates nothing. """ from .catalog import declared_actions -from .dispatch import emit_hook_event +from .dispatch import build_hook_arguments, emit_hook_event, wrap_context from .registry import HookRegistry, ToolHooks +from .tools import enumerate_tool_packages __all__: list[str] = [ "HookRegistry", "ToolHooks", + "build_hook_arguments", "declared_actions", "emit_hook_event", + "enumerate_tool_packages", + "wrap_context", ] diff --git a/tests/hooks/test_facade.py b/tests/hooks/test_facade.py index 1fd148b0..4ef2f5db 100644 --- a/tests/hooks/test_facade.py +++ b/tests/hooks/test_facade.py @@ -1,11 +1,13 @@ """Contract and logic tests for the cell declared in ``goga/hooks/CODEMANIFEST`` — the facade of the hooks platform. -The facade declares no type of its own: it re-exports the four embeddings +The facade declares no type of its own: it re-exports the seven embeddings consumers address the platform through — ``declared_actions``, -``HookRegistry``, ``ToolHooks``, and ``emit_hook_event`` — each identical to -the object its subcell package owns. The facade import is cheap: reloading -the package reads no installed-distribution mapping and builds no registry. +``HookRegistry``, ``ToolHooks``, ``emit_hook_event``, the delivery +primitives ``wrap_context`` and ``build_hook_arguments``, and +``enumerate_tool_packages`` — each identical to the object its subcell +package owns. The facade import is cheap: reloading the package reads no +installed-distribution mapping and builds no registry. """ from __future__ import annotations @@ -16,13 +18,22 @@ from goga.hooks import catalog as catalog_source from goga.hooks import dispatch as dispatch_source from goga.hooks import registry as registry_source +from goga.hooks import tools as tools_source -_FACADE_ALL = ["HookRegistry", "ToolHooks", "declared_actions", "emit_hook_event"] +_FACADE_ALL = [ + "HookRegistry", + "ToolHooks", + "build_hook_arguments", + "declared_actions", + "emit_hook_event", + "enumerate_tool_packages", + "wrap_context", +] class TestHooksPlatformFacade: - def test_all_lists_exactly_the_four_reexports(self) -> None: - """The facade declares exactly the four embeddings, alphabetically.""" + def test_all_lists_exactly_the_seven_reexports(self) -> None: + """The facade declares exactly the seven embeddings, alphabetically.""" assert goga.hooks.__all__ == _FACADE_ALL def test_declared_actions_is_the_catalog_object(self) -> None: @@ -41,6 +52,13 @@ def test_emit_hook_event_is_the_dispatch_object(self) -> None: """emit_hook_event is the emission routine, not a copy of it.""" assert goga.hooks.emit_hook_event is dispatch_source.emit_hook_event + def test_hooks_facade_reexports_delivery_primitives(self) -> None: + """The delivery primitives and the enumeration are the subcell objects.""" + assert goga.hooks.wrap_context is dispatch_source.wrap_context + assert goga.hooks.build_hook_arguments is dispatch_source.build_hook_arguments + assert goga.hooks.enumerate_tool_packages is tools_source.enumerate_tool_packages + assert {"wrap_context", "build_hook_arguments", "enumerate_tool_packages"} <= set(goga.hooks.__all__) + def test_every_declared_name_is_importable(self) -> None: """Each name of ``__all__`` resolves to a real attribute of the facade.""" for name in goga.hooks.__all__: From 0140d508bf895c8f73a970b927f82a7eb585f87f Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 20:55:18 +0000 Subject: [PATCH 013/205] feat: create questions cell structure with module skeletons --- goga/onboarding/questions/__init__.py | 10 ++++++++++ goga/onboarding/questions/answers.py | 8 ++++++++ goga/onboarding/questions/questions.py | 9 +++++++++ tests/onboarding/questions/__init__.py | 0 4 files changed, 27 insertions(+) create mode 100644 goga/onboarding/questions/__init__.py create mode 100644 goga/onboarding/questions/answers.py create mode 100644 goga/onboarding/questions/questions.py create mode 100644 tests/onboarding/questions/__init__.py diff --git a/goga/onboarding/questions/__init__.py b/goga/onboarding/questions/__init__.py new file mode 100644 index 00000000..22c3bcf7 --- /dev/null +++ b/goga/onboarding/questions/__init__.py @@ -0,0 +1,10 @@ +"""Questions cell — the declarative question-and-answer model of the session. + +The owner of the question records of every kind, the nesting groups, and +the session answer space of the onboarding session. Data and pure answer +operations only — no interactivity, no filesystem, no tool delivery. The +question records are immutable; the answer space is the single mutable +accumulator of one run. +""" + +__all__: list[str] = [] diff --git a/goga/onboarding/questions/answers.py b/goga/onboarding/questions/answers.py new file mode 100644 index 00000000..4d5b7dcf --- /dev/null +++ b/goga/onboarding/questions/answers.py @@ -0,0 +1,8 @@ +"""The session answer space of the onboarding session. + +The entity declared in the cell CODEMANIFEST with ``location: answers.py``: +the accumulator ``SessionAnswers``. The space is the single mutable +accumulator of one run — every answer, core and tool, lands here exactly +once. The structure is nested mappings keyed by question ids — groups hold +mappings, no dotted keys are ever stored. +""" diff --git a/goga/onboarding/questions/questions.py b/goga/onboarding/questions/questions.py new file mode 100644 index 00000000..e40ed0b1 --- /dev/null +++ b/goga/onboarding/questions/questions.py @@ -0,0 +1,9 @@ +"""The question records of the onboarding session. + +The entities declared in the cell CODEMANIFEST with ``location: questions.py``: +the question record ``Question`` and the nesting node ``QuestionGroup``. The +records are immutable declarative data of the survey — rendering the question +and validating the answer value belong to the survey engine. The kind fixes +the parameterization; the tree path of a node — the ids from the root to the +node joined by dots — addresses the node in skip requests and answer paths. +""" diff --git a/tests/onboarding/questions/__init__.py b/tests/onboarding/questions/__init__.py new file mode 100644 index 00000000..e69de29b From d591c078045e886c239e4e46aab199515c85939a Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 20:57:39 +0000 Subject: [PATCH 014/205] feat: implement Question and QuestionGroup records in the questions cell --- goga/onboarding/questions/__init__.py | 4 +- goga/onboarding/questions/questions.py | 64 ++++++++ tests/onboarding/questions/test_questions.py | 147 +++++++++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 tests/onboarding/questions/test_questions.py diff --git a/goga/onboarding/questions/__init__.py b/goga/onboarding/questions/__init__.py index 22c3bcf7..87d02f31 100644 --- a/goga/onboarding/questions/__init__.py +++ b/goga/onboarding/questions/__init__.py @@ -7,4 +7,6 @@ accumulator of one run. """ -__all__: list[str] = [] +from .questions import Question, QuestionGroup + +__all__: list[str] = ["Question", "QuestionGroup"] diff --git a/goga/onboarding/questions/questions.py b/goga/onboarding/questions/questions.py index e40ed0b1..380bf176 100644 --- a/goga/onboarding/questions/questions.py +++ b/goga/onboarding/questions/questions.py @@ -7,3 +7,67 @@ the parameterization; the tree path of a node — the ids from the root to the node joined by dots — addresses the node in skip requests and answer paths. """ + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, kw_only=True) +class Question: + """One declarative question record — the survey unit of the session. + + The record carries data only: rendering the question and validating the + answer value belong to the survey engine. The kind fixes the + parameterization — ``choices`` for the choice kind, ``default`` for the + input and confirm kinds, ``keys`` for the pairs kind. + + Attributes: + id: The local name of the question within its parent — unique among + the siblings of its tree position. + kind: The question kind — choice, input, confirm, or pairs. + prompt: The user-facing prompt text. + choices: The offered values of the choice kind. + default: The preselected value or the input default; a bool for the + confirm kind. + keys: The proposed keys of the repeated key-value collection of the + pairs kind. + + Requirements: + no validation runs at construction — the kinds are checked at ask + time, not here; the answer value of each kind is a string for choice + and input, a boolean for confirm, a mapping of strings for pairs. + """ + + id: str + kind: str + prompt: str + choices: list[str] | None = None + default: str | bool | None = None + keys: list[str] | None = None + + +@dataclass(frozen=True, kw_only=True) +class QuestionGroup: + """One nesting node of the question tree — a section. + + A section whose answer is the mapping of its children's answers. A group + with ``children`` of ``None`` is a purely structural node carrying no + prompt. + + Attributes: + id: The local name of the group within its parent — unique among the + siblings of its tree position. + prompt: The optional section heading; a purely structural node + carries none. + children: The nested questions and groups, in survey order. + + Requirements: + the answer value of a group is a nested mapping keyed by child ids — + never a flat dotted key; a group declared by a tool is limited to one + nesting level with simple children. + """ + + id: str + prompt: str | None = None + children: list[Question | QuestionGroup] | None = None diff --git a/tests/onboarding/questions/test_questions.py b/tests/onboarding/questions/test_questions.py new file mode 100644 index 00000000..52b4eef0 --- /dev/null +++ b/tests/onboarding/questions/test_questions.py @@ -0,0 +1,147 @@ +"""Contract and logic tests for the entities declared in +``goga/onboarding/questions/CODEMANIFEST`` with ``location: questions.py``: + +- ``Question(id, kind, prompt, choices, default, keys)`` — one declarative + question record, the survey unit of the session +- ``QuestionGroup(id, prompt, children)`` — one nesting node of the question + tree + +Data only — no validation, no rendering: the records are immutable declarative +data of the survey; kinds are checked at ask time. +""" + +from __future__ import annotations + +import dataclasses + +import pytest +from goga.onboarding.questions import Question, QuestionGroup + +from tests.conftest import is_kw_only_dataclass + +_CELL_ALL = ["Question", "QuestionGroup"] + +# --- Contract tests --- + + +class TestQuestionsContract: + def test_entities_are_importable_from_the_package_facade(self) -> None: + """Both records live on the cell package and its ``__all__`` is exact.""" + import goga.onboarding.questions as cell + + assert cell.Question is Question + assert cell.QuestionGroup is QuestionGroup + assert cell.__all__ == _CELL_ALL + + def test_question_is_a_kw_only_frozen_dataclass(self) -> None: + """``Question(id=..., kind=..., prompt=...)`` — keyword-only, frozen.""" + question = Question(id="token", kind="input", prompt="Service token") + + assert question.id == "token" + assert question.kind == "input" + assert question.prompt == "Service token" + + assert dataclasses.is_dataclass(Question) + assert Question.__dataclass_params__.frozen + assert is_kw_only_dataclass(Question) + + with pytest.raises(TypeError): + Question("token", "input", "Service token") # type: ignore[misc] + + def test_question_assignment_raises_frozen_instance_error(self) -> None: + """A declared record is never rewritten.""" + question = Question(id="token", kind="input", prompt="Service token") + + with pytest.raises(dataclasses.FrozenInstanceError): + question.prompt = "Other" # type: ignore[misc] + + def test_question_carries_exactly_the_six_declared_fields(self) -> None: + """No computed properties, no extra state — the record is data only.""" + field_names = [field.name for field in dataclasses.fields(Question)] + + assert field_names == ["id", "kind", "prompt", "choices", "default", "keys"] + + def test_question_group_constructs_without_prompt(self) -> None: + """A purely structural node carries no prompt and no children.""" + group = QuestionGroup(id="g", children=None) + + assert group.id == "g" + assert group.prompt is None + assert group.children is None + + assert dataclasses.is_dataclass(QuestionGroup) + assert QuestionGroup.__dataclass_params__.frozen + assert is_kw_only_dataclass(QuestionGroup) + + with pytest.raises(TypeError): + QuestionGroup("g") # type: ignore[misc] + + def test_question_group_assignment_raises_frozen_instance_error(self) -> None: + """A declared group is never rewritten.""" + group = QuestionGroup(id="g") + + with pytest.raises(dataclasses.FrozenInstanceError): + group.prompt = "--- Section ---" # type: ignore[misc] + + def test_question_group_carries_exactly_the_three_declared_fields(self) -> None: + """No computed properties, no extra state — the record is data only.""" + field_names = [field.name for field in dataclasses.fields(QuestionGroup)] + + assert field_names == ["id", "prompt", "children"] + + +# --- Logic tests --- + + +class TestQuestionRecords: + def test_question_exposes_all_fields_with_given_values(self) -> None: + """Every parameterization field round-trips with the given value.""" + question = Question(id="q", kind="choice", prompt="Pick", choices=["a", "b"], default="a") + + assert question.id == "q" + assert question.kind == "choice" + assert question.prompt == "Pick" + assert question.choices == ["a", "b"] + assert question.default == "a" + assert question.keys is None + + def test_group_round_trips_children_in_survey_order(self) -> None: + """A group holds the nested questions and groups, in order.""" + inner = Question(id="x", kind="input", prompt="X") + deeper = QuestionGroup(id="nested", children=[inner]) + group = QuestionGroup(id="g", prompt="--- G ---", children=[inner, deeper]) + + assert group.children == [inner, deeper] + assert group.children[0].id == "x" + assert group.children[1].children == [inner] + + def test_optional_fields_default_to_none(self) -> None: + """``None`` is the explicit absence of a parameterization.""" + question = Question(id="q", kind="input", prompt="P") + group = QuestionGroup(id="g") + + assert question.choices is None + assert question.default is None + assert question.keys is None + assert group.prompt is None + assert group.children is None + + def test_construction_performs_no_kind_validation(self) -> None: + """Kinds are checked at ask time — an unknown kind constructs.""" + question = Question(id="bad", kind="text", prompt="Weird") + + assert question.kind == "text" + + def test_records_are_hashable_value_objects(self) -> None: + """Frozen records compare and hash by value.""" + first = Question(id="q", kind="input", prompt="P") + second = Question(id="q", kind="input", prompt="P") + + assert first == second + assert len({first, second}) == 1 + + left = QuestionGroup(id="g") + right = QuestionGroup(id="g") + + assert left == right + assert len({left, right}) == 1 From c90a014738b620c08f65783a74998903358194b7 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 21:00:30 +0000 Subject: [PATCH 015/205] feat: implement SessionAnswers accumulator in the questions cell --- goga/onboarding/questions/__init__.py | 3 +- goga/onboarding/questions/answers.py | 154 +++++++++++++++ tests/onboarding/questions/test_answers.py | 192 +++++++++++++++++++ tests/onboarding/questions/test_questions.py | 2 +- 4 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 tests/onboarding/questions/test_answers.py diff --git a/goga/onboarding/questions/__init__.py b/goga/onboarding/questions/__init__.py index 87d02f31..f5444867 100644 --- a/goga/onboarding/questions/__init__.py +++ b/goga/onboarding/questions/__init__.py @@ -7,6 +7,7 @@ accumulator of one run. """ +from .answers import SessionAnswers from .questions import Question, QuestionGroup -__all__: list[str] = ["Question", "QuestionGroup"] +__all__: list[str] = ["Question", "QuestionGroup", "SessionAnswers"] diff --git a/goga/onboarding/questions/answers.py b/goga/onboarding/questions/answers.py index 4d5b7dcf..d2ba6c6a 100644 --- a/goga/onboarding/questions/answers.py +++ b/goga/onboarding/questions/answers.py @@ -6,3 +6,157 @@ once. The structure is nested mappings keyed by question ids — groups hold mappings, no dotted keys are ever stored. """ + +from __future__ import annotations + +from copy import deepcopy + + +def _resolve_parent(data: dict, segments: list[str]) -> dict: + """Walk the leading segments of one answer path, creating the intermediate mappings. + + A non-mapping value met mid-path — a scalar recorded earlier at a shorter + path — is replaced by a fresh mapping: the authoritative writer extends + the space. + + Args: + data: The nested mapping the walk mutates in place. + segments: Every segment of the dot-path but the leaf name of the + addressed entry. + + Returns: + The mapping that holds the leaf name — the parent of the addressed + entry. + """ + node: dict = data + + for segment in segments: + child = node.get(segment) + + if not isinstance(child, dict): + child = {} + node[segment] = child + + node = child + + return node + + +def _merge_into(target: dict, source: dict) -> None: + """Recursively merge ``source`` into ``target`` — mappings merge, the rest replaces. + + Args: + target: The mapping merged into, mutated in place. + source: The mapping whose entries win at every conflicting leaf. + """ + for key, value in source.items(): + existing = target.get(key) + + if isinstance(existing, dict) and isinstance(value, dict): + _merge_into(existing, value) + else: + target[key] = value + + +class SessionAnswers: + """The answer space of one session — the single mutable accumulator of the run. + + The question-to-value mapping shared by the survey, the tool + participation, and the file generation. The survey records, the tool + contributions amend, the generator snapshots — the space itself stays + silent and total: it raises nothing and logs nothing. + + Requirements: + - Created empty — the space holds no answers; ``tools`` reserves the + top-level keys of the tool sections without creating them; the + structure is nested mappings keyed by question ids — groups hold + mappings, no dotted keys are ever stored + - Every answer, core and tool, lands here exactly once + """ + + def __init__(self, tools: list[str] | None = None) -> None: + """Create the empty space. + + Args: + tools: The reserved top-level keys of the tool sections — the + identities of the tools whose blocks the plan carries; None + when the space holds no tool sections. + """ + self._tool_sections = frozenset(tools or ()) + self._data: dict = {} + + def record(self, id: str, value: str | bool | dict) -> None: + """Record the user's answer collected by the survey. + + Recording replaces — a later record at the same path overwrites the + earlier value; merging belongs to amendments. + + Args: + id: The dot-path of the answered question in the plan tree. + value: The answer value of the question kind. + """ + segments = id.split(".") + + parent = _resolve_parent(self._data, segments[:-1]) + + parent[segments[-1]] = value + + def amend(self, id: str, value: str | bool | dict) -> None: + """Apply one amendment of a tool contribution at the addressed location. + + An existing mapping at the leaf merges recursively with ``value``; a + scalar or a list replaces; an absent leaf is created. Substituting a + user's answer is a tool's lawful right — the amendment applies + silently. + + Args: + id: The dot-path of the addressed entry. + value: The amendment value. + """ + segments = id.split(".") + + parent = _resolve_parent(self._data, segments[:-1]) + + name = segments[-1] + existing = parent.get(name) + + if isinstance(existing, dict) and isinstance(value, dict): + _merge_into(existing, value) + else: + parent[name] = value + + def view_for(self, tool: str) -> dict: + """Return the isolated answer view of one tool. + + The core section — every top-level key except the reserved + tool-section names — plus the tool's own section re-keyed by local + names, without the tool prefix. The answers of other tools are never + present: coordination goes through amendments of shared sections, not + through reading foreign data. + + Args: + tool: The tool identity. + + Returns: + The isolated view — a deep copy; amendments applied after the + call do not appear in it. + """ + view = {key: deepcopy(value) for key, value in self._data.items() if key not in self._tool_sections} + + if tool in self._tool_sections: + own = self._data.get(tool) + + if isinstance(own, dict): + view.update(deepcopy(own)) + + return view + + def snapshot(self) -> dict: + """Return the full answer space for generation. + + Returns: + The complete nested structure — the core and every committed tool + section — as a deep copy of the committed state at the call + moment. + """ + return deepcopy(self._data) diff --git a/tests/onboarding/questions/test_answers.py b/tests/onboarding/questions/test_answers.py new file mode 100644 index 00000000..a0b33878 --- /dev/null +++ b/tests/onboarding/questions/test_answers.py @@ -0,0 +1,192 @@ +"""Contract and logic tests for the entity declared in +``goga/onboarding/questions/CODEMANIFEST`` with ``location: answers.py``: + +- ``SessionAnswers(tools)`` — the answer space of one session, the single + mutable accumulator of the run + +The space is total and silent — nested mappings keyed by question ids, no +dotted keys ever stored, no logging, nothing raised. +""" + +from __future__ import annotations + +from goga.onboarding.questions import SessionAnswers + +_CELL_ALL = ["Question", "QuestionGroup", "SessionAnswers"] + +# --- Contract tests --- + + +class TestSessionAnswersContract: + def test_entity_is_importable_from_the_package_facade(self) -> None: + """The accumulator lives on the cell package and its ``__all__`` is exact.""" + import goga.onboarding.questions as cell + + assert cell.SessionAnswers is SessionAnswers + assert cell.__all__ == _CELL_ALL + + def test_both_constructions_create_an_empty_space(self) -> None: + """With and without reserved tool sections the space starts empty.""" + assert SessionAnswers().snapshot() == {} + + assert SessionAnswers(tools=["my-tool"]).snapshot() == {} + + def test_reserved_names_do_not_create_tool_sections(self) -> None: + """``tools`` reserves the top-level keys without creating them.""" + answers = SessionAnswers(tools=["my-tool", "viewer"]) + + assert answers.snapshot() == {} + + +# --- Logic tests --- + + +class TestRecord: + def test_record_creates_nested_mappings(self) -> None: + """A dot-path lands as nested mappings keyed by the segments.""" + answers = SessionAnswers() + + answers.record("build.agent", "claude") + + assert answers.snapshot() == {"build": {"agent": "claude"}} + + def test_record_replaces_the_earlier_value_at_the_same_path(self) -> None: + """Recording replaces — merging belongs to amendments.""" + answers = SessionAnswers() + answers.record("build.agent", "claude") + + answers.record("build.agent", "codex") + + assert answers.snapshot() == {"build": {"agent": "codex"}} + + def test_record_over_scalar_extends_to_a_mapping(self) -> None: + """The survey is the authoritative writer — a scalar mid-path yields.""" + answers = SessionAnswers() + answers.record("a.b", 1) + + answers.record("a.b.c", 2) + + assert answers.snapshot() == {"a": {"b": {"c": 2}}} + + +class TestAmend: + def test_amend_merges_mappings_replaces_scalars(self) -> None: + """Mappings merge recursively; scalars replace.""" + answers = SessionAnswers() + answers.record("pipeline", {"agent": "codex", "env": {"A": "1"}}) + + answers.amend("pipeline", {"env": {"B": "2"}, "agent": "claude"}) + + assert answers.snapshot() == {"pipeline": {"agent": "claude", "env": {"A": "1", "B": "2"}}} + + def test_amend_creates_an_absent_leaf(self) -> None: + """An amendment of a fresh path creates the whole branch silently.""" + answers = SessionAnswers() + + answers.amend("tools", {"my-tool": "latest"}) + + assert answers.snapshot() == {"tools": {"my-tool": "latest"}} + + def test_amend_replaces_when_either_side_is_not_a_mapping(self) -> None: + """The merge happens only when both sides are mappings.""" + answers = SessionAnswers() + answers.record("flags", {"a": 1}) + + answers.amend("flags", "on") + + assert answers.snapshot() == {"flags": "on"} + + answers.amend("flags", {"b": 2}) + + assert answers.snapshot() == {"flags": {"b": 2}} + + def test_amend_replaces_lists(self) -> None: + """Lists replace — only mappings merge.""" + answers = SessionAnswers() + answers.record("items", ["a"]) + + answers.amend("items", ["b", "c"]) + + assert answers.snapshot() == {"items": ["b", "c"]} + + def test_later_amendment_wins_at_every_conflicting_leaf(self) -> None: + """Amendments apply in delivery order — the later one wins.""" + answers = SessionAnswers() + + answers.amend("env", {"A": "1", "B": "1"}) + answers.amend("env", {"B": "2"}) + + assert answers.snapshot() == {"env": {"A": "1", "B": "2"}} + + +class TestViewFor: + def test_view_for_isolates_and_flattens(self) -> None: + """Core plus the tool's own section under local names; no other tools.""" + answers = SessionAnswers(tools=["my-tool", "viewer"]) + answers.record("language", "python") + answers.record("my-tool.token", "t0") + answers.record("viewer.flag", "on") + + view = answers.view_for("my-tool") + + assert view == {"language": "python", "token": "t0"} + assert "viewer" not in view + + view["token"] = "mutated" + + assert answers.snapshot()["my-tool"]["token"] == "t0" + + def test_view_for_unknown_tool_returns_core_only(self) -> None: + """A tool without a recorded section sees the core only.""" + answers = SessionAnswers(tools=["my-tool"]) + answers.record("language", "python") + answers.record("my-tool.token", "t0") + + assert answers.view_for("not-declared") == {"language": "python"} + + def test_local_name_collision_with_core_key_wins_in_that_view_only(self) -> None: + """The own section is applied after the core — the space stays intact.""" + answers = SessionAnswers(tools=["my-tool"]) + answers.record("language", "python") + answers.record("my-tool.language", "golang") + + assert answers.view_for("my-tool") == {"language": "golang"} + assert answers.snapshot() == {"language": "python", "my-tool": {"language": "golang"}} + + def test_view_for_is_a_deep_copy_of_the_core(self) -> None: + """Mutating a nested value of the view does not touch the space.""" + answers = SessionAnswers(tools=["t"]) + answers.record("build", {"env": {"A": "1"}}) + + view = answers.view_for("t") + view["build"]["env"]["A"] = "mutated" + + assert answers.snapshot() == {"build": {"env": {"A": "1"}}} + + def test_reserved_names_come_from_the_constructor_param(self) -> None: + """No hardcoded section list — the reservation is the caller's data.""" + answers = SessionAnswers(tools=["custom-tool"]) + answers.record("custom-tool.token", "t0") + answers.record("language", "python") + + assert answers.view_for("custom-tool") == {"language": "python", "token": "t0"} + + +class TestSnapshot: + def test_snapshot_reflects_the_committed_state(self) -> None: + """The whole space — the core and every committed tool section.""" + answers = SessionAnswers(tools=["my-tool"]) + answers.record("language", "python") + answers.record("my-tool.token", "t0") + + assert answers.snapshot() == {"language": "python", "my-tool": {"token": "t0"}} + + def test_snapshot_is_a_deep_copy(self) -> None: + """Mutating the snapshot does not touch the space.""" + answers = SessionAnswers() + answers.record("build", {"env": {"A": "1"}}) + + view = answers.snapshot() + view["build"]["env"]["A"] = "mutated" + + assert answers.snapshot() == {"build": {"env": {"A": "1"}}} diff --git a/tests/onboarding/questions/test_questions.py b/tests/onboarding/questions/test_questions.py index 52b4eef0..27bae53c 100644 --- a/tests/onboarding/questions/test_questions.py +++ b/tests/onboarding/questions/test_questions.py @@ -19,7 +19,7 @@ from tests.conftest import is_kw_only_dataclass -_CELL_ALL = ["Question", "QuestionGroup"] +_CELL_ALL = ["Question", "QuestionGroup", "SessionAnswers"] # --- Contract tests --- From 61026cb5242e23d5b3654844cb2cc1a37c7c637a Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 21:02:04 +0000 Subject: [PATCH 016/205] feat: create participation cell structure with module skeletons --- goga/onboarding/participation/__init__.py | 9 +++++++++ goga/onboarding/participation/contribution.py | 9 +++++++++ goga/onboarding/participation/declaration.py | 9 +++++++++ goga/onboarding/participation/participation.py | 8 ++++++++ tests/onboarding/participation/__init__.py | 0 5 files changed, 35 insertions(+) create mode 100644 goga/onboarding/participation/__init__.py create mode 100644 goga/onboarding/participation/contribution.py create mode 100644 goga/onboarding/participation/declaration.py create mode 100644 goga/onboarding/participation/participation.py create mode 100644 tests/onboarding/participation/__init__.py diff --git a/goga/onboarding/participation/__init__.py b/goga/onboarding/participation/__init__.py new file mode 100644 index 00000000..8feafc67 --- /dev/null +++ b/goga/onboarding/participation/__init__.py @@ -0,0 +1,9 @@ +"""Participation cell — the tool participation in the onboarding session. + +The owner of the invitation, the two onboarding action moments delivered per +tool with staged control, the tool declaration and contribution surfaces, and +the isolated answer views. A failure of one tool never cancels another tool +or the session; the single fatal case is a broken package import. +""" + +__all__: list[str] = [] diff --git a/goga/onboarding/participation/contribution.py b/goga/onboarding/participation/contribution.py new file mode 100644 index 00000000..0d289a9c --- /dev/null +++ b/goga/onboarding/participation/contribution.py @@ -0,0 +1,9 @@ +"""The config contribution surface of one tool. + +The entity declared in the cell CODEMANIFEST with ``location: contribution.py``: +the moment-two surface ``ToolContribution``. The surface is delivered to one +tool's amend-config hook — the invitation marker, the isolated answer view, +and the staged buffer of the amendments and the config files. The buffered +contribution applies only after every hook of the tool completed without +failure. +""" diff --git a/goga/onboarding/participation/declaration.py b/goga/onboarding/participation/declaration.py new file mode 100644 index 00000000..ebea7453 --- /dev/null +++ b/goga/onboarding/participation/declaration.py @@ -0,0 +1,9 @@ +"""The session declaration surface of one tool. + +The entity declared in the cell CODEMANIFEST with ``location: declaration.py``: +the moment-one surface ``ToolDeclaration``. The surface is delivered to one +tool's declare-session hook — the invitation marker and the buffer of the +declared questions and skip paths. A hook of a non-invited tool returns +immediately; the buffered data is read by the engine after the delivery of +the moment completes. +""" diff --git a/goga/onboarding/participation/participation.py b/goga/onboarding/participation/participation.py new file mode 100644 index 00000000..95e8e940 --- /dev/null +++ b/goga/onboarding/participation/participation.py @@ -0,0 +1,8 @@ +"""The mediator of the tool participation of the session. + +The entity declared in the cell CODEMANIFEST with ``location: participation.py``: +the mediator ``ToolParticipation`` of both onboarding action moments — the +session declaration and the config amendment — delivered per tool with staged +control. A failure of one tool never cancels another tool or the session; +every warning names the tool, the action, and the reason. +""" diff --git a/tests/onboarding/participation/__init__.py b/tests/onboarding/participation/__init__.py new file mode 100644 index 00000000..e69de29b From 09d566e139b2629f75a03c807ae5454247c2b919 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 21:07:11 +0000 Subject: [PATCH 017/205] feat: implement ToolDeclaration and ToolContribution participation surfaces --- goga/onboarding/participation/__init__.py | 5 +- goga/onboarding/participation/contribution.py | 61 ++++++++ goga/onboarding/participation/declaration.py | 79 +++++++++++ .../participation/test_contribution.py | 105 ++++++++++++++ .../participation/test_declaration.py | 131 ++++++++++++++++++ 5 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 tests/onboarding/participation/test_contribution.py create mode 100644 tests/onboarding/participation/test_declaration.py diff --git a/goga/onboarding/participation/__init__.py b/goga/onboarding/participation/__init__.py index 8feafc67..e5f66b99 100644 --- a/goga/onboarding/participation/__init__.py +++ b/goga/onboarding/participation/__init__.py @@ -6,4 +6,7 @@ or the session; the single fatal case is a broken package import. """ -__all__: list[str] = [] +from .contribution import ToolContribution +from .declaration import ToolDeclaration + +__all__: list[str] = ["ToolContribution", "ToolDeclaration"] diff --git a/goga/onboarding/participation/contribution.py b/goga/onboarding/participation/contribution.py index 0d289a9c..6dab3625 100644 --- a/goga/onboarding/participation/contribution.py +++ b/goga/onboarding/participation/contribution.py @@ -7,3 +7,64 @@ contribution applies only after every hook of the tool completed without failure. """ + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + + +@dataclass(kw_only=True) +class ToolContribution: + """The moment-two surface of one tool — the contribution context and its staged buffer. + + The object an amend-config hook receives as ``context``: the invitation + marker the hook checks first, the isolated answer view of the tool, and + the buffers of its contribution. The contribution is staged — the engine + applies the buffered amendments and writes the buffered files only after + every hook of the tool completed without failure; a tool never writes + its config files itself. + + Attributes: + tool: The tool identity of the owning tool. + invited: The invitation marker — False marks a subscribed tool the + session did not invite. + answers: The isolated answer view of the tool — the core answers + plus its own under local names. + amendments: The buffered amendments — the path and the value, in + call order. + files: The buffered config files — the file name and the data, in + call order. + + Requirements: + writing the same file again replaces at write time — the buffer + keeps every entry in call order; substituting a user's answer is + silent — the engine commits the buffers, the surface applies + nothing itself. + """ + + tool: str + invited: bool + answers: dict + amendments: list[tuple[str, str | bool | dict]] = field(init=False, default_factory=list) + files: list[tuple[str, dict]] = field(init=False, default_factory=list) + + def answer(self, id: str, value: str | bool | dict) -> None: + """Buffer one amendment of the collected configuration. + + Args: + id: The dot-path of the addressed entry. + value: The amendment value. + """ + self.amendments.append((id, value)) + + def write_config(self, file: str, data: dict) -> None: + """Buffer one config file of the tool. + + Args: + file: The file name inside the tool's config directory. + data: The serializable mapping of the file. + """ + self.files.append((file, data)) diff --git a/goga/onboarding/participation/declaration.py b/goga/onboarding/participation/declaration.py index ebea7453..1010cca9 100644 --- a/goga/onboarding/participation/declaration.py +++ b/goga/onboarding/participation/declaration.py @@ -7,3 +7,82 @@ immediately; the buffered data is read by the engine after the delivery of the moment completes. """ + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +from ..questions import Question, QuestionGroup + +logger = logging.getLogger(__name__) + + +def _has_nested_group(group: QuestionGroup) -> bool: + """Report whether the children of the group contain a nested group. + + Args: + group: The declared group to inspect. + + Returns: + True when at least one child is itself a ``QuestionGroup`` — the + violation of the one-level rule. + """ + return group.children is not None and any(isinstance(child, QuestionGroup) for child in group.children) + + +@dataclass(kw_only=True) +class ToolDeclaration: + """The moment-one surface of one tool — the declaration context and its buffer. + + The object a declare-session hook receives as ``context``: the invitation + marker the hook checks first, the ``declare`` buffer of the tool's + questions and one-level groups, and the ``skip`` buffer of the raw skip + paths. The local names are the tool's own — the engine qualifies them + with the tool identity once it reads the buffers after the delivery of + the moment completes. + + Attributes: + tool: The tool identity of the owning tool. + invited: The invitation marker — False marks a subscribed tool the + session did not invite. + questions: The declared questions and groups, in declaration order. + skips: The declared skip paths, in declaration order. + + Requirements: + a group of a tool is limited to one nesting level with simple + children — a violation is refused with a warning naming the tool and + the reason, never an exception, and the element is not buffered. + """ + + tool: str + invited: bool + questions: list[Question | QuestionGroup] = field(init=False, default_factory=list) + skips: list[str] = field(init=False, default_factory=list) + + def declare(self, item: Question | QuestionGroup) -> None: + """Declare one question or one group of the tool's block. + + Args: + item: The question record or the one-level group. + """ + if isinstance(item, QuestionGroup) and _has_nested_group(item): + logger.warning( + "rejected declared group %s of tool %s: %s", + item.id, + self.tool, + "a tool group is limited to one nesting level with simple children", + ) + return + + self.questions.append(item) + + def skip(self, path: str) -> None: + """Declare one skip request. + + Args: + path: The raw path — unprefixed for the core tree or the tool's + own block, prefixed with a tool identity for another tool's + block. + """ + self.skips.append(path) diff --git a/tests/onboarding/participation/test_contribution.py b/tests/onboarding/participation/test_contribution.py new file mode 100644 index 00000000..a842ae76 --- /dev/null +++ b/tests/onboarding/participation/test_contribution.py @@ -0,0 +1,105 @@ +"""Contract and logic tests for the entity declared in +``goga/onboarding/participation/CODEMANIFEST`` with ``location: contribution.py``: + +- ``ToolContribution(tool, invited, answers)`` — the moment-two surface of + one tool, the config contribution context and its staged buffer + +The surface buffers what an amend-config hook contributes — the amendments +and the config files. The contribution is staged: the buffers apply only +after every hook of the tool completed without failure; writing the same +file again replaces at write time, not here. +""" + +from __future__ import annotations + +import pytest +from goga.hooks import wrap_context +from goga.onboarding.participation import ToolContribution + +from tests.conftest import is_kw_only_dataclass + +_CELL_ALL = ["ToolContribution", "ToolDeclaration"] + +# --- Contract tests --- + + +class TestToolContributionContract: + def test_entity_is_importable_from_the_package_facade(self) -> None: + """The surface lives on the cell package and its ``__all__`` is exact.""" + import goga.onboarding.participation as cell + + assert cell.ToolContribution is ToolContribution + assert cell.__all__ == _CELL_ALL + + def test_keyword_construction_starts_with_empty_buffers(self) -> None: + """``ToolContribution(tool=..., invited=..., answers=...)`` — the buffers start empty.""" + surface = ToolContribution(tool="t", invited=True, answers={"language": "python"}) + + assert surface.tool == "t" + assert surface.invited is True + assert surface.answers == {"language": "python"} + assert surface.amendments == [] + assert surface.files == [] + + def test_the_surface_is_a_kw_only_dataclass(self) -> None: + """The construction is keyword-only; positional arguments are refused.""" + assert is_kw_only_dataclass(ToolContribution) + + with pytest.raises(TypeError): + ToolContribution("t", True, {}) # type: ignore[misc] + + def test_the_surface_passes_the_delivery_view(self) -> None: + """Reads resolve and buffer calls pass through the ``wrap_context`` proxy.""" + proxy = wrap_context(ToolContribution(tool="t", invited=True, answers={})) + + assert proxy.tool == "t" + assert proxy.invited is True + + proxy.answer("tools", {"t": "latest"}) + proxy.write_config("service.yml", {"interval": 60}) + + assert proxy.amendments == [("tools", {"t": "latest"})] + assert proxy.files == [("service.yml", {"interval": 60})] + + +# --- Logic tests --- + + +class TestAnswer: + def test_answer_buffers_amendments_in_call_order(self) -> None: + """The path and the value land as one tuple per call, in call order.""" + surface = ToolContribution(tool="t", invited=True, answers={}) + + surface.answer("tools", {"t": "1.0"}) + surface.answer("pipeline.env", {"REPORT_URL": "https://example.com"}) + + assert surface.amendments == [("tools", {"t": "1.0"}), ("pipeline.env", {"REPORT_URL": "https://example.com"})] + + def test_answer_is_silent_about_the_addressed_state(self) -> None: + """Substituting a user's answer is buffered, not applied — the engine commits.""" + surface = ToolContribution(tool="t", invited=True, answers={"language": "python"}) + + surface.answer("language", "golang") + + assert surface.amendments == [("language", "golang")] + assert surface.answers == {"language": "python"} + + +class TestWriteConfig: + def test_write_config_buffers_files_in_call_order(self) -> None: + """The file name and the data land as one tuple per call, in call order.""" + surface = ToolContribution(tool="t", invited=True, answers={}) + + surface.write_config("service.yml", {"token_source": "env"}) + surface.write_config("other.yml", {"interval": 60}) + + assert surface.files == [("service.yml", {"token_source": "env"}), ("other.yml", {"interval": 60})] + + def test_a_repeated_file_keeps_both_entries(self) -> None: + """Replacement happens at write time — the buffer keeps the call order.""" + surface = ToolContribution(tool="t", invited=True, answers={}) + + surface.write_config("service.yml", {"token_source": "env"}) + surface.write_config("service.yml", {"interval": 60}) + + assert surface.files == [("service.yml", {"token_source": "env"}), ("service.yml", {"interval": 60})] diff --git a/tests/onboarding/participation/test_declaration.py b/tests/onboarding/participation/test_declaration.py new file mode 100644 index 00000000..4c27d2e6 --- /dev/null +++ b/tests/onboarding/participation/test_declaration.py @@ -0,0 +1,131 @@ +"""Contract and logic tests for the entity declared in +``goga/onboarding/participation/CODEMANIFEST`` with ``location: declaration.py``: + +- ``ToolDeclaration(tool, invited)`` — the moment-one surface of one tool, + the session declaration context and its buffer + +The surface buffers what a declare-session hook declares — the questions and +the skip paths. Structural violations are warnings, never exceptions; the +engine reads the buffers after the delivery of the moment completes. +""" + +from __future__ import annotations + +import logging + +import pytest +from goga.hooks import wrap_context +from goga.onboarding.participation import ToolDeclaration +from goga.onboarding.questions import Question, QuestionGroup + +from tests.conftest import is_kw_only_dataclass + +_CELL_ALL = ["ToolContribution", "ToolDeclaration"] + +# --- Contract tests --- + + +class TestToolDeclarationContract: + def test_entity_is_importable_from_the_package_facade(self) -> None: + """The surface lives on the cell package and its ``__all__`` is exact.""" + import goga.onboarding.participation as cell + + assert cell.ToolDeclaration is ToolDeclaration + assert cell.__all__ == _CELL_ALL + + def test_keyword_construction_starts_with_empty_buffers(self) -> None: + """``ToolDeclaration(tool=..., invited=...)`` — the buffers start empty.""" + surface = ToolDeclaration(tool="t", invited=True) + + assert surface.tool == "t" + assert surface.invited is True + assert surface.questions == [] + assert surface.skips == [] + + def test_the_surface_is_a_kw_only_dataclass(self) -> None: + """The construction is keyword-only; positional arguments are refused.""" + assert is_kw_only_dataclass(ToolDeclaration) + + with pytest.raises(TypeError): + ToolDeclaration("t", True) # type: ignore[misc] + + def test_the_surface_passes_the_delivery_view(self) -> None: + """Reads resolve and buffer calls pass through the ``wrap_context`` proxy.""" + proxy = wrap_context(ToolDeclaration(tool="t", invited=True)) + + assert proxy.tool == "t" + assert proxy.invited is True + + proxy.declare(Question(id="token", kind="input", prompt="Token")) + + assert proxy.questions[0].id == "token" + + +# --- Logic tests --- + + +class TestDeclare: + def test_declare_buffers_questions_in_declaration_order(self) -> None: + """Questions and one-level groups land in the buffer in call order.""" + surface = ToolDeclaration(tool="t", invited=True) + token = Question(id="token", kind="input", prompt="Token") + reporting = QuestionGroup( + id="reporting", + prompt="Reporting", + children=[Question(id="enabled", kind="confirm", prompt="Enable reporting?", default=False)], + ) + + surface.declare(token) + surface.declare(reporting) + + assert surface.questions == [token, reporting] + + def test_declare_rejects_nested_group_with_warning(self, caplog: pytest.LogCaptureFixture) -> None: + """A tool group is limited to one nesting level — the violation is a warning.""" + surface = ToolDeclaration(tool="t", invited=True) + + with caplog.at_level(logging.WARNING): + surface.declare(QuestionGroup(id="deep", children=[QuestionGroup(id="inner")])) + + assert surface.questions == [] + assert any("one nesting level" in record.message for record in caplog.records) + assert any("t" in record.message for record in caplog.records) + + def test_declare_never_raises(self) -> None: + """Structural violations are warnings, never exceptions.""" + surface = ToolDeclaration(tool="t", invited=False) + + surface.declare(QuestionGroup(id="deep", children=[QuestionGroup(id="inner")])) + + assert surface.questions == [] + + def test_delivery_continues_after_a_refused_group(self) -> None: + """The refused element is dropped; the following declarations stand.""" + surface = ToolDeclaration(tool="t", invited=True) + token = Question(id="token", kind="input", prompt="Token") + + surface.declare(QuestionGroup(id="deep", children=[QuestionGroup(id="inner")])) + surface.declare(token) + + assert surface.questions == [token] + + def test_a_structural_group_without_children_is_accepted(self) -> None: + """A purely structural node carries no children — nothing to refuse.""" + surface = ToolDeclaration(tool="t", invited=True) + structural = QuestionGroup(id="g") + + surface.declare(structural) + + assert surface.questions == [structural] + + +class TestSkip: + def test_skip_buffers_the_raw_path(self) -> None: + """No resolution here — the engine resolves every declared skip later.""" + surface = ToolDeclaration(tool="t", invited=True) + + surface.skip("build.env") + surface.skip("docker_image.base_image") + surface.skip("viewer.opt") + + assert surface.skips == ["build.env", "docker_image.base_image", "viewer.opt"] From eb09173ae60bbd448aa23266e0196bd3925e22af Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 21:17:41 +0000 Subject: [PATCH 018/205] feat: implement ToolParticipation mediator with staged per-tool delivery --- goga/onboarding/participation/__init__.py | 3 +- .../onboarding/participation/participation.py | 214 +++++++++++ tests/onboarding/participation/conftest.py | 85 +++++ .../participation/test_contribution.py | 2 +- .../participation/test_declaration.py | 2 +- .../participation/test_participation.py | 333 ++++++++++++++++++ 6 files changed, 636 insertions(+), 3 deletions(-) create mode 100644 tests/onboarding/participation/conftest.py create mode 100644 tests/onboarding/participation/test_participation.py diff --git a/goga/onboarding/participation/__init__.py b/goga/onboarding/participation/__init__.py index e5f66b99..c94f4adc 100644 --- a/goga/onboarding/participation/__init__.py +++ b/goga/onboarding/participation/__init__.py @@ -8,5 +8,6 @@ from .contribution import ToolContribution from .declaration import ToolDeclaration +from .participation import ToolParticipation -__all__: list[str] = ["ToolContribution", "ToolDeclaration"] +__all__: list[str] = ["ToolContribution", "ToolDeclaration", "ToolParticipation"] diff --git a/goga/onboarding/participation/participation.py b/goga/onboarding/participation/participation.py index 95e8e940..2d913c5b 100644 --- a/goga/onboarding/participation/participation.py +++ b/goga/onboarding/participation/participation.py @@ -6,3 +6,217 @@ control. A failure of one tool never cancels another tool or the session; every warning names the tool, the action, and the reason. """ + +from __future__ import annotations + +import logging + +from ...hooks import HookRegistry, build_hook_arguments, enumerate_tool_packages, wrap_context +from ..questions import SessionAnswers +from .contribution import ToolContribution +from .declaration import ToolDeclaration + +logger = logging.getLogger(__name__) + + +class ToolParticipation: + """The mediator of both onboarding action moments of one session. + + Owns the invitation set and the run registry — built once on the first + moment and shared by both — and drives the per-tool delivery of the two + actions over the public primitives of the hooks platform. Delivery is + never filtered by invitation: the marker travels to the hook inside the + delivered surface, and a subscribed tool without an invitation receives + the not-invited marker and decides on its own. + + Requirements: + - Every warning names the tool, the action, and the reason + - An invited tool without a subscription to an action participates + silently — no block, no warning + - The buffered contribution of a tool applies only after every hook + of the tool of the moment completed without failure + """ + + def __init__(self, invited: list[str]) -> None: + """Create the mediator of one session. + + Args: + invited: The invited tool identities — defensively deduplicated + here, preserving the flag order. + """ + self._invited = list(dict.fromkeys(invited)) + self._registry: HookRegistry | None = None + + @property + def invited(self) -> list[str]: + """The invited tool identities, deduplicated, in flag order — a read copy.""" + return list(self._invited) + + def _ensure_registry(self) -> HookRegistry: + """Build the run registry once — the shared state of both moments. + + Returns: + The assembled registry of the run — built on the first call and + reused by both moments; never rebuilt on the same mediator. + + Raises: + ImportError: A tool package exists but its facade fails to import + — the single fatal case; the message names the package. + """ + if self._registry is None: + registry = HookRegistry() + registry.build_once() + self._registry = registry + + return self._registry + + def _warn_for_uninstalled_invited(self) -> None: + """Warn for every invited identity that is not an installed tool package. + + The session continues without the tool's block — the warning names the + identity and moves on. A subscribed tool without an invitation is a + different, silent condition handled by the invitation marker. + """ + installed = {package.tool for package in enumerate_tool_packages()} + + for name in self._invited: + if name not in installed: + logger.warning("invited tool %s is not installed; continuing without its block", name) + + def _subscriptions_by_tool(self, registry: HookRegistry, action: str) -> dict[str, list]: + """Group the subscriptions of one onboarding action per tool. + + Args: + registry: The assembled run registry. + action: The onboarding action name — ``declare_session`` or + ``amend_config``. + + Returns: + The subscriptions of the address grouped by tool identity, the + keys in enumeration order. + """ + groups: dict[str, list] = {} + + for subscription in registry.subscriptions_for("onboarding", action): + groups.setdefault(subscription.tool, []).append(subscription) + + return groups + + def _call_hooks_of( + self, + registry: HookRegistry, + tool: str, + subscriptions: list, + surface: ToolDeclaration | ToolContribution, + action: str, + ) -> bool: + """Deliver one onboarding action to every hook of one tool. + + Args: + registry: The assembled run registry. + tool: The tool identity of the receiving tool. + subscriptions: The tool's subscriptions of the action, in + enumeration order. + surface: The tool's delivered surface — the buffer its hooks + write through the delivery view. + action: The onboarding action name, for the diagnostics. + + Returns: + True when every hook of the tool completed — the surface carries + the buffered contribution; False when a hook failed — the tool's + whole contribution is discarded and the delivery continues with + the next tool. + """ + proxy = wrap_context(surface) + + try: + for subscription in subscriptions: + subscription.hook(**build_hook_arguments(subscription.hook, proxy, registry.self_context(tool))) + except Exception as reason: + logger.warning("tool %s dropped from onboarding.%s: %s", tool, action, reason) + return False + + return True + + def collect_declarations(self) -> list[ToolDeclaration]: + """Deliver the moment one — the session declaration action. + + Algorithm: + 1. Build the run registry once for the whole session — a broken + package import is a clean error naming the package, the single + fatal case + 2. Warn for every invited identity that is not among the + installed tool packages, naming it; the session continues + without its block + 3. Deliver the declaration action to every subscriber per tool, + in enumeration order: an invited tool receives an active + surface, a subscribed tool without an invitation receives the + not-invited marker and stays silent + 4. A failing hook of a tool drops that tool's whole declaration — + a warning, the session continues; the other tools stand + 5. Return the declarations of the surviving invited tools — the + blocks of the session plan; a delivered marker surface is not + a block + + Returns: + The declarations of the surviving invited tools, in enumeration + order. + """ + registry = self._ensure_registry() + self._warn_for_uninstalled_invited() + + declarations: list[ToolDeclaration] = [] + + for tool, subscriptions in self._subscriptions_by_tool(registry, "declare_session").items(): + surface = ToolDeclaration(tool=tool, invited=tool in self._invited) + + if not self._call_hooks_of(registry, tool, subscriptions, surface, "declare_session"): + continue # a failing hook dropped the tool's whole declaration + + if not surface.invited: + continue # delivered the marker — no block of the session plan + + declarations.append(surface) + + return declarations + + def collect_contributions(self, answers: SessionAnswers) -> list[ToolContribution]: + """Deliver the moment two — the config amendment action — and commit. + + Algorithm: + 1. Deliver the amendment action to every subscriber per tool, in + enumeration order, each tool with its isolated answer view + 2. A failing hook of a tool discards its whole contribution — the + amendments and the files together — with a warning; the + session continues; the other tools stand + 3. Commit every surviving contribution: apply its buffered + amendments to ``answers`` in delivery order; collect its + buffered files + 4. Return the committed contributions + + Args: + answers: The session answer space after the survey. + + Returns: + The committed contributions, in enumeration order — the file + buffers are handed to the artifact generation. + """ + + def surface_for(tool: str) -> ToolContribution: + return ToolContribution(tool=tool, invited=tool in self._invited, answers=answers.view_for(tool)) + + registry = self._ensure_registry() + + contributions: list[ToolContribution] = [] + + for tool, subscriptions in self._subscriptions_by_tool(registry, "amend_config").items(): + surface = surface_for(tool) + + if self._call_hooks_of(registry, tool, subscriptions, surface, "amend_config"): + contributions.append(surface) + + for contribution in contributions: + for path, value in contribution.amendments: + answers.amend(path, value) + + return contributions diff --git a/tests/onboarding/participation/conftest.py b/tests/onboarding/participation/conftest.py new file mode 100644 index 00000000..d8e6a08d --- /dev/null +++ b/tests/onboarding/participation/conftest.py @@ -0,0 +1,85 @@ +"""Shared fixtures of the participation cell tests — the environment boundary. + +The delivery reaches the outside world through the hooks platform at exactly +two points: the installed-distributions mapping read by +``packages_distributions`` and the ``sys.modules`` entry of a ``goga_tool_*`` +package. The fixtures below mirror the ``tests/hooks/conftest.py`` boundary +pins — conftest fixtures do not cross test directories — so the participation +mediator, the registry, and the package access run for real. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable +from types import ModuleType +from typing import Any +from unittest import mock + +import pytest + +ENUMERATION_TARGET = "goga.hooks.tools.packages.packages_distributions" +"""The attribute the enumeration reads — the single enumeration mock point.""" + + +@pytest.fixture +def pin_package_environment( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[dict[str, list[str]]], mock.MagicMock]: + """Factory: pin the installed-packages mapping the enumeration reads. + + ``mapping`` carries the shape of ``packages_distributions()`` — a + top-level module name mapped to the distributions providing it. Only the + keys matter to the platform: the filter keeps the ``goga_tool_``-prefixed + names. Pinning the mapping is what keeps the real installed tool packages + of the development environment out of the enumeration. + + Args: + monkeypatch: the pytest patcher restoring the boundary on teardown. + + Returns: + The pinning factory: mapping in, boundary mock out. + """ + + def _pin(mapping: dict[str, list[str]]) -> mock.MagicMock: + boundary = mock.MagicMock(return_value=mapping) + + monkeypatch.setattr(ENUMERATION_TARGET, boundary) + + return boundary + + return _pin + + +@pytest.fixture +def install_tool_package( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[str, Callable[[Any], None] | None], ModuleType]: + """Factory: install one fake ``goga_tool_*`` package into ``sys.modules``. + + ``register_hooks`` becomes the facade callback of the package; omitting it + leaves the facade without a callback — the quiet-skip condition. Each call + installs one package and each installation is undone on teardown — one + restored ``sys.modules`` entry per fake package. + + Args: + monkeypatch: the pytest patcher restoring ``sys.modules`` on teardown. + + Returns: + The installing factory: module name in, the installed module out. + """ + + def _install( + module_name: str, + register_hooks: Callable[[Any], None] | None = None, + ) -> ModuleType: + module = ModuleType(module_name) + + if register_hooks is not None: + module.register_hooks = register_hooks + + monkeypatch.setitem(sys.modules, module_name, module) + + return module + + return _install diff --git a/tests/onboarding/participation/test_contribution.py b/tests/onboarding/participation/test_contribution.py index a842ae76..c523d456 100644 --- a/tests/onboarding/participation/test_contribution.py +++ b/tests/onboarding/participation/test_contribution.py @@ -18,7 +18,7 @@ from tests.conftest import is_kw_only_dataclass -_CELL_ALL = ["ToolContribution", "ToolDeclaration"] +_CELL_ALL = ["ToolContribution", "ToolDeclaration", "ToolParticipation"] # --- Contract tests --- diff --git a/tests/onboarding/participation/test_declaration.py b/tests/onboarding/participation/test_declaration.py index 4c27d2e6..e0f606a4 100644 --- a/tests/onboarding/participation/test_declaration.py +++ b/tests/onboarding/participation/test_declaration.py @@ -20,7 +20,7 @@ from tests.conftest import is_kw_only_dataclass -_CELL_ALL = ["ToolContribution", "ToolDeclaration"] +_CELL_ALL = ["ToolContribution", "ToolDeclaration", "ToolParticipation"] # --- Contract tests --- diff --git a/tests/onboarding/participation/test_participation.py b/tests/onboarding/participation/test_participation.py new file mode 100644 index 00000000..cdd6c711 --- /dev/null +++ b/tests/onboarding/participation/test_participation.py @@ -0,0 +1,333 @@ +"""Contract and logic tests for the entity declared in +``goga/onboarding/participation/CODEMANIFEST`` with ``location: participation.py``: + +- ``ToolParticipation(invited)`` — the mediator of both onboarding action + moments, delivered per tool with staged control + +The environment boundary is pinned by the shared fixtures of this test +directory (``conftest.py``) — the enumeration mapping and the fake +``goga_tool_*`` modules. The mediator, the registry, and the per-tool +delivery run for real; a failing hook of one tool drops that tool only, and +the single fatal case is a broken package import. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest +from goga.onboarding.participation import ToolParticipation +from goga.onboarding.questions import Question, SessionAnswers + +_CELL_ALL = ["ToolContribution", "ToolDeclaration", "ToolParticipation"] + + +def _module(tool: str) -> str: + """Return the module name of one fake tool identity.""" + return "goga_tool_" + tool.replace("-", "_") + + +def _mapping(*tools: str) -> dict[str, list[str]]: + """Build the pinned enumeration mapping carrying the given identities.""" + return {_module(tool): [f"goga-tool-{tool}"] for tool in tools} + + +def _registering(*envelopes: tuple[str, str, str, Callable[..., None]]) -> Callable[[Any], None]: + """Build a facade callback subscribing the given envelopes.""" + + def register_hooks(hooks: Any) -> None: + for domain, action, name, hook in envelopes: + hooks.subscribe(domain, action, name, hook) + + return register_hooks + + +def _install( + install_tool_package: Callable[[str, Callable[[Any], None] | None], object], + tool: str, + *envelopes: tuple[str, str, str, Callable[..., None]], +) -> None: + """Install one fake package whose facade subscribes the given envelopes.""" + install_tool_package(_module(tool), register_hooks=_registering(*envelopes)) + + +# --- Contract tests --- + + +class TestToolParticipationContract: + def test_entity_is_importable_from_the_package_facade(self) -> None: + """The mediator lives on the cell package and its ``__all__`` is exact.""" + import goga.onboarding.participation as cell + + assert cell.ToolParticipation is ToolParticipation + assert cell.__all__ == _CELL_ALL + + def test_construction_dedups_preserving_flag_order(self) -> None: + """``invited`` is the deduplicated identity list, in flag order.""" + mediator = ToolParticipation(invited=["a", "a", "b"]) + + assert mediator.invited == ["a", "b"] + + def test_both_moments_are_callable(self) -> None: + """The two onboarding action moments are the public surface.""" + mediator = ToolParticipation(invited=[]) + + assert callable(mediator.collect_declarations) + assert callable(mediator.collect_contributions) + + +# --- Logic tests --- + + +class TestCollectDeclarations: + def test_collect_declarations_delivers_invitation_marker( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """An invited subscribed tool receives an active surface and its declaration stands.""" + + def declare_token(context: Any) -> None: + if not context.invited: + return + context.declare(Question(id="token", kind="input", prompt="Service token")) + + pin_package_environment(_mapping("my-tool")) + _install( + install_tool_package, + "my-tool", + ("onboarding", "declare_session", "d1", declare_token), + ) + + declarations = ToolParticipation(invited=["my-tool"]).collect_declarations() + + assert len(declarations) == 1 + assert declarations[0].tool == "my-tool" + assert declarations[0].invited is True + assert declarations[0].questions[0].id == "token" + + def test_collect_declarations_warns_for_uninstalled_invited( + self, + pin_package_environment, + caplog: pytest.LogCaptureFixture, + ) -> None: + """An invited identity that is not installed warns; the session continues.""" + pin_package_environment({}) + + with caplog.at_level(logging.WARNING): + declarations = ToolParticipation(invited=["ghost"]).collect_declarations() + + assert declarations == [] + assert any("ghost" in record.message for record in caplog.records) + + def test_failing_hook_drops_whole_declaration( + self, + pin_package_environment, + install_tool_package, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A crashing hook drops its tool only — the warning names the tool and the reason.""" + + def declare_ok(context: Any) -> None: + context.declare(Question(id="token", kind="input", prompt="Token")) + + def declare_boom(context: Any) -> None: + raise RuntimeError("boom") + + pin_package_environment(_mapping("bad", "good")) + _install(install_tool_package, "bad", ("onboarding", "declare_session", "d1", declare_boom)) + _install(install_tool_package, "good", ("onboarding", "declare_session", "d1", declare_ok)) + + with caplog.at_level(logging.WARNING): + declarations = ToolParticipation(invited=["bad", "good"]).collect_declarations() + + assert [declaration.tool for declaration in declarations] == ["good"] + assert any("bad" in record.message and "boom" in record.message for record in caplog.records) + + def test_noninvited_subscribed_tool_is_marked_and_silent( + self, + pin_package_environment, + install_tool_package, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Delivery is never filtered — the not-invited marker travels to the hook.""" + captured: dict[str, bool] = {} + + def declare_session(context: Any, self: Any) -> None: + self.saw_invited = context.invited + captured["declare"] = context.invited + if not context.invited: + return + context.declare(Question(id="token", kind="input", prompt="Token")) + + def amend_config(context: Any, self: Any) -> None: + self.saw_invited = context.invited + captured["amend"] = context.invited + if not context.invited: + return + context.answer("tools", {"my-tool": "latest"}) + + pin_package_environment(_mapping("my-tool", "other")) + _install( + install_tool_package, + "my-tool", + ("onboarding", "declare_session", "d1", declare_session), + ("onboarding", "amend_config", "a1", amend_config), + ) + install_tool_package(_module("other")) # invited, installed, subscribed to nothing + + mediator = ToolParticipation(invited=["other"]) + answers = SessionAnswers(tools=["my-tool"]) + + with caplog.at_level(logging.WARNING): + declarations = mediator.collect_declarations() + contributions = mediator.collect_contributions(answers) + + assert declarations == [] + assert [contribution.tool for contribution in contributions] == ["my-tool"] + assert contributions[0].amendments == [] + assert contributions[0].files == [] + assert captured["declare"] is False + assert captured["amend"] is False + assert "my-tool" not in answers.snapshot() + assert not caplog.records # silent — not a warning + + +class TestCollectContributions: + def test_collect_contributions_commits_in_order( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """The committed amendments apply in enumeration order — the later wins at a leaf.""" + + def amend_alpha(context: Any) -> None: + if not context.invited: + return + context.answer("tools", {"alpha": "1.0"}) + + def amend_beta(context: Any) -> None: + if not context.invited: + return + context.answer("tools", {"beta": "2.0"}) + + pin_package_environment(_mapping("alpha", "beta")) + _install(install_tool_package, "alpha", ("onboarding", "amend_config", "a1", amend_alpha)) + _install(install_tool_package, "beta", ("onboarding", "amend_config", "a1", amend_beta)) + + answers = SessionAnswers() + contributions = ToolParticipation(invited=["alpha", "beta"]).collect_contributions(answers) + + assert [contribution.tool for contribution in contributions] == ["alpha", "beta"] + assert answers.snapshot()["tools"] == {"alpha": "1.0", "beta": "2.0"} + + def test_amend_failure_discards_amendments_and_files( + self, + pin_package_environment, + install_tool_package, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A hook that buffers then raises loses its whole contribution — amendments and files.""" + + def amend_boom(context: Any) -> None: + context.answer("tools", {"boom": "1.0"}) + context.write_config("x.yml", {"a": 1}) + raise RuntimeError("crash") + + pin_package_environment(_mapping("boom")) + _install(install_tool_package, "boom", ("onboarding", "amend_config", "a1", amend_boom)) + + answers = SessionAnswers() + + with caplog.at_level(logging.WARNING): + contributions = ToolParticipation(invited=["boom"]).collect_contributions(answers) + + assert contributions == [] + assert "tools" not in answers.snapshot() + assert any("boom" in record.message and "crash" in record.message for record in caplog.records) + + def test_the_isolated_view_carries_no_other_tool( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """Each hook sees the core plus its own answers — never a foreign tool's.""" + seen: dict[str, dict] = {} + + def amend_alpha(context: Any) -> None: + seen["alpha"] = dict(context.answers) + + def amend_beta(context: Any) -> None: + seen["beta"] = dict(context.answers) + + pin_package_environment(_mapping("alpha", "beta")) + _install(install_tool_package, "alpha", ("onboarding", "amend_config", "a1", amend_alpha)) + _install(install_tool_package, "beta", ("onboarding", "amend_config", "a1", amend_beta)) + + answers = SessionAnswers(tools=["alpha", "beta"]) + answers.record("language", "python") + answers.record("alpha.token", "t0") + answers.record("beta.flag", True) + + ToolParticipation(invited=["alpha", "beta"]).collect_contributions(answers) + + assert seen["alpha"] == {"language": "python", "token": "t0"} + assert seen["beta"] == {"language": "python", "flag": True} + + +class TestRegistrySharing: + def test_the_registry_is_built_once_and_shared_by_both_moments( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """Both moments read one registry — moment two reads the environment no more. + + Moment one reads the enumeration twice — the registry build and the + uninstalled-invited check; a moment two that rebuilt the registry + would read it again. + """ + + def declare_session(context: Any) -> None: + context.declare(Question(id="token", kind="input", prompt="Token")) + + def amend_config(context: Any) -> None: + if not context.invited: + return + context.answer("tools", {"my-tool": "latest"}) + + boundary = pin_package_environment(_mapping("my-tool")) + _install( + install_tool_package, + "my-tool", + ("onboarding", "declare_session", "d1", declare_session), + ("onboarding", "amend_config", "a1", amend_config), + ) + + mediator = ToolParticipation(invited=["my-tool"]) + answers = SessionAnswers() + + mediator.collect_declarations() + assert boundary.call_count == 2 # the registry build + the invited check + + mediator.collect_contributions(answers) + assert boundary.call_count == 2 # the shared registry — no rebuild + + def test_a_broken_package_import_is_fatal( + self, + pin_package_environment, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A facade that fails to import is the single fatal case — ImportError propagates.""" + package_dir = tmp_path / "goga_tool_broken" + package_dir.mkdir() + (package_dir / "__init__.py").write_text("import goga_missing_dependency\n") + monkeypatch.syspath_prepend(tmp_path) + pin_package_environment({"goga_tool_broken": ["goga-tool-broken"]}) + + with pytest.raises(ImportError, match=r"package goga_tool_broken failed to import"): + ToolParticipation(invited=["broken"]).collect_declarations() From bff704ef0ef6196d90fa2e37c9a69bd6110df618 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 21:19:04 +0000 Subject: [PATCH 019/205] feat: create survey cell structure with module skeletons --- goga/onboarding/survey/__init__.py | 11 +++++++++++ goga/onboarding/survey/core.py | 9 +++++++++ goga/onboarding/survey/plan.py | 9 +++++++++ goga/onboarding/survey/questionnaire.py | 8 ++++++++ tests/onboarding/survey/__init__.py | 0 5 files changed, 37 insertions(+) create mode 100644 goga/onboarding/survey/__init__.py create mode 100644 goga/onboarding/survey/core.py create mode 100644 goga/onboarding/survey/plan.py create mode 100644 goga/onboarding/survey/questionnaire.py create mode 100644 tests/onboarding/survey/__init__.py diff --git a/goga/onboarding/survey/__init__.py b/goga/onboarding/survey/__init__.py new file mode 100644 index 00000000..20d84824 --- /dev/null +++ b/goga/onboarding/survey/__init__.py @@ -0,0 +1,11 @@ +"""Survey cell — the survey of the onboarding session. + +The owner of the core question tree, the session plan assembly with the +tool question blocks, the application of skip requests, and the +interactive run. The survey is interactive on the host through the click +practice; the core sections are conditional on the filesystem state. +Questions are declarative data — the engine asks them itself; a tool hook +is never called to survey. +""" + +__all__: list[str] = [] diff --git a/goga/onboarding/survey/core.py b/goga/onboarding/survey/core.py new file mode 100644 index 00000000..a10e422e --- /dev/null +++ b/goga/onboarding/survey/core.py @@ -0,0 +1,9 @@ +"""The core question tree of the onboarding session. + +The entity declared in the cell CODEMANIFEST with ``location: core.py``: +the tree builder ``core_questions``. The builder composes the eight core +sections in survey order — the language choice, the base-convention gate, +the codemanifest entries, the build and pipeline executors, the docker +image decision, the tools collection, and the usages records — with the +image hints completed from the runtime minor tag, never a hardcoded one. +""" diff --git a/goga/onboarding/survey/plan.py b/goga/onboarding/survey/plan.py new file mode 100644 index 00000000..b1d9bcfb --- /dev/null +++ b/goga/onboarding/survey/plan.py @@ -0,0 +1,9 @@ +"""The session plan layer of the survey. + +The entities declared in the cell CODEMANIFEST with ``location: plan.py``: +the plan record ``SessionPlan`` and the two plan routines +``assemble_session_plan`` and ``apply_skips``. Assembly joins the core +tree with the tool question blocks in one root under the reserved-name +and local-name guards; skip application resolves every declared path and +removes the addressed subtrees as one order-independent set. +""" diff --git a/goga/onboarding/survey/questionnaire.py b/goga/onboarding/survey/questionnaire.py new file mode 100644 index 00000000..1029bb9c --- /dev/null +++ b/goga/onboarding/survey/questionnaire.py @@ -0,0 +1,8 @@ +"""The interactive survey engine of the onboarding session. + +The entity declared in the cell CODEMANIFEST with ``location: questionnaire.py``: +the engine ``Questionnaire``. The engine asks the declarative records of +the plan itself — the conditional core sections and the tool blocks under +their attribution headings — and records every collected value into the +answer space at its plan path; a tool hook is never called to survey. +""" diff --git a/tests/onboarding/survey/__init__.py b/tests/onboarding/survey/__init__.py new file mode 100644 index 00000000..e69de29b From 566801eefdfdb373a63eeb0218ba71098c5efbf4 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 21:24:00 +0000 Subject: [PATCH 020/205] feat: implement core_questions tree builder in the survey cell --- goga/onboarding/survey/__init__.py | 4 +- goga/onboarding/survey/core.py | 226 +++++++++++++++++++++++++++ tests/onboarding/survey/test_core.py | 170 ++++++++++++++++++++ 3 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 tests/onboarding/survey/test_core.py diff --git a/goga/onboarding/survey/__init__.py b/goga/onboarding/survey/__init__.py index 20d84824..bd473f93 100644 --- a/goga/onboarding/survey/__init__.py +++ b/goga/onboarding/survey/__init__.py @@ -8,4 +8,6 @@ is never called to survey. """ -__all__: list[str] = [] +from .core import core_questions + +__all__: list[str] = ["core_questions"] diff --git a/goga/onboarding/survey/core.py b/goga/onboarding/survey/core.py index a10e422e..0129f137 100644 --- a/goga/onboarding/survey/core.py +++ b/goga/onboarding/survey/core.py @@ -7,3 +7,229 @@ image decision, the tools collection, and the usages records — with the image hints completed from the runtime minor tag, never a hardcoded one. """ + +from __future__ import annotations + +from ..questions import Question, QuestionGroup + +# Language → image-family mapping of the `image_defaults` practice. The +# names carry no tag: the builder completes each with the runtime minor tag +# (``{name}:{tag}``) — a hardcoded tag never appears here. +image_defaults: dict[str, list[str]] = { + "python": [ + "qarium/goga-python-3.10", + "qarium/goga-python-3.11", + "qarium/goga-python-3.12", + "qarium/goga-python-3.13", + "qarium/goga-python-3.14", + ], + "golang": [ + "qarium/goga-golang-1.23", + "qarium/goga-golang-1.24", + "qarium/goga-golang-1.25", + "qarium/goga-golang-1.26", + ], + "javascript": [ + "qarium/goga-node-22", + "qarium/goga-node-24", + ], + "kotlin": [ + "qarium/goga-kotlin-2.0", + "qarium/goga-kotlin-2.1", + "qarium/goga-kotlin-2.2", + "qarium/goga-kotlin-2.3", + ], + "swift": [ + "qarium/goga-swift-6.0", + "qarium/goga-swift-6.1", + "qarium/goga-swift-6.2", + ], +} + +# The languages offered for selection, in survey order. +_LANGUAGES = ["python", "golang", "kotlin", "swift", "javascript"] + +# Agent → env-key mapping of the `agent_env_defaults` practice. The engine +# proposes the keys of the selected agent first, then collects arbitrary +# additions. +agent_env_defaults: dict[str, list[str]] = { + "claude": [ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_MODEL", + ], + "codex": [ + "CODEX_MODEL", + ], + "cursor": [ + "CURSOR_MODEL", + ], + "opencode": [ + "OPENCODE_MODEL", + "OPENCODE_VARIANT", + ], + "qwen": [ + "OPENAI_BASE_URL", + "OPENAI_MODEL", + ], +} + +# Agents offered for selection in the wizard. Derived from +# `agent_env_defaults` so the choice list can never drift from the set of +# agents the survey can actually configure env for — every selectable agent +# has env keys, and every agent with env keys is selectable. Order follows +# insertion order above (claude, codex first for backward-compatible UX). +_AGENTS = list(agent_env_defaults) + + +def _image_hints(image_tag: str) -> list[str]: + """Complete every image name of ``image_defaults`` with ``image_tag``. + + Args: + image_tag: The runtime minor tag appended to every image name. + + Returns: + The completed hint list, in the ``image_defaults`` family order. + """ + return [f"{name}:{image_tag}" for names in image_defaults.values() for name in names] + + +def _language_section() -> Question: + """Build the language choice — the first section of every survey.""" + return Question(id="language", kind="choice", prompt="Language", choices=list(_LANGUAGES)) + + +def _convention_section() -> QuestionGroup: + """Build the base-convention gate — present only when no file exists.""" + return QuestionGroup( + id="convention", + prompt="--- Base Convention ---", + children=[Question(id="adopt", kind="confirm", prompt="Download base convention", default=False)], + ) + + +def _codemanifest_section() -> QuestionGroup: + """Build the codemanifest entries — usages pairs and annotations input. + + The records carry no defaults: the engine pre-fills both when the + base-convention gate was accepted, not the tree. + """ + return QuestionGroup( + id="codemanifest", + prompt="--- Codemanifest ---", + children=[ + Question(id="usages", kind="pairs", prompt="Codemanifest usages (name → path)"), + Question(id="annotations", kind="input", prompt="Codemanifest annotations"), + ], + ) + + +def _executor_section(section_id: str, heading: str, agent_prompt: str, env_prompt: str) -> QuestionGroup: + """Build one executor section — an agent choice plus its env pairs. + + Args: + section_id: The section local name (build or pipeline). + heading: The section heading prompt. + agent_prompt: The agent choice prompt. + env_prompt: The env pairs prompt. + + Returns: + The executor section group. + """ + return QuestionGroup( + id=section_id, + prompt=heading, + children=[ + Question(id="agent", kind="choice", prompt=agent_prompt, choices=list(_AGENTS)), + Question(id="env", kind="pairs", prompt=env_prompt), + ], + ) + + +def _docker_image_section(image_tag: str, project_name: str | None) -> QuestionGroup: + """Build the docker image section — the Dockerfile decision and names. + + The hints completed from ``image_tag`` are data of the tree — embedded + in the ``base_image`` prompt with the last hint as its default; the + engine renders them. The ``image`` default follows ``project_name``. + + Args: + image_tag: The runtime minor tag completing the image hints. + project_name: The git-derived project name; None offers no default. + + Returns: + The docker image section group. + """ + hints = _image_hints(image_tag) + base_prompt = "\n".join(["Base image (FROM)", "Available images:", *[f" - {hint}" for hint in hints]]) + image_default = f"{project_name}:latest" if project_name is not None else None + + return QuestionGroup( + id="docker_image", + prompt="--- Docker Image ---", + children=[ + Question(id="dockerfile", kind="input", prompt="Dockerfile path", default=".goga/Dockerfile"), + Question(id="base_image", kind="input", prompt=base_prompt, default=hints[-1]), + Question(id="image", kind="input", prompt="Built image name", default=image_default), + ], + ) + + +def _tools_section() -> Question: + """Build the tools collection — name → version pairs in the version grammar. + + The prompt documents the four grammar forms of ``goga/version`` and the + created files the collection drives (the config record and the + ``.goga/tools//`` configs of the invited tools). + """ + prompt = "\n".join( + [ + "Tools recorded in .goga/config.yml (name → version); " + "invited tools contribute configs under .goga/tools//", + "Version forms: latest, N.x (newest within major N), N.M.x (newest patch within N.M), " + "N.M or N.M.K (exact pin)", + "An empty version reads as latest", + ] + ) + + return Question(id="tools", kind="pairs", prompt=prompt) + + +def core_questions(image_tag: str, project_name: str | None, convention_exists: bool) -> QuestionGroup: + """Build the core question tree of the onboarding session. + + Composes the eight core sections in survey order — language, + convention, codemanifest, build, docker_image, pipeline, tools, + usages. The convention section is omitted when the base conventions + file already exists; the image hints are completed from ``image_tag`` + (never a hardcoded tag); the built-image name default follows + ``project_name``. + + Args: + image_tag: The current minor tag completing the image hints. + project_name: The git-derived project name for the built-image name + default; None offers no default. + convention_exists: True when the base conventions file already + exists (the convention section is omitted). + + Returns: + The core tree — a group whose children are the core sections; the + root id is never addressed in answers. + """ + sections: list[Question | QuestionGroup] = [_language_section()] + + if not convention_exists: + sections.append(_convention_section()) + + sections.append(_codemanifest_section()) + sections.append(_executor_section("build", "--- Build ---", "Build agent", "Build environment variables")) + sections.append(_docker_image_section(image_tag, project_name)) + sections.append( + _executor_section("pipeline", "--- Pipeline ---", "Pipeline agent", "Pipeline environment variables") + ) + sections.append(_tools_section()) + sections.append(QuestionGroup(id="usages", prompt="--- Usages ---")) + + return QuestionGroup(id="core", children=sections) diff --git a/tests/onboarding/survey/test_core.py b/tests/onboarding/survey/test_core.py new file mode 100644 index 00000000..1d5e26ba --- /dev/null +++ b/tests/onboarding/survey/test_core.py @@ -0,0 +1,170 @@ +"""Contract and logic tests for the entity declared in +``goga/onboarding/survey/CODEMANIFEST`` with ``location: core.py``: + +- ``core_questions(image_tag, project_name, convention_exists)`` — the + core question tree builder of the session + +The tree is declarative data: the eight core sections in survey order with +the image hints completed from the runtime minor tag — never a hardcoded +one. Rendering the questions belongs to the engine. +""" + +from __future__ import annotations + +from goga.onboarding.questions import Question, QuestionGroup +from goga.onboarding.survey import core_questions + +_CELL_ALL = ["core_questions"] + +_SECTION_ORDER = ["language", "convention", "codemanifest", "build", "docker_image", "pipeline", "tools", "usages"] + +# The last hint of the completed `image_defaults` list (family order: +# python, golang, javascript, kotlin, swift) — the offered base-image default. +_LAST_HINT_1_3 = "qarium/goga-swift-6.2:1.3" + + +def _sections(tree: QuestionGroup) -> dict[str, object]: + """Index the core sections by id.""" + return {child.id: child for child in tree.children} + + +def _docker_children(tree: QuestionGroup) -> dict[str, Question]: + """Index the docker_image section children by local name.""" + docker = _sections(tree)["docker_image"] + + return {child.id: child for child in docker.children} + + +# --- Contract tests --- + + +class TestCoreContract: + def test_entity_is_importable_from_the_package_facade(self) -> None: + """The builder lives on the cell package and its ``__all__`` is exact.""" + import goga.onboarding.survey as cell + + assert cell.core_questions is core_questions + assert cell.__all__ == _CELL_ALL + + def test_builder_returns_the_core_group(self) -> None: + """``core_questions(...)`` builds the ``core`` root group.""" + tree = core_questions("1.3", "my-app", False) + + assert isinstance(tree, QuestionGroup) + assert tree.id == "core" + assert tree.children is not None + + +# --- Logic tests --- + + +class TestCoreTree: + def test_core_questions_builds_eight_sections_with_tag(self) -> None: + """The eight sections in survey order; the tag completes every hint.""" + tree = core_questions("1.3", "my-app", False) + + assert [child.id for child in tree.children] == _SECTION_ORDER + + base_image = _docker_children(tree)["base_image"] + + assert "qarium/goga-python-3.14:1.3" in base_image.prompt + assert base_image.default == _LAST_HINT_1_3 + + assert _docker_children(tree)["image"].default == "my-app:latest" + + def test_existing_convention_drops_the_convention_section(self) -> None: + """``convention_exists=True`` omits the gate; language stays first.""" + tree = core_questions("1.3", None, True) + + assert tree.children[0].id == "language" + assert "convention" not in _sections(tree) + + def test_project_name_none_omits_the_image_default(self) -> None: + """Without a project name the image question offers no default.""" + tree = core_questions("1.3", None, False) + + assert _docker_children(tree)["image"].default is None + + def test_language_is_a_choice_of_the_supported_languages(self) -> None: + """The language section is one choice question, in the survey order.""" + language = _sections(core_questions("1.3", None, True))["language"] + + assert isinstance(language, Question) + assert language.kind == "choice" + assert language.choices == ["python", "golang", "kotlin", "swift", "javascript"] + + def test_convention_gate_is_a_confirm_defaulting_to_false(self) -> None: + """The convention section carries the adopt confirm, default False.""" + convention = _sections(core_questions("1.3", None, False))["convention"] + + assert isinstance(convention, QuestionGroup) + assert convention.prompt == "--- Base Convention ---" + + adopt = convention.children[0] + + assert adopt.id == "adopt" + assert adopt.kind == "confirm" + assert adopt.default is False + + def test_codemanifest_records_carry_no_tree_defaults(self) -> None: + """Usages pairs + annotations input; the prefill is engine-side.""" + codemanifest = _sections(core_questions("1.3", None, True))["codemanifest"] + + assert [child.id for child in codemanifest.children] == ["usages", "annotations"] + assert codemanifest.children[0].kind == "pairs" + assert codemanifest.children[1].kind == "input" + assert codemanifest.children[0].default is None + assert codemanifest.children[1].default is None + + def test_executor_sections_offer_agents_and_env_pairs(self) -> None: + """Build and pipeline carry an agent choice plus env pairs.""" + sections = _sections(core_questions("1.3", None, True)) + + for section_id in ("build", "pipeline"): + section = sections[section_id] + + assert isinstance(section, QuestionGroup) + assert [child.id for child in section.children] == ["agent", "env"] + + agent, env = section.children + + assert agent.kind == "choice" + assert agent.choices == ["claude", "codex", "cursor", "opencode", "qwen"] + assert env.kind == "pairs" + + def test_docker_image_children_are_free_form_inputs(self) -> None: + """Dockerfile, base_image, image — all kind input; the path defaults.""" + children = _docker_children(core_questions("1.3", "my-app", True)) + + assert [child.id for child in children.values()] == ["dockerfile", "base_image", "image"] + assert all(child.kind == "input" for child in children.values()) + assert children["dockerfile"].default == ".goga/Dockerfile" + + def test_tools_pairs_document_the_four_version_forms(self) -> None: + """The tools section documents the grammar and the created files.""" + tools = _sections(core_questions("1.3", None, True))["tools"] + + assert isinstance(tools, Question) + assert tools.kind == "pairs" + assert "latest" in tools.prompt + assert "N.x" in tools.prompt + assert "N.M.x" in tools.prompt + assert ".goga/tools//" in tools.prompt + + def test_usages_section_is_structural(self) -> None: + """The usages section is a heading group with no declarable children.""" + usages = _sections(core_questions("1.3", None, True))["usages"] + + assert isinstance(usages, QuestionGroup) + assert usages.prompt == "--- Usages ---" + assert usages.children is None + + def test_the_tag_threads_from_the_single_argument(self) -> None: + """No hardcoded tag — every completed hint follows ``image_tag``.""" + tree = core_questions("1.4", "my-app", True) + + base_image = _docker_children(tree)["base_image"] + + assert "qarium/goga-python-3.14:1.4" in base_image.prompt + assert base_image.default == "qarium/goga-swift-6.2:1.4" + assert ":1.3" not in base_image.prompt From 432f218e1d3284ddb8d569797f759967bf1498f9 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 21:30:40 +0000 Subject: [PATCH 021/205] feat: implement survey plan layer with SessionPlan, assembly, and skip application --- goga/onboarding/survey/__init__.py | 3 +- goga/onboarding/survey/plan.py | 277 +++++++++++++++++++++++++ tests/onboarding/survey/test_core.py | 2 +- tests/onboarding/survey/test_plan.py | 290 +++++++++++++++++++++++++++ 4 files changed, 570 insertions(+), 2 deletions(-) create mode 100644 tests/onboarding/survey/test_plan.py diff --git a/goga/onboarding/survey/__init__.py b/goga/onboarding/survey/__init__.py index bd473f93..0978b383 100644 --- a/goga/onboarding/survey/__init__.py +++ b/goga/onboarding/survey/__init__.py @@ -9,5 +9,6 @@ """ from .core import core_questions +from .plan import SessionPlan, apply_skips, assemble_session_plan -__all__: list[str] = ["core_questions"] +__all__: list[str] = ["SessionPlan", "apply_skips", "assemble_session_plan", "core_questions"] diff --git a/goga/onboarding/survey/plan.py b/goga/onboarding/survey/plan.py index b1d9bcfb..41d13283 100644 --- a/goga/onboarding/survey/plan.py +++ b/goga/onboarding/survey/plan.py @@ -7,3 +7,280 @@ and local-name guards; skip application resolves every declared path and removes the addressed subtrees as one order-independent set. """ + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from ..participation import ToolDeclaration +from ..questions import Question, QuestionGroup + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class SessionPlan: + """The assembled survey plan — one tree with the tool blocks as groups. + + Attributes: + root: The plan root — the core sections followed by the tool + blocks, one group per participating tool named by the tool + identity. + tools: The participating tools in block order. + + Requirements: + the record is a pure value — the routines that produce it never + mutate the received core tree; the identities of ``tools`` reserve + the top-level answer keys of the session. + """ + + root: QuestionGroup + tools: list[str] + + +def _survivors(declaration: ToolDeclaration) -> list[Question | QuestionGroup]: + """Filter the declared items of one tool down to unique local names. + + Args: + declaration: The delivered declaration of the tool. + + Returns: + The declared items in declaration order; every repeated local + name after its first occurrence is dropped with a warning naming + the tool and the reason — the survivors of the same declaration + stand. + """ + survivors: list[Question | QuestionGroup] = [] + seen: set[str] = set() + for item in declaration.questions: + if item.id in seen: + logger.warning( + "dropped the repeated element %s of tool %s: %s", + item.id, + declaration.tool, + "the local names of a tool block must be unique among siblings", + ) + continue + seen.add(item.id) + survivors.append(item) + return survivors + + +def assemble_session_plan(core: QuestionGroup, declarations: list[ToolDeclaration]) -> SessionPlan: + """Assemble the session plan — the core tree plus the tool blocks in one root. + + The core children keep their order and come first; every declaration + with questions appends one group named by the tool identity after + them, in enumeration order. The core section names are reserved — a + tool identity colliding with one drops the tool's whole block with a + warning (the tool keeps its amendment rights); a repeated local name + within one declaration drops that element only. The received core + tree is never mutated — a fresh root over fresh containers carries + the frozen originals. + + Args: + core: The core tree built by ``core_questions``. + declarations: The collected declarations of the run, in + enumeration order. + + Returns: + The assembled plan — one root whose children are the core + sections followed by the tool blocks, and the participating tools + in block order. + """ + children: list[Question | QuestionGroup] = list(core.children) + reserved = {child.id for child in core.children} + tools: list[str] = [] + + for declaration in declarations: + if not declaration.questions: + continue + if declaration.tool in reserved: + logger.warning( + "dropped the block of tool %s: %s is a reserved core section name", + declaration.tool, + declaration.tool, + ) + continue + children.append( + QuestionGroup( + id=declaration.tool, + prompt=f"--- Tool: {declaration.tool} ---", + children=_survivors(declaration), + ) + ) + tools.append(declaration.tool) + + return SessionPlan(root=QuestionGroup(id="session", children=children), tools=tools) + + +def _own_block_locals(root: QuestionGroup, tool: str) -> set[str]: + """Collect the local names of one tool's own block in the original root. + + Args: + root: The plan root — the original, pre-removal tree. + tool: The declaring tool identity. + + Returns: + The local names of the tool's block children; an empty set when + the tool carries no block. + """ + for child in root.children or []: + if child.id == tool and isinstance(child, QuestionGroup): + return {item.id for item in child.children or []} + return set() + + +def _node_exists(root: QuestionGroup, address: list[str]) -> bool: + """Report whether the address walks to an existing node of the tree. + + Args: + root: The plan root — the original, pre-removal tree. + address: The resolved path from the root. + + Returns: + True when every segment descends into an existing child — a path + that reaches a question (a pairs node) has no children to descend + into and never resolves. + """ + node: Question | QuestionGroup | None = root + for segment in address: + if not isinstance(node, QuestionGroup) or node.children is None: + return False + node = next((child for child in node.children if child.id == segment), None) + if node is None: + return False + return True + + +def _resolve_skip( + root: QuestionGroup, + tools: list[str], + core_section_ids: set[str], + tool: str, + raw_path: str, +) -> tuple[str, ...] | None: + """Resolve one declared skip path to its address from the root. + + The resolution runs against the original tree only: a descendant of + an already-skipped node still resolves here and is absorbed by the + set application later. The rule is three-way — a path whose first + segment names a participating tool addresses that tool's block (the + declaring tool included); else a core section name addresses the core + tree from the root; else a local name of the declaring tool's own + block addresses the block under the tool identity; anything else is + a no-op announced with a warning. + + Args: + root: The plan root — the original, pre-removal tree. + tools: The participating tools of the plan. + core_section_ids: The core section names, derived from the root. + tool: The declaring tool identity. + raw_path: The declared path — unprefixed or tool-prefixed. + + Returns: + The resolved address; None when the path resolves to nothing — + announced with a warning naming the tool and the raw path. + """ + segments = raw_path.split(".") + first = segments[0] + + if first in tools or first in core_section_ids: + address = segments + elif first in _own_block_locals(root, tool): + address = [tool, *segments] + else: + logger.warning( + "ignored the skip path %s of tool %s: %s", + raw_path, + tool, + "its first segment names no tool block, core section, or own-block element", + ) + return None + + if not _node_exists(root, address): + logger.warning( + "ignored the skip path %s of tool %s: %s", + raw_path, + tool, + "the resolved path reaches no node of the session plan", + ) + return None + + return tuple(address) + + +def _prune( + node: Question | QuestionGroup, + path: tuple[str, ...], + removals: set[tuple[str, ...]], +) -> Question | QuestionGroup | None: + """Rebuild one node without the removed subtrees under it. + + Args: + node: The node to prune — a frozen original of the assembled root. + path: The address of the node from the root. + removals: The resolved removal set — one order-free whole. + + Returns: + The node with the removed subtrees gone — a rebuilt group along a + removed branch, the frozen original on an unmodified branch — or + None when the node itself is removed. + """ + if path in removals: + return None + + if isinstance(node, QuestionGroup) and node.children: + kept: list[Question | QuestionGroup] = [] + rebuilt = False + for child in node.children: + pruned = _prune(child, (*path, child.id), removals) + if pruned is None: + rebuilt = True + continue + if pruned is not child: + rebuilt = True + kept.append(pruned) + if rebuilt: + return QuestionGroup(id=node.id, prompt=node.prompt, children=kept) + + return node + + +def apply_skips(plan: SessionPlan, skips: list[tuple[str, str]]) -> SessionPlan: + """Apply the declared skip requests to the plan. + + Every path resolves against the original root and the resolved nodes + are removed as one set — the result never depends on the order of + application, and a descendant of a removed node is silently absorbed. + The rebuild touches only the branches along a removal: new groups + carry the pruned children, the frozen originals are shared elsewhere. + The returned plan keeps the same tools list — an emptied block stays. + + Args: + plan: The assembled plan. + skips: The declared skips — the declaring tool identity and the + raw path. + + Returns: + The plan with the skipped subtrees removed. + """ + root = plan.root + core_section_ids = {child.id for child in root.children or []} - set(plan.tools) + + removals: set[tuple[str, ...]] = set() + for tool, raw_path in skips: + address = _resolve_skip(root, plan.tools, core_section_ids, tool, raw_path) + if address is not None: + removals.add(address) + + if not removals: + return SessionPlan(root=root, tools=list(plan.tools)) + + kept = [ + pruned + for pruned in (_prune(child, (child.id,), removals) for child in root.children or []) + if pruned is not None + ] + return SessionPlan(root=QuestionGroup(id=root.id, prompt=root.prompt, children=kept), tools=list(plan.tools)) diff --git a/tests/onboarding/survey/test_core.py b/tests/onboarding/survey/test_core.py index 1d5e26ba..33c954a0 100644 --- a/tests/onboarding/survey/test_core.py +++ b/tests/onboarding/survey/test_core.py @@ -14,7 +14,7 @@ from goga.onboarding.questions import Question, QuestionGroup from goga.onboarding.survey import core_questions -_CELL_ALL = ["core_questions"] +_CELL_ALL = ["SessionPlan", "apply_skips", "assemble_session_plan", "core_questions"] _SECTION_ORDER = ["language", "convention", "codemanifest", "build", "docker_image", "pipeline", "tools", "usages"] diff --git a/tests/onboarding/survey/test_plan.py b/tests/onboarding/survey/test_plan.py new file mode 100644 index 00000000..b79d4ae7 --- /dev/null +++ b/tests/onboarding/survey/test_plan.py @@ -0,0 +1,290 @@ +"""Contract and logic tests for the entities declared in +``goga/onboarding/survey/CODEMANIFEST`` with ``location: plan.py``: + +- ``SessionPlan(root, tools)`` — the assembled survey plan record +- ``assemble_session_plan(core, declarations)`` — the assembly of the plan + with the tool question blocks +- ``apply_skips(plan, skips)`` — the removal of the addressed subtrees + +The plan layer is pure transformation: the core tree is never mutated, the +tool blocks join the core children in enumeration order under the +reserved-name and local-name guards, and the skips resolve against the +original tree and apply as one order-independent set. +""" + +from __future__ import annotations + +import logging + +import pytest +from goga.onboarding.participation import ToolDeclaration +from goga.onboarding.questions import Question, QuestionGroup +from goga.onboarding.survey import SessionPlan, apply_skips, assemble_session_plan + +_CELL_ALL = ["SessionPlan", "apply_skips", "assemble_session_plan", "core_questions"] + + +def _core(*children: Question | QuestionGroup) -> QuestionGroup: + """Build a minimal core root carrying the given sections.""" + return QuestionGroup(id="core", children=list(children)) + + +def _declaration( + tool: str, + *items: Question | QuestionGroup, + skips: list[str] | None = None, +) -> ToolDeclaration: + """Build one delivered declaration of a tool — items declared, skips buffered.""" + surface = ToolDeclaration(tool=tool, invited=True) + for item in items: + surface.declare(item) + for path in skips or []: + surface.skip(path) + return surface + + +def _child_ids(group: QuestionGroup) -> list[str]: + """List the local names of the group's children.""" + return [child.id for child in group.children or []] + + +def _block(root: QuestionGroup, tool: str) -> QuestionGroup: + """Return the tool block group of one plan root.""" + return next(child for child in root.children if child.id == tool) + + +def _reachable_ids(node: Question | QuestionGroup) -> set[str]: + """Collect every id of the subtree under the node, the node's included.""" + ids = {node.id} + if isinstance(node, QuestionGroup): + for child in node.children or []: + ids |= _reachable_ids(child) + return ids + + +# --- Contract tests --- + + +class TestPlanContract: + def test_entities_are_importable_from_the_package_facade(self) -> None: + """The three plan names live on the cell package; ``__all__`` is exact.""" + import goga.onboarding.survey as cell + + assert cell.SessionPlan is SessionPlan + assert cell.assemble_session_plan is assemble_session_plan + assert cell.apply_skips is apply_skips + assert cell.__all__ == _CELL_ALL + + def test_assemble_of_a_bare_core_returns_the_plan_record(self) -> None: + """``assemble_session_plan(core, [])`` — the core children, no tools.""" + core = _core(Question(id="language", kind="choice", prompt="Language")) + + plan = assemble_session_plan(core, []) + + assert isinstance(plan, SessionPlan) + assert plan.root.id == "session" + assert _child_ids(plan.root) == ["language"] + assert plan.root.children == core.children + assert plan.tools == [] + + +# --- Logic tests --- + + +class TestAssembleSessionPlan: + def test_assemble_session_plan_orders_blocks_and_drops_repeats( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Blocks follow the core in enumeration order; a repeat drops singly.""" + core = _core( + Question(id="language", kind="choice", prompt="Language"), + Question(id="tools", kind="pairs", prompt="Tools"), + ) + my_tool = _declaration( + "my-tool", + Question(id="token", kind="input", prompt="Token one"), + Question(id="token", kind="input", prompt="Token two"), + ) + viewer = _declaration("viewer", Question(id="flag", kind="confirm", prompt="Flag")) + empty_tool = _declaration("empty-tool") + + with caplog.at_level(logging.WARNING): + plan = assemble_session_plan(core, [my_tool, viewer, empty_tool]) + + assert _child_ids(plan.root) == ["language", "tools", "my-tool", "viewer"] + assert plan.tools == ["my-tool", "viewer"] + assert _child_ids(_block(plan.root, "my-tool")) == ["token"] + assert _block(plan.root, "my-tool").prompt == "--- Tool: my-tool ---" + assert any("my-tool" in record.message for record in caplog.records) + + def test_assemble_reserved_name_drops_block( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A tool identity colliding with a core section name contributes no block.""" + core = _core( + Question(id="language", kind="choice", prompt="Language"), + Question(id="tools", kind="pairs", prompt="Tools"), + ) + colliding = _declaration("tools", Question(id="token", kind="input", prompt="Token")) + + with caplog.at_level(logging.WARNING): + plan = assemble_session_plan(core, [colliding]) + + assert plan.tools == [] + assert _child_ids(plan.root) == ["language", "tools"] + assert any("tools" in record.message for record in caplog.records) + + def test_reserved_names_derive_from_the_received_core(self) -> None: + """No hardcoded name list — a core without the section admits the name.""" + core = _core(Question(id="language", kind="choice", prompt="Language")) + named = _declaration("tools", Question(id="token", kind="input", prompt="Token")) + + plan = assemble_session_plan(core, [named]) + + assert _child_ids(plan.root) == ["language", "tools"] + assert plan.tools == ["tools"] + + def test_the_core_tree_is_never_mutated(self) -> None: + """Assembly builds a fresh root over fresh containers; frozen records shared.""" + language = Question(id="language", kind="choice", prompt="Language") + core = _core(language) + declaration = _declaration("my-tool", Question(id="token", kind="input", prompt="Token")) + + plan = assemble_session_plan(core, [declaration]) + + assert plan.root is not core + assert plan.root.id == "session" + assert _child_ids(core) == ["language"] + assert plan.root.children[0] is language + + +class TestApplySkips: + def _plan(self) -> SessionPlan: + """Build the reference plan — core language/build, blocks my-tool/viewer.""" + core = _core( + Question(id="language", kind="choice", prompt="Language"), + QuestionGroup( + id="build", + prompt="--- Build ---", + children=[ + Question(id="agent", kind="choice", prompt="Build agent"), + Question(id="env", kind="pairs", prompt="Build environment variables"), + ], + ), + ) + my_tool = _declaration( + "my-tool", + QuestionGroup( + id="reporting", + children=[Question(id="enabled", kind="confirm", prompt="Enable reporting")], + ), + ) + viewer = _declaration("viewer", Question(id="opt", kind="confirm", prompt="Opt in")) + return assemble_session_plan(core, [my_tool, viewer]) + + def test_apply_skips_prefixed_own_and_unknown( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Own-block, tool-prefixed, core, and unknown paths resolve per the rule.""" + skips = [ + ("my-tool", "reporting.enabled"), + ("viewer", "my-tool.reporting.enabled"), + ("viewer", "language"), + ("my-tool", "no.such.path"), + ] + + with caplog.at_level(logging.WARNING): + pruned = apply_skips(self._plan(), skips) + + assert _child_ids(pruned.root) == ["build", "my-tool", "viewer"] + assert pruned.tools == ["my-tool", "viewer"] + + my_tool_block = _block(pruned.root, "my-tool") + + assert _child_ids(my_tool_block) == ["reporting"] + assert my_tool_block.children[0].children == [] + assert "enabled" not in _reachable_ids(my_tool_block) + assert any("no.such.path" in record.message for record in caplog.records) + + def test_reversed_skip_order_yields_the_same_plan(self) -> None: + """The skips apply as one set — the order never matters.""" + skips = [ + ("my-tool", "reporting.enabled"), + ("viewer", "my-tool.reporting.enabled"), + ("viewer", "language"), + ("my-tool", "no.such.path"), + ] + + forward = apply_skips(self._plan(), skips) + backward = apply_skips(self._plan(), list(reversed(skips))) + + assert forward.root == backward.root + assert forward.tools == backward.tools + + def test_an_emptied_block_stays_in_the_plan(self) -> None: + """Skipping the whole content of a block empties it — the block stays.""" + pruned = apply_skips(self._plan(), [("my-tool", "reporting")]) + + assert _child_ids(pruned.root) == ["language", "build", "my-tool", "viewer"] + assert _block(pruned.root, "my-tool").children == [] + assert pruned.tools == ["my-tool", "viewer"] + + def test_unmodified_branches_share_the_frozen_originals(self) -> None: + """Rebuild only along removed branches; the untouched originals are shared.""" + plan = self._plan() + build = plan.root.children[1] + my_tool_block = plan.root.children[2] + viewer = plan.root.children[3] + + pruned = apply_skips(plan, [("viewer", "language")]) + + assert pruned.root is not plan.root + assert _child_ids(pruned.root) == ["build", "my-tool", "viewer"] + assert pruned.root.children[0] is build + assert pruned.root.children[1] is my_tool_block + assert pruned.root.children[2] is viewer + + def test_a_core_section_name_wins_over_the_own_block_local(self) -> None: + """The resolution order — tool identity, core section, then own block.""" + core = _core( + Question(id="language", kind="choice", prompt="Language"), + QuestionGroup(id="build", prompt="--- Build ---"), + ) + my_tool = _declaration("my-tool", Question(id="language", kind="input", prompt="Local")) + plan = assemble_session_plan(core, [my_tool]) + + pruned = apply_skips(plan, [("my-tool", "language")]) + + assert _child_ids(pruned.root) == ["build", "my-tool"] + assert _child_ids(_block(pruned.root, "my-tool")) == ["language"] + + def test_a_path_into_a_pairs_question_is_a_noop_warning( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Inside a pairs question the pairs are not addressable — only the node.""" + core = _core( + Question(id="language", kind="choice", prompt="Language"), + Question(id="tools", kind="pairs", prompt="Tools"), + ) + plan = assemble_session_plan(core, []) + + with caplog.at_level(logging.WARNING): + pruned = apply_skips(plan, [("my-tool", "tools.goga-lint")]) + + assert _child_ids(pruned.root) == ["language", "tools"] + assert any("tools.goga-lint" in record.message for record in caplog.records) + + def test_a_descendant_of_a_skipped_node_is_absorbed_silently( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Set semantics — the descendant resolves against the original tree.""" + with caplog.at_level(logging.WARNING): + pruned = apply_skips(self._plan(), [("viewer", "build"), ("viewer", "build.env")]) + + assert _child_ids(pruned.root) == ["language", "my-tool", "viewer"] + assert not caplog.records From 1d456c20e5239372e137a83a1e208250ba51d190 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 21:41:56 +0000 Subject: [PATCH 022/205] feat: implement Questionnaire survey engine in the survey cell --- goga/onboarding/survey/__init__.py | 3 +- goga/onboarding/survey/questionnaire.py | 540 +++++++++++++++++ tests/onboarding/survey/test_core.py | 2 +- tests/onboarding/survey/test_plan.py | 2 +- tests/onboarding/survey/test_questionnaire.py | 561 ++++++++++++++++++ 5 files changed, 1105 insertions(+), 3 deletions(-) create mode 100644 tests/onboarding/survey/test_questionnaire.py diff --git a/goga/onboarding/survey/__init__.py b/goga/onboarding/survey/__init__.py index 0978b383..15ece584 100644 --- a/goga/onboarding/survey/__init__.py +++ b/goga/onboarding/survey/__init__.py @@ -10,5 +10,6 @@ from .core import core_questions from .plan import SessionPlan, apply_skips, assemble_session_plan +from .questionnaire import Questionnaire -__all__: list[str] = ["SessionPlan", "apply_skips", "assemble_session_plan", "core_questions"] +__all__: list[str] = ["Questionnaire", "SessionPlan", "apply_skips", "assemble_session_plan", "core_questions"] diff --git a/goga/onboarding/survey/questionnaire.py b/goga/onboarding/survey/questionnaire.py index 1029bb9c..aa76f5f6 100644 --- a/goga/onboarding/survey/questionnaire.py +++ b/goga/onboarding/survey/questionnaire.py @@ -6,3 +6,543 @@ their attribution headings — and records every collected value into the answer space at its plan path; a tool hook is never called to survey. """ + +from __future__ import annotations + +import logging + +import click + +from ..questions import Question, QuestionGroup, SessionAnswers +from .core import agent_env_defaults +from .plan import SessionPlan + +logger = logging.getLogger(__name__) + +# The prefill pair the base-convention gate contributes to the codemanifest +# section on acceptance — ported from the old wizard's ask_base_convention. +_CONVENTION_USAGES_PREFILL = {"conventions": ".goga/usages/conventions.md"} +_CONVENTION_ANNOTATIONS_PREFILL = "Use `conventions` for code writing rules and testing." + + +def _hint_lines(prompt: str) -> list[str]: + """Collect the hint entries a question prompt carries as list lines. + + The core tree embeds the completed image hints as ``- name:tag`` list + lines of the ``base_image`` prompt; the pull branch re-renders them for + the image ask without the FROM label. + + Args: + prompt: The prompt text of the question carrying the hints. + + Returns: + The hint entries in prompt order; an empty list when the prompt + carries no list lines. + """ + hints: list[str] = [] + for line in prompt.splitlines(): + stripped = line.strip() + if stripped.startswith("- "): + hints.append(stripped[2:]) + return hints + + +class Questionnaire: + """The interactive survey engine of the session. + + The engine asks the declarative records of the plan itself: the core + sections through their conditional patterns, then every tool block under + its attribution heading, in plan order. Confirm gates are + presentational — they drive control flow and are never recorded; only + the children present in the post-skip section are asked; an unaskable + question (an unknown kind or a missing parameterization) is skipped with + a warning naming its path. + + Requirements: + a tool hook is never called to survey — the engine asks the + buffered records itself; ``click.Abort`` propagates to the caller. + """ + + def __init__(self) -> None: + """Create the engine with no active answer space. + + The space of the run and the path of the question being asked are + engine state held only while ``run`` is active — the public ask + methods stay callable outside a run and then merely return values. + """ + self._answers: SessionAnswers | None = None + self._current_path: str | None = None + + # --- Public API --- + + def run(self, plan: SessionPlan, answers: SessionAnswers) -> None: + """Run the whole survey of one plan into the answer space. + + Echoes the session header, surveys the core sections in order + through their conditional patterns, then every tool block under its + attribution heading — an emptied block is suppressed. Every + collected value lands in ``answers`` at its plan path + (``"{tool}.{local}"`` for tool answers). + + Args: + plan: The assembled plan with skips applied. + answers: The session answer space receiving the collected + values. + + Raises: + click.Abort: Propagates from any interrupted prompt. + """ + self._answers = answers + try: + click.echo("=== Goga Project Initialization ===") + click.echo("This wizard will help you set up a new goga project.\n") + + state: dict = {} + for child in plan.root.children or []: + if child.id in plan.tools and isinstance(child, QuestionGroup): + self._survey_tool_block(child) + else: + self._survey_core_section(child, state) + finally: + self._answers = None + self._current_path = None + + def ask_question(self, question: Question) -> str | bool | dict[str, str] | None: + """Ask one simple question of its kind. + + Args: + question: The question record — its prompt, offered choices or + keys, and default are rendered as asked. + + Returns: + The answer value of the kind — a string for the choice and + input kinds, a boolean for the confirm kind, a mapping of + strings for the pairs kind; None when the question is + unaskable (an unknown kind or a missing parameterization) — + announced with a warning naming the question path, never + asked, never recorded. + """ + path = self._current_path or question.id + + if question.kind == "choice": + if not question.choices: + logger.warning("skipped the question %s: %s", path, "the choice kind requires choices") + return None + return click.prompt(question.prompt, type=click.Choice(question.choices)) + + if question.kind == "input": + return click.prompt(question.prompt, default=question.default) + + if question.kind == "confirm": + return click.confirm(question.prompt, default=question.default or False) + + if question.kind == "pairs": + return self._ask_pairs(question.prompt, question.keys) + + logger.warning("skipped the question %s: %s", path, f"unknown kind {question.kind}") + return None + + def ask_group(self, group: QuestionGroup, prefix: str | None = None) -> dict: + """Ask one group — its children in order. + + Echoes the group prompt as the heading (a heading derived from the + group id when the group carries no prompt), then asks the children + in order, recursing into nested groups. The optional ``prefix`` + (the tool id) qualifies the record paths — ``"{tool}.{local}"`` for + the answers of a tool block — through the answer space of the + active run; outside a run the call only returns the mapping. + + Args: + group: The group node — a section or a tool block. + prefix: The record-path prefix of the group — the tool id for + a tool block, the dotted parent path for a nested group. + + Returns: + The mapping of the children's answers keyed by child ids. + """ + heading = group.prompt if group.prompt is not None else f"--- {group.id} ---" + click.echo(f"\n{heading}") + + collected: dict = {} + for child in group.children or []: + path = f"{prefix}.{child.id}" if prefix else child.id + if isinstance(child, QuestionGroup): + collected[child.id] = self.ask_group(child, prefix=path) + continue + self._current_path = path + try: + value = self.ask_question(child) + finally: + self._current_path = None + if value is None: + continue + collected[child.id] = value + self._record(path, value) + + return collected + + # --- Recording and asking helpers --- + + def _record(self, path: str, value: str | bool | dict) -> None: + """Record one collected value at its plan path through the run's answer space. + + Args: + path: The plan dot-path of the answered question. + value: The answer value of the question kind. + """ + if self._answers is not None: + self._answers.record(path, value) + + def _ask_pairs(self, prompt: str, keys: list[str] | None) -> dict[str, str]: + """Collect one repeated key-value collection of the pairs kind. + + The proposed keys — the suggested env keys of an agent or the + ``keys`` parameterization of the record — are rendered and offered + first through a confirm; the add-another loop then collects + arbitrary key-value pairs (the old ``_collect_agent_env`` pattern). + + Args: + prompt: The prompt text of the pairs question. + keys: The proposed keys offered first; None or empty offers + the arbitrary loop only. + + Returns: + The collected mapping; empty when nothing was collected. + """ + pairs: dict[str, str] = {} + + click.echo(prompt) + + suggested = keys or [] + if suggested: + click.echo("Suggested keys:") + for key in suggested: + click.echo(f" - {key}") + if click.confirm("Set suggested keys?", default=False): + for key in suggested: + pairs[key] = click.prompt(f" {key}") + + if click.confirm("Add another pair?", default=False): + while True: + key = click.prompt("Key") + pairs[key] = click.prompt("Value") + if not click.confirm("Add another?", default=False): + break + + return pairs + + # --- The core-section conditional patterns --- + + def _survey_core_section(self, section: Question | QuestionGroup, state: dict) -> None: + """Survey one core section through its conditional pattern. + + The pattern is selected by the section id; the eight core sections + carry their own surveys, anything else degrades to the generic + group or single-question ask. + + Args: + section: The core section — a question or a group. + state: The per-run survey state carrying the codemanifest + prefill of the base-convention gate. + """ + if isinstance(section, QuestionGroup) and section.prompt is not None: + click.echo(f"\n{section.prompt}") + + simple = { + "language": self._survey_language, + "build": self._survey_build, + "docker_image": self._survey_docker_image, + "pipeline": self._survey_pipeline, + "tools": self._survey_tools, + } + if section.id == "convention": + self._survey_convention(section, state) + elif section.id == "codemanifest": + self._survey_codemanifest(section, state) + elif section.id == "usages": + self._survey_usages() + elif (handler := simple.get(section.id)) is not None: + handler(section) + elif isinstance(section, QuestionGroup): + self.ask_group(section) + else: + value = self.ask_question(section) + if value is not None: + self._record(section.id, value) + + def _survey_language(self, section: Question) -> None: + """Survey the language choice — the first question of every session.""" + self._record("language", self.ask_question(section)) + + def _survey_convention(self, section: QuestionGroup, state: dict) -> None: + """Survey the base-convention gate. + + The gate is presentational — its answer is never recorded; it only + pre-fills the codemanifest section on acceptance. + + Args: + section: The convention section — the adopt confirm. + state: The per-run survey state receiving the prefill pair. + """ + adopt = next((child for child in section.children or [] if child.id == "adopt"), None) + accepted = bool(self.ask_question(adopt)) if adopt is not None else False + + if accepted: + state["codemanifest_usages"] = dict(_CONVENTION_USAGES_PREFILL) + state["codemanifest_annotations"] = _CONVENTION_ANNOTATIONS_PREFILL + else: + state["codemanifest_usages"] = None + state["codemanifest_annotations"] = None + + def _survey_codemanifest(self, section: QuestionGroup, state: dict) -> None: + """Survey the codemanifest entries — the usages pairs, then the annotations input. + + Both records carry no tree defaults: the prefill of the convention + gate is engine-side state offered first. + + Args: + section: The codemanifest section — usages and annotations. + state: The per-run survey state carrying the prefill pair. + """ + children = {child.id: child for child in section.children or []} + + usages_question = children.get("usages") + if usages_question is not None: + usages = self._collect_usages(usages_question, state.get("codemanifest_usages")) + if usages: + self._record("codemanifest.usages", usages) + + annotations_question = children.get("annotations") + if annotations_question is not None: + annotations = self._collect_annotations(annotations_question, state.get("codemanifest_annotations")) + if annotations is not None: + self._record("codemanifest.annotations", annotations) + + def _collect_usages(self, question: Question, prefill: dict | None) -> dict | None: + """Collect the codemanifest usages onto the prefill of the convention gate. + + The prefill entries are offered first; the gate then offers the + repeated name-value collection, a repeated name skipped with a + note — ported from the old wizard's ask_codemanifest_usages. + + Args: + question: The usages pairs record. + prefill: The pre-filled entries of the convention gate; None + when the gate was declined or absent. + + Returns: + The merged usages mapping; None when neither prefill nor input + exists. + """ + usages = dict(prefill) if prefill else None + + click.echo(question.prompt) + if usages: + click.echo("Prefilled usages:") + for name, path in usages.items(): + click.echo(f" {name}: {path}") + + if click.confirm("Add codemanifest usages?", default=False): + if usages is None: + usages = {} + while True: + name = click.prompt("Usage name") + if name in usages: + click.echo(f'Usage "{name}" already exists, skipping.') + else: + usages[name] = click.prompt("Usage value") + if not click.confirm("Add another codemanifest usage?", default=False): + break + + return usages + + def _collect_annotations(self, question: Question, prefill: str | None) -> str | None: + """Collect the codemanifest annotations appended to the prefill text. + + Args: + question: The annotations input record. + prefill: The pre-filled text of the convention gate; None when + the gate was declined or absent. + + Returns: + The merged annotations text; None when neither exists. + """ + annotations = prefill + + if click.confirm("Add codemanifest annotations?", default=False): + custom = click.prompt(question.prompt) + annotations = f"{annotations}\n{custom}" if annotations is not None else custom + + return annotations + + def _survey_build(self, section: QuestionGroup) -> None: + """Survey the build executor — the agent gate, then agent and env.""" + self._survey_executor(section, "build") + + def _survey_pipeline(self, section: QuestionGroup) -> None: + """Survey the pipeline executor — the agent gate, then agent and env.""" + self._survey_executor(section, "pipeline") + + def _survey_executor(self, section: QuestionGroup, section_id: str) -> None: + """Survey one executor section — the gated agent choice plus env pairs. + + The gate is presentational: declining records nothing for the + section. Accepting asks the agent choice, then the env pairs with + the suggested keys of the selected agent offered first and + arbitrary additions after. A child absent from the post-skip + section is never asked — the branch collapses to the remaining + path. + + Args: + section: The executor section — agent and env. + section_id: The section local name (build or pipeline). + """ + if not click.confirm(f"Configure a {section_id} agent?", default=False): + return + + children = {child.id: child for child in section.children or []} + + agent: str | None = None + agent_question = children.get("agent") + if agent_question is not None: + agent = self.ask_question(agent_question) + self._record(f"{section_id}.agent", agent) + + env_question = children.get("env") + if env_question is not None: + env = self._ask_pairs(env_question.prompt, agent_env_defaults.get(agent, [])) + if env: + self._record(f"{section_id}.env", env) + + def _survey_docker_image(self, section: QuestionGroup) -> None: + """Survey the docker image section through the Dockerfile decision. + + A skipped ``dockerfile`` question collapses the gate — the pull + branch runs directly. With the gate: acceptance asks the Dockerfile + path, the base image of the FROM (only when present), and the + built-image name; rejection pulls a pre-built image instead. A + skipped ``base_image`` collapses the FROM — never asked, never + recorded. + + Args: + section: The docker image section — dockerfile, base_image, + image. + """ + children = {child.id: child for child in section.children or []} + + if "dockerfile" not in children: + self._ask_pull_image(children) + return + + if not click.confirm("Create Dockerfile?", default=False): + self._ask_pull_image(children) + return + + self._record("docker_image.dockerfile", self.ask_question(children["dockerfile"])) + + base_image = children.get("base_image") + if base_image is not None: + self._record("docker_image.base_image", self.ask_question(base_image)) + + image = children.get("image") + if image is not None: + self._record("docker_image.image", self.ask_question(image)) + + def _ask_pull_image(self, children: dict) -> None: + """Ask the pre-built image to pull — the no-Dockerfile branch. + + The hints of the ``base_image`` record are rendered when the tree + carries them (their last entry is the offered default); without + them the ask is plain free-form. + + Args: + children: The children of the post-skip docker image section, + keyed by local name. + """ + image = children.get("image") + if image is None: + return + + base_image = children.get("base_image") + if base_image is not None: + hints = _hint_lines(base_image.prompt) + if hints: + click.echo("Available images:") + for hint in hints: + click.echo(f" - {hint}") + value = click.prompt("Docker image", default=base_image.default) + else: + value = click.prompt("Docker image", default=image.default) + + self._record("docker_image.image", value) + + def _survey_tools(self, section: Question) -> None: + """Survey the tools collection — the confirm-gated name → version pairs. + + The gate is presentational; the prompt documents the four version + grammar forms; an empty version input reads as latest. + + Args: + section: The tools pairs record. + """ + click.echo(section.prompt) + + if not click.confirm("Add tools?", default=False): + return + + tools: dict[str, str] = {} + while True: + name = click.prompt("Tool name") + version = click.prompt("Tool version", default="latest") + tools[name] = version + if not click.confirm("Add another tool?", default=False): + break + + self._record("tools", tools) + + def _survey_usages(self) -> None: + """Survey the usages records — the confirm-gated record loop. + + The section is structural — no declarable children; the engine + drives the record loop. Per record: group, dependency name, git + URL, optional ref and root (an empty input omits the optional + entry). The records accumulate as + ``{group: {dep: {git, ref?, root?}}}`` — a later record of the + same group merges under the group key. + """ + if not click.confirm("Add usages records?", default=False): + return + + records: dict[str, dict[str, dict[str, str]]] = {} + while True: + group = click.prompt("Usage group") + dependency = click.prompt("Dependency name") + entry: dict[str, str] = {"git": click.prompt("Git URL")} + ref = click.prompt("Ref (optional)", default="") + if ref: + entry["ref"] = ref + root = click.prompt("Root (optional)", default="") + if root: + entry["root"] = root + records.setdefault(group, {})[dependency] = entry + if not click.confirm("Add another usage record?", default=False): + break + + self._record("usages", records) + + # --- The tool blocks --- + + def _survey_tool_block(self, block: QuestionGroup) -> None: + """Survey one tool block under its attribution heading. + + An emptied block is suppressed — no heading, no questions. The + heading renders from the block id when the block carries no + prompt. + + Args: + block: The tool block group — the buffered records of one + tool's declaration. + """ + if not block.children: + return + self.ask_group(block, prefix=block.id) diff --git a/tests/onboarding/survey/test_core.py b/tests/onboarding/survey/test_core.py index 33c954a0..3775618f 100644 --- a/tests/onboarding/survey/test_core.py +++ b/tests/onboarding/survey/test_core.py @@ -14,7 +14,7 @@ from goga.onboarding.questions import Question, QuestionGroup from goga.onboarding.survey import core_questions -_CELL_ALL = ["SessionPlan", "apply_skips", "assemble_session_plan", "core_questions"] +_CELL_ALL = ["Questionnaire", "SessionPlan", "apply_skips", "assemble_session_plan", "core_questions"] _SECTION_ORDER = ["language", "convention", "codemanifest", "build", "docker_image", "pipeline", "tools", "usages"] diff --git a/tests/onboarding/survey/test_plan.py b/tests/onboarding/survey/test_plan.py index b79d4ae7..b5171218 100644 --- a/tests/onboarding/survey/test_plan.py +++ b/tests/onboarding/survey/test_plan.py @@ -21,7 +21,7 @@ from goga.onboarding.questions import Question, QuestionGroup from goga.onboarding.survey import SessionPlan, apply_skips, assemble_session_plan -_CELL_ALL = ["SessionPlan", "apply_skips", "assemble_session_plan", "core_questions"] +_CELL_ALL = ["Questionnaire", "SessionPlan", "apply_skips", "assemble_session_plan", "core_questions"] def _core(*children: Question | QuestionGroup) -> QuestionGroup: diff --git a/tests/onboarding/survey/test_questionnaire.py b/tests/onboarding/survey/test_questionnaire.py new file mode 100644 index 00000000..09ac4358 --- /dev/null +++ b/tests/onboarding/survey/test_questionnaire.py @@ -0,0 +1,561 @@ +"""Contract and logic tests for the entity declared in +``goga/onboarding/survey/CODEMANIFEST`` with ``location: questionnaire.py``: + +- ``Questionnaire()`` — the interactive survey engine of the session + +The engine asks the declarative records of the plan itself — the conditional +core sections and the tool blocks under their attribution headings — and +records every value at its plan path. Confirm gates are presentational and +never recorded; a skipped subtree is never asked; an unaskable question is +skipped with a warning naming its path. +""" + +from __future__ import annotations + +import logging + +import click +import pytest +from click.testing import CliRunner, Result +from goga.onboarding.participation import ToolDeclaration +from goga.onboarding.questions import Question, QuestionGroup, SessionAnswers +from goga.onboarding.survey import ( + Questionnaire, + SessionPlan, + apply_skips, + assemble_session_plan, + core_questions, +) + +_CELL_ALL = ["Questionnaire", "SessionPlan", "apply_skips", "assemble_session_plan", "core_questions"] + +# The last completed hint of the `image_defaults` families (python, golang, +# javascript, kotlin, swift) — the offered default of every image ask. +_LAST_HINT_1_3 = "qarium/goga-swift-6.2:1.3" + + +def _declaration(tool: str, *items: Question | QuestionGroup) -> ToolDeclaration: + """Build one delivered declaration of a tool — items declared, no skips.""" + surface = ToolDeclaration(tool=tool, invited=True) + for item in items: + surface.declare(item) + return surface + + +def _minimal_plan(*declarations: ToolDeclaration) -> SessionPlan: + """Build a plan from a minimal core — the language choice only.""" + core = QuestionGroup( + id="core", + children=[Question(id="language", kind="choice", prompt="Language", choices=["python", "golang"])], + ) + return assemble_session_plan(core, list(declarations)) + + +def _full_plan(convention_exists: bool) -> SessionPlan: + """Build the plan of the full core tree with the 1.3 tag and a project name.""" + core = core_questions("1.3", "my-app", convention_exists) + return assemble_session_plan(core, []) + + +def _run_survey(plan: SessionPlan, answers: SessionAnswers, inputs: list[str]) -> Result: + """Drive one survey through a click command under the CliRunner.""" + runner = CliRunner() + + @click.command() + def _session() -> None: + Questionnaire().run(plan, answers) + + return runner.invoke(_session, input="".join(f"{line}\n" for line in inputs)) + + +# --- Contract tests --- + + +class TestQuestionnaireContract: + def test_entity_is_importable_from_the_package_facade(self) -> None: + """The engine lives on the cell package and its ``__all__`` is exact.""" + import goga.onboarding.survey as cell + + assert cell.Questionnaire is Questionnaire + assert cell.__all__ == _CELL_ALL + + def test_the_engine_constructs_with_no_arguments(self) -> None: + """``Questionnaire()`` carries no required collaborators.""" + assert Questionnaire() is not None + + def test_the_public_methods_are_callable(self) -> None: + """``run``, ``ask_question``, and ``ask_group`` are callable.""" + engine = Questionnaire() + + assert callable(engine.run) + assert callable(engine.ask_question) + assert callable(engine.ask_group) + + def test_ask_group_one_argument_shape_returns_the_mapping(self) -> None: + """The declared one-argument call asks the children and returns their mapping.""" + engine = Questionnaire() + group = QuestionGroup(id="g", prompt="--- G ---", children=[Question(id="token", kind="input", prompt="Token")]) + runner = CliRunner() + + @click.command() + def _ask() -> None: + click.echo(f"collected={engine.ask_group(group)}") + + result = runner.invoke(_ask, input="t0\n") + + assert result.exit_code == 0 + assert "'token': 't0'" in result.output + + +# --- Logic tests — the run over the plan --- + + +class TestRunSurvey: + def test_questionnaire_records_core_and_tool_answers(self) -> None: + """Core answers land at their section path; tool answers nest under the tool key.""" + plan = _minimal_plan(_declaration("my-tool", Question(id="token", kind="input", prompt="Token"))) + answers = SessionAnswers(tools=["my-tool"]) + + result = _run_survey(plan, answers, ["python", "t0"]) + + assert result.exit_code == 0 + assert answers.snapshot() == {"language": "python", "my-tool": {"token": "t0"}} + + def test_unknown_kind_is_skipped_with_warning( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + """An unknown kind is never asked — a warning names the question path.""" + plan = _minimal_plan( + _declaration( + "my-tool", + Question(id="bad", kind="text", prompt="Weird"), + Question(id="ok", kind="input", prompt="Token"), + ) + ) + answers = SessionAnswers(tools=["my-tool"]) + + with caplog.at_level(logging.WARNING): + result = _run_survey(plan, answers, ["python", "t0"]) + + assert result.exit_code == 0 + assert answers.snapshot() == {"language": "python", "my-tool": {"ok": "t0"}} + assert any("my-tool.bad" in record.message for record in caplog.records) + assert "Weird" not in result.output + + def test_missing_parameterization_is_skipped_with_warning( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A choice without choices is unaskable — skipped with a warning.""" + plan = _minimal_plan( + _declaration( + "my-tool", + Question(id="pick", kind="choice", prompt="Pick"), + Question(id="ok", kind="input", prompt="Token"), + ) + ) + answers = SessionAnswers(tools=["my-tool"]) + + with caplog.at_level(logging.WARNING): + result = _run_survey(plan, answers, ["python", "t0"]) + + assert result.exit_code == 0 + assert answers.snapshot() == {"language": "python", "my-tool": {"ok": "t0"}} + assert any("my-tool.pick" in record.message for record in caplog.records) + + def test_the_session_header_is_echoed(self) -> None: + """The run opens with the ported session header and wizard description.""" + plan = _minimal_plan() + answers = SessionAnswers() + + result = _run_survey(plan, answers, ["python"]) + + assert "=== Goga Project Initialization ===" in result.output + assert "This wizard will help you set up a new goga project." in result.output + + def test_an_emptied_tool_block_is_suppressed(self) -> None: + """A block emptied by skips renders no heading and asks nothing.""" + plan = _minimal_plan( + _declaration("my-tool", Question(id="token", kind="input", prompt="Token")), + _declaration("viewer", Question(id="opt", kind="confirm", prompt="Opt in")), + ) + skips = [("my-tool", "token"), ("viewer", "opt")] + answers = SessionAnswers(tools=["my-tool", "viewer"]) + + result = _run_survey(apply_skips(plan, skips), answers, ["python"]) + + assert result.exit_code == 0 + assert answers.snapshot() == {"language": "python"} + assert "--- Tool: my-tool ---" not in result.output + assert "--- Tool: viewer ---" not in result.output + + def test_nested_tool_groups_record_at_nested_paths(self) -> None: + """A nested group of a tool block records under its group key.""" + plan = _minimal_plan( + _declaration( + "my-tool", + QuestionGroup( + id="reporting", + children=[Question(id="enabled", kind="confirm", prompt="Enable reporting")], + ), + Question(id="token", kind="input", prompt="Token"), + ) + ) + answers = SessionAnswers(tools=["my-tool"]) + + result = _run_survey(plan, answers, ["python", "y", "t0"]) + + assert result.exit_code == 0 + assert answers.snapshot() == { + "language": "python", + "my-tool": {"reporting": {"enabled": True}, "token": "t0"}, + } + assert "--- reporting ---" in result.output + + def test_click_abort_propagates(self) -> None: + """An interrupted prompt aborts the session — the engine swallows nothing.""" + plan = _minimal_plan() + answers = SessionAnswers() + + result = _run_survey(plan, answers, []) + + assert result.exit_code != 0 + + +# --- Logic tests — the core-section patterns (ported from the old wizard) --- + + +class TestCorePatterns: + def test_convention_acceptance_prefills_codemanifest(self) -> None: + """Accepting the gate pre-fills usages and annotations; the pull image defaults.""" + answers = SessionAnswers() + + result = _run_survey( + _full_plan(convention_exists=False), + answers, + [ + "python", # Language + "y", # Download base convention + "n", # Add codemanifest usages? + "n", # Add codemanifest annotations? + "n", # Configure a build agent? + "n", # Create Dockerfile? + "", # Docker image → the last hint default + "n", # Configure a pipeline agent? + "n", # Add tools? + "n", # Add usages records? + ], + ) + + assert result.exit_code == 0 + assert answers.snapshot() == { + "language": "python", + "codemanifest": { + "usages": {"conventions": ".goga/usages/conventions.md"}, + "annotations": "Use `conventions` for code writing rules and testing.", + }, + "docker_image": {"image": _LAST_HINT_1_3}, + } + # The gate itself is presentational — never a recorded section. + assert "convention" not in answers.snapshot() + # The pull branch renders the hints, never the FROM label. + assert "qarium/goga-python-3.14:1.3" in result.output + assert "Available images:" in result.output + assert "Base image" not in result.output + + def test_convention_rejection_records_no_codemanifest(self) -> None: + """Declining the gate leaves no codemanifest entries; a custom pull image stands.""" + answers = SessionAnswers() + + result = _run_survey( + _full_plan(convention_exists=False), + answers, + [ + "golang", # Language + "n", # Download base convention + "n", # Add codemanifest usages? + "n", # Add codemanifest annotations? + "n", # Configure a build agent? + "n", # Create Dockerfile? + "my-custom/golang:2.0", # Docker image (free-form) + "n", # Configure a pipeline agent? + "n", # Add tools? + "n", # Add usages records? + ], + ) + + assert result.exit_code == 0 + assert answers.snapshot() == {"language": "golang", "docker_image": {"image": "my-custom/golang:2.0"}} + assert "codemanifest" not in answers.snapshot() + + def test_existing_convention_drops_the_gate(self) -> None: + """A core built with convention_exists=True never asks the gate.""" + answers = SessionAnswers() + + result = _run_survey( + _full_plan(convention_exists=True), + answers, + [ + "python", # Language + "n", # Add codemanifest usages? + "n", # Add codemanifest annotations? + "n", # Configure a build agent? + "n", # Create Dockerfile? + "", # Docker image → the last hint default + "n", # Configure a pipeline agent? + "n", # Add tools? + "n", # Add usages records? + ], + ) + + assert result.exit_code == 0 + assert "Download base convention" not in result.output + assert answers.snapshot() == {"language": "python", "docker_image": {"image": _LAST_HINT_1_3}} + + def test_duplicate_usage_name_is_skipped_with_a_note(self) -> None: + """A repeated usage name is skipped; the collection continues (ported).""" + answers = SessionAnswers() + + result = _run_survey( + _full_plan(convention_exists=False), + answers, + [ + "python", # Language + "y", # Download base convention + "y", # Add codemanifest usages? + "conventions", # duplicate of the prefill entry + "y", # Add another codemanifest usage? + "custom", # usage name + ".goga/usages/custom.md", # usage value + "n", # Add another codemanifest usage? + "n", # Add codemanifest annotations? + "n", # Configure a build agent? + "n", # Create Dockerfile? + "", # Docker image → the last hint default + "n", # Configure a pipeline agent? + "n", # Add tools? + "n", # Add usages records? + ], + ) + + assert result.exit_code == 0 + assert answers.snapshot()["codemanifest"]["usages"] == { + "conventions": ".goga/usages/conventions.md", + "custom": ".goga/usages/custom.md", + } + assert 'already exists, skipping.' in result.output + + def test_agent_gates_collect_env_with_suggested_keys(self) -> None: + """Accepting an agent gate records agent + env; suggested keys render first (ported).""" + answers = SessionAnswers() + + result = _run_survey( + _full_plan(convention_exists=True), + answers, + [ + "python", # Language + "n", # Add codemanifest usages? + "n", # Add codemanifest annotations? + "y", # Configure a build agent? + "claude", # Build agent + "n", # Set suggested keys? + "y", # Add another pair? + "API_KEY", # key + "secret", # value + "y", # Add another? + "MODEL", # key + "gpt-4", # value + "n", # Add another? + "n", # Create Dockerfile? + "", # Docker image → the last hint default + "y", # Configure a pipeline agent? + "codex", # Pipeline agent + "y", # Set suggested keys? (CODEX_MODEL) + "gpt-5", # CODEX_MODEL value + "n", # Add another pair? + "n", # Add tools? + "n", # Add usages records? + ], + ) + + assert result.exit_code == 0 + snapshot = answers.snapshot() + assert snapshot["build"] == {"agent": "claude", "env": {"API_KEY": "secret", "MODEL": "gpt-4"}} + assert snapshot["pipeline"] == {"agent": "codex", "env": {"CODEX_MODEL": "gpt-5"}} + assert "CODEX_MODEL" in result.output + + def test_declining_the_agent_gates_records_nothing(self) -> None: + """Declining both executor gates leaves no build or pipeline sections.""" + answers = SessionAnswers() + + result = _run_survey( + _full_plan(convention_exists=True), + answers, + [ + "python", # Language + "n", # Add codemanifest usages? + "n", # Add codemanifest annotations? + "n", # Configure a build agent? + "n", # Create Dockerfile? + "", # Docker image → the last hint default + "n", # Configure a pipeline agent? + "n", # Add tools? + "n", # Add usages records? + ], + ) + + assert result.exit_code == 0 + assert answers.snapshot() == {"language": "python", "docker_image": {"image": _LAST_HINT_1_3}} + + def test_dockerfile_branch_records_path_from_and_built_name(self) -> None: + """Accepting the Dockerfile gate asks path, FROM base, and built name (ported).""" + answers = SessionAnswers() + + result = _run_survey( + _full_plan(convention_exists=True), + answers, + [ + "python", # Language + "n", # Add codemanifest usages? + "n", # Add codemanifest annotations? + "n", # Configure a build agent? + "y", # Create Dockerfile? + "", # Dockerfile path → .goga/Dockerfile + "", # Base image (FROM) → the last hint default + "", # Built image name → my-app:latest + "n", # Configure a pipeline agent? + "n", # Add tools? + "n", # Add usages records? + ], + ) + + assert result.exit_code == 0 + assert answers.snapshot()["docker_image"] == { + "dockerfile": ".goga/Dockerfile", + "base_image": _LAST_HINT_1_3, + "image": "my-app:latest", + } + assert "Base image (FROM)" in result.output + assert "Built image name" in result.output + assert "qarium/goga-python-3.14:1.3" in result.output + + def test_a_skipped_base_image_collapses_the_from(self) -> None: + """Skipping base_image never asks the FROM; the built name still records.""" + plan = apply_skips(_full_plan(convention_exists=True), [("skipper", "docker_image.base_image")]) + answers = SessionAnswers() + + result = _run_survey( + plan, + answers, + [ + "python", # Language + "n", # Add codemanifest usages? + "n", # Add codemanifest annotations? + "n", # Configure a build agent? + "y", # Create Dockerfile? + "", # Dockerfile path → .goga/Dockerfile + "", # Built image name → my-app:latest + "n", # Configure a pipeline agent? + "n", # Add tools? + "n", # Add usages records? + ], + ) + + assert result.exit_code == 0 + assert answers.snapshot()["docker_image"] == {"dockerfile": ".goga/Dockerfile", "image": "my-app:latest"} + assert "Base image" not in result.output + + def test_a_skipped_dockerfile_runs_the_pull_branch_without_the_gate(self) -> None: + """Skipping dockerfile collapses the gate — the pull image directly.""" + plan = apply_skips(_full_plan(convention_exists=True), [("skipper", "docker_image.dockerfile")]) + answers = SessionAnswers() + + result = _run_survey( + plan, + answers, + [ + "python", # Language + "n", # Add codemanifest usages? + "n", # Add codemanifest annotations? + "n", # Configure a build agent? + "", # Docker image → the last hint default (no gate asked) + "n", # Configure a pipeline agent? + "n", # Add tools? + "n", # Add usages records? + ], + ) + + assert result.exit_code == 0 + assert answers.snapshot()["docker_image"] == {"image": _LAST_HINT_1_3} + assert "Create Dockerfile?" not in result.output + assert "Available images:" in result.output + + def test_tools_pairs_empty_version_reads_as_latest(self) -> None: + """The tools gate opens the name → version loop; an empty version is latest.""" + answers = SessionAnswers() + + result = _run_survey( + _full_plan(convention_exists=True), + answers, + [ + "python", # Language + "n", # Add codemanifest usages? + "n", # Add codemanifest annotations? + "n", # Configure a build agent? + "n", # Create Dockerfile? + "", # Docker image → the last hint default + "n", # Configure a pipeline agent? + "y", # Add tools? + "goga-lint", # Tool name + "", # Tool version → latest + "y", # Add another tool? + "goga-mkdocs", # Tool name + "1.0", # Tool version + "n", # Add another tool? + "n", # Add usages records? + ], + ) + + assert result.exit_code == 0 + assert answers.snapshot()["tools"] == {"goga-lint": "latest", "goga-mkdocs": "1.0"} + + def test_usages_record_loop_accumulates_nested_records(self) -> None: + """The usages loop nests records under their group; optional entries omit.""" + answers = SessionAnswers() + + result = _run_survey( + _full_plan(convention_exists=True), + answers, + [ + "python", # Language + "n", # Add codemanifest usages? + "n", # Add codemanifest annotations? + "n", # Configure a build agent? + "n", # Create Dockerfile? + "", # Docker image → the last hint default + "n", # Configure a pipeline agent? + "n", # Add tools? + "y", # Add usages records? + "goga/hooks", # Usage group + "goga-lint", # Dependency name + "https://github.com/qarium/goga-lint", # Git URL + "", # Ref (optional) + "", # Root (optional) + "y", # Add another usage record? + "goga/hooks", # the same group merges under its key + "goga-viewer", # Dependency name + "https://github.com/qarium/goga-viewer", # Git URL + "0.1.0", # Ref (optional) + "", # Root (optional) + "n", # Add another usage record? + ], + ) + + assert result.exit_code == 0 + assert answers.snapshot()["usages"] == { + "goga/hooks": { + "goga-lint": {"git": "https://github.com/qarium/goga-lint"}, + "goga-viewer": {"git": "https://github.com/qarium/goga-viewer", "ref": "0.1.0"}, + } + } From 1546a176061b402c6a5685f4b4125f2cd5e9bd8f Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 21:43:26 +0000 Subject: [PATCH 023/205] feat: create generator cell structure with module skeleton --- goga/onboarding/generator/generator.py | 10 ++++++++++ tests/onboarding/generator/__init__.py | 0 2 files changed, 10 insertions(+) create mode 100644 goga/onboarding/generator/generator.py create mode 100644 tests/onboarding/generator/__init__.py diff --git a/goga/onboarding/generator/generator.py b/goga/onboarding/generator/generator.py new file mode 100644 index 00000000..1f60ad63 --- /dev/null +++ b/goga/onboarding/generator/generator.py @@ -0,0 +1,10 @@ +"""The artifact generator of the onboarding session. + +The entities declared in the cell CODEMANIFEST with ``location: generator.py``: +the generator ``FileGenerator`` and the report record ``CreatedFile``. The +generator writes every artifact of the session from the committed answer +space and the committed tool contributions — the project config, the +Dockerfile, the base conventions download, and the tool config files — and +reports the created files with attribution. An existing .goga/config.yml is +never rewritten: whoever created it first wins. +""" diff --git a/tests/onboarding/generator/__init__.py b/tests/onboarding/generator/__init__.py new file mode 100644 index 00000000..e69de29b From 021cee759e998809465d342e4d8621c3b3aa9f29 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 21:48:46 +0000 Subject: [PATCH 024/205] feat: implement FileGenerator artifact core with CreatedFile record and generator cell facade --- goga/onboarding/generator/__init__.py | 12 + goga/onboarding/generator/generator.py | 317 +++++++++++++++++++ tests/onboarding/generator/test_generator.py | 234 ++++++++++++++ 3 files changed, 563 insertions(+) create mode 100644 goga/onboarding/generator/__init__.py create mode 100644 tests/onboarding/generator/test_generator.py diff --git a/goga/onboarding/generator/__init__.py b/goga/onboarding/generator/__init__.py new file mode 100644 index 00000000..e30f73e0 --- /dev/null +++ b/goga/onboarding/generator/__init__.py @@ -0,0 +1,12 @@ +"""Generator cell — the artifact generation of the onboarding session. + +The owner of every artifact write of the run: the project config, the +Dockerfile, the base conventions download, and the tool config files — +generated from the committed answer space and the committed tool +contributions, and reported with attribution. An existing .goga/config.yml +is never rewritten: whoever created it first wins. +""" + +from .generator import CreatedFile, FileGenerator + +__all__: list[str] = ["CreatedFile", "FileGenerator"] diff --git a/goga/onboarding/generator/generator.py b/goga/onboarding/generator/generator.py index 1f60ad63..527d4160 100644 --- a/goga/onboarding/generator/generator.py +++ b/goga/onboarding/generator/generator.py @@ -8,3 +8,320 @@ reports the created files with attribution. An existing .goga/config.yml is never rewritten: whoever created it first wins. """ + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path + +import requests +import yaml + +from ..participation import ToolContribution +from ..questions import SessionAnswers + +logger = logging.getLogger(__name__) + +_CONVENTION_URL_TEMPLATE = ( + "https://raw.githubusercontent.com/qarium/goga-lang-conventions/refs/heads/0.0.x/{language}/project.md" +) + +_CONVENTIONS_PATH = Path(".goga") / "usages" / "conventions.md" +_CONFIG_PATH = Path(".goga") / "config.yml" + + +class _LiteralStr(str): + """String subclass that serializes as YAML literal block scalar (|).""" + + +def _represent_literal_str(dumper: yaml.Dumper, data: _LiteralStr) -> yaml.ScalarNode: + text = data if data.endswith("\n") else data + "\n" + return dumper.represent_scalar("tag:yaml.org,2002:str", text, style="|") + + +yaml.add_representer(_LiteralStr, _represent_literal_str) + + +@dataclass(frozen=True, kw_only=True) +class CreatedFile: + """One entry of the final file report — a created file with its attribution. + + Attributes: + path: The created file path relative to the project root. + tool: The tool identity of the file; None for an engine file. + """ + + path: str + tool: str | None + + +def _executor_block(section: dict) -> dict | None: + """Assemble a build.task_executor / pipeline content dict. + + Keys are emitted in field order (``agent``, then ``env``). The block is + omitted entirely when it carries no content (no agent and no/empty env). + + Args: + section: The snapshot section carrying the ``agent`` and ``env`` + answers of one executor. + + Returns: + A dict with `agent` and/or `env` keys (in that order), or None when + the block carries no content — signalling the caller to omit it. + """ + block: dict = {} + + agent = section.get("agent") + if agent is not None: + block["agent"] = agent + + env = section.get("env") + if env: + block["env"] = env + + return block or None + + +def _codemanifest_block(section: dict) -> dict | None: + """Assemble the optional codemanifest block, or None when it has no content. + + Args: + section: The snapshot codemanifest section carrying ``usages`` and + ``annotations``. + + Returns: + A codemanifest dict with `usages` and/or `annotations` keys, or None + when neither is present — signalling the caller to omit the block. + """ + usages = section.get("usages") + annotations = section.get("annotations") + + if not usages and annotations is None: + return None + + block: dict = {} + + if usages: + block["usages"] = usages + + if annotations is not None: + block["annotations"] = _LiteralStr(annotations) + + return block + + +def _conventions_requested(snapshot: dict) -> bool: + """Check whether the snapshot asks for the base conventions download. + + Args: + snapshot: The committed answer snapshot. + + Returns: + True when the codemanifest usages carry the ``conventions`` entry — + the single condition of the download and its report entry. + """ + codemanifest = snapshot.get("codemanifest") + usages = codemanifest.get("usages") if isinstance(codemanifest, dict) else None + + return isinstance(usages, dict) and "conventions" in usages + + +def _build_config_document(snapshot: dict) -> dict: + """Assemble the config.yml mapping from the answer snapshot. + + Only the mapped fields of the snapshot enter the document — every other + top-level key (the confirm gates, the tool sections) never reaches the + config. Field order: language, image, dockerfile, build, pipeline, + codemanifest, tools, usages; optional fields and empty blocks are + omitted. + + Args: + snapshot: The committed answer snapshot. + + Returns: + The ordered mapping to serialize into .goga/config.yml. + """ + docker_image = snapshot.get("docker_image") or {} + + data: dict = {"language": snapshot["language"]} + + image = docker_image.get("image") + if image is not None: + data["image"] = image + + # The dockerfile field appears only when the Dockerfile was written — + # both the path and the base image must be present. + dockerfile = docker_image.get("dockerfile") + if dockerfile is not None and docker_image.get("base_image") is not None: + data["dockerfile"] = dockerfile + + build_block = _executor_block(snapshot.get("build") or {}) + if build_block is not None: + data["build"] = {"task_executor": build_block} + + pipeline_block = _executor_block(snapshot.get("pipeline") or {}) + if pipeline_block is not None: + data["pipeline"] = pipeline_block + + codemanifest_block = _codemanifest_block(snapshot.get("codemanifest") or {}) + if codemanifest_block is not None: + data["codemanifest"] = codemanifest_block + + tools = snapshot.get("tools") + if tools: + data["tools"] = tools + + usages = snapshot.get("usages") + if usages: + data["usages"] = usages + + return data + + +def _write_tool_configs(contributions: list[ToolContribution]) -> list[CreatedFile]: + """Write every buffered tool config file and collect the report entries. + + Args: + contributions: The committed contributions, in enumeration order. + + Returns: + The created tool files with attribution, in write order — one entry + per buffered write; a repeated file name replaces the file. + """ + files: list[CreatedFile] = [] + + for contribution in contributions: + tool_dir = Path(".goga") / "tools" / contribution.tool + + for file, data in contribution.files: + tool_dir.mkdir(parents=True, exist_ok=True) + path = tool_dir / file + + with path.open("w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False) + + files.append(CreatedFile(path=str(path), tool=contribution.tool)) + + return files + + +class FileGenerator: + """The artifact generator of the session — every write of the run. + + The generator consumes the committed answer space and the committed tool + contributions: the Dockerfile from the base-image answer, the project + config from the snapshot, the base conventions download behind the + codemanifest usages entry, and the tool config files buffered by the + tools. It is the single write path of the session — a tool never writes + its config files itself — and the report it returns is the single source + of the final file list. + + Requirements: + an existing .goga/config.yml is never rewritten — whoever created + it first wins; the guarantee lives here, not only at the caller. + """ + + def __init__(self) -> None: + """Create the generator.""" + + def generate(self, answers: SessionAnswers, contributions: list[ToolContribution]) -> list[CreatedFile]: + """Generate every artifact of the session and report the created files. + + Args: + answers: The committed answer space of the session. + contributions: The committed contributions, in enumeration order. + + Returns: + The created files with attribution — an engine file carries a + None tool, a tool file carries the tool identity — in generation + order: the Dockerfile, the downloaded conventions.md, the config, + then the tool files. + + Raises: + ValueError: When the snapshot carries no language — the single + required-field check of the session. + RuntimeError: When the conventions download fails — the config + is then not created. + """ + files: list[CreatedFile] = [] + + if not _CONFIG_PATH.is_file(): + snapshot = answers.snapshot() + + docker_image = snapshot.get("docker_image") or {} + dockerfile = docker_image.get("dockerfile") + base_image = docker_image.get("base_image") + + if dockerfile is not None and base_image is not None: + path = Path(dockerfile) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"FROM {base_image}\n", encoding="utf-8") + files.append(CreatedFile(path=dockerfile, tool=None)) + + self.generate_goga_config(answers) + + if _conventions_requested(snapshot): + files.append(CreatedFile(path=str(_CONVENTIONS_PATH), tool=None)) + + files.append(CreatedFile(path=str(_CONFIG_PATH), tool=None)) + + files.extend(_write_tool_configs(contributions)) + + return files + + def generate_goga_config(self, answers: SessionAnswers) -> None: + """Generate .goga/config.yml from the answer snapshot. + + Downloads the base conventions per the `lang_conventions` practice + when the codemanifest usages carry the conventions entry — the file + is written before the config; a download failure is a clean error + with the URL and the cause, and the config is then not created. + + Args: + answers: The committed answer space. + + Raises: + ValueError: When the snapshot carries no language — the single + required-field check. + RuntimeError: When the conventions download fails; config.yml is + NOT created. + """ + snapshot = answers.snapshot() + + language = snapshot.get("language") + if not language: + raise ValueError("the survey must record the language field — the config cannot be generated without it") + + if _conventions_requested(snapshot): + url = _CONVENTION_URL_TEMPLATE.format(language=language) + + logger.info("downloading convention", extra={"language": language, "url": url}) + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + content = response.text + except requests.RequestException as exc: + logger.error("convention download failed", extra={"url": url, "error": str(exc)}) + raise RuntimeError(f"Failed to download convention from {url}: {exc}") from exc + + _CONVENTIONS_PATH.parent.mkdir(parents=True, exist_ok=True) + _CONVENTIONS_PATH.write_text(content, encoding="utf-8") + + _CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + + data = _build_config_document(snapshot) + + with _CONFIG_PATH.open("w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False) + + def generate_tool_configs(self, contributions: list[ToolContribution]) -> None: + """Generate the tool config files from the committed contributions. + + The buffered data is written verbatim, without interpretation — the + engine is the single write path of the tool configs. + + Args: + contributions: The committed contributions, in enumeration order. + """ + _write_tool_configs(contributions) diff --git a/tests/onboarding/generator/test_generator.py b/tests/onboarding/generator/test_generator.py new file mode 100644 index 00000000..23067ff7 --- /dev/null +++ b/tests/onboarding/generator/test_generator.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import requests +import yaml +from goga.config import load_project_config +from goga.onboarding.generator import CreatedFile, FileGenerator +from goga.onboarding.participation import ToolContribution +from goga.onboarding.questions import SessionAnswers + +pytestmark = pytest.mark.usefixtures("_clean_cwd") + + +class TestContract: + """Contract-level tests for the generator cell facade.""" + + def test_file_generator_and_created_file_importable_from_facade(self) -> None: + from goga.onboarding.generator import CreatedFile, FileGenerator + + assert FileGenerator is not None + assert CreatedFile is not None + + def test_facade_all_lists_both_names(self) -> None: + import goga.onboarding.generator as facade + + assert {"CreatedFile", "FileGenerator"} <= set(facade.__all__) + + def test_file_generator_constructs_with_no_arguments(self) -> None: + assert FileGenerator() is not None + + def test_created_file_exposes_both_fields(self) -> None: + record = CreatedFile(path="p", tool=None) + + assert record.path == "p" + assert record.tool is None + + def test_created_file_carries_the_tool_identity(self) -> None: + record = CreatedFile(path=".goga/tools/my-tool/service.yml", tool="my-tool") + + assert record.tool == "my-tool" + + def test_generator_methods_callable_on_the_instance(self) -> None: + generator = FileGenerator() + + for name in ("generate", "generate_goga_config", "generate_tool_configs"): + assert callable(getattr(generator, name)) + + +class TestLogic: + """Logic tests for the snapshot-driven generator — `_clean_cwd` filesystem.""" + + def test_generate_writes_dockerfile_then_config(self) -> None: + answers = SessionAnswers() + answers.record("language", "python") + answers.record( + "docker_image", + { + "dockerfile": ".goga/Dockerfile", + "base_image": "qarium/goga-python-3.13:1.3", + "image": "my-app:latest", + }, + ) + + files = FileGenerator().generate(answers, []) + + assert Path(".goga/Dockerfile").read_text(encoding="utf-8") == "FROM qarium/goga-python-3.13:1.3\n" + + cfg = yaml.safe_load(Path(".goga/config.yml").read_text(encoding="utf-8")) + assert cfg["language"] == "python" + assert cfg["image"] == "my-app:latest" + assert cfg["dockerfile"] == ".goga/Dockerfile" + assert "base_image" not in cfg + + assert [f.path for f in files] == [".goga/Dockerfile", ".goga/config.yml"] + assert all(f.tool is None for f in files) + + def test_generate_empty_language_is_clean_error(self) -> None: + answers = SessionAnswers() + answers.record("docker_image", {"image": "my-app:latest"}) + + with pytest.raises(ValueError, match="language"): + FileGenerator().generate(answers, []) + + assert not Path(".goga/config.yml").exists() + + def test_conventions_download_failure_names_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + answers = SessionAnswers() + answers.record("language", "python") + answers.record("codemanifest", {"usages": {"conventions": ".goga/usages/conventions.md"}}) + + def _raise(url: str, timeout: int) -> None: + raise requests.ConnectionError("down") + + monkeypatch.setattr(requests, "get", _raise) + + with pytest.raises(RuntimeError, match=r"https://raw\.githubusercontent\.com/.*/python/project\.md"): + FileGenerator().generate(answers, []) + + assert not Path(".goga/config.yml").exists() + + def test_existing_config_skips_generation_returns_tool_files_only(self) -> None: + Path(".goga").mkdir() + Path(".goga/config.yml").write_text("language: python\n") + + answers = SessionAnswers() + answers.record("language", "python") + contribution = ToolContribution(tool="my-tool", invited=True, answers={}) + contribution.write_config("service.yml", {"token_source": "env"}) + + files = FileGenerator().generate(answers, [contribution]) + + assert [f.path for f in files] == [".goga/tools/my-tool/service.yml"] + assert files[0].tool == "my-tool" + assert Path(".goga/tools/my-tool/service.yml").exists() + assert not Path(".goga/Dockerfile").exists() + + def test_skipped_base_image_collapses_dockerfile_branch(self) -> None: + answers = SessionAnswers() + answers.record("language", "python") + answers.record( + "docker_image", + {"dockerfile": ".goga/Dockerfile", "image": "my-app:latest"}, + ) + + FileGenerator().generate(answers, []) + + assert not Path(".goga/Dockerfile").exists() + + cfg = yaml.safe_load(Path(".goga/config.yml").read_text(encoding="utf-8")) + assert "dockerfile" not in cfg + assert cfg["image"] == "my-app:latest" + + def test_generate_maps_the_whole_snapshot_in_field_order(self) -> None: + answers = SessionAnswers() + answers.record("language", "python") + answers.record( + "docker_image", + {"dockerfile": ".goga/Dockerfile", "base_image": "qarium/goga-python-3.13:1.3", "image": "my-app:latest"}, + ) + answers.record("build", {"agent": "claude", "env": {"API_KEY": "secret"}}) + answers.record("pipeline", {"agent": "codex", "env": {"CODEX_MODEL": "x"}}) + answers.record( + "codemanifest", + { + "usages": {"custom": ".goga/usages/custom.md"}, + "annotations": "Use conventions for code writing rules.", + }, + ) + answers.record("tools", {"my-tool": "latest"}) + answers.record("usages", {"cell": {"dep": {"git": "https://example.com/repo.git", "ref": "main"}}}) + + FileGenerator().generate(answers, []) + + text = Path(".goga/config.yml").read_text(encoding="utf-8") + cfg = yaml.safe_load(text) + + assert list(cfg.keys()) == [ + "language", + "image", + "dockerfile", + "build", + "pipeline", + "codemanifest", + "tools", + "usages", + ] + assert cfg["build"] == {"task_executor": {"agent": "claude", "env": {"API_KEY": "secret"}}} + assert cfg["pipeline"] == {"agent": "codex", "env": {"CODEX_MODEL": "x"}} + assert cfg["codemanifest"]["usages"] == {"custom": ".goga/usages/custom.md"} + assert cfg["codemanifest"]["annotations"] == "Use conventions for code writing rules.\n" + assert "annotations: |" in text + assert cfg["tools"] == {"my-tool": "latest"} + assert cfg["usages"] == {"cell": {"dep": {"git": "https://example.com/repo.git", "ref": "main"}}} + + def test_conventions_download_writes_conventions_md_between_dockerfile_and_config( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + answers = SessionAnswers() + answers.record("language", "python") + answers.record( + "docker_image", + { + "dockerfile": ".goga/Dockerfile", + "base_image": "qarium/goga-python-3.13:1.3", + "image": "my-app:latest", + }, + ) + answers.record("codemanifest", {"usages": {"conventions": ".goga/usages/conventions.md"}}) + + response = MagicMock() + response.text = "# Python conventions" + response.raise_for_status = MagicMock() + monkeypatch.setattr(requests, "get", MagicMock(return_value=response)) + + files = FileGenerator().generate(answers, []) + + assert Path(".goga/usages/conventions.md").read_text(encoding="utf-8") == "# Python conventions" + assert [f.path for f in files] == [".goga/Dockerfile", ".goga/usages/conventions.md", ".goga/config.yml"] + + def test_written_config_passes_the_project_config_loader(self) -> None: + answers = SessionAnswers() + answers.record("language", "python") + answers.record("docker_image", {"image": "my-app:latest"}) + answers.record("build", {"agent": "claude", "env": {"API_KEY": "secret"}}) + answers.record("tools", {"my-tool": "latest"}) + answers.record("usages", {"cell": {"dep": {"git": "https://example.com/repo.git"}}}) + + FileGenerator().generate(answers, []) + + config = load_project_config() + + assert config.lang == "python" + assert config.image == "my-app:latest" + assert config.build is not None + assert config.build.task_executor.agent == "claude" + assert config.tools == {"my-tool": "latest"} + assert config.usages is not None + assert config.usages["cell"]["dep"].git == "https://example.com/repo.git" + + def test_generate_tool_configs_noop_on_empty_list(self) -> None: + assert FileGenerator().generate_tool_configs([]) is None + assert not Path(".goga").exists() + + def test_generate_tool_configs_writes_yaml_with_attribution(self) -> None: + contribution = ToolContribution(tool="my-tool", invited=True, answers={}) + contribution.write_config("service.yml", {"token_source": "env"}) + contribution.write_config("service.yml", {"interval": 60}) + + FileGenerator().generate_tool_configs([contribution]) + + assert yaml.safe_load(Path(".goga/tools/my-tool/service.yml").read_text(encoding="utf-8")) == {"interval": 60} From 2075d77effad857cc1b0251cdb05be568e9367eb Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 21:50:55 +0000 Subject: [PATCH 025/205] feat: verify tool-config generation with attribution and staged-commit negative trace --- tests/onboarding/generator/conftest.py | 84 ++++++++++++++++++++ tests/onboarding/generator/test_generator.py | 52 +++++++++++- 2 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 tests/onboarding/generator/conftest.py diff --git a/tests/onboarding/generator/conftest.py b/tests/onboarding/generator/conftest.py new file mode 100644 index 00000000..4e129124 --- /dev/null +++ b/tests/onboarding/generator/conftest.py @@ -0,0 +1,84 @@ +"""Shared fixtures of the generator cell tests — the environment boundary. + +The cross-entity negative trace drives the participation delivery for real: +the installed-distributions mapping read by ``packages_distributions`` and the +``sys.modules`` entry of a ``goga_tool_*`` package are the only outside points. +The fixtures below mirror the ``tests/hooks/conftest.py`` boundary pins — +conftest fixtures do not cross test directories — so the mediator, the +registry, and the generator run for real. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable +from types import ModuleType +from typing import Any +from unittest import mock + +import pytest + +ENUMERATION_TARGET = "goga.hooks.tools.packages.packages_distributions" +"""The attribute the enumeration reads — the single enumeration mock point.""" + + +@pytest.fixture +def pin_package_environment( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[dict[str, list[str]]], mock.MagicMock]: + """Factory: pin the installed-packages mapping the enumeration reads. + + ``mapping`` carries the shape of ``packages_distributions()`` — a + top-level module name mapped to the distributions providing it. Pinning + the mapping is what keeps the real installed tool packages of the + development environment out of the enumeration. + + Args: + monkeypatch: the pytest patcher restoring the boundary on teardown. + + Returns: + The pinning factory: mapping in, boundary mock out. + """ + + def _pin(mapping: dict[str, list[str]]) -> mock.MagicMock: + boundary = mock.MagicMock(return_value=mapping) + + monkeypatch.setattr(ENUMERATION_TARGET, boundary) + + return boundary + + return _pin + + +@pytest.fixture +def install_tool_package( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[str, Callable[[Any], None] | None], ModuleType]: + """Factory: install one fake ``goga_tool_*`` package into ``sys.modules``. + + ``register_hooks`` becomes the facade callback of the package; omitting it + leaves the facade without a callback — the quiet-skip condition. Each call + installs one package and each installation is undone on teardown — one + restored ``sys.modules`` entry per fake package. + + Args: + monkeypatch: the pytest patcher restoring ``sys.modules`` on teardown. + + Returns: + The installing factory: module name in, the installed module out. + """ + + def _install( + module_name: str, + register_hooks: Callable[[Any], None] | None = None, + ) -> ModuleType: + module = ModuleType(module_name) + + if register_hooks is not None: + module.register_hooks = register_hooks + + monkeypatch.setitem(sys.modules, module_name, module) + + return module + + return _install diff --git a/tests/onboarding/generator/test_generator.py b/tests/onboarding/generator/test_generator.py index 23067ff7..dadec330 100644 --- a/tests/onboarding/generator/test_generator.py +++ b/tests/onboarding/generator/test_generator.py @@ -1,6 +1,8 @@ from __future__ import annotations +import logging from pathlib import Path +from typing import Any from unittest.mock import MagicMock import pytest @@ -8,7 +10,7 @@ import yaml from goga.config import load_project_config from goga.onboarding.generator import CreatedFile, FileGenerator -from goga.onboarding.participation import ToolContribution +from goga.onboarding.participation import ToolContribution, ToolParticipation from goga.onboarding.questions import SessionAnswers pytestmark = pytest.mark.usefixtures("_clean_cwd") @@ -224,11 +226,55 @@ def test_generate_tool_configs_noop_on_empty_list(self) -> None: assert FileGenerator().generate_tool_configs([]) is None assert not Path(".goga").exists() - def test_generate_tool_configs_writes_yaml_with_attribution(self) -> None: + def test_generate_tool_configs_with_attribution(self) -> None: + answers = SessionAnswers() + answers.record("language", "python") + contribution = ToolContribution(tool="my-tool", invited=True, answers={}) contribution.write_config("service.yml", {"token_source": "env"}) contribution.write_config("service.yml", {"interval": 60}) - FileGenerator().generate_tool_configs([contribution]) + files = FileGenerator().generate(answers, [contribution]) assert yaml.safe_load(Path(".goga/tools/my-tool/service.yml").read_text(encoding="utf-8")) == {"interval": 60} + assert files[-1].tool == "my-tool" + assert files[-1].path == ".goga/tools/my-tool/service.yml" + + +class TestStagedCommit: + """The staged-commit story end to end — the cross-entity negative trace.""" + + def test_failing_hook_discards_files_with_amendments( + self, + pin_package_environment, + install_tool_package, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A hook that buffers then raises leaves nothing behind — the core config still stands.""" + + def amend_boom(context: Any) -> None: + context.answer("tools", {"my-tool": "latest"}) + context.write_config("x.yml", {"a": 1}) + raise RuntimeError("crash") + + def register_hooks(hooks: Any) -> None: + hooks.subscribe("onboarding", "amend_config", "a1", amend_boom) + + pin_package_environment({"goga_tool_my_tool": ["goga-tool-my-tool"]}) + install_tool_package("goga_tool_my_tool", register_hooks=register_hooks) + + answers = SessionAnswers() + answers.record("language", "python") + + with caplog.at_level(logging.WARNING): + contributions = ToolParticipation(invited=["my-tool"]).collect_contributions(answers) + + FileGenerator().generate(answers, []) + + assert contributions == [] + assert "tools" not in answers.snapshot() + assert not Path(".goga/tools").exists() + + cfg = yaml.safe_load(Path(".goga/config.yml").read_text(encoding="utf-8")) + assert cfg == {"language": "python"} + assert any("my-tool" in record.message for record in caplog.records) From 68abeb37e582ae69c75f3560d6a003af3537fc56 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 22:03:24 +0000 Subject: [PATCH 026/205] feat: rewrite onboarding facade with three-collaborator InitLogic and delete the old answer modules --- goga/onboarding/__init__.py | 35 +- goga/onboarding/answers.py | 26 - goga/onboarding/generator.py | 174 --- goga/onboarding/logic.py | 131 +- goga/onboarding/questionnaire.py | 486 ------- tests/commands/test_init.py | 29 +- .../config/test_resolve_project_name_flows.py | 101 +- tests/onboarding/test_answers.py | 170 --- tests/onboarding/test_generator.py | 578 -------- tests/onboarding/test_integration.py | 461 +----- tests/onboarding/test_logic.py | 248 +++- tests/onboarding/test_questionnaire.py | 1289 ----------------- 12 files changed, 400 insertions(+), 3328 deletions(-) delete mode 100644 goga/onboarding/answers.py delete mode 100644 goga/onboarding/generator.py delete mode 100644 goga/onboarding/questionnaire.py delete mode 100644 tests/onboarding/test_answers.py delete mode 100644 tests/onboarding/test_generator.py delete mode 100644 tests/onboarding/test_questionnaire.py diff --git a/goga/onboarding/__init__.py b/goga/onboarding/__init__.py index b35559a2..80038b6e 100644 --- a/goga/onboarding/__init__.py +++ b/goga/onboarding/__init__.py @@ -1,12 +1,33 @@ -from .answers import GogaConfigAnswers, InitAnswers -from .generator import FileGenerator +"""Facade of the onboarding domain — the initialization session orchestration. + +The owner of the session orchestration and the re-export point of the +public session API of the leaf cells: the question-and-answer model, the +survey, the tool participation, and the artifact generation. Consumers +address the domain through this facade only. +""" + +from .generator import CreatedFile, FileGenerator from .logic import InitLogic -from .questionnaire import Questionnaire +from .participation import ToolContribution, ToolDeclaration, ToolParticipation +from .questions import Question, QuestionGroup, SessionAnswers +from .survey import Questionnaire, SessionPlan, apply_skips, assemble_session_plan, core_questions -__all__ = [ +# The order is the embedding order of the domain CODEMANIFEST (the 13 +# re-exported entities followed by the orchestrator) — a contract order, +# deliberately not the isort-style sort. +__all__: list[str] = [ # noqa: RUF022 + "Question", + "QuestionGroup", + "SessionAnswers", + "SessionPlan", + "Questionnaire", + "core_questions", + "assemble_session_plan", + "apply_skips", + "ToolParticipation", + "ToolDeclaration", + "ToolContribution", "FileGenerator", - "GogaConfigAnswers", - "InitAnswers", + "CreatedFile", "InitLogic", - "Questionnaire", ] diff --git a/goga/onboarding/answers.py b/goga/onboarding/answers.py deleted file mode 100644 index fce6303c..00000000 --- a/goga/onboarding/answers.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True, kw_only=True) -class GogaConfigAnswers: - """Answers for creating .goga/config.yml.""" - - language: str - image: str - agent: str | None = None - pipeline_agent: str | None = None - pipeline_env: dict | None = None - env: dict | None = None - codemanifest_usages: dict | None = None - codemanifest_annotations: str | None = None - dockerfile_path: str | None = None - dockerfile_base_image: str | None = None - - -@dataclass(frozen=True, kw_only=True) -class InitAnswers: - """User answers container. Extensible for future config files.""" - - goga_config: GogaConfigAnswers | None = None diff --git a/goga/onboarding/generator.py b/goga/onboarding/generator.py deleted file mode 100644 index 4d702b56..00000000 --- a/goga/onboarding/generator.py +++ /dev/null @@ -1,174 +0,0 @@ -from __future__ import annotations - -import logging -from pathlib import Path - -import requests -import yaml - -from .answers import GogaConfigAnswers, InitAnswers - -logger = logging.getLogger(__name__) - - -class _LiteralStr(str): - """String subclass that serializes as YAML literal block scalar (|).""" - - -def _represent_literal_str(dumper: yaml.Dumper, data: _LiteralStr) -> yaml.ScalarNode: - text = data if data.endswith("\n") else data + "\n" - return dumper.represent_scalar("tag:yaml.org,2002:str", text, style="|") - - -yaml.add_representer(_LiteralStr, _represent_literal_str) - -_CONVENTION_URL_TEMPLATE = ( - "https://raw.githubusercontent.com/qarium/goga-lang-conventions/refs/heads/0.0.x/{language}/project.md" -) - - -class FileGenerator: - """Generates project files from user answers.""" - - def __init__(self) -> None: - self._base_dir: Path = Path() - - def generate(self, answers: InitAnswers) -> None: - """Create all files based on answers. - - If dockerfile_path is set — creates a Dockerfile whose FROM is the - selected base image (dockerfile_base_image), then generates config.yml. - The top-level image field holds the name of the image built from this - Dockerfile (distinct from the FROM base). - - Args: - answers: User answers container with goga config payload. - - Raises: - RuntimeError: If convention download fails. - """ - config = answers.goga_config - - if config is None: - return - - if config.dockerfile_path is not None: - dockerfile_content = f"FROM {config.dockerfile_base_image}\n" - dockerfile = self._base_dir / config.dockerfile_path - dockerfile.parent.mkdir(parents=True, exist_ok=True) - dockerfile.write_text(dockerfile_content, encoding="utf-8") - - self.generate_goga_config(config) - - def generate_goga_config(self, config: GogaConfigAnswers) -> None: - """Create .goga/config.yml from GogaConfigAnswers. - - Downloads convention if codemanifest_usages contains 'conventions'. - Raises on download failure — config.yml is NOT created. - - Args: - config: Goga config payload with language, agent, image, and codemanifest fields. - - Raises: - RuntimeError: If convention download fails when 'conventions' usage is requested. - """ - usages = config.codemanifest_usages - - if usages is not None and "conventions" in usages: - url = _CONVENTION_URL_TEMPLATE.format(language=config.language) - - logger.info("downloading convention", extra={"language": config.language, "url": url}) - try: - response = requests.get(url, timeout=30) - response.raise_for_status() - content = response.text - except requests.RequestException as exc: - logger.error("convention download failed", extra={"url": url, "error": str(exc)}) - raise RuntimeError(f"Failed to download convention from {url}: {exc}") from exc - - usages_dir = self._base_dir / ".goga" / "usages" - usages_dir.mkdir(parents=True, exist_ok=True) - (usages_dir / "conventions.md").write_text(content, encoding="utf-8") - - goga_dir = self._base_dir / ".goga" - goga_dir.mkdir(parents=True, exist_ok=True) - - # Field order: language, image, dockerfile, build, pipeline, codemanifest. - # `dockerfile` is emitted only when dockerfile_path is set. - # `commands` has no source in GogaConfigAnswers, so it is never emitted. - # `build`/`pipeline` are emitted only when they carry content (agent or - # env); with no agent configured by default, an empty block would add no - # value, so it is omitted entirely. - data: dict = { - "language": config.language, - "image": config.image, - } - - if config.dockerfile_path is not None: - data["dockerfile"] = config.dockerfile_path - - build_block = _build_block(config.agent, config.env) - if build_block is not None: - data["build"] = {"task_executor": build_block} - - pipeline_block = _build_block(config.pipeline_agent, config.pipeline_env) - if pipeline_block is not None: - data["pipeline"] = pipeline_block - - codemanifest_block = _build_codemanifest_block(config) - if codemanifest_block is not None: - data["codemanifest"] = codemanifest_block - - with (goga_dir / "config.yml").open("w", encoding="utf-8") as f: - yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False) - - -def _build_block(agent: str | None, env: dict | None) -> dict | None: - """Assemble a build.task_executor / pipeline content dict. - - Keys are emitted in field order (`agent`, then `env`). The block is omitted - entirely when it carries no content (no agent and no/empty env). - - Args: - agent: The agent name to emit under the `agent` key, or None to omit it. - env: The environment mapping to emit under the `env` key, or None/empty - to omit it. - - Returns: - A dict with `agent` and/or `env` keys (in that order), or None when the - block carries no content — signalling the caller to omit the block. - """ - block: dict = {} - - if agent is not None: - block["agent"] = agent - - if env: - block["env"] = env - - return block or None - - -def _build_codemanifest_block(config: GogaConfigAnswers) -> dict | None: - """Assemble the optional codemanifest block, or None when it has no content. - - Args: - config: The goga config answers carrying `codemanifest_usages` and - `codemanifest_annotations`. - - Returns: - A codemanifest dict with `usages` and/or `annotations` keys, or None - when neither is present — signalling the caller to omit the block. - """ - if not config.codemanifest_usages and config.codemanifest_annotations is None: - return None - - codemanifest: dict = {} - - if config.codemanifest_usages: - codemanifest["usages"] = config.codemanifest_usages - - if config.codemanifest_annotations is not None: - codemanifest["annotations"] = _LiteralStr(config.codemanifest_annotations) - - return codemanifest diff --git a/goga/onboarding/logic.py b/goga/onboarding/logic.py index ddb57292..0baa9d44 100644 --- a/goga/onboarding/logic.py +++ b/goga/onboarding/logic.py @@ -1,38 +1,147 @@ +"""The orchestrator of one initialization session. + +The entity declared in the domain CODEMANIFEST with ``location: logic.py``: +the orchestrator ``InitLogic`` — the eight-step run that guards on the +existing config, derives the image tag from the installed version, delivers +both tool participation moments, assembles the session plan, runs the +survey, generates the artifacts, and renders the file report with +attribution. The collaborators are injected; the error tiers are the +session's own: tool failures stay soft inside the collaborators, session +errors are one clean message, a user abort is quiet. +""" + from __future__ import annotations import logging +from pathlib import Path import click +from ..config import resolve_project_name +from ..version import host_goga_version, minor_version from .generator import FileGenerator -from .questionnaire import Questionnaire +from .participation import ToolParticipation +from .questions import SessionAnswers +from .survey import Questionnaire, apply_skips, assemble_session_plan, core_questions logger = logging.getLogger(__name__) +_CONFIG_PATH = Path(".goga") / "config.yml" +_CONVENTIONS_PATH = Path(".goga") / "usages" / "conventions.md" + class InitLogic: - """Orchestrator for project initialization. + """The orchestrator of one initialization session. - Wires together Questionnaire and FileGenerator via dependency injection. + Wires together the three injected collaborators — the survey engine, the + artifact generator, and the tool participation mediator — and runs the + whole session: guard, tag derivation, declaration moment, plan assembly + with the declared skips, survey, amendment moment, generation with the + attributed file report. + + Requirements: + - A tool failure never changes the exit code — the softness of the + tool moments is theirs + - A session error is one clean message — a broken package import, + an unreadable version, an empty required field at generation — + never a traceback """ - def __init__(self, questionnaire: Questionnaire, generator: FileGenerator) -> None: + def __init__( + self, + questionnaire: Questionnaire, + generator: FileGenerator, + participation: ToolParticipation, + ) -> None: + """Create the orchestrator of one session. + + Args: + questionnaire: The survey engine asking the plan. + generator: The artifact generator writing the run's files. + participation: The tool participation mediator delivering both + tool moments. + """ self._questionnaire = questionnaire self._generator = generator + self._participation = participation def run(self) -> int: - """Run the full init flow: ask → generate. + """Run the whole session. + + Algorithm: + 1. An existing .goga/config.yml ends the session — no question + is asked, no tool event is delivered, no artifact is written + 2. Read the installed goga version and derive its minor line + for the image hints + 3. Deliver the declaration moment via the participation mediator + 4. Build the core tree, assemble the plan with the collected + declarations, and apply the declared skips + 5. Run the survey into the answer space + 6. Deliver the amendment moment and commit the surviving tool + contributions + 7. Generate the artifacts and render the file report with the + tool attribution + 8. Return 0 Returns: - 0 on success, 1 on any error. + ``0`` on success; ``1`` on a session error or a user abort. + + Raises: + Nothing — every failure is translated into the exit code; a + session error additionally emits one clean message to stderr. """ try: - answers = self._questionnaire.ask() - self._generator.generate(answers) - return 0 + return self._run_session() except click.Abort: - return 1 + return 1 # a user abort is quiet — no message, no traceback except Exception as exc: - logger.error("init flow failed", extra={"error": str(exc)}) + logger.error("the init session failed", extra={"error": str(exc)}) click.echo(f"Error: {exc}", err=True) return 1 + + def _run_session(self) -> int: + """Run the eight session steps — every exception is the caller's tier. + + Returns: + ``0`` — the session completed. + + Raises: + click.Abort: A user interrupt of the survey — the quiet tier. + Exception: A session error — the clean-message tier; the single + fatal participation case (a broken package import) arrives + here as the platform-wrapped ImportError naming the package. + """ + # 1. Whoever created .goga/config.yml first wins — the session ends. + if _CONFIG_PATH.is_file(): + return 0 + + # 2. The image hints carry the minor line of the installed version. + tag = minor_version(host_goga_version()) + + # 3. Moment one — the tool declarations of the run. + declarations = self._participation.collect_declarations() + + # 4. The plan: the core tree, the tool blocks, the declared skips. + project_name = resolve_project_name() + convention_exists = _CONVENTIONS_PATH.is_file() + core = core_questions(tag, project_name, convention_exists) + plan = assemble_session_plan(core, declarations) + skips = [(declaration.tool, path) for declaration in declarations for path in declaration.skips] + plan = apply_skips(plan, skips) + + # 5. The survey fills the answer space at the plan paths. + answers = SessionAnswers(tools=plan.tools) + self._questionnaire.run(plan, answers) + + # 6. Moment two — the committed tool contributions. + contributions = self._participation.collect_contributions(answers) + + # 7. The artifacts and the attributed report. + for entry in self._generator.generate(answers, contributions): + if entry.tool is None: + click.echo(f"created {entry.path}") + else: + click.echo(f"created {entry.path} (tool: {entry.tool})") + + # 8. Tool failures never change the exit code. + return 0 diff --git a/goga/onboarding/questionnaire.py b/goga/onboarding/questionnaire.py deleted file mode 100644 index fb2229d2..00000000 --- a/goga/onboarding/questionnaire.py +++ /dev/null @@ -1,486 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import click - -from ..config import resolve_project_name -from .answers import GogaConfigAnswers, InitAnswers - -_IMAGE_MAP: dict[str, list[str]] = { - "python": [ - "qarium/goga-python-3.10:1.3", - "qarium/goga-python-3.11:1.3", - "qarium/goga-python-3.12:1.3", - "qarium/goga-python-3.13:1.3", - "qarium/goga-python-3.14:1.3", - ], - "golang": [ - "qarium/goga-golang-1.23:1.3", - "qarium/goga-golang-1.24:1.3", - "qarium/goga-golang-1.25:1.3", - "qarium/goga-golang-1.26:1.3", - ], - "javascript": [ - "qarium/goga-node-22:1.3", - "qarium/goga-node-24:1.3", - ], - "kotlin": [ - "qarium/goga-kotlin-2.0:1.3", - "qarium/goga-kotlin-2.1:1.3", - "qarium/goga-kotlin-2.2:1.3", - "qarium/goga-kotlin-2.3:1.3", - ], - "swift": [ - "qarium/goga-swift-6.0:1.3", - "qarium/goga-swift-6.1:1.3", - "qarium/goga-swift-6.2:1.3", - ], -} - -_LANGUAGES = ["python", "golang", "kotlin", "swift", "javascript"] - -_AGENT_ENV_MAP: dict[str, list[str]] = { - "claude": [ - "ANTHROPIC_BASE_URL", - "ANTHROPIC_DEFAULT_HAIKU_MODEL", - "ANTHROPIC_DEFAULT_SONNET_MODEL", - "ANTHROPIC_DEFAULT_OPUS_MODEL", - "ANTHROPIC_MODEL", - ], - "codex": [ - "CODEX_MODEL", - ], - "cursor": [ - "CURSOR_MODEL", - ], - "opencode": [ - "OPENCODE_MODEL", - "OPENCODE_VARIANT", - ], - "qwen": [ - "OPENAI_BASE_URL", - "OPENAI_MODEL", - ], -} - -# Agents offered for selection in the wizard. Derived from `_AGENT_ENV_MAP` -# so the choice list can never drift from the set of agents the wizard can -# actually configure env for — every selectable agent has env keys, and every -# agent with env keys is selectable. Order follows insertion order above -# (claude, codex first for backward-compatible UX; newer agents appended). -_AGENTS = list(_AGENT_ENV_MAP) - - -def _collect_agent_env(agent: str | None) -> dict | None: - """Collect environment variables for an agent. - - Proposes keys from `_AGENT_ENV_MAP` for the given agent, then optionally - collects arbitrary KEY=VALUE pairs. A None agent (no agent configured) - skips the suggested-keys block and only offers arbitrary KEY=VALUE pairs. - - Args: - agent: The selected agent whose suggested env keys are proposed, or None - when no agent is configured (skips the suggested-keys block). - - Returns: - The collected env mapping, or None when nothing is collected. - """ - env: dict | None = None - - suggested_keys = _AGENT_ENV_MAP.get(agent, []) - if suggested_keys: - click.echo("Suggested env keys for selected agent:") - - for key in suggested_keys: - click.echo(f" - {key}") - - if click.confirm("Set suggested env variables?", default=False): - env = {} - for key in suggested_keys: - value = click.prompt(f" {key}") - env[key] = value - - if click.confirm("Add custom environment variable?", default=False): - if env is None: - env = {} - - while True: - key = click.prompt("Env key") - value = click.prompt("Env value") - env[key] = value - - if not click.confirm("Add another?", default=False): - break - - return env - - -class Questionnaire: - """Interactive questionnaire for project initialization.""" - - def ask(self) -> InitAnswers: - """Run full questionnaire and return collected answers. - - Returns: - InitAnswers container with the collected goga config payload. - """ - click.echo("=== Goga Project Initialization ===") - click.echo("This wizard will help you set up a new goga project.\n") - config = self.ask_goga_config() - - return InitAnswers(goga_config=config) - - def ask_goga_config(self) -> GogaConfigAnswers | None: - """Run questionnaire for .goga/config.yml creation. - - Orchestrates the per-field ask_* survey methods in order and assembles - the results into a GogaConfigAnswers. - - Returns: - GogaConfigAnswers with all collected goga config fields, or None - when .goga/config.yml already exists (the whole config survey is - skipped — a copier template may have brought its own config.yml). - """ - if Path(".goga/config.yml").is_file(): - return None - - click.echo("Collecting .goga/config.yml settings...\n") - - language = self.ask_language() - - if Path(".goga/usages/conventions.md").is_file(): - usages_prefill, annotations_prefill = None, None - else: - usages_prefill, annotations_prefill = self.ask_base_convention() - - codemanifest_usages = self.ask_codemanifest_usages(usages_prefill) - codemanifest_annotations = self.ask_codemanifest_annotations(annotations_prefill) - - agent = self.ask_agent() - - # The Dockerfile decision drives the image semantics: with a Dockerfile - # the image is BUILT from it (needs its own name + a FROM base), without - # a Dockerfile a pre-built image is pulled. - dockerfile_path = self.ask_dockerfile_path() - if dockerfile_path is not None: - dockerfile_base_image = self.ask_base_image(language) - # Derive the built-image-name default from the git origin remote - # (via goga/config) — ":latest" when a name resolves, else no - # default (the image field is required). Tolerant: any failure - # yields None and never raises. - name = resolve_project_name() - default = f"{name}:latest" if name is not None else None - image = self.ask_image_name(language=None, default=default) - else: - dockerfile_base_image = None - image = self.ask_image(language) - - env = self.ask_env(agent) - pipeline_agent = self.ask_pipeline_agent() - pipeline_env = self.ask_pipeline_env(pipeline_agent) - - return GogaConfigAnswers( - language=language, - agent=agent, - image=image, - pipeline_agent=pipeline_agent, - pipeline_env=pipeline_env, - env=env, - dockerfile_path=dockerfile_path, - dockerfile_base_image=dockerfile_base_image, - codemanifest_usages=codemanifest_usages, - codemanifest_annotations=codemanifest_annotations, - ) - - def ask_language(self) -> str: - """Survey the primary programming language. - - Returns: - One of python, golang, kotlin, swift, javascript. - """ - click.echo("--- Project Language ---") - click.echo("Select the primary programming language for your project.") - - return click.prompt( - "Language", - type=click.Choice(_LANGUAGES), - ) - - def ask_base_convention(self) -> tuple[dict | None, str | None]: - """Offer to download the base convention for the selected language. - - Acceptance pre-fills both codemanifest fields: - - codemanifest_usages with {"conventions": ".goga/usages/conventions.md"} - - codemanifest_annotations with the conventions directive text. - - Returns: - (codemanifest_usages, codemanifest_annotations) pre-fill pair; - (None, None) when the user declines. - """ - click.echo("\n--- Base Convention ---") - click.echo("Download the default code conventions for your language?") - - if click.confirm("Download base convention"): - return ( - {"conventions": ".goga/usages/conventions.md"}, - "Use `conventions` for code writing rules and testing.", - ) - - return None, None - - def ask_codemanifest_usages(self, prefill: dict | None = None) -> dict | None: - """Collect optional additional codemanifest usages onto `prefill`. - - Args: - prefill: existing usages (e.g. the base convention entry) to extend. - - Returns: - Merged usages dict, or None when neither prefill nor input exists. - """ - codemanifest_usages = prefill - click.echo("\n--- Codemanifest Usages ---") - click.echo("Add additional codemanifest usages (code practices documentation).") - - if click.confirm("Add codemanifest usages?", default=False): - if codemanifest_usages is None: - codemanifest_usages = {} - - while True: - name = click.prompt("Usage name") - - if name in codemanifest_usages: - click.echo(f'Usage "{name}" already exists, skipping.') - else: - path = click.prompt("Usage value") - codemanifest_usages[name] = path - - if not click.confirm("Add another codemanifest usage?", default=False): - break - - return codemanifest_usages - - def ask_codemanifest_annotations(self, prefill: str | None = None) -> str | None: - """Collect optional custom codemanifest annotations appended to `prefill`. - - Args: - prefill: existing annotations text to append to. - - Returns: - Merged annotations string, or None when neither exists. - """ - codemanifest_annotations = prefill - click.echo("\n--- Codemanifest Annotations ---") - click.echo("Add custom codemanifest annotations (global directives for AI agent).") - - if click.confirm("Add codemanifest annotations?", default=False): - custom = click.prompt("Annotations") - - if codemanifest_annotations is not None: - codemanifest_annotations = codemanifest_annotations + "\n" + custom - else: - codemanifest_annotations = custom - - return codemanifest_annotations - - def ask_agent(self) -> str | None: - """Survey the AI agent that builds the implementation. - - Optional: by default no agent is configured. The user must opt in via a - confirm gate, then select from the supported agents (`_AGENTS`, - currently claude, codex, cursor, opencode, qwen). Declining returns - None — the agent is omitted from the generated config. - - Returns: - One of the supported agents in `_AGENTS`; None when the user - declines to configure an agent. - """ - click.echo("\n--- AI Agent ---") - click.echo("Select the AI agent that will build implementation.") - - if not click.confirm("Configure a build agent?", default=False): - return None - - return click.prompt( - "Agent", - type=click.Choice(_AGENTS), - ) - - def ask_image(self, language: str) -> str: - """Survey the pre-built Docker image to PULL (no-Dockerfile case). - - Used when the user does NOT create a custom Dockerfile: build/pipeline - pull this pre-built image. Displays language-specific hints from - `image_defaults` and defaults to the last entry; accepts free-form input. - - Args: - language: the selected project language (drives the hint list). - - Returns: - Docker image name. Captures the top-level image field (NOT build.image). - """ - return self._prompt_language_image( - language, - section="Docker Image", - intro="Select the pre-built Docker image to use (pulled at build/pipeline time).", - prompt_label="Docker image", - ) - - def ask_base_image(self, language: str) -> str: - """Survey the BASE image for the Dockerfile FROM (Dockerfile case). - - Used when the user creates a custom Dockerfile: this image is the FROM - baseline the built image extends. Displays language-specific hints from - `image_defaults` and defaults to the last entry; accepts free-form input. - - Args: - language: the selected project language (drives the hint list). - - Returns: - Base Docker image name. Written as the Dockerfile FROM line; never - emitted to config.yml. - """ - return self._prompt_language_image( - language, - section="Dockerfile Base Image", - intro="Select the base image for the Dockerfile (the FROM line).", - prompt_label="Base image (FROM)", - ) - - def ask_image_name(self, language: str | None = None, default: str | None = None) -> str: - """Survey the NAME (tag) for the image built from the Dockerfile. - - Used when the user creates a custom Dockerfile: `goga build` runs - `docker build -t `, so the built image needs its own name, - distinct from the FROM base. Free-form input. Two modes govern the - offered default: - - - ``language`` provided (not ``None``) → legacy default - ``{language}-image:latest`` (backward-compatible with callers that - pass ``language``). - - ``language`` is ``None`` → offer ``default`` as the default; when - ``default`` is also ``None`` → no default is offered and the ``image`` - field is required (click re-prompts on empty input). - - Args: - language: the selected project language; when provided, drives the - legacy ``{language}-image:latest`` default placeholder. - default: the default to offer when ``language`` is ``None`` (e.g. - the ``:latest`` default derived from git). ``None`` - makes the ``image`` field required (no suggestion). - - Returns: - Docker image name. Captures the top-level image field (NOT build.image). - """ - offered = f"{language}-image:latest" if language is not None else default - - click.echo("\n--- Built Image Name ---") - click.echo("Name for the docker image built from the Dockerfile (the top-level image field).") - - if offered is None: - return click.prompt("Built image name") - return click.prompt("Built image name", default=offered) - - def _prompt_language_image( - self, - language: str, - *, - section: str, - intro: str, - prompt_label: str, - ) -> str: - """Prompt for a Docker image, hinting language-specific defaults. - - Lists the predefined images for `language` (from `_IMAGE_MAP`) and - defaults to the last entry; accepts free-form input. When the language - has no predefined images, no hints are shown and no default is offered. - - Args: - language: the selected project language (drives the hint list). - section: the `---` section header text. - intro: the explanatory line under the header. - prompt_label: the click.prompt label. - - Returns: - Docker image name. Defaults to the last hint when hints exist. - """ - click.echo(f"\n--- {section} ---") - click.echo(intro) - images = _IMAGE_MAP.get(language) - - if images is not None: - click.echo("Available images:") - - for img in images: - click.echo(f" - {img}") - - return click.prompt(prompt_label, default=images[-1]) - return click.prompt(prompt_label) - - def ask_dockerfile_path(self) -> str | None: - """Optionally survey a custom Dockerfile path. - - Returns: - Dockerfile path (default ".goga/Dockerfile"), or None to skip. - """ - click.echo("\n--- Custom Dockerfile ---") - click.echo("Create a custom Dockerfile for the build implementation?") - - if click.confirm("Create Dockerfile?", default=False): - return click.prompt("Dockerfile path", default=".goga/Dockerfile") - - return None - - def ask_env(self, agent: str | None) -> dict | None: - """Survey build task_executor environment variables for `agent`. - - Args: - agent: the selected build agent (drives suggested env keys); None when - no agent is configured. - - Returns: - Env dict collected via `_collect_agent_env`, or None. - """ - click.echo("\n--- Environment Variables ---") - click.echo("Configure environment variables for the build implementation.") - - return _collect_agent_env(agent) - - def ask_pipeline_agent(self) -> str | None: - """Survey the pipeline agent. - - Optional: by default no pipeline agent is configured. The user must opt - in via a confirm gate, then select from the supported agents (`_AGENTS`, - currently claude, codex, cursor, opencode, qwen). Declining returns None - — the pipeline agent is omitted from the generated config. The pipeline - agent does NOT inherit the build `agent`. - - Returns: - One of the supported agents in `_AGENTS`; None when the user - declines to configure an agent. - """ - click.echo("\n--- Pipeline Agent ---") - click.echo("Select the AI agent that will run pipelines (afm client.command).") - - if not click.confirm("Configure a pipeline agent?", default=False): - return None - - return click.prompt( - "Pipeline agent", - type=click.Choice(_AGENTS), - ) - - def ask_pipeline_env(self, pipeline_agent: str | None) -> dict | None: - """Survey pipeline environment variables for `pipeline_agent`. - - Args: - pipeline_agent: the selected pipeline agent (drives suggested env keys); - None when no agent is configured. - - Returns: - Env dict collected via `_collect_agent_env`, or None. - """ - click.echo("\n--- Pipeline Environment Variables ---") - click.echo("Configure environment variables for the pipeline implementation.") - - return _collect_agent_env(pipeline_agent) diff --git a/tests/commands/test_init.py b/tests/commands/test_init.py index 96176d18..56d46bca 100644 --- a/tests/commands/test_init.py +++ b/tests/commands/test_init.py @@ -5,10 +5,7 @@ import click from click.testing import CliRunner -from goga.onboarding.answers import GogaConfigAnswers, InitAnswers -from goga.onboarding.generator import FileGenerator -from goga.onboarding.logic import InitLogic -from goga.onboarding.questionnaire import Questionnaire +from goga.onboarding import FileGenerator, InitLogic, Questionnaire _cmd_init_module = importlib.import_module("goga.commands.init.init") @@ -51,34 +48,24 @@ class TestLogic: """Logic-level tests for init CLI command.""" def test_init_cli_command(self, tmp_path, monkeypatch) -> None: - """Successful init: exit_code == 0.""" + """Successful init: the command wires the collaborators and propagates run()'s 0.""" from goga.commands.init import init - config = GogaConfigAnswers( - language="python", - agent="claude", - image="qarium/goga-python-3.12:0.1", - pipeline_agent="claude", - env={}, - ) - answers = InitAnswers(goga_config=config) - - mock_q = mock.MagicMock(spec=Questionnaire) - mock_q.ask.return_value = answers - - gen = FileGenerator() - gen._base_dir = tmp_path + mock_logic = mock.MagicMock(spec=InitLogic) + mock_logic.run.return_value = 0 monkeypatch.chdir(tmp_path) with ( - mock.patch.object(_cmd_init_module, "Questionnaire", return_value=mock_q), - mock.patch.object(_cmd_init_module, "FileGenerator", return_value=gen), + mock.patch.object(_cmd_init_module, "Questionnaire", spec=Questionnaire), + mock.patch.object(_cmd_init_module, "FileGenerator", spec=FileGenerator), + mock.patch.object(_cmd_init_module, "InitLogic", return_value=mock_logic), ): runner = CliRunner() result = runner.invoke(init, []) assert result.exit_code == 0 + mock_logic.run.assert_called_once() def test_init_cli_returns_nonzero_on_failure(self, tmp_path, monkeypatch) -> None: """InitLogic.run() returns 1 → non-zero exit.""" diff --git a/tests/config/test_resolve_project_name_flows.py b/tests/config/test_resolve_project_name_flows.py index 2f2cefaa..5285be6e 100644 --- a/tests/config/test_resolve_project_name_flows.py +++ b/tests/config/test_resolve_project_name_flows.py @@ -9,10 +9,12 @@ ``resolve_project_name()`` and forwards the result to ``compile_flow(..., project_name=...)`` so the compiled flow-file description gets a ``[]`` prefix (``None`` ⇒ no prefix). -- **Flow C2 — onboarding image-name default.** ``Questionnaire.ask_goga_config`` - Dockerfile branch calls ``resolve_project_name()`` and derives the built-image - default ``f"{name}:latest"`` (or ``None`` when unresolved) before offering it - via the two-mode ``ask_image_name(language=None, default=...)``. +- **Flow C2 — onboarding image-name default.** ``InitLogic.run`` step 4 calls + ``resolve_project_name()`` and threads the result into + ``core_questions(tag, project_name, ...)`` whose ``image`` question default + is ``f"{name}:latest"`` (or no default when unresolved); the survey's + Dockerfile branch offers exactly that default at the ``Built image name`` + prompt. Both flows mock ``resolve_project_name`` on its owning module (per ``convention`` — mock the call, never invoke the real git subprocess) and assert the value @@ -32,7 +34,8 @@ # is patched on its own importing module). import goga.config.git.identity as _identity_module import pytest -from goga.onboarding import questionnaire as qmod +import yaml +from goga.onboarding import FileGenerator, InitLogic, Questionnaire, ToolParticipation from goga.pipeline import run_pipeline from goga.pipeline.compiler import ( BodyFormat, @@ -48,6 +51,10 @@ # attributes directly. Per [[feedback_mock_patch_module_shadowing]]. _run_pipeline_module = sys.modules["goga.pipeline.run_pipeline"] +# The onboarding consumer module of Flow C2 — the module whose bound +# ``resolve_project_name`` the session orchestrator calls (patched there). +_logic_module = sys.modules["goga.onboarding.logic"] + def _fake_documents(project_name: str | None) -> tuple[PipelineDocument, FlowDocument]: """Build the documents tuple ``compile_flow`` returns, capturing the @@ -152,13 +159,15 @@ def raise_filenotfound(_argv, **_kwargs): class TestFlowC2OnboardingDefault: """Flow C2 — onboarding image-name default. - Drives the REAL ``Questionnaire.ask_goga_config`` Dockerfile branch (not a - hand-mirrored copy of its formula) so the test asserts the production wiring - of ``resolve_project_name`` → ``ask_image_name(language=None, default=...)`` - through the code path a consumer actually runs. ``resolve_project_name`` is - mocked on its owning module (per ``convention`` — never invoke the real git - subprocess); the offered default is exactly ``f"{name}:latest"`` when a name - resolves and is absent (image required) when it does not. + Drives the REAL ``InitLogic.run`` session (real survey engine, real + generator, empty participation — not a hand-mirrored copy of its formula) + so the test asserts the production wiring of ``resolve_project_name`` → + ``core_questions(project_name=...)`` → the offered built-image default + through the code path a consumer actually runs. ``resolve_project_name`` + is mocked on the orchestrator's importing module (per ``convention`` — + never invoke the real git subprocess); the offered default is exactly + ``f"{name}:latest"`` when a name resolves and absent (image required) + when it does not. """ _CONFIRMS: ClassVar[list[bool]] = [ @@ -166,33 +175,30 @@ class TestFlowC2OnboardingDefault: False, # Add codemanifest usages? False, # Add codemanifest annotations? True, # Configure a build agent? + False, # Set suggested keys? (build env) + False, # Add another pair? (build env) True, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? + False, # Set suggested keys? (pipeline env) + False, # Add another pair? (pipeline env) + False, # Add tools? + False, # Add usages records? ] - def _run_goga_config(self, resolve_return, built_image_reply, monkeypatch, tmp_path): - """Drive ``ask_goga_config`` to its Dockerfile branch; capture the offered - built-image default and return the resulting :class:`GogaConfigAnswers`.""" - # ask_goga_config() short-circuits to None when .goga/config.yml exists; - # the repo CWD contains the goga project's own config.yml, so run in a - # clean tmp_path (no config.yml) to reach the Dockerfile branch. + def _run_session(self, resolve_return, built_image_reply, monkeypatch, tmp_path): + """Drive ``InitLogic.run`` to its Dockerfile branch; capture the offered + built-image default and return the session exit code with the capture.""" + # The session guard ends the run when .goga/config.yml exists; the repo + # CWD contains the goga project's own config.yml, so run in a clean + # tmp_path (no config.yml) to reach the Dockerfile branch. monkeypatch.chdir(tmp_path) + monkeypatch.setattr(_logic_module, "host_goga_version", lambda: "1.3.0") + monkeypatch.setattr("goga.hooks.tools.packages.packages_distributions", lambda: {}) captured: dict = {} - def fake_image_prompt(message, *args, **kwargs): - if message == "Built image name": - captured["default"] = kwargs.get("default") - return built_image_reply - return "default-placeholder" - prompts = iter( [ "python", # language - "claude", # agent + "claude", # build agent "Dockerfile", # dockerfile path "qarium/goga-python-3.12:1.0", # base image (FROM) "claude", # pipeline agent @@ -201,32 +207,41 @@ def fake_image_prompt(message, *args, **kwargs): def prompt_router(message, *args, **kwargs): if message == "Built image name": - return fake_image_prompt(message, *args, **kwargs) + captured["default"] = kwargs.get("default") + return built_image_reply return next(prompts) - monkeypatch.setattr(qmod, "resolve_project_name", lambda: resolve_return) + monkeypatch.setattr(_logic_module, "resolve_project_name", lambda: resolve_return) with ( mock.patch("click.prompt", side_effect=prompt_router), mock.patch("click.confirm", side_effect=iter(self._CONFIRMS)), ): - result = qmod.Questionnaire().ask_goga_config() + exit_code = InitLogic( + questionnaire=Questionnaire(), + generator=FileGenerator(), + participation=ToolParticipation(invited=[]), + ).run() - return result, captured + return exit_code, captured def test_c2_name_offers_name_latest_as_default(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """resolve_project_name → 'widget' → ask_image_name offered 'widget:latest'.""" - result, captured = self._run_goga_config("widget", "widget:latest", monkeypatch, tmp_path) + """resolve_project_name → 'widget' → the Built image name prompt offered 'widget:latest'.""" + exit_code, captured = self._run_session("widget", "widget:latest", monkeypatch, tmp_path) + assert exit_code == 0 assert captured["default"] == "widget:latest" - assert result.image == "widget:latest" - assert result.dockerfile_path == "Dockerfile" + cfg = yaml.safe_load((tmp_path / ".goga" / "config.yml").read_text()) + assert cfg["image"] == "widget:latest" + assert cfg["dockerfile"] == "Dockerfile" def test_c2_none_offers_no_default_image_required(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """resolve_project_name → None → ask_image_name called with no default (image required).""" - result, captured = self._run_goga_config(None, "provided-image:latest", monkeypatch, tmp_path) + """resolve_project_name → None → the Built image name prompt offered no default.""" + exit_code, captured = self._run_session(None, "provided-image:latest", monkeypatch, tmp_path) + assert exit_code == 0 assert captured["default"] is None - assert result.image == "provided-image:latest" + cfg = yaml.safe_load((tmp_path / ".goga" / "config.yml").read_text()) + assert cfg["image"] == "provided-image:latest" class TestFacadeSingleEntryPoint: @@ -250,5 +265,5 @@ def test_pipeline_consumer_bound_name_is_the_facade_routine(self) -> None: assert _run_pipeline_module.resolve_project_name is _identity_module.resolve_project_name def test_onboarding_consumer_bound_name_is_the_facade_routine(self) -> None: - """questionnaire's imported ``resolve_project_name`` is the facade routine.""" - assert qmod.resolve_project_name is _identity_module.resolve_project_name + """The session orchestrator's imported ``resolve_project_name`` is the facade routine.""" + assert _logic_module.resolve_project_name is _identity_module.resolve_project_name diff --git a/tests/onboarding/test_answers.py b/tests/onboarding/test_answers.py deleted file mode 100644 index 629fac83..00000000 --- a/tests/onboarding/test_answers.py +++ /dev/null @@ -1,170 +0,0 @@ -from __future__ import annotations - -import dataclasses -from typing import get_type_hints - -import pytest -from goga.onboarding.answers import GogaConfigAnswers, InitAnswers - - -class TestContract: - """Contract-level tests for InitAnswers and GogaConfigAnswers.""" - - def test_goga_config_answers_importable_from_answers(self) -> None: - from goga.onboarding.answers import GogaConfigAnswers - - assert GogaConfigAnswers is not None - - def test_init_answers_importable_from_answers(self) -> None: - from goga.onboarding.answers import InitAnswers - - assert InitAnswers is not None - - def test_init_answers_has_goga_config_property(self) -> None: - hints = get_type_hints(InitAnswers) - assert "goga_config" in hints - - def test_init_answers_goga_config_is_optional(self) -> None: - hints = get_type_hints(InitAnswers) - assert hints["goga_config"] == GogaConfigAnswers | None - - def test_goga_config_answers_has_all_declared_properties(self) -> None: - hints = get_type_hints(GogaConfigAnswers) - expected = { - "language", - "agent", - "image", - "pipeline_agent", - "pipeline_env", - "env", - "codemanifest_usages", - "codemanifest_annotations", - "dockerfile_path", - } - assert expected.issubset(hints.keys()) - - def test_goga_config_answers_property_types(self) -> None: - hints = get_type_hints(GogaConfigAnswers) - assert hints["language"] is str - assert hints["image"] is str - assert hints["agent"] == str | None - assert hints["pipeline_agent"] == str | None - assert hints["pipeline_env"] == dict | None - assert hints["env"] == dict | None - - def test_goga_config_answers_field_names_in_contract_order(self) -> None: - names = [f.name for f in dataclasses.fields(GogaConfigAnswers)] - assert names == [ - "language", - "image", - "agent", - "pipeline_agent", - "pipeline_env", - "env", - "codemanifest_usages", - "codemanifest_annotations", - "dockerfile_path", - "dockerfile_base_image", - ] - - def test_constructors_accept_kwargs(self) -> None: - cfg = GogaConfigAnswers( - language="python", - agent="claude", - image="qarium/goga-python-3.12:0.1", - pipeline_agent="claude", - ) - answers = InitAnswers(goga_config=cfg) - assert answers.goga_config is cfg - - -class TestLogic: - """Logic tests for InitAnswers and GogaConfigAnswers.""" - - def test_goga_config_answers_is_frozen(self) -> None: - cfg = GogaConfigAnswers( - language="python", - agent="claude", - image="qarium/goga-python-3.12:0.1", - pipeline_agent="claude", - ) - - with pytest.raises(dataclasses.FrozenInstanceError): - cfg.language = "go" # type: ignore[misc] - - def test_init_answers_is_frozen(self) -> None: - cfg = GogaConfigAnswers( - language="python", - agent="claude", - image="qarium/goga-python-3.12:0.1", - pipeline_agent="claude", - ) - answers = InitAnswers(goga_config=cfg) - - with pytest.raises(dataclasses.FrozenInstanceError): - answers.goga_config = cfg # type: ignore[misc] - - def test_goga_config_answers_kw_only(self) -> None: - with pytest.raises(TypeError): - GogaConfigAnswers("python", "claude", "img", "claude") # type: ignore[call-arg] - - def test_goga_config_answers_agents_default_none(self) -> None: - cfg = GogaConfigAnswers( - language="python", - image="qarium/goga-python-3.12:0.1", - ) - assert cfg.agent is None - assert cfg.pipeline_agent is None - - def test_goga_config_answers_defaults_none(self) -> None: - cfg = GogaConfigAnswers( - language="go", - agent="claude", - image="qarium/goga-golang-1.23:0.1", - pipeline_agent="claude", - ) - assert cfg.pipeline_env is None - assert cfg.env is None - assert cfg.codemanifest_usages is None - assert cfg.codemanifest_annotations is None - assert cfg.dockerfile_path is None - - def test_goga_config_answers_with_codemanifest(self) -> None: - cfg = GogaConfigAnswers( - language="python", - agent="claude", - image="qarium/goga-python-3.12:0.1", - pipeline_agent="codex", - pipeline_env={"CODEX_MODEL": "o4-mini"}, - codemanifest_usages={"conventions": ".goga/usages/conventions.md"}, - codemanifest_annotations="Use conventions for code rules.", - ) - assert cfg.pipeline_agent == "codex" - assert cfg.pipeline_env == {"CODEX_MODEL": "o4-mini"} - assert cfg.codemanifest_usages == {"conventions": ".goga/usages/conventions.md"} - assert cfg.codemanifest_annotations == "Use conventions for code rules." - - def test_init_answers_kw_only(self) -> None: - cfg = GogaConfigAnswers( - language="python", - agent="claude", - image="qarium/goga-python-3.12:0.1", - pipeline_agent="claude", - ) - - with pytest.raises(TypeError): - InitAnswers(cfg) # type: ignore[call-arg] - - def test_init_answers_goga_config_accepts_none(self) -> None: - answers = InitAnswers(goga_config=None) - assert answers.goga_config is None - - def test_init_answers_goga_config_round_trips_non_none(self) -> None: - cfg = GogaConfigAnswers( - language="python", - agent="claude", - image="qarium/goga-python-3.12:0.1", - pipeline_agent="claude", - ) - answers = InitAnswers(goga_config=cfg) - assert answers.goga_config is cfg diff --git a/tests/onboarding/test_generator.py b/tests/onboarding/test_generator.py deleted file mode 100644 index d623f719..00000000 --- a/tests/onboarding/test_generator.py +++ /dev/null @@ -1,578 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import requests.exceptions -import yaml -from goga.config import load_project_config -from goga.onboarding.answers import GogaConfigAnswers, InitAnswers -from goga.onboarding.generator import FileGenerator - - -class TestContract: - """Contract-level tests for FileGenerator.""" - - def test_file_generator_importable_from_generator(self) -> None: - from goga.onboarding.generator import FileGenerator - - assert FileGenerator is not None - - def test_file_generator_constructor_no_args(self) -> None: - gen = FileGenerator() - assert gen is not None - - def test_file_generator_has_generate_method(self) -> None: - gen = FileGenerator() - assert hasattr(gen, "generate") - assert callable(gen.generate) - - def test_file_generator_has_generate_goga_config_method(self) -> None: - gen = FileGenerator() - assert hasattr(gen, "generate_goga_config") - assert callable(gen.generate_goga_config) - - def test_file_generator_generate_accepts_none_goga_config(self, tmp_path: Path) -> None: - """generate() must be callable with InitAnswers(goga_config=None) without raising. - - The None guard skips config.yml + Dockerfile generation entirely. - """ - gen = FileGenerator() - gen._base_dir = tmp_path - answers = InitAnswers(goga_config=None) - - # Must not raise — the None guard returns early. - gen.generate(answers) - - -class TestLogic: - """Logic tests for FileGenerator — uses tmp_path and mocks urllib.""" - - def _make_config( # noqa: PLR0913, PLR0917 - self, - language: str = "python", - agent: str = "claude", - image: str = "qarium/goga-python-3.12:1.0", - pipeline_agent: str = "claude", - pipeline_env: dict | None = None, - env: dict | None = None, - codemanifest_usages: dict | None = None, - codemanifest_annotations: str | None = None, - dockerfile_path: str | None = None, - dockerfile_base_image: str | None = None, - ) -> GogaConfigAnswers: - return GogaConfigAnswers( - language=language, - agent=agent, - image=image, - pipeline_agent=pipeline_agent, - pipeline_env=pipeline_env, - env=env, - codemanifest_usages=codemanifest_usages, - codemanifest_annotations=codemanifest_annotations, - dockerfile_path=dockerfile_path, - dockerfile_base_image=dockerfile_base_image, - ) - - def _make_gen(self, tmp_path: Path) -> FileGenerator: - gen = FileGenerator() - gen._base_dir = tmp_path - return gen - - def _load_yaml(self, config_path: Path) -> dict: - with config_path.open() as f: - return yaml.safe_load(f) - - def test_generate_goga_config_yaml_compatible_with_load_config(self, tmp_path: Path) -> None: - """Generated YAML must be parseable by load_project_config().""" - config = self._make_config( - language="python", - agent="claude", - image="qarium/goga-python-3.12:1.0", - pipeline_agent="codex", - pipeline_env={"CODEX_MODEL": "o4-mini"}, - env={"API_KEY": "secret"}, - codemanifest_usages={"conventions": ".goga/usages/conventions.md"}, - codemanifest_annotations="Use conventions for code rules.", - ) - mock_response = MagicMock() - mock_response.text = "# Python conventions" - mock_response.status_code = 200 - mock_response.raise_for_status = MagicMock() - - gen = self._make_gen(tmp_path) - - with patch("goga.onboarding.generator.requests.get", return_value=mock_response): - gen.generate_goga_config(config) - - config_path = tmp_path / ".goga" / "config.yml" - assert config_path.exists() - - data = self._load_yaml(config_path) - - # Verify structure compatible with load_project_config() - assert data["language"] == "python" - assert data["image"] == "qarium/goga-python-3.12:1.0" - assert data["build"]["task_executor"]["agent"] == "claude" - assert data["build"]["task_executor"]["env"] == {"API_KEY": "secret"} - assert "image" not in data["build"] - assert data["pipeline"]["agent"] == "codex" - assert data["pipeline"]["env"] == {"CODEX_MODEL": "o4-mini"} - assert data["codemanifest"]["usages"] == {"conventions": ".goga/usages/conventions.md"} - assert data["codemanifest"]["annotations"] == "Use conventions for code rules.\n" - - def test_generate_goga_config_round_trips_through_load_config( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Generated config.yml must load without error via load_project_config().""" - config = self._make_config( - language="python", - agent="claude", - image="qarium/goga-python-3.12:1.0", - pipeline_agent="claude", - env={"API_KEY": "secret"}, - ) - gen = self._make_gen(tmp_path) - gen.generate_goga_config(config) - - monkeypatch.chdir(tmp_path) - loaded = load_project_config() - assert loaded.lang == "python" - assert loaded.image == "qarium/goga-python-3.12:1.0" - assert loaded.pipeline.agent == "claude" - assert loaded.build.task_executor.env == {"API_KEY": "secret"} - - def test_generate_creates_goga_directory_when_missing(self, tmp_path: Path) -> None: - """generate_goga_config() must create .goga/ directory if absent.""" - config = self._make_config(language="golang") - gen = self._make_gen(tmp_path) - gen.generate_goga_config(config) - - assert (tmp_path / ".goga").is_dir() - assert (tmp_path / ".goga" / "config.yml").exists() - - def test_generate_golang_language_url(self, tmp_path: Path) -> None: - """When language=golang, the URL must contain 'golang'.""" - config = self._make_config( - language="golang", - agent="claude", - image="qarium/goga-golang-1.23:1.0", - codemanifest_usages={"conventions": ".goga/usages/conventions.md"}, - ) - answers = InitAnswers(goga_config=config) - - mock_response = MagicMock() - mock_response.text = "# Go conventions" - mock_response.status_code = 200 - mock_response.raise_for_status = MagicMock() - - gen = self._make_gen(tmp_path) - - with patch("goga.onboarding.generator.requests.get", return_value=mock_response) as mock_get: - gen.generate(answers) - - called_url = mock_get.call_args[0][0] - assert "golang" in called_url - - def test_generate_skips_convention_when_no_usages(self, tmp_path: Path) -> None: - """When codemanifest_usages=None, no HTTP request, config.yml created without codemanifest.""" - config = self._make_config(language="python", codemanifest_usages=None) - answers = InitAnswers(goga_config=config) - - gen = self._make_gen(tmp_path) - - with patch("goga.onboarding.generator.requests.get") as mock_get: - gen.generate(answers) - - mock_get.assert_not_called() - - config_path = tmp_path / ".goga" / "config.yml" - assert config_path.exists() - data = self._load_yaml(config_path) - assert "codemanifest" not in data - - def test_generate_skips_convention_when_usages_without_conventions_key(self, tmp_path: Path) -> None: - """When codemanifest_usages has no 'conventions' key, no HTTP request, config.yml has codemanifest.usages.""" - config = self._make_config( - language="python", - codemanifest_usages={"custom": ".goga/usages/custom.md"}, - ) - answers = InitAnswers(goga_config=config) - - gen = self._make_gen(tmp_path) - - with patch("goga.onboarding.generator.requests.get") as mock_get: - gen.generate(answers) - - mock_get.assert_not_called() - - config_path = tmp_path / ".goga" / "config.yml" - assert config_path.exists() - data = self._load_yaml(config_path) - assert data["codemanifest"]["usages"] == {"custom": ".goga/usages/custom.md"} - assert not (tmp_path / ".goga" / "usages").exists() - - def test_generate_creates_usages_directory_for_convention(self, tmp_path: Path) -> None: - """When convention=True, .goga/usages/ is created and conventions.md written.""" - config = self._make_config( - language="python", - codemanifest_usages={"conventions": ".goga/usages/conventions.md"}, - codemanifest_annotations="Use conventions.", - ) - answers = InitAnswers(goga_config=config) - - mock_response = MagicMock() - mock_response.text = "# Python conventions content" - mock_response.status_code = 200 - mock_response.raise_for_status = MagicMock() - - gen = self._make_gen(tmp_path) - - with patch("goga.onboarding.generator.requests.get", return_value=mock_response): - gen.generate(answers) - - conventions_path = tmp_path / ".goga" / "usages" / "conventions.md" - assert conventions_path.exists() - content = conventions_path.read_text(encoding="utf-8") - assert content == "# Python conventions content" - - def test_generate_convention_download_fails_propagates(self, tmp_path: Path) -> None: - """When urlopen raises URLError, RuntimeError wraps it and config.yml is NOT created.""" - config = self._make_config( - language="python", - codemanifest_usages={"conventions": ".goga/usages/conventions.md"}, - ) - answers = InitAnswers(goga_config=config) - - gen = self._make_gen(tmp_path) - - with ( - patch( - "goga.onboarding.generator.requests.get", - side_effect=requests.exceptions.ConnectionError("Network error"), - ), - pytest.raises(RuntimeError, match="Failed to download convention"), - ): - gen.generate(answers) - - # config.yml must NOT be created - assert not (tmp_path / ".goga" / "config.yml").exists() - - def test_generate_reinit_overwrites_existing_config(self, tmp_path: Path) -> None: - """If .goga/config.yml already exists, generate_goga_config overwrites it.""" - goga_dir = tmp_path / ".goga" - goga_dir.mkdir() - (goga_dir / "config.yml").write_text("language: old_lang\n") - - config = self._make_config(language="golang", agent="claude", image="qarium/goga-golang-1.23:1.0") - gen = self._make_gen(tmp_path) - gen.generate_goga_config(config) - - data = self._load_yaml(goga_dir / "config.yml") - assert data["language"] == "golang" - - def test_file_generator_generate_skips_when_goga_config_none(self, tmp_path: Path) -> None: - """When goga_config is None, generate() must skip all file artefacts. - - No .goga/config.yml (and no Dockerfile) is written — the leading guard - returns before generate_goga_config is reached. - """ - gen = self._make_gen(tmp_path) - answers = InitAnswers(goga_config=None) - - gen.generate(answers) - - assert (tmp_path / ".goga" / "config.yml").exists() is False - # .goga directory itself should not be created by generate on the skip path. - assert (tmp_path / ".goga").exists() is False - - def test_file_generator_generate_none_does_not_call_generate_goga_config(self, tmp_path: Path) -> None: - """On the None path, generate_goga_config must not be invoked at all.""" - gen = self._make_gen(tmp_path) - answers = InitAnswers(goga_config=None) - - with patch.object(gen, "generate_goga_config") as mock_gen_config: - gen.generate(answers) - - mock_gen_config.assert_not_called() - - # --- New tests for Dockerfile generation --- - - def test_generator_creates_dockerfile(self, tmp_path: Path) -> None: - """When dockerfile_path is set, Dockerfile is created with FROM base image. - - The FROM line uses `dockerfile_base_image` (the baseline), while the - top-level `image` field holds the name of the image built from it. - """ - config = self._make_config( - image="my-python-app:latest", - dockerfile_path="Dockerfile", - dockerfile_base_image="qarium/goga-python-3.14:1.0", - ) - answers = InitAnswers(goga_config=config) - - gen = self._make_gen(tmp_path) - gen.generate(answers) - - dockerfile = tmp_path / "Dockerfile" - assert dockerfile.exists() - content = dockerfile.read_text(encoding="utf-8") - assert content == "FROM qarium/goga-python-3.14:1.0\n" - - def test_generator_no_dockerfile_when_none(self, tmp_path: Path) -> None: - """When dockerfile_path is None, no Dockerfile is created.""" - config = self._make_config(dockerfile_path=None) - answers = InitAnswers(goga_config=config) - - gen = self._make_gen(tmp_path) - - with patch("goga.onboarding.generator.requests.get"): - gen.generate(answers) - - assert not (tmp_path / "Dockerfile").exists() - - def test_generator_config_yml_no_dockerfile_field(self, tmp_path: Path) -> None: - """dockerfile is emitted at the top level (not under build) when dockerfile_path is set.""" - config = self._make_config(dockerfile_path="Dockerfile") - gen = self._make_gen(tmp_path) - gen.generate_goga_config(config) - - data = self._load_yaml(tmp_path / ".goga" / "config.yml") - assert data["dockerfile"] == "Dockerfile" - assert "dockerfile" not in data["build"] - - def test_generator_emits_dockerfile_after_image(self, tmp_path: Path) -> None: - """dockerfile is emitted immediately after image (top level) when set.""" - config = self._make_config(dockerfile_path="Dockerfile") - gen = self._make_gen(tmp_path) - gen.generate_goga_config(config) - - text = (tmp_path / ".goga" / "config.yml").read_text(encoding="utf-8") - assert text.index("language:") < text.index("image:") - assert text.index("image:") < text.index("dockerfile:") - assert text.index("dockerfile:") < text.index("build:") - assert "dockerfile: Dockerfile" in text - - def test_generator_omits_dockerfile_when_none(self, tmp_path: Path) -> None: - """dockerfile is omitted entirely when dockerfile_path is None.""" - config = self._make_config(dockerfile_path=None) - gen = self._make_gen(tmp_path) - gen.generate_goga_config(config) - - text = (tmp_path / ".goga" / "config.yml").read_text(encoding="utf-8") - assert "dockerfile" not in text - - def test_generator_dockerfile_custom_path(self, tmp_path: Path) -> None: - """Dockerfile can be created at a custom path; FROM uses the base image.""" - config = self._make_config( - image="my-golang-app:latest", - dockerfile_path="docker/Dockerfile", - dockerfile_base_image="qarium/goga-golang-1.26:1.0", - ) - answers = InitAnswers(goga_config=config) - - gen = self._make_gen(tmp_path) - gen.generate(answers) - - dockerfile = tmp_path / "docker" / "Dockerfile" - assert dockerfile.exists() - content = dockerfile.read_text(encoding="utf-8") - assert content == "FROM qarium/goga-golang-1.26:1.0\n" - - def test_generator_no_env_in_yaml_when_none(self, tmp_path: Path) -> None: - """When env is None, 'env' key must not appear in config.yml.""" - config = self._make_config(env=None) - gen = self._make_gen(tmp_path) - gen.generate_goga_config(config) - - data = self._load_yaml(tmp_path / ".goga" / "config.yml") - assert "env" not in data["build"]["task_executor"] - - def test_generator_no_env_in_yaml_when_empty(self, tmp_path: Path) -> None: - """When env is empty dict, 'env' key must not appear in config.yml.""" - config = self._make_config(env={}) - gen = self._make_gen(tmp_path) - gen.generate_goga_config(config) - - data = self._load_yaml(tmp_path / ".goga" / "config.yml") - assert "env" not in data["build"]["task_executor"] - - def test_generator_env_in_yaml_when_provided(self, tmp_path: Path) -> None: - """When env has values, 'env' key must appear in config.yml.""" - config = self._make_config(env={"API_KEY": "secret"}) - gen = self._make_gen(tmp_path) - gen.generate_goga_config(config) - - data = self._load_yaml(tmp_path / ".goga" / "config.yml") - assert data["build"]["task_executor"]["env"] == {"API_KEY": "secret"} - - -# --- New tests for the new schema (top-level image + pipeline block) --- - - -class TestNewSchema: - """Tests for the new top-level image + pipeline block YAML schema.""" - - def _make_config(self, **kwargs) -> GogaConfigAnswers: # type: ignore[no-untyped-def] - defaults = { - "language": "python", - "agent": "claude", - "image": "qarium/foo:1.0", - "pipeline_agent": "claude", - } - defaults.update(kwargs) - return GogaConfigAnswers(**defaults) - - def _make_gen(self, tmp_path: Path) -> FileGenerator: - gen = FileGenerator() - gen._base_dir = tmp_path - return gen - - def _load_yaml(self, config_path: Path) -> dict: - with config_path.open() as f: - return yaml.safe_load(f) - - def test_generate_goga_config_emits_yaml_in_correct_order(self, tmp_path: Path) -> None: - """Top-level keys appear in canonical order (commands omitted — no source).""" - config = self._make_config( - codemanifest_usages={"conventions": ".goga/usages/conventions.md"}, - codemanifest_annotations="Use conventions.", - ) - mock_response = MagicMock() - mock_response.text = "# Python conventions" - mock_response.status_code = 200 - mock_response.raise_for_status = MagicMock() - - gen = FileGenerator() - gen._base_dir = tmp_path - - with patch("goga.onboarding.generator.requests.get", return_value=mock_response): - gen.generate_goga_config(config) - - data = self._load_yaml(tmp_path / ".goga" / "config.yml") - canonical = ["language", "image", "commands", "build", "pipeline", "codemanifest"] - present = list(data.keys()) - # present keys must be a subsequence of the canonical order - assert present == [k for k in canonical if k in present] - assert present == ["language", "image", "build", "pipeline", "codemanifest"] - - def test_generate_goga_config_emits_dockerfile_in_full_canonical_order(self, tmp_path: Path) -> None: - """With dockerfile set, the full canonical order is - language, image, dockerfile, build, pipeline, codemanifest — dockerfile - sits between image and build (not after build).""" - config = self._make_config( - dockerfile_path="Dockerfile", - codemanifest_usages={"conventions": ".goga/usages/conventions.md"}, - codemanifest_annotations="Use conventions.", - ) - mock_response = MagicMock() - mock_response.text = "# Python conventions" - mock_response.status_code = 200 - mock_response.raise_for_status = MagicMock() - - gen = FileGenerator() - gen._base_dir = tmp_path - - with patch("goga.onboarding.generator.requests.get", return_value=mock_response): - gen.generate_goga_config(config) - - data = self._load_yaml(tmp_path / ".goga" / "config.yml") - assert list(data.keys()) == ["language", "image", "dockerfile", "build", "pipeline", "codemanifest"] - - def test_generate_goga_config_emits_pipeline_block_when_agent_set(self, tmp_path: Path) -> None: - """pipeline: block is emitted when a pipeline agent is set (even without env).""" - config = self._make_config(pipeline_agent="claude", pipeline_env=None) - gen = FileGenerator() - gen._base_dir = tmp_path - gen.generate_goga_config(config) - - text = (tmp_path / ".goga" / "config.yml").read_text(encoding="utf-8") - assert "pipeline:" in text - data = self._load_yaml(tmp_path / ".goga" / "config.yml") - assert data["pipeline"]["agent"] == "claude" - assert "env" not in data["pipeline"] - - def test_generate_goga_config_omits_pipeline_when_no_agent_no_env(self, tmp_path: Path) -> None: - """No pipeline agent and no pipeline env → the pipeline block is omitted entirely.""" - config = self._make_config(pipeline_agent=None, pipeline_env=None) - gen = FileGenerator() - gen._base_dir = tmp_path - gen.generate_goga_config(config) - - data = self._load_yaml(tmp_path / ".goga" / "config.yml") - assert "pipeline" not in data - - def test_generate_goga_config_omits_build_when_no_agent_no_env(self, tmp_path: Path) -> None: - """No build agent and no build env → the build block is omitted entirely.""" - config = self._make_config(agent=None, env=None) - gen = FileGenerator() - gen._base_dir = tmp_path - gen.generate_goga_config(config) - - data = self._load_yaml(tmp_path / ".goga" / "config.yml") - assert "build" not in data - - def test_generate_goga_config_emits_build_env_without_agent(self, tmp_path: Path) -> None: - """Build env is emitted even when the build agent is None.""" - config = self._make_config(agent=None, env={"API_KEY": "secret"}) - gen = FileGenerator() - gen._base_dir = tmp_path - gen.generate_goga_config(config) - - data = self._load_yaml(tmp_path / ".goga" / "config.yml") - assert data["build"]["task_executor"]["env"] == {"API_KEY": "secret"} - assert "agent" not in data["build"]["task_executor"] - - def test_generate_goga_config_omits_agent_keys_when_none(self, tmp_path: Path) -> None: - """agent keys are omitted from both build and pipeline when None.""" - config = self._make_config(agent="claude", pipeline_agent=None) - gen = FileGenerator() - gen._base_dir = tmp_path - gen.generate_goga_config(config) - - data = self._load_yaml(tmp_path / ".goga" / "config.yml") - assert data["build"]["task_executor"]["agent"] == "claude" - assert "pipeline" not in data - - def test_generate_goga_config_emits_image_at_top_level(self, tmp_path: Path) -> None: - """image is emitted at the top level, never under build:.""" - config = self._make_config(image="qarium/foo:1.0") - gen = FileGenerator() - gen._base_dir = tmp_path - gen.generate_goga_config(config) - - data = self._load_yaml(tmp_path / ".goga" / "config.yml") - assert data["image"] == "qarium/foo:1.0" - assert "image" not in data["build"] - - def test_generate_goga_config_omits_pipeline_env_when_none(self, tmp_path: Path) -> None: - """When pipeline_env is None, env: is absent from the pipeline block.""" - config = self._make_config(pipeline_env=None) - gen = FileGenerator() - gen._base_dir = tmp_path - gen.generate_goga_config(config) - - data = self._load_yaml(tmp_path / ".goga" / "config.yml") - assert "pipeline" in data - assert "env" not in data["pipeline"] - - def test_generate_goga_config_emits_pipeline_env_when_provided(self, tmp_path: Path) -> None: - """When pipeline_env has values, env: appears under pipeline.""" - config = self._make_config(pipeline_env={"FOO": "1"}) - gen = FileGenerator() - gen._base_dir = tmp_path - gen.generate_goga_config(config) - - data = self._load_yaml(tmp_path / ".goga" / "config.yml") - assert data["pipeline"]["env"] == {"FOO": "1"} - - def test_generate_goga_config_omits_pipeline_env_when_empty(self, tmp_path: Path) -> None: - """Empty pipeline_env dict is omitted from config.yml.""" - config = self._make_config(pipeline_env={}) - gen = FileGenerator() - gen._base_dir = tmp_path - gen.generate_goga_config(config) - - data = self._load_yaml(tmp_path / ".goga" / "config.yml") - assert "env" not in data["pipeline"] diff --git a/tests/onboarding/test_integration.py b/tests/onboarding/test_integration.py index 1b04c3dd..892821aa 100644 --- a/tests/onboarding/test_integration.py +++ b/tests/onboarding/test_integration.py @@ -1,451 +1,10 @@ -from __future__ import annotations - -from pathlib import Path -from unittest.mock import MagicMock, patch - -import click -import pytest -import requests.exceptions -import yaml -from goga.onboarding.answers import GogaConfigAnswers, InitAnswers -from goga.onboarding.generator import FileGenerator -from goga.onboarding.logic import InitLogic -from goga.onboarding.questionnaire import Questionnaire - - -def _make_gen(tmp_path: Path) -> FileGenerator: - gen = FileGenerator() - gen._base_dir = tmp_path - return gen - - -def _load_yaml(config_path: Path) -> dict: - with config_path.open() as f: - return yaml.safe_load(f) - - -class TestIntegration: - """End-to-end integration tests: Questionnaire → InitLogic → FileGenerator.""" - - def test_init_full_flow_with_convention(self, tmp_path: Path) -> None: - """Full flow: python, convention=True, agent=claude, default image, no env.""" - config = GogaConfigAnswers( - language="python", - agent="claude", - image="qarium/goga-python-3.12:1.0", - pipeline_agent="claude", - codemanifest_usages={"conventions": ".goga/usages/conventions.md"}, - codemanifest_annotations="Использовать `conventions` для правил написания кода и тестов.", - ) - answers = InitAnswers(goga_config=config) - - mock_q = MagicMock(spec=Questionnaire) - mock_q.ask.return_value = answers - - gen = _make_gen(tmp_path) - logic = InitLogic(mock_q, gen) - - mock_response = MagicMock() - mock_response.text = "# Python conventions mock" - mock_response.status_code = 200 - mock_response.raise_for_status = MagicMock() - - with patch("goga.onboarding.generator.requests.get", return_value=mock_response): - result = logic.run() - - assert result == 0 - - config_path = tmp_path / ".goga" / "config.yml" - assert config_path.exists() - data = _load_yaml(config_path) - assert data["language"] == "python" - assert data["image"] == "qarium/goga-python-3.12:1.0" - assert data["pipeline"]["agent"] == "claude" - assert data["codemanifest"]["usages"] == {"conventions": ".goga/usages/conventions.md"} - - conventions_path = tmp_path / ".goga" / "usages" / "conventions.md" - assert conventions_path.exists() - assert conventions_path.read_text(encoding="utf-8") == "# Python conventions mock" - - def test_init_without_convention(self, tmp_path: Path) -> None: - """Golang without convention: no codemanifest section, no .goga/usages/.""" - config = GogaConfigAnswers( - language="golang", - agent="claude", - image="qarium/goga-golang-1.23:1.0", - pipeline_agent="claude", - ) - answers = InitAnswers(goga_config=config) - - mock_q = MagicMock(spec=Questionnaire) - mock_q.ask.return_value = answers - - gen = _make_gen(tmp_path) - logic = InitLogic(mock_q, gen) - - with patch("goga.onboarding.generator.requests.get") as mock_get: - result = logic.run() - - assert result == 0 - - config_path = tmp_path / ".goga" / "config.yml" - data = _load_yaml(config_path) - assert data["language"] == "golang" - assert "codemanifest" not in data - assert not (tmp_path / ".goga" / "usages").exists() - mock_get.assert_not_called() - - def test_init_with_env_vars(self, tmp_path: Path) -> None: - """Environment variables are written to config.yml.""" - config = GogaConfigAnswers( - language="python", - agent="claude", - image="qarium/goga-python-3.12:1.0", - pipeline_agent="claude", - env={"API_KEY": "secret", "MODEL": "gpt-4"}, - ) - answers = InitAnswers(goga_config=config) - - mock_q = MagicMock(spec=Questionnaire) - mock_q.ask.return_value = answers - - gen = _make_gen(tmp_path) - logic = InitLogic(mock_q, gen) - result = logic.run() - - assert result == 0 - - data = _load_yaml(tmp_path / ".goga" / "config.yml") - assert data["build"]["task_executor"]["env"] == {"API_KEY": "secret", "MODEL": "gpt-4"} - - def test_init_with_custom_usages_added_to_convention(self, tmp_path: Path) -> None: - """Convention + custom usage: codemanifest.usages has both keys.""" - config = GogaConfigAnswers( - language="python", - agent="claude", - image="qarium/goga-python-3.12:1.0", - pipeline_agent="claude", - codemanifest_usages={ - "conventions": ".goga/usages/conventions.md", - "custom": ".goga/usages/custom.md", - }, - codemanifest_annotations="Use conventions.", - ) - answers = InitAnswers(goga_config=config) - - mock_q = MagicMock(spec=Questionnaire) - mock_q.ask.return_value = answers - - gen = _make_gen(tmp_path) - logic = InitLogic(mock_q, gen) - - mock_response = MagicMock() - mock_response.text = "# mock" - mock_response.status_code = 200 - mock_response.raise_for_status = MagicMock() - - with patch("goga.onboarding.generator.requests.get", return_value=mock_response): - result = logic.run() - - assert result == 0 - - data = _load_yaml(tmp_path / ".goga" / "config.yml") - usages = data["codemanifest"]["usages"] - assert "conventions" in usages - assert "custom" in usages - - def test_init_custom_usages_without_convention(self, tmp_path: Path) -> None: - """Custom usage without convention: usages={"custom": "..."}, no HTTP download.""" - config = GogaConfigAnswers( - language="python", - agent="claude", - image="qarium/goga-python-3.12:1.0", - pipeline_agent="claude", - codemanifest_usages={"custom": ".goga/usages/custom.md"}, - ) - answers = InitAnswers(goga_config=config) - - mock_q = MagicMock(spec=Questionnaire) - mock_q.ask.return_value = answers - - gen = _make_gen(tmp_path) - logic = InitLogic(mock_q, gen) - - with patch("goga.onboarding.generator.requests.get") as mock_get: - result = logic.run() - - assert result == 0 - mock_get.assert_not_called() - - data = _load_yaml(tmp_path / ".goga" / "config.yml") - assert data["codemanifest"]["usages"] == {"custom": ".goga/usages/custom.md"} - assert not (tmp_path / ".goga" / "usages").exists() - - def test_init_empty_env(self, tmp_path: Path) -> None: - """Empty env is omitted from config.yml.""" - config = GogaConfigAnswers( - language="python", - agent="claude", - image="qarium/goga-python-3.12:1.0", - pipeline_agent="claude", - ) - answers = InitAnswers(goga_config=config) - - mock_q = MagicMock(spec=Questionnaire) - mock_q.ask.return_value = answers - - gen = _make_gen(tmp_path) - logic = InitLogic(mock_q, gen) - result = logic.run() - - assert result == 0 - - data = _load_yaml(tmp_path / ".goga" / "config.yml") - assert "env" not in data["build"]["task_executor"] - - def test_init_reinit_overwrites_existing_config(self, tmp_path: Path) -> None: - """Pre-existing config.yml is overwritten on re-init.""" - goga_dir = tmp_path / ".goga" - goga_dir.mkdir() - (goga_dir / "config.yml").write_text("language: old_lang\n") - - config = GogaConfigAnswers( - language="golang", - agent="claude", - image="qarium/goga-golang-1.23:1.0", - pipeline_agent="claude", - ) - answers = InitAnswers(goga_config=config) - - mock_q = MagicMock(spec=Questionnaire) - mock_q.ask.return_value = answers - - gen = _make_gen(tmp_path) - logic = InitLogic(mock_q, gen) - result = logic.run() - - assert result == 0 - - data = _load_yaml(tmp_path / ".goga" / "config.yml") - assert data["language"] == "golang" - - def test_init_custom_annotations_appended_to_convention(self, tmp_path: Path) -> None: - """Convention annotations + custom annotations are concatenated via newline.""" - config = GogaConfigAnswers( - language="python", - agent="claude", - image="qarium/goga-python-3.12:1.0", - pipeline_agent="claude", - codemanifest_usages={"conventions": ".goga/usages/conventions.md"}, - codemanifest_annotations=("Использовать `conventions` для правил написания кода и тестов.\nCustom rule"), - ) - answers = InitAnswers(goga_config=config) - - mock_q = MagicMock(spec=Questionnaire) - mock_q.ask.return_value = answers - - gen = _make_gen(tmp_path) - logic = InitLogic(mock_q, gen) - - mock_response = MagicMock() - mock_response.text = "# mock" - mock_response.status_code = 200 - mock_response.raise_for_status = MagicMock() - - with patch("goga.onboarding.generator.requests.get", return_value=mock_response): - result = logic.run() - - assert result == 0 - - data = _load_yaml(tmp_path / ".goga" / "config.yml") - annotations = data["codemanifest"]["annotations"] - assert annotations.startswith("Использовать") - assert "Custom rule" in annotations - - def test_init_user_cancels_questionnaire(self, tmp_path: Path) -> None: - """User cancels (click.Abort): run() returns 1, no .goga/ created.""" - mock_q = MagicMock(spec=Questionnaire) - mock_q.ask.side_effect = click.Abort() - - gen = _make_gen(tmp_path) - logic = InitLogic(mock_q, gen) - result = logic.run() - - assert result == 1 - assert not (tmp_path / ".goga").exists() - - def test_init_convention_download_fails(self, tmp_path: Path) -> None: - """URLError during convention download: run() returns 1, no files created.""" - config = GogaConfigAnswers( - language="python", - agent="claude", - image="qarium/goga-python-3.12:1.0", - pipeline_agent="claude", - codemanifest_usages={"conventions": ".goga/usages/conventions.md"}, - codemanifest_annotations="Use conventions.", - ) - answers = InitAnswers(goga_config=config) - - mock_q = MagicMock(spec=Questionnaire) - mock_q.ask.return_value = answers - - gen = _make_gen(tmp_path) - logic = InitLogic(mock_q, gen) - - with patch( - "goga.onboarding.generator.requests.get", - side_effect=requests.exceptions.ConnectionError("Network error"), - ): - result = logic.run() - - assert result == 1 - assert not (tmp_path / ".goga" / "config.yml").exists() - assert not (tmp_path / ".goga" / "usages" / "conventions.md").exists() - - def test_init_emits_pipeline_block_in_generated_config(self, tmp_path: Path) -> None: - """Generated config.yml always contains a pipeline: block (agent required by load_project_config).""" - config = GogaConfigAnswers( - language="python", - agent="claude", - image="qarium/goga-python-3.12:1.0", - pipeline_agent="codex", - pipeline_env={"CODEX_MODEL": "o4-mini"}, - ) - answers = InitAnswers(goga_config=config) - - mock_q = MagicMock(spec=Questionnaire) - mock_q.ask.return_value = answers - - gen = _make_gen(tmp_path) - logic = InitLogic(mock_q, gen) - result = logic.run() - - assert result == 0 - - data = _load_yaml(tmp_path / ".goga" / "config.yml") - assert data["pipeline"]["agent"] == "codex" - assert data["pipeline"]["env"] == {"CODEX_MODEL": "o4-mini"} - assert "image" not in data["build"] - - def test_init_generates_goga_dockerfile_at_new_default_path( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """End-to-end (D5): accept Dockerfile + Enter → .goga/Dockerfile created & recorded. - - Cross-entity scenario exercising the full chain - Questionnaire.ask_goga_config → InitLogic.run → FileGenerator.generate. - The user accepts the Dockerfile creation and presses Enter on the path - prompt, taking the new `.goga/Dockerfile` default (most common case). - Asserts the default propagates into both the filesystem and config.yml. - """ - # ask_goga_config() short-circuits to None when .goga/config.yml exists; - # the repo CWD contains the goga project's own config.yml, so run in a - # clean tmp_path (no config.yml) to take the non-skip survey path. - monkeypatch.chdir(tmp_path) - other_prompts = iter( - [ - "python", # language - "claude", # agent - "qarium/goga-python-3.12:1.0", # base image (FROM) - "my-python-image:latest", # built image name - "claude", # pipeline agent - ] - ) - - def fake_prompt(message, *args, **kwargs): - # On the Dockerfile path prompt, simulate pressing Enter (no input) - # → click.prompt returns its `default`. - if message == "Dockerfile path": - return kwargs.get("default") - return next(other_prompts) - - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - True, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - gen = FileGenerator() - gen._base_dir = tmp_path - logic = InitLogic(Questionnaire(), gen) - - with patch("click.prompt", side_effect=fake_prompt), patch("click.confirm", side_effect=confirms): - result = logic.run() - - assert result == 0 - - dockerfile = tmp_path / ".goga" / "Dockerfile" - assert dockerfile.exists() - content = dockerfile.read_text(encoding="utf-8") - assert content == "FROM qarium/goga-python-3.12:1.0\n" - - config_yml = (tmp_path / ".goga" / "config.yml").read_text(encoding="utf-8") - assert "dockerfile: .goga/Dockerfile" in config_yml - assert "image: my-python-image:latest" in config_yml - - def test_init_custom_dockerfile_path_flows_through_chain( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Cross-entity: a custom Dockerfile path reaches both the FS and config.yml. - - Confirms that the survey answer (dockerfile_path) is threaded unchanged - through Questionnaire → InitLogic → FileGenerator, not hardcoded to the - default. The file lands at the user-chosen location under .goga/. - """ - # Run in a clean dir without .goga/config.yml so ask_goga_config() does - # not short-circuit to None (the repo CWD has the goga project's config). - monkeypatch.chdir(tmp_path) - prompts = iter( - [ - "python", # language - "claude", # agent - ".goga/custom.Dockerfile", # dockerfile path (typed, not default) - "qarium/goga-python-3.12:1.0", # base image (FROM) - "my-python-image:latest", # built image name - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - True, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - gen = FileGenerator() - gen._base_dir = tmp_path - logic = InitLogic(Questionnaire(), gen) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - result = logic.run() - - assert result == 0 - - dockerfile = tmp_path / ".goga" / "custom.Dockerfile" - assert dockerfile.exists() - assert dockerfile.read_text(encoding="utf-8") == "FROM qarium/goga-python-3.12:1.0\n" - - config_yml = (tmp_path / ".goga" / "config.yml").read_text(encoding="utf-8") - assert "dockerfile: .goga/custom.Dockerfile" in config_yml - assert "image: my-python-image:latest" in config_yml +"""End-to-end onboarding session tests — placeholder for the invited-tool suite. + +The old integration suite of this file drove the deleted flat-answer model +and the old per-field questionnaire; its cases were ported into the +leaf-cell test layout — ``questions/``, ``survey/``, ``generator/``, and +``test_logic.py`` — during the facade rewrite. The cross-entity suite of +the whole feature (invitation → dedup → both tool moments → survey → +amendments → generation → attributed report through the CLI) is rebuilt in +this file together with the end-to-end task of the plan. +""" diff --git a/tests/onboarding/test_logic.py b/tests/onboarding/test_logic.py index b9457eb0..b95fc85c 100644 --- a/tests/onboarding/test_logic.py +++ b/tests/onboarding/test_logic.py @@ -1,100 +1,204 @@ +"""Contract and logic tests for the onboarding domain facade. + +The entity declared in ``goga/onboarding/CODEMANIFEST`` with +``location: logic.py``: the orchestrator ``InitLogic`` of one initialization +session — together with the 13 facade re-exports of the leaf cells in the +embedding order of the CODEMANIFEST. The collaborators are stubbed where the +subject is the orchestrator's own control flow (the existing-config guard, +the session error tiers, the degraded plain session); the real participation +mediator runs in the broken-import case — the single fatal path of the tool +moments. The filesystem boundary is pinned by the ``_clean_cwd`` fixture of +this test directory. +""" + from __future__ import annotations +import logging +import sys +from importlib.metadata import PackageNotFoundError +from pathlib import Path from unittest.mock import MagicMock -from goga.onboarding.answers import GogaConfigAnswers, InitAnswers -from goga.onboarding.logic import InitLogic +import pytest +from goga.onboarding import InitLogic, ToolParticipation +from goga.onboarding.generator import FileGenerator + +_logic_module = sys.modules["goga.onboarding.logic"] + +_EMBEDDING_ALL = [ + "Question", + "QuestionGroup", + "SessionAnswers", + "SessionPlan", + "Questionnaire", + "core_questions", + "assemble_session_plan", + "apply_skips", + "ToolParticipation", + "ToolDeclaration", + "ToolContribution", + "FileGenerator", + "CreatedFile", + "InitLogic", +] + +# The attribute the enumeration reads — the single enumeration mock point +# (mirrors the participation test directory; conftest fixtures do not cross +# test directories). +_ENUMERATION_TARGET = "goga.hooks.tools.packages.packages_distributions" + +# Every run test operates on the filesystem state of a clean project dir — +# the repo CWD carries the goga project's own .goga/. +pytestmark = pytest.mark.usefixtures("_clean_cwd") class TestContract: - """Contract-level tests for InitLogic.""" + """Contract-level tests for the facade surface and the constructor.""" - def test_init_logic_importable_from_logic(self) -> None: - from goga.onboarding.logic import InitLogic + def test_facade_reexports_the_session_api_in_embedding_order(self) -> None: + """The 13 embeddings plus InitLogic are the facade surface, in the CODEMANIFEST order.""" + import goga.onboarding as facade - assert InitLogic is not None + assert facade.__all__ == _EMBEDDING_ALL - def test_init_logic_constructor_accepts_two_args(self) -> None: - mock_q = MagicMock() - mock_g = MagicMock() - logic = InitLogic(mock_q, mock_g) - assert logic is not None + for name in _EMBEDDING_ALL: + assert getattr(facade, name) is not None - def test_init_logic_has_run_method_returning_int(self) -> None: - mock_q = MagicMock() - mock_g = MagicMock() - logic = InitLogic(mock_q, mock_g) - result = logic.run() - assert isinstance(result, int) - - -class TestLogic: - """Logic tests for InitLogic — mocks Questionnaire and FileGenerator.""" - - def _make_config(self) -> GogaConfigAnswers: - return GogaConfigAnswers( - language="python", - agent="claude", - image="qarium/goga-python-3.12:0.1", - pipeline_agent="claude", - env={}, - ) + def test_init_logic_importable_from_logic_module(self) -> None: + """The orchestrator lives in the declared ``location: logic.py``.""" + assert sys.modules["goga.onboarding.logic"].InitLogic is InitLogic - def test_logic_run_returns_zero_on_success(self) -> None: - mock_q = MagicMock() - mock_g = MagicMock() - answers = InitAnswers(goga_config=self._make_config()) - mock_q.ask.return_value = answers - - logic = InitLogic(mock_q, mock_g) - result = logic.run() + def test_constructor_requires_three_collaborators(self) -> None: + """The questionnaire, the generator, and the participation are all required.""" + logic = InitLogic(questionnaire=MagicMock(), generator=MagicMock(), participation=MagicMock()) - assert result == 0 + assert logic is not None + assert callable(logic.run) - def test_logic_run_returns_one_on_abort(self) -> None: - import click + with pytest.raises(TypeError): + InitLogic(MagicMock(), MagicMock()) # type: ignore[call-arg] - mock_q = MagicMock() - mock_g = MagicMock() - mock_q.ask.side_effect = click.Abort() + def test_facade_no_longer_exports_the_old_answer_records(self) -> None: + """The flat-answer surface of the old facade is gone for good.""" + import goga.onboarding as facade - logic = InitLogic(mock_q, mock_g) - result = logic.run() + assert not hasattr(facade, "InitAnswers") + assert not hasattr(facade, "GogaConfigAnswers") + assert "InitAnswers" not in facade.__all__ + assert "GogaConfigAnswers" not in facade.__all__ - assert result == 1 - def test_logic_run_returns_one_on_generic_exception(self) -> None: - mock_q = MagicMock() - mock_g = MagicMock() - answers = InitAnswers(goga_config=self._make_config()) - mock_q.ask.return_value = answers - mock_g.generate.side_effect = RuntimeError("disk full") +class TestRun: + """Logic tests for the eight-step ``run`` and its three error tiers.""" - logic = InitLogic(mock_q, mock_g) - result = logic.run() + def test_existing_config_ends_session_silently(self, tmp_path) -> None: + """An existing .goga/config.yml ends the session — no prompts, no events, no artifacts.""" + (tmp_path / ".goga").mkdir() + (tmp_path / ".goga" / "config.yml").write_text("language: python\n", encoding="utf-8") - assert result == 1 + mock_q, mock_g, mock_p = MagicMock(), MagicMock(), MagicMock() + logic = InitLogic(questionnaire=mock_q, generator=mock_g, participation=mock_p) - def test_logic_run_does_not_create_files_on_abort(self) -> None: - import click + assert logic.run() == 0 + mock_p.collect_declarations.assert_not_called() + mock_q.run.assert_not_called() + mock_g.generate.assert_not_called() - mock_q = MagicMock() - mock_g = MagicMock() - mock_q.ask.side_effect = click.Abort() + def test_unreadable_version_is_clean_error(self, monkeypatch, capsys) -> None: + """An unreadable installed version is one clean message, exit 1, no traceback.""" + monkeypatch.setattr(_logic_module, "host_goga_version", MagicMock(side_effect=PackageNotFoundError("goga"))) + + logic = InitLogic(questionnaire=MagicMock(), generator=MagicMock(), participation=MagicMock()) + + assert logic.run() == 1 + + captured = capsys.readouterr() + assert "Error:" in captured.err + assert "Traceback" not in captured.err + assert "Traceback" not in captured.out + + def test_broken_package_import_is_clean_session_error( + self, + tmp_path, + monkeypatch, + capsys, + ) -> None: + """A tool package whose facade fails to import is one clean message naming the package.""" + monkeypatch.setattr(_logic_module, "host_goga_version", lambda: "1.3.0") + + package_dir = tmp_path / "goga_tool_broken" + package_dir.mkdir() + (package_dir / "__init__.py").write_text("import goga_missing_dependency\n", encoding="utf-8") + monkeypatch.syspath_prepend(tmp_path) + monkeypatch.setattr(_ENUMERATION_TARGET, lambda: {"goga_tool_broken": ["goga-tool-broken"]}) + + logic = InitLogic( + questionnaire=MagicMock(), + generator=MagicMock(), + participation=ToolParticipation(invited=["broken"]), + ) - logic = InitLogic(mock_q, mock_g) - logic.run() + assert logic.run() == 1 + + captured = capsys.readouterr() + assert "Error:" in captured.err + assert "goga_tool_broken" in captured.err + assert "Traceback" not in captured.err + assert "Traceback" not in captured.out + + def test_zero_invited_tools_degrades_to_the_plain_session(self, monkeypatch, caplog) -> None: + """No invitations and no installed packages — the core-only survey, exit 0.""" + monkeypatch.setattr(_logic_module, "host_goga_version", lambda: "1.3.0") + monkeypatch.setattr(_ENUMERATION_TARGET, lambda: {}) + + captured: dict = {} + + class _RecordingQuestionnaire: + """Stub recording the received plan and answering the single required field.""" + + def run(self, plan, answers) -> None: # type: ignore[no-untyped-def] + captured["tools"] = list(plan.tools) + captured["section_ids"] = [child.id for child in plan.root.children] + answers.record("language", "python") + + with caplog.at_level(logging.WARNING): + logic = InitLogic( + questionnaire=_RecordingQuestionnaire(), # type: ignore[arg-type] + generator=FileGenerator(), + participation=ToolParticipation(invited=[]), + ) + + assert logic.run() == 0 + + assert captured["tools"] == [] + assert captured["section_ids"] == [ + "language", + "convention", + "codemanifest", + "build", + "docker_image", + "pipeline", + "tools", + "usages", + ] + assert not caplog.records # no uninstalled-invited warnings — nothing was invited + assert Path(".goga/config.yml").is_file() + assert not Path(".goga/tools").exists() + + def test_abort_during_the_survey_is_quiet_exit_one(self, monkeypatch, capsys) -> None: + """A user abort is exit 1 with no message and no traceback.""" + import click - mock_g.generate.assert_not_called() + monkeypatch.setattr(_logic_module, "host_goga_version", lambda: "1.3.0") + monkeypatch.setattr(_ENUMERATION_TARGET, lambda: {}) - def test_logic_run_calls_ask_then_generate(self) -> None: mock_q = MagicMock() - mock_g = MagicMock() - answers = InitAnswers(goga_config=self._make_config()) - mock_q.ask.return_value = answers + mock_q.run.side_effect = click.Abort() + + logic = InitLogic(questionnaire=mock_q, generator=MagicMock(), participation=ToolParticipation(invited=[])) - logic = InitLogic(mock_q, mock_g) - logic.run() + assert logic.run() == 1 - mock_q.ask.assert_called_once() - mock_g.generate.assert_called_once_with(answers) + captured = capsys.readouterr() + assert "Error:" not in captured.err + assert "Traceback" not in captured.err diff --git a/tests/onboarding/test_questionnaire.py b/tests/onboarding/test_questionnaire.py deleted file mode 100644 index 3622f902..00000000 --- a/tests/onboarding/test_questionnaire.py +++ /dev/null @@ -1,1289 +0,0 @@ -from __future__ import annotations - -from typing import get_type_hints -from unittest.mock import patch - -import pytest -from goga.onboarding.answers import GogaConfigAnswers, InitAnswers -from goga.onboarding.questionnaire import Questionnaire - -# Apply the `_clean_cwd` fixture (tests/onboarding/conftest.py) to every test -# in this module: survey tests need a CWD without .goga/config.yml. -pytestmark = pytest.mark.usefixtures("_clean_cwd") - - -class TestContract: - """Contract-level tests for Questionnaire.""" - - def test_questionnaire_importable_from_questionnaire(self) -> None: - from goga.onboarding.questionnaire import Questionnaire - - assert Questionnaire is not None - - def test_questionnaire_constructor_no_args(self) -> None: - q = Questionnaire() - assert q is not None - - def test_questionnaire_has_ask_method(self) -> None: - q = Questionnaire() - assert hasattr(q, "ask") - assert callable(q.ask) - - def test_questionnaire_has_ask_goga_config_method(self) -> None: - q = Questionnaire() - assert hasattr(q, "ask_goga_config") - assert callable(q.ask_goga_config) - - def test_ask_goga_config_return_annotation_is_optional(self) -> None: - """ask_goga_config returns GogaConfigAnswers | None (skip whole survey).""" - hints = get_type_hints(Questionnaire.ask_goga_config) - assert hints["return"] == GogaConfigAnswers | None - - def test_ask_image_name_two_mode_signature_contract(self) -> None: - """ask_image_name is two-mode: (language=None, default=None) both default to None.""" - import inspect - - params = inspect.signature(Questionnaire.ask_image_name).parameters - - assert "language" in params - assert "default" in params - assert params["language"].default is None - assert params["default"].default is None - - -class TestLogic: - """Logic tests for Questionnaire — mock click.prompt/click.confirm. - - Confirm consumption order matches code execution: - 1. Convention (download base convention?) - 2. Usages (outer: add codemanifest usages?) - 3. Usages (inner loop: add another?) - 4. Annotations (add codemanifest annotations?) - 5. Build agent (configure a build agent?) — gates the Agent prompt - 6. Dockerfile (create Dockerfile?) - 7. Env suggestions for task_executor (set suggested env variables?) - 8. Custom env for task_executor (add custom environment variable? — while loop) - 9. Pipeline agent (configure a pipeline agent?) — gates the Pipeline agent prompt - 10. Env suggestions for pipeline (set suggested env variables?) - 11. Custom env for pipeline (add custom environment variable? — while loop) - - Prompt consumption order: - 1. Language - 2. (If usages: usage name, usage path, in loop) - 3. (If annotations: annotations text) - 4. Agent (only when build-agent confirm is True) - 5. Image - 6. (If dockerfile: dockerfile path) - 7. (If task env suggestions: value for each key) - 8. (If custom task env: key, value, in loop) - 9. Pipeline agent (only when pipeline-agent confirm is True) - 10. (If pipeline env suggestions: value for each key) - 11. (If custom pipeline env: key, value, in loop) - """ - - # The `_clean_cwd` fixture lives in tests/onboarding/conftest.py and is - # applied module-wide via `pytestmark` (see the top of this file). - - def test_questionnaire_ask_goga_config_python_with_convention(self) -> None: - prompts = iter( - [ - "python", # language - "claude", # agent - "qarium/goga-python-3.12:1.0", # image - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - True, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert isinstance(result, GogaConfigAnswers) - assert result.language == "python" - assert result.agent == "claude" - assert result.image == "qarium/goga-python-3.12:1.0" - assert result.env is None - assert result.pipeline_agent == "claude" - assert result.pipeline_env is None - assert result.dockerfile_path is None - assert result.codemanifest_usages == {"conventions": ".goga/usages/conventions.md"} - assert result.codemanifest_annotations == "Use `conventions` for code writing rules and testing." - - def test_questionnaire_ask_goga_config_golang_without_convention(self) -> None: - prompts = iter( - [ - "golang", # language - "claude", # agent - "qarium/goga-golang-1.23:1.0", # image - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert isinstance(result, GogaConfigAnswers) - assert result.language == "golang" - assert result.codemanifest_usages is None - assert result.codemanifest_annotations is None - assert result.dockerfile_path is None - - def test_questionnaire_ask_goga_config_with_env_vars(self) -> None: - prompts = iter( - [ - "python", # language - "claude", # agent - "qarium/goga-python-3.12:1.0", # image - "API_KEY", # env key 1 - "secret", # env value 1 - "MODEL", # env key 2 - "gpt-4", # env value 2 - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - True, # Add custom task env variable? (first) - True, # Add custom task env variable? (second) - False, # Add custom task env variable? (stop) - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.env == {"API_KEY": "secret", "MODEL": "gpt-4"} - - def test_questionnaire_ask_goga_config_custom_usages_merge(self) -> None: - prompts = iter( - [ - "python", # language - "custom", # usage name - ".goga/usages/custom.md", # usage path - "claude", # agent - "qarium/goga-python-3.12:1.0", # image - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - True, # Download base convention? - True, # Add codemanifest usages? - False, # Add another codemanifest usage? (stop) - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert "conventions" in result.codemanifest_usages - assert "custom" in result.codemanifest_usages - assert result.codemanifest_usages["custom"] == ".goga/usages/custom.md" - - def test_questionnaire_ask_goga_config_custom_usages_no_convention(self) -> None: - prompts = iter( - [ - "python", # language - "custom", # usage name - ".goga/usages/custom.md", # usage path - "claude", # agent - "qarium/goga-python-3.12:1.0", # image - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - False, # Download base convention? - True, # Add codemanifest usages? - False, # Add another codemanifest usage? (stop) - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.codemanifest_usages == {"custom": ".goga/usages/custom.md"} - - def test_questionnaire_ask_goga_config_custom_annotations_appended(self) -> None: - prompts = iter( - [ - "python", # language - "Custom rule for project", # annotations text - "claude", # agent - "qarium/goga-python-3.12:1.0", # image - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - True, # Download base convention? - False, # Add codemanifest usages? - True, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.codemanifest_annotations is not None - assert result.codemanifest_annotations.startswith("Use") - assert "Custom rule for project" in result.codemanifest_annotations - assert "\n" in result.codemanifest_annotations - - def test_questionnaire_ask_goga_config_custom_image_with_predefined(self) -> None: - prompts = iter( - [ - "golang", # language - "claude", # agent - "my-custom/golang:2.0", # image (custom, not from predefined list) - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.language == "golang" - assert result.image == "my-custom/golang:2.0" - - def test_questionnaire_ask_goga_config_kotlin_with_predefined_images(self) -> None: - prompts = iter( - [ - "kotlin", # language - "claude", # agent - "qarium/goga-kotlin-2.3.21:1.0", # image (default from predefined list) - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.language == "kotlin" - assert result.image == "qarium/goga-kotlin-2.3.21:1.0" - - def test_questionnaire_ask_goga_config_swift_with_predefined_images(self) -> None: - prompts = iter( - [ - "swift", # language - "claude", # agent - "qarium/goga-swift-6.2.4:1.0", # image (default from predefined list) - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.language == "swift" - assert result.image == "qarium/goga-swift-6.2.4:1.0" - - def test_questionnaire_ask_goga_config_javascript_with_predefined_images(self) -> None: - prompts = iter( - [ - "javascript", # language - "claude", # agent - "qarium/goga-node-24:1.0", # image (default from predefined list) - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.language == "javascript" - assert result.image == "qarium/goga-node-24:1.0" - - def test_all_languages_have_image_map_entries(self) -> None: - from goga.onboarding.questionnaire import _IMAGE_MAP, _LANGUAGES - - for language in _LANGUAGES: - assert language in _IMAGE_MAP, f"Language '{language}' missing from _IMAGE_MAP" - - def test_image_map_defaults_use_version_1_3(self) -> None: - """All suggested Docker images use the current default tag `:1.3`.""" - from goga.onboarding.questionnaire import _IMAGE_MAP - - for language, images in _IMAGE_MAP.items(): - assert images, f"Language '{language}' has no image entries" - for image in images: - assert image.endswith(":1.3"), f"Image '{image}' for language '{language}' must use the :1.3 tag" - - def test_questionnaire_ask_goga_config_duplicate_usage_name_skipped(self) -> None: - prompts = iter( - [ - "python", # language - "conventions", # duplicate usage name (already set by convention) - "custom", # usage name (new, after loop continues) - ".goga/usages/custom.md", # usage path - "claude", # agent - "qarium/goga-python-3.12:1.0", # image - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - True, # Download base convention? - True, # Add codemanifest usages? - True, # Add another codemanifest usage? (continue after duplicate skip) - False, # Add another codemanifest usage? (stop after custom added) - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with ( - patch("click.prompt", side_effect=prompts), - patch("click.echo") as mock_echo, - patch("click.confirm", side_effect=confirms), - ): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.codemanifest_usages["conventions"] == ".goga/usages/conventions.md" - assert "custom" in result.codemanifest_usages - assert any('Usage "conventions" already exists, skipping.' in str(c) for c in mock_echo.call_args_list) - - def test_questionnaire_ask_returns_init_answers(self) -> None: - prompts = iter( - [ - "python", # language - "claude", # agent - "qarium/goga-python-3.12:1.0", # image - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask() - - assert isinstance(result, InitAnswers) - assert isinstance(result.goga_config, GogaConfigAnswers) - - # --- New tests for Dockerfile and env suggestions --- - - def test_cpp_not_in_language_choices(self) -> None: - """cpp must not appear in language choices.""" - from goga.onboarding.questionnaire import _LANGUAGES - - assert "cpp" not in _LANGUAGES - - def test_questionnaire_ask_goga_config_with_dockerfile(self) -> None: - prompts = iter( - [ - "python", # language - "claude", # agent - "Dockerfile", # dockerfile path - "qarium/goga-python-3.12:1.0", # base image (FROM) - "my-python-image:latest", # built image name - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - True, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with ( - patch("click.prompt", side_effect=prompts), - patch("click.confirm", side_effect=confirms), - patch("goga.onboarding.questionnaire.resolve_project_name", return_value=None), - ): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.dockerfile_path == "Dockerfile" - assert result.dockerfile_base_image == "qarium/goga-python-3.12:1.0" - assert result.image == "my-python-image:latest" - - def test_questionnaire_ask_goga_config_without_dockerfile_asks_pull_image(self) -> None: - """Without a Dockerfile, only the pull image is asked (no base/name split).""" - prompts = iter( - [ - "python", # language - "claude", # agent - "qarium/goga-python-3.12:1.0", # pull image - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.dockerfile_path is None - assert result.dockerfile_base_image is None - assert result.image == "qarium/goga-python-3.12:1.0" - - def test_questionnaire_dockerfile_default_is_goga_dockerfile(self) -> None: - """Step 7 default for Dockerfile path is `.goga/Dockerfile` (Enter accepted).""" - other_prompts = iter( - [ - "python", # language - "claude", # agent - "qarium/goga-python-3.12:1.0", # base image (FROM) - "my-python-image:latest", # built image name - "claude", # pipeline agent - ] - ) - - def fake_prompt(message, *args, **kwargs): - # On the Dockerfile path prompt, simulate the user pressing Enter - # (no interactive input) → click.prompt returns its `default`. - if message == "Dockerfile path": - return kwargs.get("default") - return next(other_prompts) - - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - True, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with ( - patch("click.prompt", side_effect=fake_prompt), - patch("click.confirm", side_effect=confirms), - patch("goga.onboarding.questionnaire.resolve_project_name", return_value=None), - ): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.dockerfile_path == ".goga/Dockerfile" - - def test_questionnaire_ask_goga_config_without_dockerfile(self) -> None: - prompts = iter( - [ - "python", # language - "claude", # agent - "qarium/goga-python-3.12:1.0", # image - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.dockerfile_path is None - - def test_questionnaire_env_suggested_keys_for_claude(self) -> None: - prompts = iter( - [ - "python", # language - "claude", # agent - "qarium/goga-python-3.12:1.0", # image - "https://api.z.ai/api/anthropic", # ANTHROPIC_BASE_URL - "glm-4.7", # ANTHROPIC_DEFAULT_HAIKU_MODEL - "glm-5-turbo", # ANTHROPIC_DEFAULT_SONNET_MODEL - "glm-5.1", # ANTHROPIC_DEFAULT_OPUS_MODEL - "glm-5.2", # ANTHROPIC_MODEL - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - True, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.env == { - "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-4.7", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5-turbo", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5.1", - "ANTHROPIC_MODEL": "glm-5.2", - } - - def test_questionnaire_ask_goga_config_codex_agent(self) -> None: - prompts = iter( - [ - "python", # language - "codex", # agent - "qarium/goga-python-3.12:1.0", # image - "codex", # pipeline agent - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.agent == "codex" - assert result.env is None - - def test_questionnaire_ask_goga_config_supports_new_agents(self) -> None: - """The wizard accepts the full supported agent set (cursor build, qwen pipeline). - - Regression guard for the bug where build/pipeline agent selection only - offered claude and codex despite cursor/opencode/qwen being supported. - """ - prompts = iter( - [ - "python", # language - "cursor", # agent - "qarium/goga-python-3.12:1.0", # image - "qwen", # pipeline agent - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.agent == "cursor" - assert result.pipeline_agent == "qwen" - assert result.env is None - assert result.pipeline_env is None - - def test_questionnaire_codex_env_suggested_keys(self) -> None: - prompts = iter( - [ - "python", # language - "codex", # agent - "qarium/goga-python-3.12:1.0", # image - "o4-mini", # CODEX_MODEL (task env) - "codex", # pipeline agent - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - True, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.env == {"CODEX_MODEL": "o4-mini"} - - def test_agent_env_map_contains_codex(self) -> None: - from goga.onboarding.questionnaire import _AGENT_ENV_MAP - - assert "codex" in _AGENT_ENV_MAP - assert _AGENT_ENV_MAP["codex"] == ["CODEX_MODEL"] - - def test_agents_choice_equals_agent_env_map_keys(self) -> None: - """The build/pipeline agent choice list never drifts from _AGENT_ENV_MAP keys.""" - from goga.onboarding.questionnaire import _AGENT_ENV_MAP, _AGENTS - - assert list(_AGENT_ENV_MAP) == _AGENTS - # Every previously-unselectable supported agent must now be offered. - for agent in ("claude", "codex", "cursor", "opencode", "qwen"): - assert agent in _AGENTS - - def test_agent_choice_accepts_all_supported_agents(self) -> None: - """click.Choice built from _AGENTS validates every supported agent. - - Exercises the real click.Choice validation (convert raises BadParameter - for values outside the choice set) — this is the guard that the mocked - flow tests cannot exercise and that would have caught the original bug. - """ - from click import Choice - from click.exceptions import BadParameter - from goga.onboarding.questionnaire import _AGENTS - - choice = Choice(_AGENTS) - - for agent in ("claude", "codex", "cursor", "opencode", "qwen"): - assert choice.convert(agent, None, None) == agent - - # A value outside the supported set must still be rejected. - with pytest.raises(BadParameter): - choice.convert("not-a-real-agent", None, None) - - def test_questionnaire_env_skip_suggested_custom_only(self) -> None: - prompts = iter( - [ - "python", # language - "claude", # agent - "qarium/goga-python-3.12:1.0", # image - "MY_KEY", # custom env key - "my_value", # custom env value - "claude", # pipeline agent - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - True, # Add custom task env variable? - False, # Add custom task env variable? (stop) - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.env == {"MY_KEY": "my_value"} - - # --- New tests for steps 9 (pipeline_agent) and 10 (pipeline_env) --- - - def test_questionnaire_pipeline_agent_does_not_inherit_build_agent(self) -> None: - """The pipeline agent never defaults to the build agent — declining yields None.""" - prompts = iter( - [ - "python", # language - "claude", # agent - "qarium/goga-python-3.12:1.0", # image - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - False, # Configure a pipeline agent? (decline → None) - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.agent == "claude" - assert result.pipeline_agent is None - - def test_questionnaire_decline_both_agents_yields_none(self) -> None: - """By default no agents are configured — declining both yields None for each.""" - prompts = iter( - [ - "python", # language - "qarium/goga-python-3.12:1.0", # image - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - False, # Configure a build agent? (decline → None) - False, # Create Dockerfile? - False, # Add custom task env variable? - False, # Configure a pipeline agent? (decline → None) - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.agent is None - assert result.pipeline_agent is None - assert result.env is None - assert result.pipeline_env is None - - def test_questionnaire_pipeline_agent_can_differ_from_build_agent(self) -> None: - """The pipeline agent can be set independently and differ from the build agent.""" - prompts = iter( - [ - "python", # language - "claude", # agent - "qarium/goga-python-3.12:1.0", # image - "codex", # pipeline agent (override default) - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.agent == "claude" - assert result.pipeline_agent == "codex" - - def test_questionnaire_pipeline_env_collected_separately_from_task_env(self) -> None: - """Step 10 collects pipeline env independently (codex suggested key).""" - prompts = iter( - [ - "python", # language - "claude", # agent - "qarium/goga-python-3.12:1.0", # image - "codex", # pipeline agent - "gpt-5", # CODEX_MODEL (pipeline env) - ] - ) - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - True, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with patch("click.prompt", side_effect=prompts), patch("click.confirm", side_effect=confirms): - q = Questionnaire() - result = q.ask_goga_config() - - assert result.env is None - assert result.pipeline_env == {"CODEX_MODEL": "gpt-5"} - - -class TestAskImageNameTwoMode: - """Logic tests for the two-mode ask_image_name(language=None, default=None).""" - - def test_ask_image_name_accepts_default_from_resolve_project_name(self) -> None: - """(a) positive — resolve_project_name → 'widget' → default 'widget:latest' accepted.""" - from goga.onboarding import questionnaire as qmod - - captured: dict = {} - - def fake_prompt(message, *args, **kwargs): - # Record the default offered, then simulate the user accepting it. - captured["default"] = kwargs.get("default") - return kwargs.get("default") - - with ( - patch("goga.onboarding.questionnaire.resolve_project_name", return_value="widget"), - patch("click.prompt", side_effect=fake_prompt), - patch("click.echo"), - ): - # Mirror the consumer logic (ask_goga_config Dockerfile branch). Access - # resolve_project_name through the module so the patch takes effect. - name = qmod.resolve_project_name() - default = f"{name}:latest" if name is not None else None - image = qmod.Questionnaire().ask_image_name(language=None, default=default) - - assert name == "widget" - assert captured["default"] == "widget:latest" - assert image == "widget:latest" - - def test_ask_image_name_required_when_resolve_project_name_returns_none(self) -> None: - """(b) negative — resolve_project_name → None → no default, image required. - - The offered default is None (click.prompt called with no default), so the - user's first empty line is rejected and a real value is required. - """ - from goga.onboarding import questionnaire as qmod - - captured: list[dict] = [] - - def fake_prompt(message, *args, **kwargs): - captured.append({"has_default": "default" in kwargs}) - # The implementation calls click.prompt("Built image name") (no default) - # exactly once — return a real value. - return "real-image:latest" - - with ( - patch("goga.onboarding.questionnaire.resolve_project_name", return_value=None), - patch("click.prompt", side_effect=fake_prompt), - patch("click.echo"), - ): - name = qmod.resolve_project_name() - default = f"{name}:latest" if name is not None else None - image = qmod.Questionnaire().ask_image_name(language=None, default=default) - - assert name is None - assert default is None - assert all(not c["has_default"] for c in captured) - assert image == "real-image:latest" - - def test_ask_image_name_legacy_language_default_still_offered(self) -> None: - """(c) legacy compatibility — ask_image_name(language='python') offers 'python-image:latest'.""" - captured: dict = {} - - def fake_prompt(message, *args, **kwargs): - captured["default"] = kwargs.get("default") - return kwargs.get("default") - - with patch("click.prompt", side_effect=fake_prompt), patch("click.echo"): - image = Questionnaire().ask_image_name(language="python") - - assert captured["default"] == "python-image:latest" - assert image == "python-image:latest" - - def test_ask_image_name_legacy_language_ignores_default_arg(self) -> None: - """When language is provided, the default arg is ignored in favor of the legacy default.""" - captured: dict = {} - - def fake_prompt(message, *args, **kwargs): - captured["default"] = kwargs.get("default") - return "custom" - - with patch("click.prompt", side_effect=fake_prompt), patch("click.echo"): - image = Questionnaire().ask_image_name(language="golang", default="ignored:latest") - - assert captured["default"] == "golang-image:latest" - assert image == "custom" - - -class TestAskGogaConfigDockerfileBranch: - """Logic tests for the ask_goga_config Dockerfile branch wiring of resolve_project_name.""" - - def test_dockerfile_branch_uses_resolve_project_name_for_default(self) -> None: - """(d) resolve_project_name → 'widget' → ask_image_name offered 'widget:latest'.""" - captured: dict = {} - - def fake_prompt(message, *args, **kwargs): - if message == "Built image name": - captured["default"] = kwargs.get("default") - return kwargs.get("default") - return "default-placeholder" - - prompts = iter( - [ - "python", # language - "claude", # agent - "Dockerfile", # dockerfile path - "qarium/goga-python-3.12:1.0", # base image (FROM) - "claude", # pipeline agent - ] - ) - - def prompt_router(message, *args, **kwargs): - if message == "Built image name": - return fake_prompt(message, *args, **kwargs) - return next(prompts) - - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - True, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with ( - patch("goga.onboarding.questionnaire.resolve_project_name", return_value="widget"), - patch("click.prompt", side_effect=prompt_router), - patch("click.confirm", side_effect=confirms), - ): - result = Questionnaire().ask_goga_config() - - assert captured["default"] == "widget:latest" - assert result.image == "widget:latest" - assert result.dockerfile_path == "Dockerfile" - - def test_dockerfile_branch_no_default_when_resolve_project_name_none(self) -> None: - """(d) resolve_project_name → None → ask_image_name called with no default (image required).""" - captured: list[dict] = [] - - def fake_prompt(message, *args, **kwargs): - if message == "Built image name": - captured.append({"has_default": "default" in kwargs}) - return "provided-image:latest" - return "default-placeholder" - - prompts = iter( - [ - "python", # language - "claude", # agent - "Dockerfile", # dockerfile path - "qarium/goga-python-3.12:1.0", # base image (FROM) - "claude", # pipeline agent - ] - ) - - def prompt_router(message, *args, **kwargs): - if message == "Built image name": - return fake_prompt(message, *args, **kwargs) - return next(prompts) - - confirms = iter( - [ - False, # Download base convention? - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - True, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with ( - patch("goga.onboarding.questionnaire.resolve_project_name", return_value=None), - patch("click.prompt", side_effect=prompt_router), - patch("click.confirm", side_effect=confirms), - ): - result = Questionnaire().ask_goga_config() - - assert all(not c["has_default"] for c in captured) - assert result.image == "provided-image:latest" - - -class TestAskGogaConfigConditionalSkip: - """Logic tests for the filesystem-conditional ask_goga_config behavior.""" - - def test_ask_goga_config_returns_none_when_config_yml_exists( - self, - tmp_path, - monkeypatch, - ) -> None: - """When .goga/config.yml exists, the whole config survey is skipped.""" - goga = tmp_path / ".goga" - goga.mkdir() - (goga / "config.yml").write_text("language: python\n") - monkeypatch.chdir(tmp_path) - - # The survey must be fully skipped: nothing should be echoed, and no - # prompt/confirm should fire. Patch them to raise if touched. - with ( - patch("click.echo") as mock_echo, - patch("click.prompt") as mock_prompt, - patch("click.confirm") as mock_confirm, - ): - result = Questionnaire().ask_goga_config() - - assert result is None - assert all("Collecting" not in str(c) for c in mock_echo.call_args_list) - mock_prompt.assert_not_called() - mock_confirm.assert_not_called() - - def test_ask_goga_config_skips_base_convention_when_conventions_md_exists( - self, - tmp_path, - monkeypatch, - ) -> None: - """When conventions.md exists (config.yml absent), base convention is skipped. - - ask_base_convention must NOT be invoked — its prefill is (None, None). - Drive the remaining survey deterministically and assert the result is - assembled and "Base Convention" is never printed. - """ - goga = tmp_path / ".goga" - usages = goga / "usages" - usages.mkdir(parents=True) - (usages / "conventions.md").write_text("# conventions\n") - monkeypatch.chdir(tmp_path) - - prompts = iter( - [ - "python", # language - "claude", # agent - "qarium/goga-python-3.12:1.0", # image - "claude", # pipeline agent - ] - ) - # No "Download base convention?" confirm — that step is skipped. The - # confirms below follow the survey order AFTER ask_base_convention. - confirms = iter( - [ - False, # Add codemanifest usages? - False, # Add codemanifest annotations? - True, # Configure a build agent? - False, # Create Dockerfile? - False, # Set suggested task env variables? - False, # Add custom task env variable? - True, # Configure a pipeline agent? - False, # Set suggested pipeline env variables? - False, # Add custom pipeline env variable? - ] - ) - - with ( - patch("click.prompt", side_effect=prompts), - patch("click.echo") as mock_echo, - patch("click.confirm", side_effect=confirms), - ): - result = Questionnaire().ask_goga_config() - - assert result is not None - # base convention prefill is (None, None), so no conventions entry is added - assert result.codemanifest_usages is None - assert result.codemanifest_annotations is None - assert all("Base Convention" not in str(c) for c in mock_echo.call_args_list) - - def test_ask_wraps_none_into_init_answers_when_config_yml_exists( - self, - tmp_path, - monkeypatch, - ) -> None: - """ask() wraps the Optional result (None) into InitAnswers transparently.""" - goga = tmp_path / ".goga" - goga.mkdir() - (goga / "config.yml").write_text("language: python\n") - monkeypatch.chdir(tmp_path) - - with patch("click.prompt"), patch("click.confirm"): - result = Questionnaire().ask() - - assert isinstance(result, InitAnswers) - assert result.goga_config is None From 5db85314fe437468ebece854c9f94e31b62c182c Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 22:07:58 +0000 Subject: [PATCH 027/205] feat: add -t/--tool invitation flag to init command with dedup and ToolParticipation wiring --- .../2026/onboarding-refctoring/plan.md | 1449 +++++++++++++++++ goga/commands/init/init.py | 63 +- tests/commands/test_init.py | 138 ++ 3 files changed, 1641 insertions(+), 9 deletions(-) create mode 100644 .goga/history/2026/onboarding-refctoring/plan.md diff --git a/.goga/history/2026/onboarding-refctoring/plan.md b/.goga/history/2026/onboarding-refctoring/plan.md new file mode 100644 index 00000000..2fed7bb0 --- /dev/null +++ b/.goga/history/2026/onboarding-refctoring/plan.md @@ -0,0 +1,1449 @@ +# Plan: `onboarding-refctoring` — extensible `goga init` onboarding (tool participation via hooks actions + dynamic image tag) + +## Purpose + +Materialize the contracts of the topic «Расширяемый онбординг `goga init`: участие +тулз через hooks-действия и динамический тег образов» into code: the four new +onboarding leaf cells (questions, participation, survey, generator), the +rewritten onboarding domain facade with the new `InitLogic`, the CLI `-t/--tool` +invitation flag, the `minor_version` tag routine, the two onboarding catalog +records, and the three hooks-facade re-exports. + +After implementation the package provides: +- a declarative question-and-answer model (`Question`, `QuestionGroup`, + `SessionAnswers`) shared by every session participant; +- per-tool participation in the session through the two hooks actions + (`onboarding/declare_session`, `onboarding/amend_config`) delivered per tool + with staged control and isolated answer views; +- a survey engine that asks the declarative records (core sections + tool + blocks) and records answers at plan dot-paths; +- artifact generation from the committed answer space (Dockerfile, config.yml, + conventions download, tool configs) with attribution; +- image hints completed with the runtime minor tag — never a hardcoded tag; +- `goga init [-t name]...` with dedup, invitation validation, and opaque + passthrough. + +The most important gaps between contract and code: none of the four leaf cells +exists yet (only CODEMANIFESTs); the old flat `InitAnswers`/`GogaConfigAnswers` +model, the old per-field `Questionnaire.ask_*` engine, and the old +`FileGenerator.generate(answers)` API must be replaced (modules deleted, logic +ported); `goga.hooks` does not yet re-export the delivery primitives; the +catalog carries no onboarding records; `minor_version` does not exist; the +`init` command has no `-t` flag. + +Overall implementation strategy: dependency order leaves → root +(version → hooks catalog → hooks facade → questions → participation → survey → +generator → onboarding facade → commands/init), TDD per coding task, full +suite + `goga lint` at the end. The old modules are deleted in the facade +rewrite task — never earlier (the old facade imports them until then). + +## Context + +### Contract Surface + +**Entity: `minor_version(version: str) -> minor: str`** +- Type: function (Routine) +- Declared `location`: `goga/version/version.py` +- Facade obligation: must be importable from `goga.version` +- Properties/Methods: none (routine) +- Semantic requirements from descriptions: reduce a version string to its `N.M` + line; reuse the module-private `_release_segments` reducer (version.py:71); + a missing minor segment is treated as `"0"`; an argument with no leading + numeric major raises `ValueError` (message from the shared reducer); pure + function — deterministic, no I/O, no logging; richer tails reduce silently + (`1.2.1.dev3`, `1.2.0rc1`, `1.2.0.post1`, `1.2.0+local` → `1.2`); do not + read the installed version — the caller owns the metadata boundary +- Imported dependencies: none +- Annotation context: `convention` practice (docstring style, pure-function + discipline); mirrors `resolve_version`'s shape recognition + +**Entity: `declared_actions()` data change (+2 records)** +- Type: data change in `goga/hooks/catalog/catalog.py` (`_DECLARED_ACTIONS`) +- Facade obligation: re-exported through `goga.hooks` (already present — + `from .catalog import declared_actions`) +- Semantic requirements: add + `Action(domain="onboarding", name="declare_session", error_class="soft")` + and `Action(domain="onboarding", name="amend_config", error_class="soft")`; + published records are never rewritten (the statuses record is untouched); + ordering stays domain-then-name (`onboarding/amend_config`, + `onboarding/declare_session`, `statuses/register_statuses`); the catalog + stays supported-data only +- Annotation context: catalog contract Requirements already list all three + records — the code must now match + +**Entity: `goga.hooks` facade re-exports (+3)** +- Type: re-export (embeddings `->wrap_context: {}`, `->build_hook_arguments: {}`, `->enumerate_tool_packages: {}`) +- Declared `location`: `goga/hooks/__init__.py` +- Facade obligation: `wrap_context`, `build_hook_arguments` importable from + `goga.hooks` (source `goga/hooks/dispatch`), `enumerate_tool_packages` + importable from `goga.hooks` (source `goga/hooks/tools`); all three in + `__all__` +- Semantic requirements: both sub-facades already export them + (dispatch/__init__.py, tools/__init__.py verified); importing `goga.hooks` + imports no `goga_tool_*` package and enumerates nothing (facade docstring + invariant holds); no local name shadows the imports; the existing + `declared_actions` re-export is untouched + +**Entity: `Question(id, kind, prompt, choices=None, default=None, keys=None)`** +- Type: class (Entity, frozen dataclass `kw_only=True`) +- Declared `location`: `goga/onboarding/questions/questions.py` +- Facade obligation: must be importable from `goga.onboarding.questions (and + re-exported by `goga.onboarding`) +- Properties: `id -> str` (local name, unique among siblings), `kind -> str` + (choice | input | confirm | pairs), `prompt -> str`, `choices -> + list[str] | None`, `default -> str | bool | None`, `keys -> list[str] | None` +- Semantic requirements: the record carries data only — rendering and answer + validation belong to the survey engine; the kind fixes the parameterization + (`choices` for choice, `default` for input/confirm, `keys` for pairs); + answer values: choice/input — string, confirm — boolean, pairs — mapping of + strings; no validation at construction (kinds are checked at ask time) +- Annotation context: `convention` data-model rules + +**Entity: `QuestionGroup(id, prompt=None, children=None)`** +- Type: class (Entity, frozen dataclass `kw_only=True`) +- Declared `location`: `goga/onboarding/questions/questions.py` +- Facade obligation: importable from `goga.onboarding.questions` (and `goga.onboarding`) +- Properties: `id -> str`, `prompt -> str | None` (None for a purely structural + node), `children -> list[Question | QuestionGroup] | None` +- Semantic requirements: the tree path (ids from root joined by dots) addresses + the node in skip requests and answer paths; a group's answer value is a + nested mapping keyed by child ids — never a flat dotted key; a tool-declared + group is limited to one nesting level with simple children + +**Entity: `SessionAnswers(tools: list[str] | None = None)`** +- Type: class (Entity — the single mutable accumulator of one run) +- Declared `location`: `goga/onboarding/questions/answers.py` +- Facade obligation: importable from `goga.onboarding.questions` (and `goga.onboarding`) +- Methods: + - `record(id: str, value: str | bool | dict) -> None` — resolve `id` + segment by segment creating intermediate mappings; set the value at the + leaf; recording REPLACES (never merges) + - `amend(id: str, value: str | bool | dict) -> None` — same walk; an + existing mapping at the leaf merges recursively with `value`; scalars and + lists replace; an absent leaf is created; silent + - `view_for(tool: str) -> view: dict` — core section (every top-level key + except the reserved tool-section names given at construction) plus the + tool's own section re-keyed by local names without the tool prefix; a deep + copy — a snapshot; other tools never present + - `snapshot() -> view: dict` — deepcopy of the complete nested structure +- Semantic requirements: created empty — `tools` reserves the top-level keys + without creating them; nested mappings only, no dotted keys ever stored; + amendments apply in delivery order — a later amendment wins at every + conflicting leaf; substituting a user's answer is silent +- Edge semantics: `record` over an existing scalar with a deeper path replaces + the scalar with an intermediate mapping (the survey is authoritative); + `view_for` of a tool without a recorded section → core-only view; a local + name colliding with a core key wins in THAT tool's view only (update order) + +**Entity: `ToolDeclaration(tool: str, invited: bool)`** +- Type: class (Entity — moment-one surface + buffer) +- Declared `location`: `goga/onboarding/participation/declaration.py` +- Facade obligation: importable from `goga.onboarding.participation` (and `goga.onboarding`) +- Properties: `tool -> str`, `invited -> bool`, `questions -> + list[Question | QuestionGroup]` (declaration order), `skips -> list[str]` +- Methods: `declare(item)` — buffer one question or one-level group; a group + whose children contain a `QuestionGroup` is refused with a logged warning + naming the tool and the reason, the element is NOT buffered (structural + violations are warnings, never exceptions); `skip(path)` — append the raw + path string, no resolution here +- Semantic requirements: a hook of a non-invited tool returns immediately — no + member is called; buffered data is read by the engine after delivery; a hook + is never called to survey + +**Entity: `ToolContribution(tool: str, invited: bool, answers: dict)`** +- Type: class (Entity — moment-two surface + staged buffer) +- Declared `location`: `goga/onboarding/participation/contribution.py` +- Facade obligation: importable from `goga.onboarding.participation` (and `goga.onboarding`) +- Properties: `tool -> str`, `invited -> bool`, `answers -> dict` (the isolated + view), `amendments -> list[tuple[str, str | bool | dict]]` (call order), + `files -> list[tuple[str, dict]]` (call order) +- Methods: `answer(id, value)` — buffer one amendment; `write_config(file, + data)` — buffer one config file; the engine serializes and writes — a tool + never writes its config files itself; writing the same file again replaces + at write time +- Semantic requirements: the contribution is staged — buffered amendments and + files apply only after every hook of the tool completed without failure; the + view carries nothing of the other tools + +**Entity: `ToolParticipation(invited: list[str])`** +- Type: class (Entity — mediator of both onboarding action moments) +- Declared `location`: `goga/onboarding/participation/participation.py` +- Facade obligation: importable from `goga.onboarding.participation` (and `goga.onboarding`) +- Properties: `invited -> list[str]` (deduplicated, flag order — defensive + dedup in the constructor via `list(dict.fromkeys(invited))`) +- Methods: + - `collect_declarations() -> list[ToolDeclaration]` — build the run registry + once (`HookRegistry()`; `build_once()`; `ImportError` propagates — the + single fatal case); warn for every invited identity not among + `enumerate_tool_packages()` naming it; group + `registry.subscriptions_for("onboarding", "declare_session")` per tool + preserving enumeration order; per tool: `surface = ToolDeclaration(tool, + invited=tool in self._invited)`, `proxy = wrap_context(surface)`, per + subscription `hook(**build_hook_arguments(hook, proxy, + registry.self_context(tool)))`; any `Exception` from a hook of the tool → + the whole declaration is discarded with a warning naming tool, action, + reason; return surviving declarations in enumeration order + - `collect_contributions(answers: SessionAnswers) -> list[ToolContribution]` + — same delivery over `subscriptions_for("onboarding", "amend_config")` + with `surface = ToolContribution(tool, invited, answers=answers.view_for(tool))`; + a failing hook discards the tool's whole contribution (amendments AND + files) with a warning; commit pass in enumeration order: + `answers.amend(path, value)` per buffered amendment; return the committed + contributions +- Semantic requirements: every warning names the tool, the action, and the + reason; an invited tool without a subscription participates silently; a + subscribed tool without an invitation receives the not-invited marker (the + hook returns immediately — the marker is never filtered by the platform); + `_registry` is built lazily and shared by both moments +- Imported dependencies: `Question`, `QuestionGroup`, `SessionAnswers` + + `question-records` usage (from `goga/onboarding/questions`); `HookRegistry`, + `wrap_context`, `build_hook_arguments`, `enumerate_tool_packages` + + `per-tool-delivery`, `registering-hooks` usages (from `goga/hooks`) + +**Entity: `core_questions(image_tag: str, project_name: str | None, convention_exists: bool) -> tree: QuestionGroup`** +- Type: function (Routine) +- Declared `location`: `goga/onboarding/survey/core.py` +- Facade obligation: importable from `goga.onboarding.survey` (and `goga.onboarding`) +- Semantic requirements: build the eight sections in survey order — `language` + (choice, order python/golang/kotlin/swift/javascript), `convention` (only + when `convention_exists` is False; a confirm `adopt` with default False), + `codemanifest` (usages pairs + annotations input), `build` (agent choice + + env pairs), `docker_image` (dockerfile input default `.goga/Dockerfile`, + base_image input with the tag-completed hints embedded in the prompt and + default = LAST hint, image input with default `f"{project_name}:latest"` or + no default when the name is None), `pipeline` (agent choice + env pairs), + `tools` (pairs: name → version; empty version reads as latest; the four + grammar forms documented in the prompt), `usages` (structural group, no + declarable children — the engine drives the record loop); return + `QuestionGroup(id="core", children=sections)` — the root id is never + addressed in answers; the tag is never hardcoded (completed from + `image_tag` via the `image_defaults` practice mapping) +- Imported dependencies: `Question`, `QuestionGroup` (from questions cell) + +**Entity: `assemble_session_plan(core: QuestionGroup, declarations: list[ToolDeclaration]) -> plan: SessionPlan`** +- Type: function (Routine) +- Declared `location`: `goga/onboarding/survey/plan.py` +- Facade obligation: importable from `goga.onboarding.survey` (and `goga.onboarding`) +- Semantic requirements: `children = list(core.children)`; `reserved = {child.id + for child in core.children}` (derived from the RECEIVED core — no hardcoded + name list); per declaration in enumeration order: empty `questions` → no + block; `declaration.tool in reserved` → warning naming the tool and the + reserved name, the whole block is dropped (fix q2); local-name dedup within + the declaration (a repeated id drops THAT element with a warning naming the + tool and the reason; survivors stand); append `QuestionGroup(id=tool, + prompt=f"--- Tool: {tool} ---", children=survivors)` and `tools.append(tool)`; + return `SessionPlan(root=QuestionGroup(id="session", children=children), + tools=tools)` — a fresh root, the core tree is never mutated; a tool whose + every element was dropped still gets its (empty) block appended + +**Entity: `apply_skips(plan: SessionPlan, skips: list[tuple[str, str]]) -> plan: SessionPlan`** +- Type: function (Routine) +- Declared `location`: `goga/onboarding/survey/plan.py` +- Facade obligation: importable from `goga.onboarding.survey` (and `goga.onboarding`) +- Semantic requirements: resolve every raw path against the ORIGINAL root: + `segments[0]` a tool identity in `plan.tools` → address is the full path; + ELIF `segments[0]` a core section id → address is the path from the root; + ELIF `segments[0]` a local name of the DECLARING tool's own block → address + is `[tool] + segments`; ELSE → warning no-op; existence is checked against + the original tree only (a descendant of an already-skipped node resolves and + is silently absorbed — set semantics, order-independent); rebuild new + `QuestionGroup`s along removed branches, share frozen originals on + unmodified branches; return a NEW `SessionPlan` with the same `tools` list + (an emptied block stays); a path into a pairs question has no children to + resolve → no-op warning; `core_section_ids` derived as + `set(c.id for c in root.children) - set(plan.tools)` + +**Entity: `SessionPlan(root: QuestionGroup, tools: list[str])`** +- Type: class (Entity, data record) +- Declared `location`: `goga/onboarding/survey/plan.py` +- Facade obligation: importable from `goga.onboarding.survey` (and `goga.onboarding`) +- Properties: `root -> QuestionGroup` (core children followed by tool blocks), + `tools -> list[str]` (participating tools in block order) + +**Entity: `Questionnaire()`** +- Type: class (Entity — the interactive survey engine) +- Declared `location`: `goga/onboarding/survey/questionnaire.py` +- Facade obligation: importable from `goga.onboarding.survey` (and `goga.onboarding`) +- Methods: + - `run(plan: SessionPlan, answers: SessionAnswers) -> None` — echo the + session header (`=== Goga Project Initialization ===` + wizard + description, ported from the old `ask`); iterate `plan.root.children` in + order; membership in `plan.tools` distinguishes tool blocks (echo the + block prompt as the attribution heading, then `ask_group` with prefix = + the tool id; suppress emptied blocks) from core sections + (`survey_core_section`); records land at plan dot-paths (`"{tool}.{local}"` + for tool answers); `click.Abort` propagates + - `ask_question(question: Question) -> value` — choice → + `click.prompt(prompt, type=click.Choice(choices))`; input → + `click.prompt(prompt, default=default)` (None default → required); + confirm → `click.confirm(prompt, default=default or False)`; pairs → + proposed-keys confirm + per-key prompts, then an add-another loop of + arbitrary key/value prompts (return `{}` when nothing collected); ELSE + (unknown kind or missing parameterization such as a choice without + `choices`) → `logger.warning` naming the question path (first segment is + the tool identity) and the reason; the question is skipped — not asked, + not recorded; the survey continues (tier 1 soft) + - `ask_group(group: QuestionGroup, prefix: str | None = None) -> dict` — + echo the group prompt as heading; children in order; recurse into + groups. The optional `prefix` (the tool id) qualifies the record paths + (`"{tool}.{local}"`); with the default `None` the declared + one-argument call shape of the CODEMANIFEST + (`ask_group(group) -> value: dict`) stays valid — matching the design's + `ask_group(group, prefix=None)` +- Core-section conditional patterns (the engine's own logic — port the old + per-field ask methods here): confirm gates are presentational (asked, drive + control flow, NEVER recorded); only the children PRESENT in the post-skip + section are asked — a skipped child is never asked; a branch whose driving + question is absent collapses to the remaining path: + - `language` → choice ask + - `convention` → confirm gate; accept → prefill + `({"conventions": ".goga/usages/conventions.md"}, "Use \`conventions\` for + code writing rules and testing.")` for codemanifest; reject → `(None, None)` + - `codemanifest` → usages pairs (prefill entries offered first), annotations + input (prefill text) + - `build` → confirm gate; accept → agent choice then env pairs (suggested + keys from `agent_env_defaults[agent]` prompted first, then arbitrary + additions — the old `_collect_agent_env` behavior); reject → nothing + recorded + - `docker_image` → IF the dockerfile question is absent (skipped) → no + gate, the pull branch directly (image ask with hint presentation when + base_image is present, else plain free-form); ELSE confirm gate + ("Create Dockerfile?"): accept → dockerfile input → base_image ask only + when present → image ask (plain label, record default); reject → the pull + branch; a skipped `base_image` collapses the FROM — never asked, never + recorded + - `pipeline` → confirm gate, same shape as build + - `tools` → confirm gate; accept → pairs loop (name prompt, version prompt; + empty input → "latest") + - `usages` → confirm gate; accept → record loop per record (group, + dependency name, git URL, optional ref, optional root; empty → omitted); + accumulate `{group: {dep: {git, ref?, root?}}}` (a later record of the + same group merges under the group key); record the accumulated mapping at + `"usages"` +- Imported dependencies: `Question`, `QuestionGroup`, `SessionAnswers`, + `ToolDeclaration`; practices `click`, `image_defaults`, `agent_env_defaults` + +**Entity: `FileGenerator()`** +- Type: class (Entity — the artifact generator, new snapshot-driven API) +- Declared `location`: `goga/onboarding/generator/generator.py` +- Facade obligation: importable from `goga.onboarding.generator` (and `goga.onboarding`) +- Methods: + - `generate(answers: SessionAnswers, contributions: list[ToolContribution]) + -> files: list[CreatedFile]` — `Path(".goga/config.yml").is_file()` → + skip the config and Dockerfile generation (jump to tool configs — the + guarantee lives here, not only at the caller); ELSE: snapshot; Dockerfile + written ONLY when BOTH `docker_image.dockerfile` AND + `docker_image.base_image` are present (`FROM {base_image}\n`, + `mkdir(parents=True, exist_ok=True)`, `CreatedFile(path, None)`); a + skipped `base_image` collapses the branch — no Dockerfile and the config + `dockerfile` field is omitted; then `generate_goga_config`; then + `generate_tool_configs`; return the created files in generation order + (Dockerfile, conventions.md when downloaded, config.yml, tool files) + - `generate_goga_config(answers) -> None` — snapshot; `language` empty → + clean `ValueError` naming the field (the single required-field check); + conventions entry (when `codemanifest.usages` carries the `"conventions"` + key) → download per `lang_conventions` (`requests.get(url, timeout=30)`), + failure → clean error with the URL and the cause, config.yml NOT created; + write `.goga/usages/conventions.md` first; `mkdir .goga`; assemble the + ordered document per the mapping table; `yaml.dump(default_flow_style=False, + allow_unicode=True, sort_keys=False)`; field order: language, image, + dockerfile, build, pipeline, codemanifest, tools, usages + - `generate_tool_configs(contributions) -> None` — per contribution, per + buffered `(file, data)` in call order → `.goga/tools//`; + a repeated file name replaces +- Snapshot → YAML field mapping (normative): `language` → language; + `docker_image.image` → image; `docker_image.dockerfile` → dockerfile + (omitted when absent — and absent when the Dockerfile was not written); + `docker_image.base_image` → the Dockerfile FROM line only, NEVER emitted to + the config; `build.{agent,env}` → `build.task_executor.{agent,env}` (nested + under `task_executor`); `pipeline.{agent,env}` → the flat pipeline block; + `codemanifest.{usages,annotations}` → the codemanifest block (annotations + via the `_LiteralStr` literal-block representer); `tools` → top-level tools + (dict[str,str]); `usages` → the nested records (dict[str, dict[str, + DepConfig]], `git` required, `ref`/`root` optional); confirm-gate answers + never carried (never recorded in the first place); no entry for tool + sections — their data reaches `.goga/tools//` through `write_config` +- Imported dependencies: `SessionAnswers`, `ToolContribution`; practices + `yaml`, `lang_conventions` + +**Entity: `CreatedFile(path: str, tool: str | None)`** +- Type: class (Entity, frozen dataclass) +- Declared `location`: `goga/onboarding/generator/generator.py` +- Facade obligation: importable from `goga.onboarding.generator` (and `goga.onboarding`) +- Properties: `path -> str` (relative to the project root), `tool -> str | + None` (None for an engine file) + +**Entity: `InitLogic(questionnaire: Questionnaire, generator: FileGenerator, participation: ToolParticipation)`** +- Type: class (Entity — orchestrator, rewritten) +- Declared `location`: `goga/onboarding/logic.py` +- Facade obligation: importable from `goga.onboarding` +- Methods: `run() -> exit_code: int` — the eight steps: + 1. existing `.goga/config.yml` → `return 0` immediately (no prompts, no + events, no artifacts) + 2. `version = host_goga_version()` — `PackageNotFoundError` → one clean + message, `return 1`; `tag = minor_version(version)` — `ValueError` → + clean message, `return 1` (defensive) + 3. `declarations = self._participation.collect_declarations()` — + `ImportError` → one clean message naming the package, `return 1` + 4. `project_name = resolve_project_name()` (tolerant, `None` on failure); + `convention_exists = Path(".goga/usages/conventions.md").is_file()`; + `core = core_questions(tag, project_name, convention_exists)`; + `plan = assemble_session_plan(core, declarations)`; + `skips = [(d.tool, p) for d in declarations for p in d.skips]`; + `plan = apply_skips(plan, skips)` + 5. `answers = SessionAnswers(tools=plan.tools)`; + `self._questionnaire.run(plan, answers)` — `click.Abort` → `return 1` + (quiet); unexpected `Exception` → one clean message, `return 1` + 6. `contributions = self._participation.collect_contributions(answers)` + 7. `files = self._generator.generate(answers, contributions)`; render the + report `created {path}` / `created {path} (tool: {tool})` + 8. `return 0` — tool failures never change the exit code +- Error tiers (cross-cutting): tool failures → `logger.warning`, element/tool + drops, exit code untouched; session errors (broken import, unreadable + version, empty language, download failure) → ONE `click.echo("Error: …", + err=True)` + exit 1, never a traceback; user aborts (`click.Abort`) → + exit 1, quiet +- Facade re-exports (13 embeddings, in order): `Question`, `QuestionGroup`, + `SessionAnswers`, `SessionPlan`, `Questionnaire`, `core_questions`, + `assemble_session_plan`, `apply_skips`, `ToolParticipation`, + `ToolDeclaration`, `ToolContribution`, `FileGenerator`, `CreatedFile` + +**Entity: `init(tpl, upgrade, ref, tools)` (changed)** +- Type: function (CLI command, `goga/commands/init/init.py`) +- Facade obligation: exposed as the `goga init` click command +- Signature change: `tools: tuple[str, ...]` via `@click.option("-t", + "--tool", "tools", multiple=True, ...)` +- Semantic requirements (algorithm steps 1–6): ref placement check (ported) → + `--ref requires or --upgrade`; mode resolution (ported; `` + + `--upgrade` mutual exclusion) → `UPGRADE | SCAFFOLD_THEN_ONBOARDING | + BARE_ONBOARDING`; invitation validation — `tools` non-empty AND mode + UPGRADE → `-t/--tool requires an onboarding session and --upgrade runs + none`, exit 1; dedup preserving flag order (`list(dict.fromkeys(tools))` — + the tuple→list conversion happens here); already-initialized guard + (BARE_ONBOARDING only, ported); dispatch — UPGRADE: `Scaffold().upgrade(ref)`; + both onboarding modes: `InitLogic(Questionnaire(), FileGenerator(), + ToolParticipation(invited=deduped))` → `ctx.exit(logic.run())` +- Constraints: the command passes names through as opaque data — no + installation checks; delegates execution; scaffold before onboarding when + `tpl` is given + +### Interaction Diagram and Data Flows + +Verbatim from the design document — the runtime composition every coding +task of this plan contributes to: + +``` +CLI: goga init [-t name]... [] [--upgrade] [--ref r] + └─ init (goga/commands/init) ── validates flags, dedups tools + ├─ Scaffold (tpl modes; unchanged) + └─ InitLogic(Questionnaire, FileGenerator, ToolParticipation(tools)) + │ + │ 1. guard: existing .goga/config.yml → return 0 + │ 2. host_goga_version → minor_version → tag + │ 3. ToolParticipation.collect_declarations ──── moment one + │ ├─ HookRegistry.build_once ── enumerate_tool_packages + │ │ └─ goga_tool_* facades: register_hooks(hooks) + │ │ subscribe("onboarding","declare_session",…) + │ ├─ per tool: wrap_context(ToolDeclaration) + + │ │ build_hook_arguments(hook, view, self_context) + │ │ → hook(context[, self]) → context.declare/.skip + │ └─ declarations: list[ToolDeclaration] + │ 4. resolve_project_name (goga/config), conventions.md check + │ core_questions(tag, name, exists) → core tree + │ assemble_session_plan(core, declarations) → SessionPlan + │ apply_skips(plan, (tool, path) pairs) → SessionPlan + │ 5. SessionAnswers(tools=plan.tools) + │ Questionnaire.run(plan, answers) ── click survey + │ 6. ToolParticipation.collect_contributions(answers) ─ moment two + │ ├─ per tool: view_for(tool) → ToolContribution(view) + │ │ wrap_context + build_hook_arguments → hook(context) + │ │ → context.answer / context.write_config + │ └─ commit: answers.amend(...) per contribution; files kept + │ 7. FileGenerator.generate(answers, contributions) + │ ├─ Dockerfile (FROM base_image) when dockerfile path + │ ├─ generate_goga_config: snapshot → .goga/config.yml + │ │ └─ conventions download (lang_conventions) + │ └─ generate_tool_configs → .goga/tools// + │ 8. file report with attribution → exit 0 +``` + +Data flows (verbatim from the design document): + +- **Invitation flow**: CLI `-t` names → dedup (order-preserving) → + `ToolParticipation(invited)` → per-tool `invited` marker on both + surfaces → hook-side early return when False. +- **Declaration flow**: hook buffers `Question`/`QuestionGroup` + + skip paths → `ToolDeclaration.questions/.skips` → plan blocks named by + tool identity → skips `(tool, raw_path)` → `apply_skips`. +- **Answer flow**: `Questionnaire.run` records at plan paths → nested + mappings in `SessionAnswers` → `view_for(tool)` isolates per tool → + amendments `answer(id, value)` → committed via `amend` (recursive merge) + → `snapshot()` → config mapping. +- **Tag flow**: `host_goga_version()` → `minor_version()` → `"N.M"` → + `core_questions(image_tag)` → completed hints in the tree → prompts. +- **File flow**: committed `ToolContribution.files` + + snapshot → `FileGenerator.generate` → `list[CreatedFile]` → report. + +Runtime construction order: `ToolParticipation` and `Questionnaire` +and `FileGenerator` are constructed by `init` and injected into +`InitLogic`; `SessionAnswers` is constructed inside `InitLogic.run` after +`apply_skips` (it needs `plan.tools`); `HookRegistry` is constructed once +inside `ToolParticipation` and shared by both moments. + +### Re-exports + +- `->minor_version` — source: `goga/version/version.py` (local type in the + version cell); facade obligation: importable from `goga.version`; add to + `__all__` in `goga/version/__init__.py` +- `->wrap_context`, `->build_hook_arguments` — source: `goga/hooks/dispatch` + (Imports entry, sub-facade verified to export both); facade obligation: + importable from `goga.hooks` +- `->enumerate_tool_packages` — source: `goga/hooks/tools` (Imports entry, + sub-facade verified); facade obligation: importable from `goga.hooks` +- The 13 onboarding embeddings (see `InitLogic` above) — sources: + `goga/onboarding/questions` (3), `goga/onboarding/survey` (5), + `goga/onboarding/participation` (3), `goga/onboarding/generator` (2); + facade obligation: importable from `goga.onboarding`, in the embedding + order of the CODEMANIFEST; the facade docstring states the domain-facade + role + +### Usages Context + +- `convention` (`.goga/usages/conventions.md`) — mandatory code conventions: + relative imports, dataclasses `kw_only=True`, docstring discipline, module + logger per file, REPL/test infrastructure, `pytest tests/ -x` / `ruff check + /` commands. Relevant to EVERY task of this plan. +- `click` (`.goga/usages/cooks/click.md`) — the prompting cookbook: + `click.prompt` / `click.confirm` / `click.Choice`, repeated collections. + Relevant to the `Questionnaire` engine and the `init` command. +- `image_defaults` (inline in `goga/onboarding/survey/CODEMANIFEST`) — + language → image-family mapping, tag completed at runtime, suggestions + displayed, default = last entry, free-form accepted: + python `qarium/goga-python-{3.10-3.14}`, golang + `qarium/goga-golang-{1.23-1.26}`, javascript `qarium/goga-node-{22,24}`, + kotlin `qarium/goga-kotlin-{2.0-2.3}`, swift `qarium/goga-swift-{6.0-6.2}`. + Relevant to `core_questions` (builds the hints) and `Questionnaire` + (renders them). +- `agent_env_defaults` (inline, survey) — agent → env key mapping (claude: + ANTHROPIC_BASE_URL, ANTHROPIC_DEFAULT_HAIKU_MODEL, + ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL, + ANTHROPIC_MODEL; codex: CODEX_MODEL; cursor: CURSOR_MODEL; opencode: + OPENCODE_MODEL, OPENCODE_VARIANT; qwen: OPENAI_BASE_URL, OPENAI_MODEL). + Relevant to `core_questions` and the engine's build/pipeline env pairs. +- `yaml` (inline, generator) — `yaml.dump(default_flow_style=False)` for + config and tool files; `sort_keys=False, allow_unicode=True`; annotations + via the `_LiteralStr` representer (ported from the old generator). +- `lang_conventions` (inline, generator) — URL template + `https://raw.githubusercontent.com/qarium/goga-lang-conventions/refs/heads/0.0.x/{language}/project.md`; + save to `.goga/usages/conventions.md`; `requests.get(url, timeout=30)`; + failure → clean error with URL and cause. + +### Imported Usages + +- `minor-line` from `goga/version` (`goga/version/.usages/minor-line.md`) — + reading the installed version and deriving the tag; used by the `InitLogic` + task. Status: current, no changes needed. +- `question-records` from `goga/onboarding/questions` + (`goga/onboarding/questions/.usages/question-records.md`) — the record + structure and the answer addressing rules; imported by the survey, + participation, and generator tasks. Status: current. +- `per-tool-delivery`, `registering-hooks` from `goga/hooks` + (`goga/hooks/.usages/{per-tool-delivery,registering-hooks}.md`) — the + staged per-tool delivery loop and the registration contract; imported by + the participation tasks. Status: current. +- `session-participation` from `goga/onboarding/participation` + (`goga/onboarding/participation/.usages/session-participation.md`) — the + two tool moments; used by the generator and `InitLogic` tasks. Status: + current. +- `tool-contexts` from `goga/onboarding/participation` + (`goga/onboarding/participation/.usages/tool-contexts.md`) — the hook + signature pattern (`context` first, optional `self`); used by the + participation tests. Status: current. +- `survey-run` from `goga/onboarding/survey` + (`goga/onboarding/survey/.usages/survey-run.md`) — the plan assembly and + the survey; used by the `InitLogic` task. Status: current. +- `artifact-generation` from `goga/onboarding/generator` + (`goga/onboarding/generator/.usages/artifact-generation.md`) — the + generation and the file report; used by the `InitLogic` task. Status: + current. +- `onboarding-usage` from `goga/onboarding` + (`goga/onboarding/.usages/onboarding-usage.md`) — the session API and the + invitation semantics; used by the `init` command task. Status: current. +- `scaffold-usage` from `goga/scaffold` — the Scaffold API; used by the + `init` command task. Status: current (unchanged). + +### Local Usages + +No new local usage files are planned. The design stage already created and +updated every usage file referenced by the contracts (`minor-line.md`, +`per-tool-delivery.md`, `question-records.md`, `session-participation.md`, +`tool-contexts.md`, `survey-run.md`, `artifact-generation.md`, +`onboarding-usage.md`, `init.md` — all verified current in the design +review). Implementation tasks must keep the code consistent with them but do +not create or modify usage files. + +### External Dependencies + +- `click` — CLI framework: the survey prompting (`prompt`, `confirm`, + `Choice`, `Abort`) and the `goga init` command (`@click.option(multiple=True)`) +- `requests` — the conventions download (`requests.get(url, timeout=30)`, + `requests.RequestException`) +- `PyYAML` (`yaml`) — config and tool file serialization, the `_LiteralStr` + literal-block representer +- `importlib.metadata` — `host_goga_version` (already in version.py; + `PackageNotFoundError` handling in `InitLogic`) +- Tools: `pytest` (with `CliRunner`), `ruff`, `goga lint` + +## Facts + +- The DSL graph has 76 cells, `goga lint` exits 0 — the baseline is green; + the plan's changes are additive to the graph (new leaf cells + facade + re-exports). +- `_release_segments(version)` already exists at `goga/version/version.py:71` + (module-private, returns `(major, minor | None)`, raises `ValueError` on no + leading numeric major) — `minor_version` reuses it, does not duplicate it. +- `wrap_context` is at `goga/hooks/dispatch/delivery.py:28` (resolves + attribute reads and bound methods; writes blocked — `declare`/`skip`/ + `answer`/`write_config` are method calls and pass through); + `build_hook_arguments` at `delivery.py:69` (only a declared `context` and + optional `self` receive values). +- `HookRegistry` (`goga/hooks/registry/state.py`) provides `build_once()`, + `subscriptions_for(domain, action)`, `self_context(tool)`; + `subscribe` resolves the address through `declared_actions` + (registration.py:99) — without the two catalog records every onboarding + subscription is rejected as «unknown action». +- `goga/hooks/dispatch/__init__.py` and `goga/hooks/tools/__init__.py` + already export `wrap_context`, `build_hook_arguments`, + `enumerate_tool_packages` — the facade re-export is a pure wiring change. +- Old modules to port from (then delete): `goga/onboarding/questionnaire.py` + (486 lines: `_IMAGE_MAP`, `_LANGUAGES` order python/golang/kotlin/swift/ + javascript, `_AGENT_ENV_MAP`, `_AGENTS`, `_collect_agent_env`, prompt + texts, docker branch), `goga/onboarding/generator.py` (174 lines: + `_LiteralStr` + representer, `_CONVENTION_URL_TEMPLATE`, download logic, + `_build_block`), `goga/onboarding/answers.py` (26 lines, to delete without + porting — replaced by `SessionAnswers`). +- Old facade `goga/onboarding/__init__.py` exports `GogaConfigAnswers`, + `InitAnswers` — the surface must never expose them again after the rewrite. +- The old `init.py` (104 lines) already carries the ref-placement and mode + logic to port verbatim; it lacks only the `tools` parameter. +- Python conventions: type hints mandatory; `snake_case` functions/methods, + `PascalCase` classes; relative intra-package imports; module docstrings in + the established style; one module logger per file; ruff line-length 120, + mccabe max-complexity 10. +- Test infrastructure: pytest `testpaths=["tests"]`; `tests/conftest.py` + autouse `_isolate_home`; `tests/onboarding/conftest.py` provides + `_clean_cwd` (applies to nested test dirs automatically); the repo CWD + contains its own `.goga/` — every filesystem test needs `_clean_cwd`; + tool-package simulation pattern lives in `tests/hooks/conftest.py` + (`sys.modules` injection + enumeration monkeypatch); `tests/hooks/` has + per-cell subpackages (catalog/, dispatch/, registry/, tools/) plus + `test_facade.py`. +- The project-config loader (`goga/config/project/loader.py`) validates the + written config: `tools` is `dict[str, str]` (loader.py:253); `usages` is + `dict[str, dict[str, DepConfig]]` with `git` required and `ref`/`root` + optional strings (loader.py:365-370) — the mapping table matches. +- Runtime construction order: `ToolParticipation`, `Questionnaire`, + `FileGenerator` are constructed by `init` and injected into `InitLogic`; + `SessionAnswers` is constructed inside `InitLogic.run` after `apply_skips` + (it needs `plan.tools`); `HookRegistry` is constructed once inside + `ToolParticipation` and shared by both moments. +- Reverse dependencies of the changed cells are unaffected: `goga/version` + consumers (docker, commands/upgrade, commands/install) use existing names; + `goga/hooks` consumers (history/statuses, commands/hooks) use existing + re-exports; all changes are additive. +- The `review` core section is explicitly out of scope — do not add it. + +## Gap Analysis + +- Missing contract entities: + - `minor_version` — not implemented (version.py lacks it) + - `Question`, `QuestionGroup`, `SessionAnswers` — cells have only + CODEMANIFEST, no code + - `ToolDeclaration`, `ToolContribution`, `ToolParticipation` — no code + - `core_questions`, `assemble_session_plan`, `apply_skips`, `SessionPlan`, + new `Questionnaire` — no code + - new-API `FileGenerator`, `CreatedFile` — no code + - new `InitLogic` (3-collaborator constructor, 8-step run) — old 2- + collaborator version present +- Missing facade exposure: + - `goga/version/__init__.py`: `minor_version` absent from imports/`__all__` + - `goga/hooks/__init__.py`: `wrap_context`, `build_hook_arguments`, + `enumerate_tool_packages` absent + - `goga/onboarding/__init__.py`: exports the wrong surface + (`GogaConfigAnswers`, `InitAnswers`); the 13 embeddings + `InitLogic` + missing +- Incorrect `location` placement: none — all planned files match the + CODEMANIFEST `location`s; the leaf-cell packages + (`goga/onboarding/{questions,participation,survey,generator}/`) exist as + directories with CODEMANIFEST only. +- API mismatches: + - old `FileGenerator.generate(answers: InitAnswers) -> None` vs new + `generate(answers: SessionAnswers, contributions: list[ToolContribution]) + -> list[CreatedFile]` + - old `Questionnaire.ask()` family vs new `run(plan, answers)` + + `ask_question` + `ask_group` + - old `init(tpl, upgrade, ref)` vs new `init(tpl, upgrade, ref, tools)` +- Behavioral mismatches: + - no tool participation exists at all (no onboarding catalog records, no + delivery) + - image hints carry the tag only via the old wizard's per-language lists — + the new tag threading (`host_goga_version` → `minor_version` → + `core_questions`) does not exist + - no `usages`/`tools` survey sections in the old wizard (NEW user-facing + sections) +- Existing code that can be reused: + - `_release_segments` (version.py) — direct reuse + - old questionnaire data + prompt texts + `_collect_agent_env` — port into + survey cell + - old generator `_LiteralStr`, URL template, download logic, `_build_block` + — port into generator cell + - old init.py validation order — port verbatim, extend + - hooks platform (registry, dispatch, tools sub-cells) — consume via the + facade, no changes +- Test coverage gaps: all 33 scenarios of the design's Test Stack Trace; + existing `tests/onboarding/test_answers.py`, `test_generator.py`, + `test_questionnaire.py` test the old API and are deleted with the modules + (cases ported); `tests/commands/test_init.py` imports the deleted modules + at collection level (`goga.onboarding.answers`, `goga.onboarding.questionnaire`) + and is adapted to the facade imports in Task 17; + `tests/onboarding/test_logic.py` and + `test_integration.py` are rewritten; `tests/version/test_version.py`, + `tests/hooks/catalog/test_catalog.py`, `tests/hooks/test_facade.py` are + extended. +- Missing visibility in workspace or git: the four leaf-cell directories are + untracked (`?? goga/onboarding/{generator,participation,questions,survey}/`) + — CODEMANIFESTs only; `goga/hooks/.usages/per-tool-delivery.md`, + `goga/version/.usages/minor-line.md`, `goga/commands/init/.usages/` + init.md updates are uncommitted but present (no action needed by this plan). + +--- + +## Tasks + +> **Package ordering rule**: coding tasks for each package are completed before starting the next. Within each coding task, contract tests are written first (TDD workflow). + +### Task 1: `minor_version` routine in the version cell (TDD coding) + +The version cell (leaf, `goga/version/`) gains the routine +`minor_version(version: str) -> minor: str` at `location: version.py` — the +`N.M` line derivation consumed by the onboarding image hints. The routine is +a pure function reusing the module-private `_release_segments` reducer that +already exists at `goga/version/version.py:71` (`(major, minor | None)`, +`ValueError` on no leading numeric major) — mirror `resolve_version`'s shape +recognition, do not duplicate the reducer. Also add the facade re-export: +`minor_version` importable from `goga.version`, added to `__all__` in +`goga/version/__init__.py` (alphabetical position maintained by the linter's +isort). Extend `tests/version/test_version.py`. + +**Usages relevant to this task:** +- `convention`: docstring style in the established version.py pattern; the + pure-function discipline (no side effects, deterministic output, no + logging); `kw` conventions for tests; run tests with `pytest tests/version/test_version.py -v`. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): in `tests/version/test_version.py` add — `from goga.version import minor_version` succeeds; `"minor_version" in goga.version.__all__`; `callable(minor_version)` +- [x] **Code**: implement `minor_version(version: str) -> str` in `goga/version/version.py` placed after `host_goga_version`, algorithm: (1) `major, minor_seg = _release_segments(version)`; (2) `minor = minor_seg if minor_seg is not None else "0"`; (3) `return f"{major}.{minor}"` — `ValueError` propagates from the reducer +- [x] **Code**: add `from .version import … minor_version` and the `__all__` entry in `goga/version/__init__.py` +- [x] **Interface verification**: `pytest tests/version/test_version.py -v` — the contract tests pass +- [x] **Logic tests**: add `test_minor_version_reduces_to_minor_line` — assertions: `minor_version("1.3.2") == "1.3"`; `minor_version("1.2.1.dev3") == "1.2"`; `minor_version("1.2.0rc1") == "1.2"`; `minor_version("1.2.0.post1") == "1.2"`; `minor_version("1.2.0+local") == "1.2"`; `minor_version("2") == "2.0"` (missing minor → 0). Add `test_minor_version_no_major_raises` — `with pytest.raises(ValueError): minor_version("latest")` (a `match` was added for the repo's PT011 lint rule) +- [x] **Debugging**: `pytest tests/version/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [x] **Contract re-verification**: facade importable; signature `(version: str) -> str`; pure (no I/O, no logging); `goga version` CLI behavior untouched +- [x] **Lint**: `ruff check goga/version/` — fix formatting if necessary + +### Task 2: onboarding action records in the hooks catalog (TDD coding) + +The catalog cell (leaf, `goga/hooks/catalog/`) gains two records in the +`_DECLARED_ACTIONS` constant of `catalog.py`: +`Action(domain="onboarding", name="declare_session", error_class="soft")` +and `Action(domain="onboarding", name="amend_config", error_class="soft")`. +This is the additive catalog extension that makes every tool subscription of +the two onboarding actions acceptable at `HookRegistrar.subscribe` +(registration.py:99 resolves addresses through `declared_actions()`) — +without it the whole feature dies at registration. The statuses record is +untouched; published records are never rewritten; the catalog stays +supported-data only. Extend `tests/hooks/catalog/test_catalog.py`. + +**Usages relevant to this task:** +- `convention`: the data-model rules; test conventions. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests**: the existing catalog surface tests in `tests/hooks/catalog/test_catalog.py` must keep passing after the change (facade `declared_actions` importable, record shape unchanged) — run them first to establish the baseline +- [x] **Code**: append the two `Action(...)` records to `_DECLARED_ACTIONS` in `goga/hooks/catalog/catalog.py` (order in the constant is irrelevant — `declared_actions()` sorts by `(domain, name)`) +- [x] **Interface verification**: `pytest tests/hooks/catalog/ -v` — the baseline still passes +- [x] **Logic tests**: add `test_catalog_carries_onboarding_actions` — assertions: `records = declared_actions()`; `("onboarding", "declare_session", "soft")` and `("onboarding", "amend_config", "soft")` are among `{(r.domain, r.name, r.error_class) for r in records}`; `[(r.domain, r.name) for r in records] == sorted((r.domain, r.name) for r in records)`; the statuses record still present +- [x] **Debugging**: `pytest tests/hooks/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: `declared_actions()` returns a new list per call; records frozen; `goga hooks` inspection output gains exactly the two onboarding rows (additive only) +- [x] **Lint**: `ruff check goga/hooks/catalog/` — fix formatting if necessary + +### Task 3: hooks facade re-exports of the delivery primitives (infrastructure) + +The hooks facade (`goga/hooks/__init__.py`) re-exports three names so that +domains orchestrating per-tool delivery address the platform through the +facade only: `wrap_context` and `build_hook_arguments` (from +`goga/hooks/dispatch`, already exported by its sub-facade) and +`enumerate_tool_packages` (from `goga/hooks/tools`, already exported). Both +the participation cell and `goga/hooks/.usages/per-tool-delivery.md` import +`from goga.hooks import HookRegistry, wrap_context, build_hook_arguments` — +a missing re-export is an ImportError of the whole onboarding domain. No +name collision exists; the `declared_actions` re-export is untouched; +importing `goga.hooks` must keep importing no `goga_tool_*` package and +enumerating nothing. Extend `tests/hooks/test_facade.py`. + +**Usages relevant to this task:** +- `convention`: relative intra-package imports (`from .dispatch import …`). + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): in `tests/hooks/test_facade.py` add `test_hooks_facade_reexports_delivery_primitives` — assertions: `goga.hooks.wrap_context is goga.hooks.dispatch.wrap_context`; `goga.hooks.build_hook_arguments is goga.hooks.dispatch.build_hook_arguments`; `goga.hooks.enumerate_tool_packages is goga.hooks.tools.enumerate_tool_packages`; `{"wrap_context", "build_hook_arguments", "enumerate_tool_packages"} <= set(goga.hooks.__all__)` +- [x] **Code**: in `goga/hooks/__init__.py` add `from .dispatch import build_hook_arguments, emit_hook_event, wrap_context` (replacing the single-name import) and `from .tools import enumerate_tool_packages`; extend `__all__` with the three names +- [x] Verify facade accessibility: `pytest tests/hooks/test_facade.py -v` — all pass, including the pre-existing facade invariants (no import side effects) +- [x] Lint: `ruff check goga/hooks/__init__.py` — fix formatting if necessary + +### Task 4: questions cell structure (infrastructure) + +Create the package structure of the new leaf cell +`goga/onboarding/questions/` (the directory exists with CODEMANIFEST only — +untracked). The cell owns the declarative question-and-answer model: data +and pure answer operations only — no interactivity, no filesystem, no tool +delivery. Create the two `location` module files with module docstrings and +the package facade `__init__.py` with the domain docstring (no exports yet — +the entity tasks add them). Create the test subpackage +`tests/onboarding/questions/` (`__init__.py` empty). The +`tests/onboarding/conftest.py` `_clean_cwd` fixture applies automatically to +nested dirs. + +**Usages relevant to this task:** +- `convention`: module docstrings in the established style (see + `goga/hooks/catalog/catalog.py` header); relative imports; test + infrastructure layout. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] Create `goga/onboarding/questions/questions.py` — module docstring naming the cell entities (`Question`, `QuestionGroup` at `location: questions.py`), no code yet +- [x] Create `goga/onboarding/questions/answers.py` — module docstring naming `SessionAnswers` at `location: answers.py`, no code yet +- [x] Create `goga/onboarding/questions/__init__.py` — domain docstring (the cell owns the declarative question-and-answer model of the onboarding session); empty `__all__: list[str] = []` placeholder to be filled by the entity tasks +- [x] Create `tests/onboarding/questions/__init__.py` (empty) +- [x] Verify importability: `python -c "import goga.onboarding.questions"` — exits 0 +- [x] Lint: `ruff check goga/onboarding/questions/` — passes + +### Task 5: `Question` and `QuestionGroup` records (TDD coding) + +Implement the two immutable declarative records of the questions cell at +`location: goga/onboarding/questions/questions.py` and expose them through +the cell facade. Frozen dataclasses, `kw_only=True`, fields exactly per the +signatures; `None` only for explicit absence (`choices`/`default`/`keys` on +`Question`; `prompt`/`children` on `QuestionGroup`). NO validation in the +records — kinds are checked at ask time, not at construction; no methods, no +properties beyond the fields (data-only discipline). A `QuestionGroup` with +`children=None` is a structural node carrying no prompt. + +**Usages relevant to this task:** +- `convention`: dataclass rules — `@dataclass(frozen=True, kw_only=True)` in + the `catalog.py` `Action` style; attribute docstrings; do not log (pure + records never log). +- `question-records` (imported from the questions cell itself — + `goga/onboarding/questions/.usages/question-records.md`): the record + structure and the answer addressing rules; construct with keyword + arguments, e.g. `Question(id="token", kind="input", prompt="Service token")`. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): create `tests/onboarding/questions/test_questions.py` — `from goga.onboarding.questions import Question, QuestionGroup` succeeds; both in `__all__`; keyword construction `Question(id="token", kind="input", prompt="Service token")` works; frozen (assigning a field raises `dataclasses.FrozenInstanceError`); `QuestionGroup(id="g", children=None)` constructible without `prompt` +- [x] **Code**: implement `Question(id: str, kind: str, prompt: str, choices: list[str] | None = None, default: str | bool | None = None, keys: list[str] | None = None)` and `QuestionGroup(id: str, prompt: str | None = None, children: list[Question | QuestionGroup] | None = None)` in `goga/onboarding/questions/questions.py` — frozen `kw_only` dataclasses, field docstrings per the CODEMANIFEST property annotations +- [x] **Code**: export both from `goga/onboarding/questions/__init__.py` (`from .questions import Question, QuestionGroup`, `__all__` entries) +- [x] **Interface verification**: `pytest tests/onboarding/questions/test_questions.py -v` — contract tests pass +- [x] **Logic tests**: positive — `Question(id="q", kind="choice", prompt="Pick", choices=["a", "b"], default="a")` exposes all fields with the given values; a group round-trips `children=[Question(id="x", kind="input", prompt="X")]`; negative — positional construction is refused (`kw_only`); edge — defaults are `None` for `choices`/`default`/`keys`/`prompt`/`children` +- [x] **Debugging**: `pytest tests/onboarding/questions/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: no methods or computed properties beyond the fields; no validation raising at construction; hashable/immutable value objects +- [x] **Lint**: `ruff check goga/onboarding/questions/` — fix formatting if necessary + +### Task 6: `SessionAnswers` accumulator (TDD coding) + +Implement the single mutable accumulator of one run at `location: +goga/onboarding/questions/answers.py` and expose it through the cell facade. +Constructor `SessionAnswers(tools: list[str] | None = None)` creates an +empty space; `tools` reserves the top-level keys of the tool sections +WITHOUT creating them (`_tool_sections = frozenset(tools or ())`, +`_data = {}`). Four methods: `record` (segment walk creating intermediate +dicts; set leaf — REPLACE, never merge; a leaf collision with an existing +scalar mid-path is replaced by a mapping — the survey is the authoritative +writer), `amend` (same walk; at the leaf an existing dict AND a dict value → +recursive per-key merge; otherwise plain assignment; silent — no warning; +delivery order is the caller's responsibility), `view_for(tool)` (deepcopy +of the core items — every top-level key except the reserved names — updated +with a deepcopy of the tool's own section re-keyed by local names; a +local-name collision with a core key wins in THAT tool's view only; +an absent own section → core-only view), `snapshot()` (`deepcopy(_data)`). +No dotted keys are ever stored as literal keys — the path is split. + +**Usages relevant to this task:** +- `convention`: type hints mandatory (`dict`, `str | bool | dict` value + types); no logging (the accumulator is total, raises nothing). +- `question-records` (`goga/onboarding/questions/.usages/question-records.md`): + the answer addressing rules — plan dot-paths (`"build.agent"`, + `"my-tool.token"`), nested mappings keyed by question ids. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): create `tests/onboarding/questions/test_answers.py` — `from goga.onboarding.questions import SessionAnswers` succeeds; in `__all__`; `SessionAnswers()` and `SessionAnswers(tools=["my-tool"])` both construct empty (`snapshot() == {}`) +- [x] **Code**: implement `SessionAnswers` with `record`, `amend`, `view_for`, `snapshot` per the semantics above (walk helper may be a module-private function) +- [x] **Code**: export `SessionAnswers` from `goga/onboarding/questions/__init__.py` +- [x] **Interface verification**: `pytest tests/onboarding/questions/test_answers.py -v` — contract tests pass +- [x] **Logic tests** (from the design, transfer verbatim): `test_record_creates_nested_mappings` — `answers.record("build.agent", "claude")` → `snapshot() == {"build": {"agent": "claude"}}`; `test_amend_merges_mappings_replaces_scalars` — setup `answers.record("pipeline", {"agent": "codex", "env": {"A": "1"}})`, input `answers.amend("pipeline", {"env": {"B": "2"}, "agent": "claude"})` → `snapshot() == {"pipeline": {"agent": "claude", "env": {"A": "1", "B": "2"}}}`; `test_view_for_isolates_and_flattens` — setup `SessionAnswers(tools=["my-tool", "viewer"])` with `language`/`my-tool.token`/`viewer.flag` recorded, `view_for("my-tool")` → `{"language": "python", "token": "t0"}`, `"viewer" not in view`, mutating the view does not touch the space (`answers.snapshot()["my-tool"]["token"] == "t0"`); `test_view_for_unknown_tool_returns_core_only` — `view_for("not-declared")` → core only; edge — `record("a.b", 1)` then `record("a.b.c", 2)` replaces the scalar with a mapping +- [x] **Debugging**: `pytest tests/onboarding/questions/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: reserved names come from the constructor param (fix q1 — no hardcoded list); merge happens only when BOTH sides are mappings; every returned view is a deep copy +- [x] **Lint**: `ruff check goga/onboarding/questions/` — fix formatting if necessary + +### Task 7: participation cell structure (infrastructure) + +Create the package structure of the new leaf cell +`goga/onboarding/participation/` (directory exists with CODEMANIFEST only). +The cell owns the tool participation: the invitation, the two onboarding +action moments delivered per tool with staged control, the surfaces, and +the isolated answer views. Create the three `location` module files with +module docstrings and the facade. Create the test subpackage +`tests/onboarding/participation/`. + +**Usages relevant to this task:** +- `convention`: module docstrings; relative imports; test layout. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] Create `goga/onboarding/participation/declaration.py` — module docstring naming `ToolDeclaration` +- [x] Create `goga/onboarding/participation/contribution.py` — module docstring naming `ToolContribution` +- [x] Create `goga/onboarding/participation/participation.py` — module docstring naming `ToolParticipation` +- [x] Create `goga/onboarding/participation/__init__.py` — domain docstring; empty `__all__` placeholder +- [x] Create `tests/onboarding/participation/__init__.py` (empty) +- [x] Verify importability: `python -c "import goga.onboarding.participation"` — exits 0 +- [x] Lint: `ruff check goga/onboarding/participation/` — passes + +### Task 8: `ToolDeclaration` and `ToolContribution` surfaces (TDD coding) + +Implement the two delivery surfaces at `location: +goga/onboarding/participation/{declaration.py, contribution.py}` and expose +them through the cell facade. Both are the hook-facing objects wrapped by +`wrap_context` (attribute reads resolve; writes are blocked; the buffer +methods are calls and pass through). `ToolDeclaration(tool: str, invited: +bool)` — properties `tool`, `invited`, `questions` (declaration order), +`skips`; `declare(item)` enforces the one-level rule: a `QuestionGroup` +whose `children` contain a `QuestionGroup` is refused with a +`logger.warning` naming the tool and the reason ("a tool group is limited +to one nesting level with simple children"), the element is NOT buffered, +delivery continues (structural violations are warnings, never exceptions); +accepted items append to `questions`. `skip(path)` appends the raw string — +no resolution here. `ToolContribution(tool: str, invited: bool, answers: +dict)` — properties `tool`, `invited`, `answers`, `amendments`, `files`; +`answer(id, value)` appends `(id, value)` keeping call order; +`write_config(file, data)` appends `(file, data)` — a later same name +replaces at write time, not here. Both carry a module logger +(`logger = logging.getLogger(__name__)`). + +**Usages relevant to this task:** +- `convention`: dataclass/buffer style; one module logger per file; warnings + carry the tool name and the reason. +- `question-records` (`goga/onboarding/questions/.usages/question-records.md`): + the declaration records — `Question` or a one-level `QuestionGroup`. +- `registering-hooks` (`goga/hooks/.usages/registering-hooks.md`): the hook + signature (`context` first, optional `self`) and the failure handling + behind the actions — the surfaces are what a hook receives as `context`. +- `per-tool-delivery` (`goga/hooks/.usages/per-tool-delivery.md`): the staged + delivery loop the surfaces participate in. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): create `tests/onboarding/participation/test_declaration.py` and `test_contribution.py` — `from goga.onboarding.participation import ToolDeclaration, ToolContribution` succeeds; both in `__all__`; keyword construction `ToolDeclaration(tool="t", invited=True)` / `ToolContribution(tool="t", invited=True, answers={})` works; buffers start empty +- [x] **Code**: implement `ToolDeclaration` in `declaration.py` (fields `tool`, `invited`; buffered `questions: list`, `skips: list` — mutable lists on a non-frozen dataclass or equivalent) and `ToolContribution` in `contribution.py` (fields `tool`, `invited`, `answers`; buffered `amendments`, `files`) +- [x] **Code**: export both from `goga/onboarding/participation/__init__.py` +- [x] **Interface verification**: `pytest tests/onboarding/participation/ -v` — contract tests pass +- [x] **Logic tests**: positive — `declare(Question(id="token", kind="input", prompt="Token"))` buffers it in order; `skip("build.env")` buffers the raw string; `answer("tools", {"t": "1.0"})` and `write_config("service.yml", {"a": 1})` buffer tuples in call order (a same-named file buffered twice keeps BOTH entries — replacement happens at write time). Negative/edge: `test_declare_rejects_nested_group_with_warning` — `surface.declare(QuestionGroup(id="deep", children=[QuestionGroup(id="inner")]))` with caplog at WARNING → `surface.questions == []` AND `any("one nesting level" in r.message for r in caplog.records)` AND `any("t" in r.message for r in caplog.records)` (two independent `any()` joined by `and`) +- [x] **Debugging**: `pytest tests/onboarding/participation/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: `declare` never raises; the one-level Requirement is enforced at the surface (single point); buffers readable via the properties after delivery +- [x] **Lint**: `ruff check goga/onboarding/participation/` — fix formatting if necessary + +### Task 9: `ToolParticipation` mediator (TDD coding) + +Implement the mediator of both onboarding action moments at `location: +goga/onboarding/participation/participation.py` and expose it through the +cell facade. Constructor `ToolParticipation(invited: list[str])` — defensive +dedup preserving flag order (`_invited = list(dict.fromkeys(invited))`), +`_registry = None` (built lazily by `_ensure_registry()` — `HookRegistry()` ++ `build_once()`; `ImportError` from a broken package import propagates — +the single fatal case). `collect_declarations()`: warn for every invited +identity not among `{pkg.tool for pkg in enumerate_tool_packages()}` +("invited tool %s is not installed; continuing without its block"); group +`registry.subscriptions_for("onboarding", "declare_session")` per +`subscription.tool` preserving enumeration order; per tool build the surface +(`invited=tool in self._invited`), `proxy = wrap_context(surface)`, call +`sub.hook(**build_hook_arguments(sub.hook, proxy, registry.self_context(tool)))` +per subscription; any `Exception` → `logger.warning` naming tool, action +("onboarding.declare_session"), reason; the whole declaration of that tool +is discarded; return the surviving surfaces in enumeration order. +`collect_contributions(answers)`: identical delivery over +`subscriptions_for("onboarding", "amend_config")` with +`ToolContribution(tool, invited, answers=answers.view_for(tool))`; a +failure discards amendments AND files together; then a commit pass in +enumeration order — `answers.amend(path, value)` per buffered amendment; +return the committed contributions. Import the platform names from the +facade: `from goga.hooks import HookRegistry, build_hook_arguments, +enumerate_tool_packages, wrap_context` (enabled by Task 3). + +Test setup pattern (from the design's General Setup): fake installed +packages via monkeypatched `goga.hooks.enumerate_tool_packages` (or +`packages_distributions`) + hooks registered directly through +`HookRegistrar`/a fake facade module injected via `sys.modules` — the +existing `tests/hooks/conftest.py` pattern. Caplog at WARNING for the +warning-path assertions. + +**Usages relevant to this task:** +- `convention`: one module logger; warnings name the tool, the action, and + the reason. +- `per-tool-delivery` (`goga/hooks/.usages/per-tool-delivery.md`): the + staged delivery loop — commit only after every hook of the tool + succeeded; delivery is NEVER filtered by invitation (the marker travels + to the hook). +- `registering-hooks` (`goga/hooks/.usages/registering-hooks.md`): the + registration envelope behind the two actions. +- `tool-contexts` (`goga/onboarding/participation/.usages/tool-contexts.md`): + the hook signature pattern the fake hooks in tests follow (`context` + first, optional `self`). +- `question-records`: the declaration records the hooks buffer. +- `session-participation` + (`goga/onboarding/participation/.usages/session-participation.md`): the + two moments' composition this class realizes. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): create `tests/onboarding/participation/test_participation.py` — `from goga.onboarding.participation import ToolParticipation` succeeds; in `__all__`; `ToolParticipation(invited=["a", "a", "b"]).invited == ["a", "b"]` (dedup, flag order); `collect_declarations`/`collect_contributions` callable +- [x] **Code**: implement `ToolParticipation` with `_ensure_registry`, `collect_declarations`, `collect_contributions` per the semantics above +- [x] **Code**: export `ToolParticipation` from `goga/onboarding/participation/__init__.py` +- [x] **Interface verification**: `pytest tests/onboarding/participation/test_participation.py -v` — contract tests pass +- [x] **Logic tests** (from the design, transfer verbatim): `test_collect_declarations_delivers_invitation_marker` — fake `goga_tool_my-tool` subscribed `("onboarding", "declare_session", "d1", hook)`, `ToolParticipation(invited=["my-tool"])` → one declaration, `tool == "my-tool"`, `invited is True`, `questions[0].id == "token"`; `test_collect_contributions_commits_in_order` — two fake tools alpha/beta (alpha first) each buffering `context.answer("tools", {name: version})` → `[c.tool for c in contributions] == ["alpha", "beta"]` and `answers.snapshot()["tools"] == {"alpha": "1.0", "beta": "2.0"}`; `test_collect_declarations_warns_for_uninstalled_invited` — no packages, `invited=["ghost"]`, caplog → `declarations == []` and a warning naming "ghost"; `test_failing_hook_drops_whole_declaration` — tools `bad` (raises `RuntimeError("boom")`) and `good` (declares one) → `[d.tool for d in declarations] == ["good"]`, warning carries "bad" and "boom"; `test_noninvited_subscribed_tool_is_marked_and_silent` — fake tool subscribed to BOTH actions, hooks record `self.saw_invited = context.invited` and return immediately when not invited, `ToolParticipation(invited=["other"])` → `declarations == []`, `[c.tool for c in contributions] == ["my-tool"]` with empty `amendments`/`files`, captured marker is False, `"my-tool" not in answers.snapshot()`, `not caplog.records` (silent — not a warning); moment-two failure — a tool whose `amend_config` hook buffers then raises → `contributions == []`, `"tools" not in answers.snapshot()` (participation side only; the generate side is covered in Task 16) +- [x] **Debugging**: `pytest tests/onboarding/participation/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: `_registry` built once and shared by both moments; delivery order is enumeration order; the invitation marker is never filtered platform-side; `ImportError` propagates uncaught +- [x] **Lint**: `ruff check goga/onboarding/participation/` — fix formatting, apply decomposition if necessary + +### Task 10: survey cell structure (infrastructure) + +Create the package structure of the new leaf cell +`goga/onboarding/survey/` (directory exists with CODEMANIFEST only). The +cell owns the survey: the core question tree, the plan assembly, the skip +application, and the interactive run. Create the three `location` module +files with module docstrings and the facade. Create the test subpackage +`tests/onboarding/survey/`. + +**Usages relevant to this task:** +- `convention`: module docstrings; relative imports; test layout. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] Create `goga/onboarding/survey/core.py` — module docstring naming `core_questions` +- [x] Create `goga/onboarding/survey/plan.py` — module docstring naming `assemble_session_plan`, `apply_skips`, `SessionPlan` +- [x] Create `goga/onboarding/survey/questionnaire.py` — module docstring naming `Questionnaire` +- [x] Create `goga/onboarding/survey/__init__.py` — domain docstring; empty `__all__` placeholder +- [x] Create `tests/onboarding/survey/__init__.py` (empty) +- [x] Verify importability: `python -c "import goga.onboarding.survey"` — exits 0 +- [x] Lint: `ruff check goga/onboarding/survey/` — passes + +### Task 11: `core_questions` tree builder (TDD coding) + +Implement the core tree builder at `location: goga/onboarding/survey/core.py` +and expose it through the cell facade. Port the data of the old wizard from +`goga/onboarding/questionnaire.py` (do not rewrite from scratch): +`_IMAGE_MAP` → the `image_defaults` mapping (families per the practice), +`_LANGUAGES = ["python", "golang", "kotlin", "swift", "javascript"]`, +`_AGENT_ENV_MAP` → the `agent_env_defaults` mapping, `_AGENTS = +list(_AGENT_ENV_MAP)`. Signature `core_questions(image_tag: str, +project_name: str | None, convention_exists: bool) -> QuestionGroup`. +Sections in order: (1) `language` — choice of `_LANGUAGES`; (2) `convention` +— only when `not convention_exists`: `QuestionGroup(id="convention", +prompt="--- Base Convention ---", children=[Question(id="adopt", kind="confirm", +prompt="Download base convention", default=False)])`; (3) `codemanifest` — +usages pairs + annotations input (defaults supplied at ask time when the +convention gate was accepted — engine-side prefill, NOT tree defaults); +(4) `build` — agent choice + env pairs; (5) `docker_image` — dockerfile +input (default `.goga/Dockerfile`), base_image input (prompt embeds the +completed hint list, default = LAST entry), image input (default +`f"{project_name}:latest"` or absent when `project_name is None`); (6) +`pipeline` — agent choice + env pairs; (7) `tools` — pairs question with the +four-form grammar documented in the prompt; (8) `usages` — structural +`QuestionGroup(id="usages", prompt="--- Usages ---")` with no declarable +children. Return `QuestionGroup(id="core", children=sections)`. The +completed hints are data of the tree (embedded in the `base_image` prompt + +default) — the ENGINE renders them; the tag is never hardcoded. The +`tools`/`usages` sections are NEW user-facing sections — keep their prompt +texts aligned with the created-files list of +`goga/commands/init/.usages/init.md` (`.goga/config.yml`, +`.goga/usages/conventions.md`, the Dockerfile, `.goga/tools//`) +— a design instruction carried into this task. + +**Usages relevant to this task:** +- `convention`: docstring style; pure builder (no I/O). +- `image_defaults` (inline practice in the survey CODEMANIFEST): the + language → family mapping; complete each name with `image_tag`; default = + last entry; free-form accepted (kind stays `input`). +- `agent_env_defaults` (inline practice): the agent → env key mapping for + the `keys` parameterization of the env pairs questions. +- `question-records`: the record structure the builder emits. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): create `tests/onboarding/survey/test_core.py` — `from goga.onboarding.survey import core_questions` succeeds; in `__all__`; `core_questions("1.3", "my-app", False)` returns a `QuestionGroup` with `id == "core"` +- [x] **Code**: port the mapping data and implement `core_questions` in `goga/onboarding/survey/core.py` per the section list above +- [x] **Code**: export `core_questions` from `goga/onboarding/survey/__init__.py` +- [x] **Interface verification**: `pytest tests/onboarding/survey/test_core.py -v` — contract tests pass +- [x] **Logic tests**: `test_core_questions_builds_eight_sections_with_tag` — assertions: `[child.id for child in core.children] == ["language", "convention", "codemanifest", "build", "docker_image", "pipeline", "tools", "usages"]`; the `base_image` question's prompt contains `"qarium/goga-python-3.14:1.3"` and its default equals the last completed hint; the `image` question default == `"my-app:latest"`; `core_questions("1.3", None, True)` drops the `convention` section (first section is `language`); edge — `project_name=None` → the `image` default is `None` (absent) +- [x] **Debugging**: `pytest tests/onboarding/survey/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: the root id `"core"` is never addressed in answers (sections are the top-level keys); kind of `base_image`/`image` stays `input` (free-form); the tag threads from the single argument — no hardcoded `1.3` +- [x] **Lint**: `ruff check goga/onboarding/survey/` — fix formatting if necessary + +### Task 12: plan layer — `SessionPlan`, `assemble_session_plan`, `apply_skips` (TDD coding) + +Implement the plan layer at `location: goga/onboarding/survey/plan.py` and +expose the three names through the cell facade. `SessionPlan(root, tools)` +is a data record (frozen `kw_only` dataclass). `assemble_session_plan(core, +declarations)` — the algorithm with the fix-q2 guard (reserved names +derived from the RECEIVED core's children; a colliding tool identity drops +the whole block with a warning naming the tool and the reserved name; the +tool keeps its amendment rights) and the local-name dedup (a repeated id +drops THAT element with a warning; survivors stand; a fully-dropped tool +still gets its empty block). `apply_skips(plan, skips)` — the three-way +resolution rule (prefixed / core / own-block) against the ORIGINAL root, +set semantics (order-independent, descendants of removed nodes silently +absorbed), rebuild with new groups along changed branches sharing frozen +originals, a NEW `SessionPlan` with the same `tools` list, no-op warnings +for unresolvable paths. Module logger for the warnings. + +**Usages relevant to this task:** +- `convention`: pure transformers; one module logger; warnings name the + tool and the reason. +- `question-records`: the record structure and the tree-path addressing + (ids joined by dots). +- `survey-run` (`goga/onboarding/survey/.usages/survey-run.md`): the + reserved-names note of `assemble_session_plan` and the plan semantics. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): create `tests/onboarding/survey/test_plan.py` — `from goga.onboarding.survey import SessionPlan, assemble_session_plan, apply_skips` succeeds; all three in `__all__`; `assemble_session_plan(core, [])` returns a `SessionPlan` whose root children equal the core children and `tools == []` +- [x] **Code**: implement `SessionPlan`, `assemble_session_plan`, `apply_skips` in `goga/onboarding/survey/plan.py` per the algorithms above (a `_resolve_path` helper and a `_rebuild_without` helper are natural internal decomposition) +- [x] **Code**: export the three names from `goga/onboarding/survey/__init__.py` +- [x] **Interface verification**: `pytest tests/onboarding/survey/test_plan.py -v` — contract tests pass +- [x] **Logic tests** (from the design, transfer verbatim): `test_assemble_session_plan_orders_blocks_and_drops_repeats` — core with `language`, `tools`; declarations of `my-tool` (two questions both id `token`), `viewer` (one), `empty-tool` (none) → root children ids `["language", "tools", "my-tool", "viewer"]`, `plan.tools == ["my-tool", "viewer"]`, the my-tool block has exactly one `token` child; `test_assemble_reserved_name_drops_block` — core with a `tools` section; a declaration of a tool with identity `tools` → `plan.tools == []`, root children `["tools"]` (core only), a warning naming "tools" in caplog; `test_apply_skips_prefixed_own_and_unknown` — plan with core `language` + `build` and blocks `my-tool` (group `reporting` with `enabled`) and `viewer` (`opt`); skips `[("my-tool", "reporting.enabled"), ("viewer", "my-tool.reporting.enabled"), ("viewer", "language"), ("my-tool", "no.such.path")]` → `language` absent, `build` present, both blocks present (`my-tool` emptied group), `"enabled"` not reachable under the my-tool block, a warning naming "no.such.path"; edge — applying the same plan's skips in reversed order yields the same result (order independence) +- [x] **Debugging**: `pytest tests/onboarding/survey/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: the core tree is never mutated (records frozen, fresh containers); an emptied block stays in the plan; `core_section_ids` derived, not hardcoded +- [x] **Lint**: `ruff check goga/onboarding/survey/` — fix formatting, apply decomposition if necessary + +### Task 13: `Questionnaire` survey engine (TDD coding) + +Implement the interactive engine at `location: +goga/onboarding/survey/questionnaire.py` and expose it through the cell +facade. Port the prompt texts and the interactive patterns of the old +`goga/onboarding/questionnaire.py` (session header, language choice, base +convention gate with prefill, codemanifest usages/annotations, agent +gates, `_collect_agent_env`, docker branch with hints, image name) — the +old `ask`/`ask_*` per-field methods become the engine's core-section +patterns; `ask_goga_config`'s config-exists short-circuit is NOT ported +(that guard moved to `InitLogic`/`FileGenerator`). API: `run(plan, +answers)` — header echo, core sections via the conditional patterns, tool +blocks after the core under the attribution heading (suppress emptied +blocks; a group with `prompt=None` still renders the heading from the block +id); records land at plan dot-paths. `ask_question(question)` — the four +kinds via click plus the ELSE soft-skip branch (unknown kind or missing +parameterization → warning naming the question path, not asked, not +recorded, survey continues). `ask_group(group, prefix=None)` — heading +echo, children in order, recursion (the optional prefix keeps the +contract's one-argument call shape). The core-section rule: only the children +present in the post-skip section are asked; the confirm gates are +presentational (never recorded); the docker_image branch collapses per the +rule (skipped `dockerfile` → the pull branch directly; skipped `base_image` +→ the FROM is never recorded/asked). The usages record loop accumulates +`{group: {dep: {git, ref?, root?}}}` and records it at `"usages"`. +`click.Abort` propagates. Test with `CliRunner` (the existing +`tests/onboarding/test_questionnaire.py` pattern — port the old cases into +`tests/onboarding/survey/test_questionnaire.py`). + +**Usages relevant to this task:** +- `click` (`.goga/usages/cooks/click.md`): `click.prompt` / + `click.confirm` / `click.Choice`; the repeated key-value collection + pattern; `_collect_agent_env` is the template for the gated env pairs. +- `convention`: one module logger (the ELSE branch warns); type hints. +- `image_defaults` (inline practice): render the hint lines of the + `base_image` prompt, default the last, accept free-form. +- `agent_env_defaults` (inline practice): prompt the suggested keys of the + selected agent first, then arbitrary additions. +- `question-records`: the records the engine asks; the answer-value types + per kind. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): create `tests/onboarding/survey/test_questionnaire.py` — `from goga.onboarding.survey import Questionnaire` succeeds; in `__all__`; `Questionnaire()` constructs with no arguments; `run`, `ask_question`, `ask_group` callable +- [x] **Code**: implement `Questionnaire` in `goga/onboarding/survey/questionnaire.py` — `run`, `ask_question`, `ask_group`, and the private core-section patterns (`_survey_language`, `_survey_convention`, `_survey_codemanifest`, `_survey_build`, `_survey_docker_image`, `_survey_pipeline`, `_survey_tools`, `_survey_usages` — or an equivalent internal decomposition under mccabe 10) +- [x] **Code**: export `Questionnaire` from `goga/onboarding/survey/__init__.py` +- [x] **Interface verification**: `pytest tests/onboarding/survey/test_questionnaire.py -v` — contract tests pass +- [x] **Logic tests** (from the design, transfer verbatim): `test_questionnaire_records_core_and_tool_answers` — plan from a minimal core (`language` choice) + `my-tool` block (input `token`); `answers = SessionAnswers(tools=["my-tool"])`; CliRunner inputs `["python", "t0"]` → `answers.snapshot() == {"language": "python", "my-tool": {"token": "t0"}}`; `test_unknown_kind_is_skipped_with_warning` — a `my-tool` block with `Question(id="bad", kind="text", prompt="Weird")` and `Question(id="ok", kind="input", prompt="Token")`; input `["t0"]`; caplog → `answers.snapshot() == {"my-tool": {"ok": "t0"}}`, a warning naming "my-tool.bad", `"Weird" not in result.output`; port the old questionnaire test cases (convention gate accept/reject prefill, agent gates, docker branch hints, image default) adapted to the plan/answers API +- [x] **Debugging**: `pytest tests/onboarding/survey/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: gates never record; a skipped subtree is never asked; tool answers nest under the reserved tool key; a hook is never called to survey (the engine asks the buffered records) +- [x] **Lint**: `ruff check goga/onboarding/survey/` — fix formatting, apply decomposition if necessary + +### Task 14: generator cell structure (infrastructure) + +Create the package structure of the new leaf cell +`goga/onboarding/generator/` (directory exists with CODEMANIFEST only; the +OLD module `goga/onboarding/generator.py` — a file — coexists until the +facade rewrite deletes it; Python resolves `goga.onboarding.generator` to +the package once it has `__init__.py`, so create the facade only when the +entity code is ready in Task 15 — this task creates the module file and the +test subpackage, and the facade together with Task 15's first code step). +Create `goga/onboarding/generator/generator.py` with its module docstring +and `tests/onboarding/generator/__init__.py`. + +**Usages relevant to this task:** +- `convention`: module docstrings; test layout. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] Create `goga/onboarding/generator/generator.py` — module docstring naming `FileGenerator` and `CreatedFile` at `location: generator.py` +- [x] Create `tests/onboarding/generator/__init__.py` (empty) +- [x] Verify no import shadowing breakage: `pytest tests/onboarding/ -x --co -q` — the OLD tests still collect and pass (the old `generator.py` module is untouched; the new package has no `__init__.py` yet, so `goga.onboarding.generator` still resolves to the old file) +- [x] Lint: `ruff check goga/onboarding/generator/` — passes + +### Task 15: `FileGenerator.generate` + `generate_goga_config` + `CreatedFile` (TDD coding) + +Implement the artifact generator core at `location: +goga/onboarding/generator/generator.py` and expose `FileGenerator` + +`CreatedFile` through the cell facade (`goga/onboarding/generator/__init__.py` +created NOW — from this point `goga.onboarding.generator` resolves to the +package). Port from the old `goga/onboarding/generator.py`: the +`_LiteralStr` class + `yaml.add_representer` registration, the +`_CONVENTION_URL_TEMPLATE`, the requests download with timeout 30 and the +clean error with URL and cause, the `_build_block` shape, the field order. +New API: `generate(answers, contributions) -> list[CreatedFile]` — the +existing-config guard (`Path(".goga/config.yml").is_file()` → skip the +config and Dockerfile generation, jump to tool configs); snapshot; the +Dockerfile written ONLY when BOTH `docker_image.dockerfile` AND +`docker_image.base_image` are present (`FROM {base_image}\n`, +`CreatedFile(path, None)`); then `generate_goga_config(answers)` (the +conventions download writes `.goga/usages/conventions.md` → `CreatedFile` +BEFORE the config serialization); then `generate_tool_configs` +(implemented fully in this task; Task 16 verifies it in isolation and +adds the cross-entity negative trace of the design). +`generate_goga_config(answers)` — snapshot; empty `language` → +`ValueError` naming the field; the conventions entry check; `mkdir .goga`; +the mapping table (see Contract Surface — build nests under +`task_executor`, pipeline stays flat, `base_image` NEVER emitted, +`dockerfile` omitted when absent, blocks omitted when empty, annotations as +a `_LiteralStr` literal block); `yaml.dump(default_flow_style=False, +allow_unicode=True, sort_keys=False)`. `CreatedFile(path, tool)` — frozen +`kw_only` dataclass. + +**Usages relevant to this task:** +- `yaml` (inline practice in the generator CODEMANIFEST): + `yaml.dump(default_flow_style=False)`; `sort_keys=False, + allow_unicode=True`; the literal-block representer for annotations. +- `lang_conventions` (inline practice): the URL template, the target path, + `requests.get(url, timeout=30)`, the failure semantics (clean error with + URL + cause; config.yml NOT created on failure). +- `question-records`: the answer-space structure the snapshot yields. +- `session-participation`: the committed contributions shape. +- `convention`: docstring style; the module logger is NOT needed for the + happy path (errors are exceptions, not warnings). + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): create `tests/onboarding/generator/test_generator.py` — `from goga.onboarding.generator import CreatedFile, FileGenerator` succeeds; both in `__all__`; `FileGenerator()` constructs; `CreatedFile(path="p", tool=None)` exposes both fields; `generate`/`generate_goga_config`/`generate_tool_configs` callable on the instance +- [x] **Code**: port `_LiteralStr` + representer and the URL template; implement `CreatedFile`, `FileGenerator.generate`, `FileGenerator.generate_goga_config`, and `FileGenerator.generate_tool_configs` (per-contribution loop writing `.goga/tools//` in call order, a repeated name replacing) in `goga/onboarding/generator/generator.py` +- [x] **Code**: create `goga/onboarding/generator/__init__.py` — domain docstring + `from .generator import CreatedFile, FileGenerator` + `__all__` +- [x] **Interface verification**: `pytest tests/onboarding/generator/ -v` — contract tests pass; `python -c "from goga.onboarding.generator import FileGenerator"` — exits 0 +- [x] **Logic tests** (from the design, transfer verbatim): `test_generate_writes_dockerfile_then_config` — `_clean_cwd`; answers with `docker_image = {"dockerfile": ".goga/Dockerfile", "base_image": "qarium/goga-python-3.13:1.3", "image": "my-app:latest"}`, `language = "python"`, no conventions entry, contributions `[]` → `Path(".goga/Dockerfile").read_text() == "FROM qarium/goga-python-3.13:1.3\n"`; `cfg = yaml.safe_load(...)` → `cfg["language"] == "python"`, `cfg["image"] == "my-app:latest"`, `cfg["dockerfile"] == ".goga/Dockerfile"`, `"base_image" not in cfg`; `[f.path for f in files] == [".goga/Dockerfile", ".goga/config.yml"]`, all `f.tool is None`; `test_generate_empty_language_is_clean_error` — snapshot without `language` → `with pytest.raises(ValueError, match="language")`, `not Path(".goga/config.yml").exists()`; `test_conventions_download_failure_names_url` — answers with `language="python"` and `codemanifest={"usages": {"conventions": ".goga/usages/conventions.md"}}`, `requests.get` monkeypatched to raise `requests.ConnectionError("down")` → `with pytest.raises(RuntimeError, match="https://raw.githubusercontent.com/.*/python/project.md")`, `not Path(".goga/config.yml").exists()`; edge — existing config.yml → generate skips config/Dockerfile and returns only tool-file entries +- [x] **Debugging**: `pytest tests/onboarding/generator/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: the written file passes the project-config loader (`from goga.config import load config schema` — the core schema loader); field order language, image, dockerfile, build, pipeline, codemanifest, tools, usages; `base_image` never in the config +- [x] **Lint**: `ruff check goga/onboarding/generator/` — fix formatting if necessary + +### Task 16: tool-config generation with attribution (TDD coding) + +Complete the tool-config write path of the generator cell: the staged-commit +story end to end. `generate_tool_configs(contributions)` iterates the +committed contributions in enumeration order and their buffered `(file, +data)` in call order, serializes per the `yaml` practice, and writes +`.goga/tools//` — a repeated file name replaces; every entry +returns `CreatedFile(path, tool)` with the tool identity (attribution). +Task 15 implemented the method fully; this task verifies it in isolation +and adds the cross-entity negative trace of the design. + +**Usages relevant to this task:** +- `yaml` (inline practice): the tool file serialization. +- `session-participation` (`goga/onboarding/participation/.usages/session-participation.md`): + the committed contributions — buffers of `write_config` calls. +- `convention`: test conventions; `_clean_cwd` for filesystem tests. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests**: `from goga.onboarding.generator import FileGenerator`; `generate_tool_configs([])` is a no-op returning `None`; the method exists on the facade-exported class +- [x] **Code**: ensure `generate_tool_configs` matches the contract (per-contribution, per-buffer loops; `.goga/tools//`; replace on repeat; `CreatedFile(path, tool)` appended by `generate`) +- [x] **Interface verification**: `pytest tests/onboarding/generator/ -v` — all pass +- [x] **Logic tests** (from the design, transfer verbatim): `test_generate_tool_configs_with_attribution` — `_clean_cwd`; `answers = SessionAnswers()` with `answers.record("language", "python")` and no docker_image; a committed contribution of `my-tool` with files `[("service.yml", {"token_source": "env"}), ("service.yml", {"interval": 60})]` → `yaml.safe_load(Path(".goga/tools/my-tool/service.yml").read_text()) == {"interval": 60}` (later buffer wins); the last `CreatedFile` has `tool == "my-tool"` and `path == ".goga/tools/my-tool/service.yml"`; `test_failing_hook_discards_files_with_amendments` — `_clean_cwd`; a fake tool subscribed to `amend_config` whose hook buffers `answer("tools", …)` and `write_config("x.yml", …)` then raises; `answers.record("language", "python")` in the setup (the generator's required-field gate would otherwise fire before the assertions); run `collect_contributions(answers)` then `FileGenerator().generate(answers, [])` → `contributions == []`, `"tools" not in answers.snapshot()`, `not Path(".goga/tools").exists()`, config.yml written from the recorded core +- [x] **Debugging**: `pytest tests/onboarding/generator/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: the engine is the single write path of tool configs — data written verbatim, no interpretation; attribution None for engine files, identity for tool files +- [x] **Lint**: `ruff check goga/onboarding/generator/` — fix formatting if necessary + +### Task 17: onboarding facade rewrite — `InitLogic`, 13 re-exports, old-module deletion (TDD coding) + +Rewrite the onboarding domain facade (`goga/onboarding/`): the new +`InitLogic` at `location: logic.py` (constructor gains `participation: +ToolParticipation` — three injected collaborators), the facade +`__init__.py` re-exporting the 13 embedded types + `InitLogic` in the +embedding order of the CODEMANIFEST, and the deletion of the old modules +`goga/onboarding/answers.py`, `goga/onboarding/questionnaire.py`, +`goga/onboarding/generator.py` together with their old test files +`tests/onboarding/test_answers.py`, `tests/onboarding/test_generator.py`, +`tests/onboarding/test_questionnaire.py` (their cases were ported into the +leaf test layout in Tasks 5–16). The facade must never expose +`InitAnswers`/`GogaConfigAnswers` again. `InitLogic.run()` implements the +eight steps (see Contract Surface — guard, version/tag, moment one, +plan assembly with `resolve_project_name` from `goga/config` and the +conventions check, survey, moment two, generation + report, `return 0`) and +the three error tiers (tool failures soft — already warned inside the +collaborators; session errors — ONE `click.echo(f"Error: {exc}", err=True)` ++ `logger.error`, exit 1, never a traceback; `click.Abort` — exit 1, +quiet). Rewrite `tests/onboarding/test_logic.py` for the new constructor +and API. + +**Usages relevant to this task:** +- `convention`: dependency-injection style of the old logic.py; relative + imports (`from .generator import FileGenerator`, `from .participation + import ToolParticipation`, `from .questions import SessionAnswers`, + `from .survey import Questionnaire, apply_skips, assemble_session_plan, + core_questions`); one module logger. +- `minor-line` (`goga/version/.usages/minor-line.md`): reading the + installed version and deriving the tag (`host_goga_version` → + `minor_version`). +- `session-participation`: the two tool moments. +- `survey-run`: the plan assembly and the survey. +- `artifact-generation`: the generation and the file report. +- `onboarding-usage` (`goga/onboarding/.usages/onboarding-usage.md`): the + facade import list this task realizes (13 embeddings + `InitLogic`). + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): rewrite `tests/onboarding/test_logic.py` — `from goga.onboarding import InitLogic` plus the 13 re-exported names succeeds; all 14 in `__all__`; `InitLogic(questionnaire, generator, participation)` requires three positional/keyword collaborators; `goga.onboarding` no longer exports `InitAnswers`/`GogaConfigAnswers` (`not hasattr(goga.onboarding, "InitAnswers")`) +- [x] **Code**: rewrite `goga/onboarding/logic.py` — the new `InitLogic` with the eight-step `run()` per the Contract Surface algorithm +- [x] **Code**: rewrite `goga/onboarding/__init__.py` — the domain-facade docstring; imports from the four leaves + `InitLogic` from `.logic`; `__all__` with the 13 embeddings in CODEMANIFEST embedding order followed by `InitLogic` (mirror the same order in the import block) +- [x] **Code**: delete `goga/onboarding/answers.py`, `goga/onboarding/questionnaire.py`, `goga/onboarding/generator.py`; delete `tests/onboarding/test_answers.py`, `tests/onboarding/test_generator.py`, `tests/onboarding/test_questionnaire.py` +- [x] **Code**: adapt `tests/commands/test_init.py` to the deletion — replace its module-level imports of the deleted modules (`from goga.onboarding.answers import GogaConfigAnswers, InitAnswers`; `from goga.onboarding.questionnaire import Questionnaire`) with facade imports (`from goga.onboarding import FileGenerator, InitLogic, Questionnaire, ToolParticipation`); rewrite the tests that drive the real `InitLogic` (the `mock_q.ask` / `InitAnswers` stubbing of `test_init_cli_command`) into the `mock.patch.object(_cmd_init_module, "InitLogic", ...)` pattern used by the file's other tests — the exit-code propagation they verify is unchanged. Also drop the stale `GogaConfigAnswers` mention from the `_run_goga_config` docstring in `tests/config/test_resolve_project_name_flows.py` (a leftover docstring reference, not an import) +- [x] **Interface verification**: `pytest tests/onboarding/test_logic.py -v` — contract tests pass; `pytest tests/commands/ --co -q` — collects cleanly; `grep -r "InitAnswers\|GogaConfigAnswers" goga/ tests/` returns no hits +- [x] **Logic tests** (from the design, transfer verbatim): `test_existing_config_ends_session_silently` — `_clean_cwd` with `.goga/config.yml` pre-created; stubbed collaborators asserting no calls → `run() == 0`, neither `questionnaire.run` nor `participation.collect_declarations` nor `generator.generate` called; `test_unreadable_version_is_clean_error` — `host_goga_version` monkeypatched to raise `PackageNotFoundError("goga")` → `run() == 1`, "Error:" in output, "Traceback" not in output; `test_broken_package_import_is_clean_session_error` — enumeration monkeypatched to a package whose facade raises on import (platform-wrapped `ImportError`) → `run() == 1`, "Error:" and "goga_tool_broken" in output, "Traceback" not in output; edge — zero invited tools and no installed packages → the session degrades to the plain behavior (`declarations == []`, `contributions == []`, core-only survey) +- [x] **Debugging**: `pytest tests/onboarding/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: the facade import order matches the embeddings; no old names anywhere; the `InitLogic` error tiers hold (one message, no traceback, exit codes 0/1) +- [x] **Lint**: `ruff check goga/onboarding/` — fix formatting if necessary + +### Task 18: `init` command — `-t/--tool` invitation flag (TDD coding) + +Extend the CLI command at `location: goga/commands/init/init.py` with the +repeatable `-t/--tool` option: `@click.option("-t", "--tool", "tools", +multiple=True, help=...)` (help text mirroring `init.md`). The command +signature becomes `init(tpl, upgrade, ref, tools: tuple[str, ...])`. +Validation order (contract algorithm 1–6): ref placement check (ported +verbatim) → mode resolution (ported; mutual exclusion) → invitation +validation (`tools` non-empty AND mode UPGRADE → message `-t/--tool +requires an onboarding session and --upgrade runs none`, exit 1) → dedup +preserving flag order (`list(dict.fromkeys(tools))` — the tuple→list +conversion happens HERE; the facade signature +`ToolParticipation(invited: list[str])` receives a list) → +already-initialized guard (BARE_ONBOARDING only, ported) → dispatch +(UPGRADE: `Scaffold().upgrade(ref)`; both onboarding modes: +`InitLogic(Questionnaire(), FileGenerator(), ToolParticipation(invited= +deduped))` → `ctx.exit(logic.run())`). The command passes names through as +opaque data — no installation checks. Port the existing ref/mode/guard +logic verbatim from the current init.py; do not restructure it. The +module-level imports of `tests/commands/test_init.py` were already +adapted to the facade in Task 17 — this task only extends the file with +the `-t/--tool` tests. + +**Usages relevant to this task:** +- `click` (`.goga/usages/cooks/click.md`): `@click.option(multiple=True)`; + the command wrapper conventions. +- `onboarding-usage` (`goga/onboarding/.usages/onboarding-usage.md`): the + session API and the invitation semantics; the message text of the + rejection. +- `scaffold-usage` (`goga/scaffold/.usages/scaffold-usage.md`): the + `Scaffold` API for the UPGRADE dispatch. +- `conventions` (`.goga/usages/conventions.md`): the command's code style. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): extend `tests/commands/test_init.py` — `runner.invoke(init_cli, ["--help"])` shows `-t, --tool`; the command accepts repeated `-t`; `-t` with `--upgrade` rejected +- [x] **Code**: add the `tools` option and the parameter to `init` in `goga/commands/init/init.py`; insert the invitation-validation step and the dedup step in the contract order; wire `ToolParticipation` into both onboarding dispatch branches +- [x] **Interface verification**: `pytest tests/commands/test_init.py -v` — contract tests pass; the pre-existing mode/ref/guard tests still pass (ported logic untouched) +- [x] **Logic tests** (from the design, transfer verbatim): `test_init_rejects_tools_with_upgrade` — `runner.invoke(init_cli, ["--upgrade", "-t", "my-tool"])` → exit 1, `"-t/--tool requires an onboarding session" in result.output`; `test_init_dedup_preserves_flag_order` — stubbed `InitLogic` capturing the constructed `ToolParticipation`; `["-t", "b", "-t", "a", "-t", "b"]` → `captured.invited == ["b", "a"]`; edge — `-t` with `` allowed (SCAFFOLD_THEN_ONBOARDING carries the invitation into the session) +- [x] **Debugging**: `pytest tests/commands/test_init.py -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: validation order matches the contract algorithm 1–6; opaque passthrough (no installation checks in the command); `--upgrade` never runs onboarding +- [x] **Lint**: `ruff check goga/commands/init/` — fix formatting if necessary + +### Task 19: Integration tests — end-to-end session with invited tools + +Cross-entity verification of the whole feature through the CLI: invitation +→ dedup → both moments → survey → amendments → generation → attributed +report. Rewrite `tests/onboarding/test_integration.py` (the old file tests +the old API). Setup pattern: `_clean_cwd`; fake installed +`goga_tool_my-tool` package (enumeration monkeypatched; `register_hooks` +subscribes both actions) — the `tests/hooks/conftest.py` `sys.modules` +injection pattern; `CliRunner` inputs walking the whole survey; caplog at +WARNING. + +**Usages relevant to this task:** +- `onboarding-usage`: the full session API composition the test drives. +- `session-participation`: the fake tool's hook bodies (declare/amend + contexts). +- `tool-contexts` (`goga/onboarding/participation/.usages/tool-contexts.md`): + the hook signature pattern for the fakes. +- `artifact-generation`: the expected artifacts and the report format. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] Rewrite `tests/onboarding/test_integration.py` with the shared fake-tool fixtures (declare + amend subscribed; declares a `token` input; buffers `answer("tools", {"my-tool": "latest"})` and `write_config("service.yml", {...})`) +- [ ] Test cross-entity interaction: `test_init_full_session_with_invited_tool` — `runner.invoke(init_cli, ["-t", "my-tool", "-t", "my-tool"])` with the full survey inputs → `result.exit_code == 0`; `cfg = yaml.safe_load(Path(".goga/config.yml").read_text())` → `cfg["tools"] == {"my-tool": "latest"}`; `Path(".goga/tools/my-tool/service.yml").exists()`; `"(tool: my-tool)" in result.output` +- [ ] Test edge case: `test_tool_failure_never_changes_exit_code` — the full-session setup with the tool's `amend_config` hook raising instead of contributing → `result.exit_code == 0`; `not Path(".goga/tools/my-tool").exists()`; a warning naming the tool in caplog +- [ ] Test edge case: `test_skip_of_base_image_collapses_dockerfile_branch` — fake tool whose `declare_session` hook calls only `context.skip("docker_image.base_image")`; CliRunner inputs: language `python`, every confirm gate `n` except the Dockerfile gate `y`, dockerfile path default (empty input), built image name `my-app:latest` → `result.exit_code == 0`; `"Base image" not in result.output`; `cfg["image"] == "my-app:latest"` and `"dockerfile" not in cfg` and `"base_image" not in cfg`; `not Path(".goga/Dockerfile").exists()` +- [ ] Run validation: `pytest tests/onboarding/ -x` then the full suite `pytest tests/ -x` — all pass +- [ ] Final platform check: `goga lint` — 0 errors (76+ cells; the four new cells join the graph) + +--- + +## Validation Commands + +- `pytest tests/version/ -v`: version cell tests (`minor_version`) +- `pytest tests/hooks/ -v`: hooks catalog + facade re-export tests +- `pytest tests/onboarding/questions/ -v`: questions cell tests +- `pytest tests/onboarding/participation/ -v`: participation cell tests +- `pytest tests/onboarding/survey/ -v`: survey cell tests +- `pytest tests/onboarding/generator/ -v`: generator cell tests +- `pytest tests/onboarding/ -v`: onboarding domain (logic + integration) +- `pytest tests/commands/ -v`: init CLI tests +- `pytest tests/ -x`: Run all tests (full suite) +- `ruff check goga/`: Lint check (line-length 120, mccabe 10) +- `goga lint`: Facade/contract graph check — must stay 0 errors +- `python -c "from goga.onboarding import InitLogic, Question, QuestionGroup, SessionAnswers, SessionPlan, Questionnaire, core_questions, assemble_session_plan, apply_skips, ToolParticipation, ToolDeclaration, ToolContribution, FileGenerator, CreatedFile"`: Verify that all facade entities are importable +- `python -c "from goga.hooks import HookRegistry, wrap_context, build_hook_arguments, enumerate_tool_packages"`: Verify the hooks facade re-exports +- `python -c "from goga.version import minor_version"`: Verify the version facade re-export + +--- + +## Completion Criteria + +- [ ] Every contract entity is implemented in the correct `location` +- [ ] Every contract entity is accessible from the facade +- [ ] Properties and methods match the declared API +- [ ] Descriptions are reflected in behavior +- [ ] Contract dependencies are met +- [ ] Re-exports are accessible from the facade +- [ ] Every coding task followed the TDD workflow (contract tests → code → verification → logic tests → debugging → re-verification → lint) +- [ ] Contract tests and logic tests cover facade, API, and behavior within each coding task +- [ ] Integration tests exist where cross-entity scenarios require them +- [ ] No package boundary was expanded +- [ ] `CODEMANIFEST` files were not modified (contract is read-only) +- [ ] All validation commands pass +- [ ] Every Usages entry is mentioned in at least one task (Phase 2 calibration) +- [ ] The old modules `goga/onboarding/{answers,questionnaire,generator}.py` and their test files are deleted; `InitAnswers`/`GogaConfigAnswers` appear nowhere +- [ ] The written `.goga/config.yml` passes the project-config loader +- [ ] The image tag is never hardcoded — it threads `host_goga_version` → `minor_version` → `core_questions` diff --git a/goga/commands/init/init.py b/goga/commands/init/init.py index 8f14cb2b..aa37d94f 100644 --- a/goga/commands/init/init.py +++ b/goga/commands/init/init.py @@ -4,7 +4,7 @@ import click -from ...onboarding import FileGenerator, InitLogic, Questionnaire +from ...onboarding import FileGenerator, InitLogic, Questionnaire, ToolParticipation from ...scaffold import Scaffold # Execution modes owned by this integrator. @@ -26,8 +26,21 @@ default=None, help="Override the git ref: with the URL fragment, with --upgrade the migration target", ) +@click.option( + "-t", + "--tool", + "tools", + multiple=True, + help="Invite a tool package into the onboarding session (repeatable)", +) @click.pass_context -def init(ctx: click.Context, tpl: str | None, upgrade: bool, ref: str | None) -> None: +def init( + ctx: click.Context, + tpl: str | None, + upgrade: bool, + ref: str | None, + tools: tuple[str, ...], +) -> None: """Initialize a new goga project interactively or from a copier template.""" # 1. --ref placement validation: ref is meaningful only with a template source. if ref is not None and tpl is None and not upgrade: @@ -40,13 +53,21 @@ def init(ctx: click.Context, tpl: str | None, upgrade: bool, ref: str | None) -> if mode is None: return # _resolve_mode already emitted an error and called ctx.exit(1). - # 3. Already-initialized guard — BARE_ONBOARDING only. + # 3. Invitation validation: an invitation needs an onboarding session. + if not _validate_invitation(tools, mode, ctx): + return # _validate_invitation already emitted an error and called ctx.exit(1). + + # 4. Dedup the invitations preserving the flag order — one block per tool. + # The tuple→list conversion happens here: the domain receives a list. + invited = list(dict.fromkeys(tools)) + + # 5. Already-initialized guard — BARE_ONBOARDING only. if mode == _BARE_ONBOARDING and Path(".goga").is_dir(): click.echo("Project already initialized", err=True) ctx.exit(1) return - # 4. Dispatch. + # 6. Dispatch. if mode == _UPGRADE: scaffold = Scaffold() ctx.exit(scaffold.upgrade(ref)) @@ -58,12 +79,9 @@ def init(ctx: click.Context, tpl: str | None, upgrade: bool, ref: str | None) -> if sc != 0: ctx.exit(sc) return - logic = InitLogic(Questionnaire(), FileGenerator()) - ctx.exit(logic.run()) - return - # BARE_ONBOARDING - logic = InitLogic(Questionnaire(), FileGenerator()) + # Both onboarding modes (BARE and template-given) carry the invitations. + logic = InitLogic(Questionnaire(), FileGenerator(), ToolParticipation(invited=invited)) ctx.exit(logic.run()) @@ -102,3 +120,30 @@ def _resolve_mode( return _SCAFFOLD_THEN_ONBOARDING return _BARE_ONBOARDING + + +def _validate_invitation( + tools: tuple[str, ...], + mode: str, + ctx: click.Context, +) -> bool: + """Validate the invitation flag against the resolved execution mode. + + An invitation acts in a session that runs onboarding — ``--upgrade`` + runs none, so the combination is rejected. + + Args: + tools: the invited tool names from the repeated ``-t/--tool`` flag. + mode: the resolved execution mode constant. + ctx: the click context, used to exit with code 1 on invalid input. + + Returns: + ``True`` when the combination is valid; ``False`` when it was + rejected (the error is emitted and ``ctx.exit(1)`` called first). + """ + if tools and mode == _UPGRADE: + click.echo("-t/--tool requires an onboarding session and --upgrade runs none", err=True) + ctx.exit(1) + return False + + return True diff --git a/tests/commands/test_init.py b/tests/commands/test_init.py index 56d46bca..1110e1c0 100644 --- a/tests/commands/test_init.py +++ b/tests/commands/test_init.py @@ -43,6 +43,61 @@ def test_init_exposes_tpl_upgrade_ref_params(self) -> None: assert isinstance(arg_kinds["upgrade"], click.Option) assert isinstance(arg_kinds["ref"], click.Option) + def test_init_help_shows_tool_flag(self) -> None: + """--help renders the repeatable -t, --tool option.""" + from goga.commands.init import init + + runner = CliRunner() + result = runner.invoke(init, ["--help"]) + + assert result.exit_code == 0 + assert "-t, --tool" in result.output + + def test_init_tools_param_is_repeatable_option(self) -> None: + """The tools param is a multiple click option bound to -t/--tool.""" + from goga.commands.init import init + + tools_param = next((param for param in init.params if param.name == "tools"), None) + + assert tools_param is not None + assert isinstance(tools_param, click.Option) + assert tools_param.multiple is True + + def test_init_accepts_repeated_tool_flags(self, tmp_path, monkeypatch) -> None: + """Repeated -t occurrences parse into one onboarding session.""" + from goga.commands.init import init + + mock_logic = mock.MagicMock(spec=InitLogic) + mock_logic.run.return_value = 0 + + monkeypatch.chdir(tmp_path) + + with ( + mock.patch.object(_cmd_init_module, "Questionnaire"), + mock.patch.object(_cmd_init_module, "FileGenerator"), + mock.patch.object(_cmd_init_module, "ToolParticipation"), + mock.patch.object(_cmd_init_module, "InitLogic", return_value=mock_logic), + ): + runner = CliRunner() + result = runner.invoke(init, ["-t", "a", "-t", "b"]) + + assert result.exit_code == 0 + mock_logic.run.assert_called_once() + + def test_init_rejects_tool_flag_with_upgrade(self, tmp_path, monkeypatch) -> None: + """-t combined with --upgrade is rejected at the command level.""" + from goga.commands.init import init + + mock_scaffold = mock.MagicMock() + + monkeypatch.chdir(tmp_path) + + with mock.patch.object(_cmd_init_module, "Scaffold", return_value=mock_scaffold): + runner = CliRunner() + result = runner.invoke(init, ["--upgrade", "-t", "my-tool"]) + + assert result.exit_code != 0 + class TestLogic: """Logic-level tests for init CLI command.""" @@ -337,3 +392,86 @@ def test_init_upgrade_does_not_run_onboarding(self, tmp_path, monkeypatch) -> No assert result.exit_code == 0 mock_logic.run.assert_not_called() + + def test_init_rejects_tools_with_upgrade(self, tmp_path, monkeypatch) -> None: + """-t with --upgrade → exit 1, the invitation message, no delegate runs.""" + from goga.commands.init import init + + mock_scaffold = mock.MagicMock() + mock_logic = mock.MagicMock(spec=InitLogic) + + monkeypatch.chdir(tmp_path) + + with ( + mock.patch.object(_cmd_init_module, "Scaffold", return_value=mock_scaffold), + mock.patch.object(_cmd_init_module, "InitLogic", return_value=mock_logic), + ): + runner = CliRunner() + result = runner.invoke(init, ["--upgrade", "-t", "my-tool"]) + + assert result.exit_code == 1 + assert "-t/--tool requires an onboarding session" in result.output + mock_scaffold.upgrade.assert_not_called() + mock_logic.run.assert_not_called() + + def test_init_dedup_preserves_flag_order(self, tmp_path, monkeypatch) -> None: + """Repeated names dedup in flag order; a plain list reaches ToolParticipation.""" + from goga.commands.init import init + + mock_logic = mock.MagicMock(spec=InitLogic) + mock_logic.run.return_value = 0 + + captured: dict[str, object] = {} + + def _fake_participation(invited): + captured["invited"] = invited + return mock.sentinel.participation + + monkeypatch.chdir(tmp_path) + + with ( + mock.patch.object(_cmd_init_module, "Questionnaire"), + mock.patch.object(_cmd_init_module, "FileGenerator"), + mock.patch.object(_cmd_init_module, "ToolParticipation", _fake_participation), + mock.patch.object(_cmd_init_module, "InitLogic", return_value=mock_logic) as fake_logic_cls, + ): + runner = CliRunner() + result = runner.invoke(init, ["-t", "b", "-t", "a", "-t", "b"]) + + assert result.exit_code == 0 + assert captured["invited"] == ["b", "a"] + assert isinstance(captured["invited"], list) + fake_logic_cls.assert_called_once() + assert fake_logic_cls.call_args.args[2] is mock.sentinel.participation + + def test_init_tool_with_tpl_carries_invitation(self, tmp_path, monkeypatch) -> None: + """SCAFFOLD_THEN_ONBOARDING: -t with is allowed and reaches the session.""" + from goga.commands.init import init + + mock_scaffold = mock.MagicMock() + mock_scaffold.generate.return_value = 0 + mock_logic = mock.MagicMock(spec=InitLogic) + mock_logic.run.return_value = 0 + + captured: dict[str, object] = {} + + def _fake_participation(invited): + captured["invited"] = invited + return mock.sentinel.participation + + monkeypatch.chdir(tmp_path) + + with ( + mock.patch.object(_cmd_init_module, "Scaffold", return_value=mock_scaffold), + mock.patch.object(_cmd_init_module, "Questionnaire"), + mock.patch.object(_cmd_init_module, "FileGenerator"), + mock.patch.object(_cmd_init_module, "ToolParticipation", _fake_participation), + mock.patch.object(_cmd_init_module, "InitLogic", return_value=mock_logic), + ): + runner = CliRunner() + result = runner.invoke(init, ["https://example.com/tpl.git", "-t", "my-tool"]) + + assert result.exit_code == 0 + assert captured["invited"] == ["my-tool"] + mock_scaffold.generate.assert_called_once_with("https://example.com/tpl.git", None) + mock_logic.run.assert_called_once() From 362996394da851f8dfb845e7d8f72245fb4ffd9a Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 22:13:16 +0000 Subject: [PATCH 028/205] feat: add end-to-end invited-tool integration tests for the onboarding session --- .../2026/onboarding-refctoring/plan.md | 44 ++-- tests/onboarding/test_integration.py | 201 +++++++++++++++++- 2 files changed, 214 insertions(+), 31 deletions(-) diff --git a/.goga/history/2026/onboarding-refctoring/plan.md b/.goga/history/2026/onboarding-refctoring/plan.md index 2fed7bb0..e87e0cdd 100644 --- a/.goga/history/2026/onboarding-refctoring/plan.md +++ b/.goga/history/2026/onboarding-refctoring/plan.md @@ -1401,12 +1401,12 @@ WARNING. **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] Rewrite `tests/onboarding/test_integration.py` with the shared fake-tool fixtures (declare + amend subscribed; declares a `token` input; buffers `answer("tools", {"my-tool": "latest"})` and `write_config("service.yml", {...})`) -- [ ] Test cross-entity interaction: `test_init_full_session_with_invited_tool` — `runner.invoke(init_cli, ["-t", "my-tool", "-t", "my-tool"])` with the full survey inputs → `result.exit_code == 0`; `cfg = yaml.safe_load(Path(".goga/config.yml").read_text())` → `cfg["tools"] == {"my-tool": "latest"}`; `Path(".goga/tools/my-tool/service.yml").exists()`; `"(tool: my-tool)" in result.output` -- [ ] Test edge case: `test_tool_failure_never_changes_exit_code` — the full-session setup with the tool's `amend_config` hook raising instead of contributing → `result.exit_code == 0`; `not Path(".goga/tools/my-tool").exists()`; a warning naming the tool in caplog -- [ ] Test edge case: `test_skip_of_base_image_collapses_dockerfile_branch` — fake tool whose `declare_session` hook calls only `context.skip("docker_image.base_image")`; CliRunner inputs: language `python`, every confirm gate `n` except the Dockerfile gate `y`, dockerfile path default (empty input), built image name `my-app:latest` → `result.exit_code == 0`; `"Base image" not in result.output`; `cfg["image"] == "my-app:latest"` and `"dockerfile" not in cfg` and `"base_image" not in cfg`; `not Path(".goga/Dockerfile").exists()` -- [ ] Run validation: `pytest tests/onboarding/ -x` then the full suite `pytest tests/ -x` — all pass -- [ ] Final platform check: `goga lint` — 0 errors (76+ cells; the four new cells join the graph) +- [x] Rewrite `tests/onboarding/test_integration.py` with the shared fake-tool fixtures (declare + amend subscribed; declares a `token` input; buffers `answer("tools", {"my-tool": "latest"})` and `write_config("service.yml", {...})`) +- [x] Test cross-entity interaction: `test_init_full_session_with_invited_tool` — `runner.invoke(init_cli, ["-t", "my-tool", "-t", "my-tool"])` with the full survey inputs → `result.exit_code == 0`; `cfg = yaml.safe_load(Path(".goga/config.yml").read_text())` → `cfg["tools"] == {"my-tool": "latest"}`; `Path(".goga/tools/my-tool/service.yml").exists()`; `"(tool: my-tool)" in result.output` +- [x] Test edge case: `test_tool_failure_never_changes_exit_code` — the full-session setup with the tool's `amend_config` hook raising instead of contributing → `result.exit_code == 0`; `not Path(".goga/tools/my-tool").exists()`; a warning naming the tool in caplog +- [x] Test edge case: `test_skip_of_base_image_collapses_dockerfile_branch` — fake tool whose `declare_session` hook calls only `context.skip("docker_image.base_image")`; CliRunner inputs: language `python`, every confirm gate `n` except the Dockerfile gate `y`, dockerfile path default (empty input), built image name `my-app:latest` → `result.exit_code == 0`; `"Base image" not in result.output`; `cfg["image"] == "my-app:latest"` and `"dockerfile" not in cfg` and `"base_image" not in cfg`; `not Path(".goga/Dockerfile").exists()` +- [x] Run validation: `pytest tests/onboarding/ -x` then the full suite `pytest tests/ -x` — all pass (137 onboarding, 5471 total) +- [x] Final platform check: `goga lint` — 0 errors (76+ cells; the four new cells join the graph) --- @@ -1431,19 +1431,19 @@ WARNING. ## Completion Criteria -- [ ] Every contract entity is implemented in the correct `location` -- [ ] Every contract entity is accessible from the facade -- [ ] Properties and methods match the declared API -- [ ] Descriptions are reflected in behavior -- [ ] Contract dependencies are met -- [ ] Re-exports are accessible from the facade -- [ ] Every coding task followed the TDD workflow (contract tests → code → verification → logic tests → debugging → re-verification → lint) -- [ ] Contract tests and logic tests cover facade, API, and behavior within each coding task -- [ ] Integration tests exist where cross-entity scenarios require them -- [ ] No package boundary was expanded -- [ ] `CODEMANIFEST` files were not modified (contract is read-only) -- [ ] All validation commands pass -- [ ] Every Usages entry is mentioned in at least one task (Phase 2 calibration) -- [ ] The old modules `goga/onboarding/{answers,questionnaire,generator}.py` and their test files are deleted; `InitAnswers`/`GogaConfigAnswers` appear nowhere -- [ ] The written `.goga/config.yml` passes the project-config loader -- [ ] The image tag is never hardcoded — it threads `host_goga_version` → `minor_version` → `core_questions` +- [x] Every contract entity is implemented in the correct `location` +- [x] Every contract entity is accessible from the facade +- [x] Properties and methods match the declared API +- [x] Descriptions are reflected in behavior +- [x] Contract dependencies are met +- [x] Re-exports are accessible from the facade +- [x] Every coding task followed the TDD workflow (contract tests → code → verification → logic tests → debugging → re-verification → lint) +- [x] Contract tests and logic tests cover facade, API, and behavior within each coding task +- [x] Integration tests exist where cross-entity scenarios require them +- [x] No package boundary was expanded +- [x] `CODEMANIFEST` files were not modified (contract is read-only) +- [x] All validation commands pass +- [x] Every Usages entry is mentioned in at least one task (Phase 2 calibration) +- [x] The old modules `goga/onboarding/{answers,questionnaire,generator}.py` and their test files are deleted; `InitAnswers`/`GogaConfigAnswers` appear nowhere +- [x] The written `.goga/config.yml` passes the project-config loader +- [x] The image tag is never hardcoded — it threads `host_goga_version` → `minor_version` → `core_questions` diff --git a/tests/onboarding/test_integration.py b/tests/onboarding/test_integration.py index 892821aa..edf035ad 100644 --- a/tests/onboarding/test_integration.py +++ b/tests/onboarding/test_integration.py @@ -1,10 +1,193 @@ -"""End-to-end onboarding session tests — placeholder for the invited-tool suite. - -The old integration suite of this file drove the deleted flat-answer model -and the old per-field questionnaire; its cases were ported into the -leaf-cell test layout — ``questions/``, ``survey/``, ``generator/``, and -``test_logic.py`` — during the facade rewrite. The cross-entity suite of -the whole feature (invitation → dedup → both tool moments → survey → -amendments → generation → attributed report through the CLI) is rebuilt in -this file together with the end-to-end task of the plan. +"""End-to-end session tests — the invited-tool feature through the CLI. + +The whole runtime composition of the design drives for real: the CLI +invitation flags, the dedup, both tool participation moments, the survey, +the committed amendments, the generation, and the attributed file report. +The environment boundary is pinned exactly as the hooks platform tests pin +it — the installed-distributions mapping and the ``sys.modules`` entry of +one fake ``goga_tool_*`` package whose facade subscribes both onboarding +actions; the registry build, the registration, the per-tool delivery, the +survey, and the generator run for real. The filesystem boundary is pinned +by the ``_clean_cwd`` fixture of this test directory. """ + +from __future__ import annotations + +import logging +import sys +from collections.abc import Callable +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest +import yaml +from click.testing import CliRunner +from goga.commands.init import init as init_cli +from goga.onboarding.questions import Question + +# The attribute the enumeration reads — the single enumeration mock point +# (mirrors the hooks test directory; conftest fixtures do not cross test +# directories). +_ENUMERATION_TARGET = "goga.hooks.tools.packages.packages_distributions" + +# The fake tool identity and its top-level module name — the identity is +# environment-assigned: the goga_tool_ prefix drops, underscores become +# hyphens. +_TOOL = "my-tool" +_TOOL_MODULE = "goga_tool_my_tool" + +# The hook-body shape of the fake package — context first, optional self. +_Hook = Callable[..., None] +_InstallTool = Callable[[_Hook | None, _Hook | None], None] + +# The full survey walked with the least input: the language choice, every +# confirm gate declined, the offered pull-image default accepted, the +# invited tool's token answered. +_FULL_SESSION_INPUTS = [ + "python", # the language choice + "n", # the base-convention gate + "n", # the codemanifest usages collection + "n", # the codemanifest annotations + "n", # the build agent gate + "n", # the Dockerfile gate — the pull branch runs + "", # the pulled image — the offered hint default + "n", # the pipeline agent gate + "n", # the tools collection gate + "n", # the usages records gate + "t0", # the invited tool's token +] + +# Every test of this file operates on the filesystem state of a clean +# project dir — the repo CWD carries the goga project's own .goga/. +pytestmark = pytest.mark.usefixtures("_clean_cwd") + + +def _declare_token(context: Any) -> None: + """Declare the standard block of the fake tool — one token input.""" + if not context.invited: + return + context.declare(Question(id="token", kind="input", prompt="Service token")) + + +def _amend_service(context: Any) -> None: + """Contribute the standard amendment — the tools record and one config file.""" + if not context.invited: + return + context.answer("tools", {_TOOL: "latest"}) + context.write_config("service.yml", {"token_source": "env"}) + + +@pytest.fixture +def install_tool(monkeypatch: pytest.MonkeyPatch) -> _InstallTool: + """Install the fake ``my-tool`` package subscribed to the onboarding actions. + + The standard fake declares the token input and contributes the tools + record plus one service config; a test overrides either hook body (or + passes None to leave that action unsubscribed). The enumeration boundary + is pinned to the single fake identity — the registry build, the + registration, and the per-tool delivery run for real. + + Args: + monkeypatch: The pytest patcher restoring the boundary on teardown. + + Returns: + The installing factory: the declare and amend hook bodies in, None out. + """ + + def _install(declare: _Hook | None = _declare_token, amend: _Hook | None = _amend_service) -> None: + def register_hooks(hooks: Any) -> None: + if declare is not None: + hooks.subscribe("onboarding", "declare_session", "declare", declare) + if amend is not None: + hooks.subscribe("onboarding", "amend_config", "amend", amend) + + module = ModuleType(_TOOL_MODULE) + module.register_hooks = register_hooks + + monkeypatch.setitem(sys.modules, _TOOL_MODULE, module) + monkeypatch.setattr(_ENUMERATION_TARGET, lambda: {_TOOL_MODULE: [f"goga-tool-{_TOOL}"]}) + + return _install + + +class TestInvitedToolSession: + """The invited-tool session end to end — through the real CLI command.""" + + def test_init_full_session_with_invited_tool(self, install_tool: _InstallTool) -> None: + """Invitation → dedup → both moments → survey → amendments → attributed report.""" + install_tool() + + result = CliRunner().invoke( + init_cli, + ["-t", _TOOL, "-t", _TOOL], + input="\n".join(_FULL_SESSION_INPUTS) + "\n", + ) + + assert result.exit_code == 0, result.output + + cfg = yaml.safe_load(Path(".goga/config.yml").read_text(encoding="utf-8")) + assert cfg["language"] == "python" + assert cfg["tools"] == {"my-tool": "latest"} + + assert Path(".goga/tools/my-tool/service.yml").exists() + assert "(tool: my-tool)" in result.output + + def test_tool_failure_never_changes_exit_code( + self, + install_tool: _InstallTool, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A crashing amend hook drops the tool's contribution — the session still exits 0.""" + + def amend_boom(context: Any) -> None: + raise RuntimeError("amend boom") + + install_tool(amend=amend_boom) + + with caplog.at_level(logging.WARNING): + result = CliRunner().invoke( + init_cli, + ["-t", _TOOL], + input="\n".join(_FULL_SESSION_INPUTS) + "\n", + ) + + assert result.exit_code == 0, result.output + assert Path(".goga/config.yml").is_file() + assert not Path(".goga/tools/my-tool").exists() + assert any(_TOOL in record.message for record in caplog.records) + + def test_skip_of_base_image_collapses_dockerfile_branch(self, install_tool: _InstallTool) -> None: + """A tool-declared skip of the base image collapses the FROM — no Dockerfile, no config field.""" + + def declare_skip(context: Any) -> None: + if not context.invited: + return + context.skip("docker_image.base_image") + + install_tool(declare=declare_skip, amend=None) + + inputs = [ + "python", # the language choice + "n", # the base-convention gate + "n", # the codemanifest usages collection + "n", # the codemanifest annotations + "n", # the build agent gate + "y", # the Dockerfile gate — accepted + "", # the Dockerfile path — the .goga/Dockerfile default + "my-app:latest", # the built image name + "n", # the pipeline agent gate + "n", # the tools collection gate + "n", # the usages records gate + ] + + result = CliRunner().invoke(init_cli, ["-t", _TOOL], input="\n".join(inputs) + "\n") + + assert result.exit_code == 0, result.output + assert "Base image" not in result.output + + cfg = yaml.safe_load(Path(".goga/config.yml").read_text(encoding="utf-8")) + assert cfg["image"] == "my-app:latest" + assert "dockerfile" not in cfg + assert "base_image" not in cfg + assert not Path(".goga/Dockerfile").exists() From 726d38a9637b6fc4d14648b0389eb8414e143764 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 22:35:41 +0000 Subject: [PATCH 029/205] fix: address code review findings --- README.md | 4 +- docs/features/hooks/api.md | 11 +- docs/features/hooks/hooks.md | 6 +- docs/features/hooks/index.md | 2 +- docs/features/init/api.md | 43 +-- docs/features/init/cli.md | 65 ++-- docs/features/init/configuration.md | 2 +- docs/features/init/hooks.md | 32 +- docs/getting-started.md | 18 +- goga/commands/pipeline/file_roots.py | 19 +- goga/onboarding/participation/declaration.py | 15 +- goga/onboarding/survey/questionnaire.py | 105 +++++-- tests/commands/pipeline/test_file_roots.py | 23 ++ .../pipeline/test_run_pipeline_container.py | 34 +++ tests/onboarding/generator/test_generator.py | 25 +- .../participation/test_declaration.py | 21 ++ .../participation/test_participation.py | 10 +- tests/onboarding/survey/test_plan.py | 33 ++ tests/onboarding/survey/test_questionnaire.py | 288 +++++++++++++++++- tests/onboarding/test_integration.py | 3 + 20 files changed, 644 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 6fe53b29..0aa74c76 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Start a new project from scratch and ship your first piece of work end-to-end. goga init ``` -You can also start from a [copier](https://copier.readthedocs.io/) template (`goga init `, optionally pinned with `#ref` or `--ref`), and later migrate a scaffolded project with `goga init --upgrade`. See [`goga init`](https://qarium.github.io/goga/features/init/cli/) for the full surface. +You can also start from a [copier](https://copier.readthedocs.io/) template (`goga init `, optionally pinned with `#ref` or `--ref`), and later migrate a scaffolded project with `goga init --upgrade`. Installed tools can be invited into the wizard with `goga init -t ` (repeatable) — the tool then contributes its own questions and its config files under `.goga/tools//`. See [`goga init`](https://qarium.github.io/goga/features/init/cli/) for the full surface. **2. Open your agent** — launch the agent you connected via `goga connect` (e.g., Claude Code) in the project directory. All `goga-` skills are now available. @@ -443,7 +443,7 @@ A valid tool **must**: A tool **may** additionally expose an `install(user: str | None = None)` callable in its facade package: `goga install` calls it after a successful pip, passing the initiating user (`SUDO_USER` when goga itself runs under sudo, else the current OS user) only when the parameter is declared keyword-capable. A missing or non-callable `install` is skipped quietly. -A tool **may** also expose a `register_hooks(hooks)` callable to extend goga domains with its own hooks — today, the topic status scale. goga calls it when a command first reaches a hook checkpoint that needs statuses, or when you inspect the registry with `goga hooks`; commands that use no hooks never call it: +A tool **may** also expose a `register_hooks(hooks)` callable to extend goga domains with its own hooks — today, the topic status scale and the onboarding session (`declare_session`/`amend_config`, reached via `goga init -t `). goga calls it when a command first reaches a hook checkpoint of the run, or when you inspect the registry with `goga hooks`; commands that use no hooks never call it: ```python def register_hooks(hooks): diff --git a/docs/features/hooks/api.md b/docs/features/hooks/api.md index beaeff69..465398df 100644 --- a/docs/features/hooks/api.md +++ b/docs/features/hooks/api.md @@ -1,13 +1,16 @@ # Hooks — API -The facade of the domain package **`goga.hooks`** — the extension surface of the goga domains for installed tool packages. The facade declares no type of its own: it re-exports the declared action catalog, the run registry with its per-tool inspection view, and the emission of an action at a domain checkpoint. Importing the package imports no tool package and enumerates nothing. +The facade of the domain package **`goga.hooks`** — the extension surface of the goga domains for installed tool packages. The facade declares no type of its own: it re-exports the declared action catalog, the run registry with its per-tool inspection view, the emission of an action at a domain checkpoint, the delivery primitives for domains that drive per-tool delivery themselves, and the installed `goga_tool_*` package enumeration. Importing the package imports no tool package and enumerates nothing. The signatures below are the CODEMANIFEST contract of the platform cells. ## The facade ```python -from goga.hooks import HookRegistry, ToolHooks, declared_actions, emit_hook_event +from goga.hooks import ( + HookRegistry, ToolHooks, build_hook_arguments, declared_actions, + emit_hook_event, enumerate_tool_packages, wrap_context, +) ``` | Name | Origin | Purpose | @@ -15,6 +18,10 @@ from goga.hooks import HookRegistry, ToolHooks, declared_actions, emit_hook_even | `declared_actions()` | `goga.hooks.catalog` | The declared action catalog | | `HookRegistry()`, `ToolHooks` | `goga.hooks.registry` | The run registry and its per-tool view | | `emit_hook_event(...)` | `goga.hooks.dispatch` | The emission of an action at a domain checkpoint | +| `wrap_context(...)`, `build_hook_arguments(...)` | `goga.hooks.dispatch` | The delivery primitives for domains that drive per-tool delivery themselves (staged contributions) | +| `enumerate_tool_packages()` | `goga.hooks.tools` | The installed `goga_tool_*` package enumeration | + +The delivery primitives serve the staged per-tool delivery pattern — a domain commits a tool's contribution only after all its hooks succeed (the onboarding session is the in-tree consumer). ## The action catalog diff --git a/docs/features/hooks/hooks.md b/docs/features/hooks/hooks.md index 9b41d907..3676de7f 100644 --- a/docs/features/hooks/hooks.md +++ b/docs/features/hooks/hooks.md @@ -16,7 +16,7 @@ def register_published(context): `hooks.subscribe(domain, action, name, hook)` registers one hook: -- `domain` + `action` — the action address: the semantic owner domain and the action name within it (`"statuses"` / `"register_statuses"` is the topic-status action — see [History — Hooks](../history/hooks.md)). +- `domain` + `action` — the action address: the semantic owner domain and the action name within it (`"statuses"` / `"register_statuses"` is the topic-status action — see [History — Hooks](../history/hooks.md); `"onboarding"` / `"declare_session"` and `"onboarding"` / `"amend_config"` are the onboarding-session actions a tool is invited into via `goga init -t ` — see [Init — Hooks](../init/hooks.md)). - `name` — the hook name, unique per tool per address; registrations appear in the [`goga hooks`](cli.md) tree under their tool line. - `hook` — the callable executed when the action fires. @@ -26,14 +26,14 @@ The tool identity is assigned by goga from the package name — a package never A hook receives values only for the parameters it declares by the fixed offered names — `context` and `self`: -- `context` — the delivered object of the action. Read attributes and call methods freely; attribute assignment is blocked. What the object carries is fixed by the owner domain's contract — for `register_statuses` it is the status registration surface (`register(name, filepath, before=..., after=...)`, names stored qualified `.`; see [History — Hooks](../history/hooks.md) for the scale rules). +- `context` — the delivered object of the action. Read attributes and call methods freely; attribute assignment is blocked. What the object carries is fixed by the owner domain's contract — for `register_statuses` it is the status registration surface (`register(name, filepath, before=..., after=...)`, names stored qualified `.`; see [History — Hooks](../history/hooks.md) for the scale rules). For the onboarding actions it is the declaration or contribution surface described in [Init — Hooks](../init/hooks.md). - `self` — the isolated context of your tool. One instance links all its hook invocations of a run; freely mutable by your tool, invisible to the domains. The declaration order does not matter; names you did not declare receive nothing. ## Error classes and diagnostics -Each action in the catalog fixes how a failing hook is treated. The topic-status action is **soft**: a failing hook is skipped with a stderr warning naming the tool, the action, and the reason, and the command continues. A **hard** action stops the command at the first failing hook with a clean error — the class is chosen by the owner domain when it declares the action. +Each action in the catalog fixes how a failing hook is treated. The topic-status and the onboarding actions are **soft**: a failing hook is skipped with a stderr warning naming the tool, the action, and the reason, and the command continues. A **hard** action stops the command at the first failing hook with a clean error — the class is chosen by the owner domain when it declares the action. At registration: a wrong address, an empty name, or a repeated name on the same address is refused with a stderr warning naming the tool, the action, and the reason — the registration is skipped, the rest apply. A crashing callback is a warning; the registrations made before the crash survive. A broken package import is the only fatal case: a clean error naming the package. diff --git a/docs/features/hooks/index.md b/docs/features/hooks/index.md index 835097dc..be480bf6 100644 --- a/docs/features/hooks/index.md +++ b/docs/features/hooks/index.md @@ -8,7 +8,7 @@ The hooks domain is the mechanism behind every domain extension: a domain declar - **Tool packages extend domains with no goga code changes** — a package registers its hooks at run time; registration is never cached, so package edits apply from the next run without reinstall. - **Inspection** — `goga hooks` assembles the registry once and prints it as a tree: tool, domain, action — the fact of registration, including every refused registration with its reason. -The declared actions today: the status-scale registration of the [History](../history/hooks.md) domain. The authoring side — how a tool package writes its `register_hooks` callback — is the [registration contract](hooks.md). +The declared actions today: the status-scale registration of the [History](../history/hooks.md) domain and the two onboarding actions of the [Init](../init/hooks.md) domain (`onboarding/declare_session`, `onboarding/amend_config`, both soft — a tool reaches them via `goga init -t `). The authoring side — how a tool package writes its `register_hooks` callback — is the [registration contract](hooks.md). ## Model diff --git a/docs/features/init/api.md b/docs/features/init/api.md index 66b601d9..01475b29 100644 --- a/docs/features/init/api.md +++ b/docs/features/init/api.md @@ -1,35 +1,44 @@ # Init — API -The facade of the domain package **`goga.onboarding`** — the interactive project initialization and template scaffolding. +The facade of the domain package **`goga.onboarding`** — the interactive project initialization and the invited-tool session. -The signatures below are the CODEMANIFEST contract of the cell. +The signatures below are the CODEMANIFEST contract of the cells. ```python -InitLogic(questionnaire: Questionnaire, generator: FileGenerator) +InitLogic(questionnaire: Questionnaire, generator: FileGenerator, + participation: ToolParticipation) Questionnaire() FileGenerator() +ToolParticipation(invited: list[str]) ``` -- `InitLogic` — the orchestration: run the questionnaire, resolve the answers (template answers first, the interactive dialogue for what the template left open), and generate the project files. -- `Questionnaire` — the interactive dialogue — the questions behind `.goga/config.yml` and the optional Dockerfile. -- `FileGenerator` — the file materialization: `.goga/config.yml`, the Dockerfile, and the template scaffold application (a copier template with `goga init --upgrade` migrates an existing scaffold). +The facade re-exports the full contract surface: ```python -InitAnswers(goga_config: GogaConfigAnswers | None = None) -GogaConfigAnswers(language: str, image: str, agent: str | None, - pipeline_agent: str | None, pipeline_env: dict | None, - env: dict | None, codemanifest_usages: dict | None, - codemanifest_annotations: str | None, - dockerfile_path: str | None, dockerfile_base_image: str | None) +from goga.onboarding import ( + CreatedFile, FileGenerator, InitLogic, Question, QuestionGroup, + Questionnaire, SessionAnswers, SessionPlan, ToolParticipation, + apply_skips, assemble_session_plan, core_questions, +) ``` -The resolved answers: the project language and image, the build/pipeline agent settings with their env layers, the `codemanifest` section values, and the optional Dockerfile pair (path + base image). `InitAnswers` with `goga_config=None` — a template answered everything. +- `InitLogic` — the orchestrator: guard on the existing config, derive the image tag from the installed version, deliver both tool moments, run the survey, generate the artifacts, render the attributed file report. +- `Questionnaire` — the survey engine: asks the plan's core sections and tool blocks, records every value at its plan path. +- `FileGenerator` — the artifact generator: `.goga/config.yml`, the Dockerfile, the conventions download, and the tool configs under `.goga/tools//`. +- `ToolParticipation` — the mediator delivering the two onboarding hook moments to the invited tools. +- `Question` / `QuestionGroup` — the declarative question records; `SessionAnswers` — the answer accumulator; `SessionPlan` / `assemble_session_plan` / `apply_skips` — the plan layer; `core_questions` — the core tree builder; `CreatedFile` — one report entry with tool attribution. ## Example ```python -from goga.onboarding import FileGenerator, InitLogic, Questionnaire - -logic = InitLogic(questionnaire=Questionnaire(), generator=FileGenerator()) -logic.run() +from goga.onboarding import FileGenerator, InitLogic, Questionnaire, ToolParticipation + +logic = InitLogic( + questionnaire=Questionnaire(), + generator=FileGenerator(), + participation=ToolParticipation(invited=["my-tool", "viewer"]), +) +exit_code = logic.run() ``` + +**Returns:** exit code — `0` on success, nonzero on a session error or a user abort. A failing tool is soft: its contribution is discarded with a warning and the session still returns `0`. An existing `.goga/config.yml` ends the session immediately — no questions, no tool events, no artifacts. diff --git a/docs/features/init/cli.md b/docs/features/init/cli.md index b35f3e7b..19bfacf6 100644 --- a/docs/features/init/cli.md +++ b/docs/features/init/cli.md @@ -5,7 +5,7 @@ Interactive project initialization wizard, with optional template scaffolding. ## Synopsis ```bash -goga init [TPL] [--ref REF] +goga init [TPL] [-t NAME]... [--ref REF] goga init --upgrade [--ref REF] ``` @@ -19,6 +19,8 @@ goga init --upgrade [--ref REF] `` and `--upgrade` are mutually exclusive: `--upgrade` updates state tied to a specific repository already recorded in `.goga/scaffold.yml`. `--ref` is meaningful only with `` or `--upgrade` (a bare `--ref` is rejected). +Both modes that run onboarding accept **tool invitations**: `goga init -t ` (repeatable) invites installed tool packages into the session. An invited tool declares its own questions (asked after the core sections under a `--- Tool: ---` heading), may skip core questions it replaces, and contributes config files written under `.goga/tools//`. A repeated name deduplicates into one invitation preserving the flag order. An invited but not installed name produces a warning — the session continues. A failing tool hook never changes the exit code. See [Init — Hooks](hooks.md) for the tool-author contract. + ### Interactivity The bare wizard is fully interactive. Press `Ctrl+C` at any time to abort. @@ -32,54 +34,59 @@ With a template (`goga init `), copier asks every template question that ha | Invocation | Mode | Behavior | |---|---|---| | `goga init` | Bare onboarding | Interactive questionnaire; refuses if `.goga/` exists. | -| `goga init [--ref REF]` | Scaffold then onboarding | Copier `run_copy` from ``, then the conditional questionnaire. | +| `goga init [-t NAME]...` | Bare onboarding + tools | The questionnaire plus the invited tools' question blocks and config files. | +| `goga init [--ref REF] [-t NAME]...` | Scaffold then onboarding | Copier `run_copy` from ``, then the conditional questionnaire. | | `goga init --upgrade [--ref REF]` | Upgrade | Copier `run_update`; no onboarding. Requires `.goga/scaffold.yml`. | ### Questionnaire Flow -The wizard proceeds through the following steps in order. **The entire survey is skipped when `.goga/config.yml` already exists** (for example, when a copier template brought its own config) — `ask_goga_config` short-circuits and no file is (re)written. +The wizard proceeds through the following steps in order. **The entire session is skipped when `.goga/config.yml` already exists** (for example, when a copier template brought its own config) — no question is asked and no file is (re)written. 1. **Language** -- Select the primary programming language. Choices: `python`, `golang`, `kotlin`, `swift`, `javascript`. -2. **Base Convention** -- Optionally download the default code conventions for the selected language from the [goga-lang-conventions](https://github.com/qarium/goga-lang-conventions) repository. **Skipped when `.goga/usages/conventions.md` already exists** (for example, when a template brought its own conventions); the prefill is treated as `(None, None)`. - -3. **Codemanifest Usages** -- Add additional named usages (code practice documentation entries). Each usage has a name and a file path. +2. **Base Convention** -- Optionally download the default code conventions for the selected language from the [goga-lang-conventions](https://github.com/qarium/goga-lang-conventions) repository. Accepting pre-fills the codemanifest step (a `conventions` usage entry and a starter annotation). **Skipped when `.goga/usages/conventions.md` already exists** (for example, when a template brought its own conventions); the prefill is treated as `(None, None)`. -4. **Codemanifest Annotations** -- Add custom annotations (global directives for the AI agent) that will be stored in the configuration. +3. **Codemanifest Usages** -- Add additional named usages (code practice documentation entries). Each usage has a name and a file path. The convention prefill entries are offered first when step 2 was accepted. -5. **Build Agent** -- Confirm-gated (defaults to **No**). Decline to skip configuring a build agent (the `agent` key is then omitted from the generated config; `goga build` raises a clean `ClickException` if it later needs one). Accept to select an AI executor: `claude`, `codex`, `cursor`, `opencode`, or `qwen`. +4. **Codemanifest Annotations** -- Add custom annotations (global directives for the AI agent) that will be stored in the configuration; a custom annotation appends to the pre-filled text when step 2 was accepted. -6. **Custom Dockerfile** -- Optionally create a custom Dockerfile. When accepted, the suggested path is `.goga/Dockerfile` (saved inside the project-scoped `.goga/` directory); press Enter to accept it or type a different path. The Dockerfile decision drives the next step (image semantics differ). +5. **Build Agent and Environment** -- Confirm-gated (defaults to **No**). Decline to skip configuring a build agent (the `build` key is then omitted from the generated config; `goga build` raises a clean `ClickException` if it later needs one). Accept to select an AI executor — `claude`, `codex`, `cursor`, `opencode`, or `qwen` — then collect its environment variables: the suggested keys of the selected agent are offered first (e.g., `ANTHROPIC_BASE_URL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_MODEL` for Claude; `CODEX_MODEL` for Codex), arbitrary `KEY=VALUE` pairs after. -7. **Docker Image** (depends on step 6): +6. **Docker Image** -- The Dockerfile decision: - - **If you created a Dockerfile**, the image is **built from it**, so you are asked for two things: + - **Create Dockerfile?** (defaults to **No**) — when accepted, the image is **built from it**, so you are asked for three things: + - **Dockerfile path** -- the suggested path is `.goga/Dockerfile` (saved inside the project-scoped `.goga/` directory); press Enter to accept it or type a different path. - **Base image (FROM)** -- the baseline the Dockerfile extends. Available images depend on the chosen language (table below). This is written to the Dockerfile's `FROM` line only; it is not stored in `config.yml`. - **Built image name** -- the name/tag for the image built from your Dockerfile (`goga build` runs `docker build -t `). Free-form; defaults to `:latest`, where `` is derived from your git `origin` remote URL (basename with `.git` stripped). When no git remote is available, no default is offered and the image name is required. Stored as the top-level `image` in `config.yml`. - - **If you did not create a Dockerfile**, the image is a **pre-built image to pull**. Select it from the language-specific list (table below); it is stored as the top-level `image` in `config.yml`. + - **If you decline**, the image is a **pre-built image to pull**. Select it from the language-specific list (table below); it is stored as the top-level `image` in `config.yml`. + + The `:` suffix of every offered image is the minor line of the installed goga (e.g. `1.3` while on goga 1.3.x) — the offered hints always match your installed version line. | Language | Images | |---|---| - | python | `qarium/goga-python-3.10:1.3` ... `qarium/goga-python-3.14:1.3` | - | golang | `qarium/goga-golang-1.23:1.3` ... `qarium/goga-golang-1.26:1.3` | - | javascript | `qarium/goga-node-22:1.3`, `qarium/goga-node-24:1.3` | - | kotlin | `qarium/goga-kotlin-2.0:1.3` ... `qarium/goga-kotlin-2.3:1.3` | - | swift | `qarium/goga-swift-6.0:1.3` ... `qarium/goga-swift-6.2:1.3` | + | python | `qarium/goga-python-3.10:` ... `qarium/goga-python-3.14:` | + | golang | `qarium/goga-golang-1.23:` ... `qarium/goga-golang-1.26:` | + | javascript | `qarium/goga-node-22:`, `qarium/goga-node-24:` | + | kotlin | `qarium/goga-kotlin-2.0:` ... `qarium/goga-kotlin-2.3:` | + | swift | `qarium/goga-swift-6.0:` ... `qarium/goga-swift-6.2:` | -8. **Environment Variables** -- Configure environment variables for the build. Suggested keys are offered per agent (e.g., `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL` for Claude; `CODEX_MODEL` for Codex). You can also add arbitrary custom variables. +7. **Pipeline Agent and Environment** -- Confirm-gated (defaults to **No**). Decline to skip configuring a pipeline agent (the `pipeline` key is omitted; a per-stage workflow agent or the pipeline's own default then covers the absent global agent). Accept to select an AI executor and collect its environment variables (same shape as step 5). Does **not** inherit the build agent — build and pipeline are collected via independent confirm-gates, so they can diverge or both be left unset. -9. **Pipeline Agent** -- Confirm-gated (defaults to **No**). Decline to skip configuring a pipeline agent (the `pipeline.agent` key is omitted; a per-stage workflow agent or the pipeline's own default then covers the absent global agent). Accept to select an AI executor: `claude`, `codex`, `cursor`, `opencode`, or `qwen`. Does **not** inherit the build agent from step 5 — build and pipeline are collected via independent confirm-gates, so they can diverge or both be left unset. +8. **Tools** -- Confirm-gated (defaults to **No**). Collect `name → version` pairs recorded as the top-level `tools` list of `config.yml` (consumed by `goga install` bulk mode). Version forms: `latest`, `N.x` (newest within major N), `N.M.x` (newest patch within N.M), `N.M` or `N.M.K` (exact pin); an empty version reads as `latest`. -10. **Pipeline Environment Variables** -- Configure environment variables for the pipeline container. Suggested keys are offered per agent (same shape as step 8). You can also add arbitrary `KEY=VALUE` variables. Omitted entirely when nothing is collected. +9. **Usages Records** -- Confirm-gated (defaults to **No**). Collect git dependency records — group, dependency name, git URL, optional ref and root — recorded as the top-level `usages` tree of `config.yml` (consumed by `goga usages sync`). + +10. **Tool Blocks** -- For every invited tool (`-t`), the questions the tool declared are asked under a `--- Tool: ---` heading; the answers configure the tool's own files. A tool may also have skipped core questions it replaces — those are never asked. ### Generated Files -After the questionnaire completes, `goga init` creates: +After the questionnaire completes, `goga init` creates (each path is echoed as `created ` in the run report — tool files as `created (tool: )`): -- **`.goga/config.yml`** -- Project configuration. Fields, in order: `language`, top-level `image`, optional `dockerfile` (when a custom Dockerfile is requested), `build` (emitted only when it carries content — a non-None agent and/or a non-empty env), `pipeline` (likewise emitted only when it carries content), and optional `codemanifest`. A freshly-initialized project with no agent and no env omits both `build` and `pipeline`; the consumer commands raise a clean `ClickException` when an agent is actually needed. +- **`.goga/config.yml`** -- Project configuration. Fields, in order: `language`, top-level `image`, optional `dockerfile` (when a custom Dockerfile is requested), `build` (emitted only when it carries content — a non-None agent and/or a non-empty env), `pipeline` (likewise emitted only when it carries content), optional `codemanifest`, optional `tools` (the step 8 collection), and optional `usages` (the step 9 records). A freshly-initialized project with no agent and no env omits both `build` and `pipeline`; the consumer commands raise a clean `ClickException` when an agent is actually needed. - **`.goga/usages/conventions.md`** -- (If base convention was downloaded) Language-specific code conventions. - **`.goga/Dockerfile`** -- (If requested) A Dockerfile whose `FROM` line is the selected base image, written at the suggested path inside `.goga/`. When created, a top-level `dockerfile:` entry (defaulting to `.goga/Dockerfile`) is also written to `.goga/config.yml`, and the top-level `image` holds the **name of the image built from it** (the `docker build -t` tag) — so `goga build --update` / `goga pipeline --update` build the image locally instead of pulling it. +- **`.goga/tools//`** -- (Per invited tool) The config files the tool's `amend_config` hook buffered, written by the engine — a tool never writes its own config. Tool amendments may also substitute collected answers (e.g. the `tools` record). When `goga init ` is used, copier additionally writes: @@ -93,6 +100,12 @@ Run the initialization wizard: goga init ``` +Run the wizard with invited tool packages (repeatable; duplicates deduplicate): + +```bash +goga init -t my-tool -t viewer +``` + Scaffold a project from a copier template, then run the conditional questionnaire: ```bash @@ -104,6 +117,9 @@ goga init https://github.com/qarium/my-template.git#v1.0 # Override the ref explicitly (--ref wins over a fragment) goga init https://github.com/qarium/my-template.git#v1.0 --ref main + +# Scaffold and invite a tool into the session +goga init https://github.com/qarium/my-template.git -t my-tool ``` Migrate a previously scaffolded project to a newer template version: @@ -121,6 +137,7 @@ goga init --upgrade --ref v2.0 | Option/Argument | Type | Default | Purpose | |---|---|---|---| | `TPL` (positional, optional) | string | None | Copier template source — a git URL, optionally carrying a `#ref` fragment. Triggers scaffold-then-onboarding mode. Mutually exclusive with `--upgrade`. | +| `-t`, `--tool NAME` (repeatable) | string | None | Invite the named tool package into the onboarding session. Acts in both modes that run onboarding (bare and ``-given); a repeated name deduplicates into one invitation and one block, preserving the flag order. The names are opaque to the command — the onboarding domain warns for invited-but-not-installed names. Rejected with `--upgrade`. | | `--upgrade` | flag | False | Migrate a previously scaffolded project via copier `run_update`; no onboarding. Mutually exclusive with ``. | | `--ref REF` | string | None | Override the git ref. With `` it overrides the URL fragment; with `--upgrade` it sets the migration target ref. Requires `` or `--upgrade` (a bare `--ref` is rejected). | @@ -128,5 +145,5 @@ goga init --upgrade --ref v2.0 | Code | Meaning | |---|---| -| `0` | Success — files generated (onboarding), template scaffolded, or migration applied. | -| `1` | Error or user abort (`Ctrl+C`). Includes: project already initialized (bare `init` with `.goga/` present); `` and `--upgrade` given together (mutually exclusive); `--ref` given without `` or `--upgrade`; copier scaffold/upgrade failure (bad template URL, git error, missing `.goga/scaffold.yml` on upgrade); or onboarding failure (a nonzero exit code from a delegate — `Scaffold.generate`/`Scaffold.upgrade`, `InitLogic.run` — is propagated verbatim). | +| `0` | Success — files generated (onboarding), template scaffolded, or migration applied. A failing tool hook never changes the exit code. | +| `1` | Error or user abort (`Ctrl+C`). Includes: project already initialized (bare `init` with `.goga/` present); `` and `--upgrade` given together (mutually exclusive); `-t/--tool` given with `--upgrade` (an invitation needs an onboarding session); `--ref` given without `` or `--upgrade`; copier scaffold/upgrade failure (bad template URL, git error, missing `.goga/scaffold.yml` on upgrade); or onboarding failure (a nonzero exit code from a delegate — `Scaffold.generate`/`Scaffold.upgrade`, `InitLogic.run` — is propagated verbatim). | diff --git a/docs/features/init/configuration.md b/docs/features/init/configuration.md index c1c629b9..28e653b1 100644 --- a/docs/features/init/configuration.md +++ b/docs/features/init/configuration.md @@ -1,5 +1,5 @@ # Init — Configuration -The init domain reads **no section of `.goga/config.yml`** — it **writes** the file: the questionnaire's answers become the initial `language`, `image`, `build`, and `codemanifest` values (and, optionally, a project Dockerfile). +The init domain reads **no section of `.goga/config.yml`** — it **writes** the file: the questionnaire's answers become the initial `language`, `image`, `build`, `pipeline`, `codemanifest`, `tools`, and `usages` values (and, optionally, a project Dockerfile plus the invited tools' configs under `.goga/tools//`). What each written field means afterwards is covered by the domains that read it — see [Project Configuration](../../configuration/project.md) and the per-domain [Configuration](../index.md#the-page-model) pages. diff --git a/docs/features/init/hooks.md b/docs/features/init/hooks.md index 83ded91f..748b1893 100644 --- a/docs/features/init/hooks.md +++ b/docs/features/init/hooks.md @@ -1,5 +1,33 @@ # Init — Hooks -The init domain exposes **no hook actions** for tool packages today. +The init domain exposes **two hook actions** for tool packages — the onboarding session participation: -Initialization is a one-time interactive flow over the goga assets; the tool packages enter the project afterwards — through [Install](../install/index.md) and [Connect](../connect/index.md). The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). +| Address | Error class | Fires | +|---|---|---| +| `onboarding / declare_session` | soft | Before the survey — the tool declares its questions and skip requests. | +| `onboarding / amend_config` | soft | After the survey — the tool amends the collected answers and contributes config files. | + +A tool reaches the session through an invitation: `goga init -t ` (repeatable). A subscribed tool that was **not** invited still receives both contexts, but with `invited=False` — the expected behavior is to return immediately and stay silent. An invited name that is not installed produces a warning; the session continues. + +## `declare_session` — the moment before the survey + +The hook receives a `ToolDeclaration` context: + +- `invited` — the invitation marker; check it first. +- `declare(item)` — buffer one `Question` or a one-level `QuestionGroup` (simple children only). The engine asks the buffered records itself after the delivery completes — **a hook is never called to survey**. Structural violations (a nested group, a non-record object) are refused with a warning; the element is not buffered. +- `skip(path)` — buffer one skip path: an unprefixed path addresses a core question (`"docker_image.base_image"`), a `.`-prefixed path addresses another tool's block. Unresolvable paths are a no-op with a warning. + +The buffered questions are asked after the core sections under a `--- Tool: ---` attribution heading; the answers nest under the tool's key. + +## `amend_config` — the moment after the survey + +The hook receives a `ToolContribution` context: + +- `invited` — the invitation marker. +- `answers` — an isolated read-only view of the collected answers: the core answers plus the tool's own under local names. +- `answer(id, value)` — amend an answer (e.g. `answer("tools", {...})`); amendments are committed via a recursive merge after the moment completes. +- `write_config(file, data)` — buffer one config file; the engine writes it under `.goga/tools//` — **a tool never writes its own config**. + +Delivery is staged per tool: a tool's whole contribution (amendments and files) commits only after all its hooks succeed. A failing hook is soft — the tool's contribution is discarded with a stderr warning naming the tool, the action, and the reason; the session continues and the exit code never changes because of a tool. + +The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md); the registration contract for tool authors in [Hooks — The registration contract](../hooks/hooks.md). diff --git a/docs/getting-started.md b/docs/getting-started.md index c7814a20..0f044d76 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -45,14 +45,16 @@ The wizard will prompt you for: 2. **Convention** -- Optionally download language-specific conventions from the goga-lang-conventions repository 3. **Codemanifest usages** -- Optional named practices (key-value pairs) for your project 4. **Codemanifest annotations** -- Optional free-text instructions for AI agents -5. **Agent** -- Confirm-gated (defaults to No). Decline to skip the build agent, or accept and choose an agent — `claude`, `codex`, `cursor`, `opencode`, or `qwen` -6. **Custom Dockerfile** -- Optionally create a custom Dockerfile (suggested path `.goga/Dockerfile`). This decision drives the next step: image semantics differ between the two branches. -7. **Docker image** (depends on step 6): - - **If you create a Dockerfile**, the image is **built from it**, so you provide two values: the **base image** for the `FROM` line (chosen from the language-specific list), and a **built image name/tag** (what `goga build` tags with `docker build -t`). The built image name defaults to `:latest`, where `` is derived from your git `origin` remote URL; when no git remote is available, no default is offered and the name is required. +5. **Build agent and environment** -- Confirm-gated (defaults to No). Decline to skip the build agent, or accept and choose an agent — `claude`, `codex`, `cursor`, `opencode`, or `qwen` — then set its env vars (agent-specific keys suggested first) +6. **Docker image** -- Choose whether to create a custom Dockerfile (suggested path `.goga/Dockerfile`): + - **If you create a Dockerfile**, the image is **built from it**, so you provide the **base image** for the `FROM` line (chosen from the language-specific list), and a **built image name/tag** (what `goga build` tags with `docker build -t`). The built image name defaults to `:latest`, where `` is derived from your git `origin` remote URL; when no git remote is available, no default is offered and the name is required. - **If you skip the Dockerfile**, you pick a **pre-built image to pull** from the language-specific list (or enter a custom one). -8. **Environment variables** -- Set agent-specific env vars (e.g., `ANTHROPIC_API_KEY`) -9. **Pipeline agent** -- Confirm-gated (defaults to No). Decline to skip the pipeline agent, or accept and choose an agent — `claude`, `codex`, `cursor`, `opencode`, or `qwen`. Does not inherit the build agent from step 5 — the two are collected independently -10. **Pipeline environment variables** -- Set env vars for the pipeline container (e.g., `ANTHROPIC_API_KEY`) +7. **Pipeline agent and environment** -- Confirm-gated (defaults to No). Decline to skip the pipeline agent, or accept and choose an agent — `claude`, `codex`, `cursor`, `opencode`, or `qwen` — then set its env vars. Does not inherit the build agent — the two are collected independently +8. **Tools** -- Confirm-gated (defaults to No). Record `name → version` pairs in the config's top-level `tools` list (consumed by `goga install` bulk mode) +9. **Usages records** -- Confirm-gated (defaults to No). Record git dependencies (group, name, git URL, optional ref/root) in the config's top-level `usages` tree (consumed by `goga usages sync`) +10. **Tool blocks** -- Only with invited tools: the questions each invited tool declared, asked under its own heading + +Invited tools are configured at initialization time with `goga init -t ` (repeatable) — the tool contributes its own questions and its config files land under `.goga/tools//`. ### What `goga init` creates @@ -62,6 +64,8 @@ The wizard will prompt you for: usages/ conventions.md # Language conventions (if downloaded) Dockerfile # Optional, if you chose to create one (default location) + tools/ + / # Tool configs (per invited tool, goga init -t) ``` ### Starting from a template (optional) diff --git a/goga/commands/pipeline/file_roots.py b/goga/commands/pipeline/file_roots.py index 91c6378d..ab750d1c 100644 --- a/goga/commands/pipeline/file_roots.py +++ b/goga/commands/pipeline/file_roots.py @@ -151,13 +151,16 @@ def _root_id_for(container: str, taken_ids: set[str]) -> str: """Derive a list-unique id for an extra root from its container path. The base id strips the leading ``/`` and maps every remaining ``/`` to - ``-`` (``/home/goga/data`` → ``home-goga-data``). On collision — with the - reserved ``"project"`` id or with an id already taken by an earlier root — - the id is suffixed with ``-`` plus the first 8 hex characters of the - sha256 of the EXACT container path, so the suffix depends only on the path - itself while the need for it depends on token order. Distinct container - paths never collide in the list, so same-base paths always receive - distinct suffixes. + ``-`` (``/home/goga/data`` → ``home-goga-data``); the degenerate mount + point ``/`` strips to the empty string, which would violate the payload + contract, so it maps to ``"root"`` (colliding naturally with a later + ``/root`` mount through the suffix rule). On collision — with the + reserved ``"project"`` id or with an id already taken by an earlier + root — the id is suffixed with ``-`` plus the first 8 hex characters of + the sha256 of the EXACT container path, so the suffix depends only on + the path itself while the need for it depends on token order. Distinct + container paths never collide in the list, so same-base paths always + receive distinct suffixes. Args: container: The in-container mount point (unique within a launch). @@ -167,7 +170,7 @@ def _root_id_for(container: str, taken_ids: set[str]) -> str: Returns: The id for the new extra root; the caller adds it to ``taken_ids``. """ - base = container.lstrip("/").replace("/", "-") + base = container.lstrip("/").replace("/", "-") or "root" if base == "project" or base in taken_ids: suffix = hashlib.sha256(container.encode("utf-8")).hexdigest()[:8] return f"{base}-{suffix}" diff --git a/goga/onboarding/participation/declaration.py b/goga/onboarding/participation/declaration.py index 1010cca9..7e6b655c 100644 --- a/goga/onboarding/participation/declaration.py +++ b/goga/onboarding/participation/declaration.py @@ -50,9 +50,10 @@ class ToolDeclaration: skips: The declared skip paths, in declaration order. Requirements: - a group of a tool is limited to one nesting level with simple - children — a violation is refused with a warning naming the tool and - the reason, never an exception, and the element is not buffered. + only a ``Question`` record or a one-level ``QuestionGroup`` is + buffered — any other object, and a group whose children contain a + nested group, is refused with a warning naming the tool and the + reason, never an exception, and the element is not buffered. """ tool: str @@ -66,6 +67,14 @@ def declare(self, item: Question | QuestionGroup) -> None: Args: item: The question record or the one-level group. """ + if not isinstance(item, (Question, QuestionGroup)): + logger.warning( + "rejected the declared element of tool %s: %s", + self.tool, + f"only a Question record or a one-level QuestionGroup can be declared, got {type(item).__name__}", + ) + return + if isinstance(item, QuestionGroup) and _has_nested_group(item): logger.warning( "rejected declared group %s of tool %s: %s", diff --git a/goga/onboarding/survey/questionnaire.py b/goga/onboarding/survey/questionnaire.py index aa76f5f6..26653cc2 100644 --- a/goga/onboarding/survey/questionnaire.py +++ b/goga/onboarding/survey/questionnaire.py @@ -14,7 +14,7 @@ import click from ..questions import Question, QuestionGroup, SessionAnswers -from .core import agent_env_defaults +from .core import agent_env_defaults, image_defaults from .plan import SessionPlan logger = logging.getLogger(__name__) @@ -47,6 +47,36 @@ def _hint_lines(prompt: str) -> list[str]: return hints +def _language_hints(base_image: Question, language: str | None) -> tuple[list[str], str | None]: + """Filter the hint lines of the ``base_image`` prompt by the selected language. + + The core tree embeds the completed hints of every language family; the + engine renders the family of the selected language with its last entry + as the offered default (the ``image_defaults`` practice — the hints + depend on the selected language). An absent or unknown language falls + back to every hint with the tree default. + + Args: + base_image: The base image question carrying the hint lines of the + tree. + language: The recorded language answer; None when the language + question was never asked. + + Returns: + The hint lines to render and the offered default. + """ + hints = _hint_lines(base_image.prompt) + if language is None: + return hints, base_image.default + + names = set(image_defaults.get(language, [])) + family = [hint for hint in hints if hint.rsplit(":", 1)[0] in names] + if not family: + return hints, base_image.default + + return family, family[-1] + + class Questionnaire: """The interactive survey engine of the session. @@ -242,23 +272,25 @@ def _survey_core_section(self, section: Question | QuestionGroup, state: dict) - Args: section: The core section — a question or a group. - state: The per-run survey state carrying the codemanifest - prefill of the base-convention gate. + state: The per-run survey state carrying the recorded language + and the codemanifest prefill of the base-convention gate. """ if isinstance(section, QuestionGroup) and section.prompt is not None: click.echo(f"\n{section.prompt}") simple = { - "language": self._survey_language, "build": self._survey_build, - "docker_image": self._survey_docker_image, "pipeline": self._survey_pipeline, "tools": self._survey_tools, } - if section.id == "convention": + if section.id == "language": + self._survey_language(section, state) + elif section.id == "convention": self._survey_convention(section, state) elif section.id == "codemanifest": self._survey_codemanifest(section, state) + elif section.id == "docker_image": + self._survey_docker_image(section, state) elif section.id == "usages": self._survey_usages() elif (handler := simple.get(section.id)) is not None: @@ -270,9 +302,15 @@ def _survey_core_section(self, section: Question | QuestionGroup, state: dict) - if value is not None: self._record(section.id, value) - def _survey_language(self, section: Question) -> None: - """Survey the language choice — the first question of every session.""" - self._record("language", self.ask_question(section)) + def _survey_language(self, section: Question, state: dict) -> None: + """Survey the language choice — the first question of every session. + + The recorded language drives the image hint family of the docker + image section. + """ + language = self.ask_question(section) + self._record("language", language) + state["language"] = language def _survey_convention(self, section: QuestionGroup, state: dict) -> None: """Survey the base-convention gate. @@ -288,11 +326,8 @@ def _survey_convention(self, section: QuestionGroup, state: dict) -> None: accepted = bool(self.ask_question(adopt)) if adopt is not None else False if accepted: - state["codemanifest_usages"] = dict(_CONVENTION_USAGES_PREFILL) + state["codemanifest_usages"] = _CONVENTION_USAGES_PREFILL state["codemanifest_annotations"] = _CONVENTION_ANNOTATIONS_PREFILL - else: - state["codemanifest_usages"] = None - state["codemanifest_annotations"] = None def _survey_codemanifest(self, section: QuestionGroup, state: dict) -> None: """Survey the codemanifest entries — the usages pairs, then the annotations input. @@ -414,7 +449,7 @@ def _survey_executor(self, section: QuestionGroup, section_id: str) -> None: if env: self._record(f"{section_id}.env", env) - def _survey_docker_image(self, section: QuestionGroup) -> None: + def _survey_docker_image(self, section: QuestionGroup, state: dict) -> None: """Survey the docker image section through the Dockerfile decision. A skipped ``dockerfile`` question collapses the gate — the pull @@ -422,42 +457,61 @@ def _survey_docker_image(self, section: QuestionGroup) -> None: path, the base image of the FROM (only when present), and the built-image name; rejection pulls a pre-built image instead. A skipped ``base_image`` collapses the FROM — never asked, never - recorded. + recorded. The rendered hints and the offered default follow the + family of the recorded language (the ``image_defaults`` practice). Args: section: The docker image section — dockerfile, base_image, image. + state: The per-run survey state carrying the recorded language. """ children = {child.id: child for child in section.children or []} + language = state.get("language") if "dockerfile" not in children: - self._ask_pull_image(children) + self._ask_pull_image(children, language) return if not click.confirm("Create Dockerfile?", default=False): - self._ask_pull_image(children) + self._ask_pull_image(children, language) return self._record("docker_image.dockerfile", self.ask_question(children["dockerfile"])) base_image = children.get("base_image") if base_image is not None: - self._record("docker_image.base_image", self.ask_question(base_image)) + hints, default = _language_hints(base_image, language) + self._render_hints(hints) + self._record("docker_image.base_image", click.prompt("Base image (FROM)", default=default)) image = children.get("image") if image is not None: self._record("docker_image.image", self.ask_question(image)) - def _ask_pull_image(self, children: dict) -> None: + def _render_hints(self, hints: list[str]) -> None: + """Echo the hint lines of one image ask — the family of the selected language. + + Args: + hints: The hint lines to render; empty renders nothing. + """ + if hints: + click.echo("Available images:") + for hint in hints: + click.echo(f" - {hint}") + + def _ask_pull_image(self, children: dict, language: str | None) -> None: """Ask the pre-built image to pull — the no-Dockerfile branch. The hints of the ``base_image`` record are rendered when the tree - carries them (their last entry is the offered default); without - them the ask is plain free-form. + carries them, filtered to the family of the selected language (its + last entry is the offered default); without them the ask is plain + free-form. Args: children: The children of the post-skip docker image section, keyed by local name. + language: The recorded language answer; None renders every + hint of the tree. """ image = children.get("image") if image is None: @@ -465,12 +519,9 @@ def _ask_pull_image(self, children: dict) -> None: base_image = children.get("base_image") if base_image is not None: - hints = _hint_lines(base_image.prompt) - if hints: - click.echo("Available images:") - for hint in hints: - click.echo(f" - {hint}") - value = click.prompt("Docker image", default=base_image.default) + hints, default = _language_hints(base_image, language) + self._render_hints(hints) + value = click.prompt("Docker image", default=default) else: value = click.prompt("Docker image", default=image.default) diff --git a/tests/commands/pipeline/test_file_roots.py b/tests/commands/pipeline/test_file_roots.py index 30d153b7..6961975e 100644 --- a/tests/commands/pipeline/test_file_roots.py +++ b/tests/commands/pipeline/test_file_roots.py @@ -239,6 +239,29 @@ def test_collect_file_roots_id_never_collides_with_project(self, tmp_path: Path) assert roots[1].id == "project-ea0135bc" assert roots[1].id != "project" + def test_collect_file_roots_root_mount_point_maps_to_a_valid_id(self, tmp_path: Path) -> None: + """A container path of `/` never yields the empty id — it maps to `root`.""" + (tmp_path / "all").mkdir() + + roots = collect_file_roots(["-v", f"{tmp_path}/all:/"]) + + assert [r.id for r in roots] == ["project", "root"] + assert roots[1].container_path == "/" + + def test_collect_file_roots_root_id_collides_with_a_root_subpath_mount(self, tmp_path: Path) -> None: + """/ and /root both map to the `root` base — the second gets the sha256 suffix.""" + (tmp_path / "all").mkdir() + (tmp_path / "home").mkdir() + + roots = collect_file_roots( + [ + "-v", f"{tmp_path}/all:/", + "-v", f"{tmp_path}/home:/root", + ] + ) + + assert [r.id for r in roots] == ["project", "root", "root-94a6b447"] + def test_collect_file_roots_id_unique_on_sanitization_collision(self, tmp_path: Path) -> None: """Two container paths sanitizing to the same base get distinct ids (second gets the sha256 suffix).""" (tmp_path / "goga" / "data").mkdir(parents=True) diff --git a/tests/commands/pipeline/test_run_pipeline_container.py b/tests/commands/pipeline/test_run_pipeline_container.py index 3e05653c..0aba8b81 100644 --- a/tests/commands/pipeline/test_run_pipeline_container.py +++ b/tests/commands/pipeline/test_run_pipeline_container.py @@ -471,6 +471,40 @@ def capture(env: dict[str, str], extra_env: tuple[str, ...] = ()) -> Path: assert payload["roots"][0]["container_path"] == "/workspace" assert payload["roots"][1]["container_path"] == "/home/goga/data" + def test_env_file_writes_file_roots_even_without_mounts(self, tmp_path: Path, monkeypatch) -> None: + """The roots layer is written on EVERY run launch — no mounts leaves the project-only list.""" + config = _make_config() + monkeypatch.setattr(_rpc_mod, "_check_docker", lambda: True) + monkeypatch.setattr(_rpc_mod, "_allocate_port", lambda: 50321) + monkeypatch.setattr(_rpc_mod, "_read_git_config", lambda: {}) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + _rpc_mod, + "load_home_config", + lambda: HomeConfig(env={}, docker=DockerArgsConfig(run=[])), + ) + + captured_env: dict[str, str] = {} + real_write = _rpc_mod._write_env_file + + def capture(env: dict[str, str], extra_env: tuple[str, ...] = ()) -> Path: + captured_env.update(env) + return real_write(env, extra_env) + + monkeypatch.setattr(_rpc_mod, "_write_env_file", capture) + + mock_proc = mock.Mock() + mock_proc.wait.return_value = 0 + with ( + mock.patch.object(subprocess, "Popen", return_value=mock_proc), + mock.patch.object(subprocess, "run"), + ): + run_pipeline_container("deploy", config) + + assert "AFM_DOCKER_FILE_ROOTS" in captured_env + payload = json.loads(base64.b64decode(captured_env["AFM_DOCKER_FILE_ROOTS"])) + assert [root["container_path"] for root in payload["roots"]] == ["/workspace"] + def test_extra_env_file_roots_override_wins(self, tmp_path: Path, monkeypatch) -> None: """A raw -e AFM_DOCKER_FILE_ROOTS line is written after the launcher line (last-write-wins).""" (tmp_path / "data").mkdir() diff --git a/tests/onboarding/generator/test_generator.py b/tests/onboarding/generator/test_generator.py index dadec330..20035e53 100644 --- a/tests/onboarding/generator/test_generator.py +++ b/tests/onboarding/generator/test_generator.py @@ -20,18 +20,18 @@ class TestContract: """Contract-level tests for the generator cell facade.""" def test_file_generator_and_created_file_importable_from_facade(self) -> None: - from goga.onboarding.generator import CreatedFile, FileGenerator + import goga.onboarding.generator as cell - assert FileGenerator is not None - assert CreatedFile is not None + assert cell.FileGenerator is FileGenerator + assert cell.CreatedFile is CreatedFile def test_facade_all_lists_both_names(self) -> None: import goga.onboarding.generator as facade - assert {"CreatedFile", "FileGenerator"} <= set(facade.__all__) + assert facade.__all__ == ["CreatedFile", "FileGenerator"] def test_file_generator_constructs_with_no_arguments(self) -> None: - assert FileGenerator() is not None + assert isinstance(FileGenerator(), FileGenerator) def test_created_file_exposes_both_fields(self) -> None: record = CreatedFile(path="p", tool=None) @@ -177,6 +177,21 @@ def test_generate_maps_the_whole_snapshot_in_field_order(self) -> None: assert cfg["tools"] == {"my-tool": "latest"} assert cfg["usages"] == {"cell": {"dep": {"git": "https://example.com/repo.git", "ref": "main"}}} + def test_annotations_without_usages_still_emit_the_codemanifest_block(self) -> None: + """An annotations-only codemanifest section is emitted — no usages needed.""" + answers = SessionAnswers() + answers.record("language", "python") + answers.record("codemanifest", {"annotations": "Keep it small."}) + + FileGenerator().generate(answers, []) + + text = Path(".goga/config.yml").read_text(encoding="utf-8") + cfg = yaml.safe_load(text) + + assert list(cfg.keys()) == ["language", "codemanifest"] + assert cfg["codemanifest"] == {"annotations": "Keep it small.\n"} + assert "annotations: |" in text + def test_conventions_download_writes_conventions_md_between_dockerfile_and_config( self, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/onboarding/participation/test_declaration.py b/tests/onboarding/participation/test_declaration.py index e0f606a4..8c3d236a 100644 --- a/tests/onboarding/participation/test_declaration.py +++ b/tests/onboarding/participation/test_declaration.py @@ -91,6 +91,27 @@ def test_declare_rejects_nested_group_with_warning(self, caplog: pytest.LogCaptu assert any("one nesting level" in record.message for record in caplog.records) assert any("t" in record.message for record in caplog.records) + def test_declare_rejects_a_non_record_with_warning(self, caplog: pytest.LogCaptureFixture) -> None: + """An object that is neither Question nor QuestionGroup is never buffered.""" + surface = ToolDeclaration(tool="t", invited=True) + + with caplog.at_level(logging.WARNING): + surface.declare("not a record") # type: ignore[arg-type] + + assert surface.questions == [] + assert any("only a Question record or a one-level QuestionGroup" in record.message for record in caplog.records) + assert any("t" in record.message for record in caplog.records) + + def test_a_refused_non_record_does_not_stop_the_declaration(self) -> None: + """The refused element is dropped; the following declarations stand.""" + surface = ToolDeclaration(tool="t", invited=True) + token = Question(id="token", kind="input", prompt="Token") + + surface.declare({"id": "token"}) # type: ignore[arg-type] + surface.declare(token) + + assert surface.questions == [token] + def test_declare_never_raises(self) -> None: """Structural violations are warnings, never exceptions.""" surface = ToolDeclaration(tool="t", invited=False) diff --git a/tests/onboarding/participation/test_participation.py b/tests/onboarding/participation/test_participation.py index cdd6c711..87cdb332 100644 --- a/tests/onboarding/participation/test_participation.py +++ b/tests/onboarding/participation/test_participation.py @@ -286,9 +286,9 @@ def test_the_registry_is_built_once_and_shared_by_both_moments( ) -> None: """Both moments read one registry — moment two reads the environment no more. - Moment one reads the enumeration twice — the registry build and the - uninstalled-invited check; a moment two that rebuilt the registry - would read it again. + Moment one reads the enumeration as often as it needs (the registry + build plus the uninstalled-invited check); a moment two that rebuilt + the registry would read it again. """ def declare_session(context: Any) -> None: @@ -311,10 +311,10 @@ def amend_config(context: Any) -> None: answers = SessionAnswers() mediator.collect_declarations() - assert boundary.call_count == 2 # the registry build + the invited check + reads_after_moment_one = boundary.call_count mediator.collect_contributions(answers) - assert boundary.call_count == 2 # the shared registry — no rebuild + assert boundary.call_count == reads_after_moment_one # the shared registry — no rebuild def test_a_broken_package_import_is_fatal( self, diff --git a/tests/onboarding/survey/test_plan.py b/tests/onboarding/survey/test_plan.py index b5171218..0c236f70 100644 --- a/tests/onboarding/survey/test_plan.py +++ b/tests/onboarding/survey/test_plan.py @@ -278,6 +278,39 @@ def test_a_path_into_a_pairs_question_is_a_noop_warning( assert _child_ids(pruned.root) == ["language", "tools"] assert any("tools.goga-lint" in record.message for record in caplog.records) + def test_a_skip_from_a_tool_without_a_block_is_a_noop_warning( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A tool that declared no questions carries no own block — no local resolves.""" + plan = assemble_session_plan( + _core(Question(id="language", kind="choice", prompt="Language")), + [_declaration("silent-tool"), _declaration("viewer", Question(id="opt", kind="confirm", prompt="Opt in"))], + ) + + with caplog.at_level(logging.WARNING): + pruned = apply_skips(plan, [("silent-tool", "opt")]) + + assert _child_ids(pruned.root) == ["language", "viewer"] + assert _child_ids(_block(pruned.root, "viewer")) == ["opt"] + assert any("opt" in record.message for record in caplog.records) + assert any("own-block element" in record.message for record in caplog.records) + + def test_a_resolved_path_reaching_no_node_is_a_noop_warning( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A well-prefixed own-block path whose deeper segments miss warns and stands.""" + plan = self._plan() + + with caplog.at_level(logging.WARNING): + pruned = apply_skips(plan, [("my-tool", "reporting.nonexistent")]) + + assert _child_ids(pruned.root) == ["language", "build", "my-tool", "viewer"] + assert _child_ids(_block(pruned.root, "my-tool")) == ["reporting"] + assert any("reporting.nonexistent" in record.message for record in caplog.records) + assert any("reaches no node" in record.message for record in caplog.records) + def test_a_descendant_of_a_skipped_node_is_absorbed_silently( self, caplog: pytest.LogCaptureFixture, diff --git a/tests/onboarding/survey/test_questionnaire.py b/tests/onboarding/survey/test_questionnaire.py index 09ac4358..9a8d76cc 100644 --- a/tests/onboarding/survey/test_questionnaire.py +++ b/tests/onboarding/survey/test_questionnaire.py @@ -29,9 +29,11 @@ _CELL_ALL = ["Questionnaire", "SessionPlan", "apply_skips", "assemble_session_plan", "core_questions"] -# The last completed hint of the `image_defaults` families (python, golang, -# javascript, kotlin, swift) — the offered default of every image ask. -_LAST_HINT_1_3 = "qarium/goga-swift-6.2:1.3" +# The last completed hint of the python family of `image_defaults` — the +# offered default of every image ask after the python language choice (the +# hints follow the selected language; a language never asked falls back to +# the last hint of every family, the swift one). +_PYTHON_LAST_HINT_1_3 = "qarium/goga-python-3.14:1.3" def _declaration(tool: str, *items: Question | QuestionGroup) -> ToolDeclaration: @@ -255,7 +257,7 @@ def test_convention_acceptance_prefills_codemanifest(self) -> None: "usages": {"conventions": ".goga/usages/conventions.md"}, "annotations": "Use `conventions` for code writing rules and testing.", }, - "docker_image": {"image": _LAST_HINT_1_3}, + "docker_image": {"image": _PYTHON_LAST_HINT_1_3}, } # The gate itself is presentational — never a recorded section. assert "convention" not in answers.snapshot() @@ -311,7 +313,7 @@ def test_existing_convention_drops_the_gate(self) -> None: assert result.exit_code == 0 assert "Download base convention" not in result.output - assert answers.snapshot() == {"language": "python", "docker_image": {"image": _LAST_HINT_1_3}} + assert answers.snapshot() == {"language": "python", "docker_image": {"image": _PYTHON_LAST_HINT_1_3}} def test_duplicate_usage_name_is_skipped_with_a_note(self) -> None: """A repeated usage name is skipped; the collection continues (ported).""" @@ -406,7 +408,7 @@ def test_declining_the_agent_gates_records_nothing(self) -> None: ) assert result.exit_code == 0 - assert answers.snapshot() == {"language": "python", "docker_image": {"image": _LAST_HINT_1_3}} + assert answers.snapshot() == {"language": "python", "docker_image": {"image": _PYTHON_LAST_HINT_1_3}} def test_dockerfile_branch_records_path_from_and_built_name(self) -> None: """Accepting the Dockerfile gate asks path, FROM base, and built name (ported).""" @@ -433,7 +435,7 @@ def test_dockerfile_branch_records_path_from_and_built_name(self) -> None: assert result.exit_code == 0 assert answers.snapshot()["docker_image"] == { "dockerfile": ".goga/Dockerfile", - "base_image": _LAST_HINT_1_3, + "base_image": _PYTHON_LAST_HINT_1_3, "image": "my-app:latest", } assert "Base image (FROM)" in result.output @@ -487,7 +489,7 @@ def test_a_skipped_dockerfile_runs_the_pull_branch_without_the_gate(self) -> Non ) assert result.exit_code == 0 - assert answers.snapshot()["docker_image"] == {"image": _LAST_HINT_1_3} + assert answers.snapshot()["docker_image"] == {"image": _PYTHON_LAST_HINT_1_3} assert "Create Dockerfile?" not in result.output assert "Available images:" in result.output @@ -559,3 +561,273 @@ def test_usages_record_loop_accumulates_nested_records(self) -> None: "goga-viewer": {"git": "https://github.com/qarium/goga-viewer", "ref": "0.1.0"}, } } + + def test_custom_annotations_append_to_the_prefill(self) -> None: + """Accepting the annotations collection appends to the convention prefill.""" + answers = SessionAnswers() + + result = _run_survey( + _full_plan(convention_exists=False), + answers, + [ + "python", # Language + "y", # Download base convention + "n", # Add codemanifest usages? + "y", # Add codemanifest annotations? + "Keep it small.", # the custom annotation + "n", # Configure a build agent? + "n", # Create Dockerfile? + "", # Docker image → the last hint default + "n", # Configure a pipeline agent? + "n", # Add tools? + "n", # Add usages records? + ], + ) + + assert result.exit_code == 0 + assert answers.snapshot()["codemanifest"]["annotations"] == ( + "Use `conventions` for code writing rules and testing.\nKeep it small." + ) + + def test_custom_annotations_without_prefill_stand_alone(self) -> None: + """A declined gate leaves no prefill — the custom annotation stands alone.""" + answers = SessionAnswers() + + result = _run_survey( + _full_plan(convention_exists=False), + answers, + [ + "python", # Language + "n", # Download base convention + "n", # Add codemanifest usages? + "y", # Add codemanifest annotations? + "Keep it small.", # the custom annotation + "n", # Configure a build agent? + "n", # Create Dockerfile? + "", # Docker image → the last hint default + "n", # Configure a pipeline agent? + "n", # Add tools? + "n", # Add usages records? + ], + ) + + assert result.exit_code == 0 + assert answers.snapshot()["codemanifest"]["annotations"] == "Keep it small." + + def test_usages_collection_without_prefill_starts_empty(self) -> None: + """A declined gate leaves no prefill — the collected usages stand alone.""" + answers = SessionAnswers() + + result = _run_survey( + _full_plan(convention_exists=False), + answers, + [ + "python", # Language + "n", # Download base convention + "y", # Add codemanifest usages? + "docs", # usage name + ".goga/usages/docs.md", # usage value + "n", # Add another codemanifest usage? + "n", # Add codemanifest annotations? + "n", # Configure a build agent? + "n", # Create Dockerfile? + "", # Docker image → the last hint default + "n", # Configure a pipeline agent? + "n", # Add tools? + "n", # Add usages records? + ], + ) + + assert result.exit_code == 0 + assert answers.snapshot()["codemanifest"]["usages"] == {"docs": ".goga/usages/docs.md"} + + def test_usages_record_with_a_root_entry(self) -> None: + """A non-empty Root lands in the record; the optional entry is kept.""" + answers = SessionAnswers() + + result = _run_survey( + _full_plan(convention_exists=True), + answers, + [ + "python", # Language + "n", # Add codemanifest usages? + "n", # Add codemanifest annotations? + "n", # Configure a build agent? + "n", # Create Dockerfile? + "", # Docker image → the last hint default + "n", # Configure a pipeline agent? + "n", # Add tools? + "y", # Add usages records? + "goga/hooks", # Usage group + "goga-lint", # Dependency name + "https://github.com/qarium/goga-lint", # Git URL + "0.1.0", # Ref (optional) + "src", # Root (optional) + "n", # Add another usage record? + ], + ) + + assert result.exit_code == 0 + assert answers.snapshot()["usages"] == { + "goga/hooks": { + "goga-lint": { + "git": "https://github.com/qarium/goga-lint", + "ref": "0.1.0", + "root": "src", + } + } + } + + def test_image_hints_and_default_follow_the_selected_language(self) -> None: + """The rendered hints are the selected language's family; its last entry defaults.""" + answers = SessionAnswers() + + result = _run_survey( + _full_plan(convention_exists=True), + answers, + [ + "golang", # Language + "n", # Add codemanifest usages? + "n", # Add codemanifest annotations? + "n", # Configure a build agent? + "n", # Create Dockerfile? + "", # Docker image → the golang family default + "n", # Configure a pipeline agent? + "n", # Add tools? + "n", # Add usages records? + ], + ) + + assert result.exit_code == 0 + assert answers.snapshot()["docker_image"]["image"] == "qarium/goga-golang-1.26:1.3" + assert "qarium/goga-golang-1.26:1.3" in result.output + assert "goga-python" not in result.output + assert "goga-swift" not in result.output + + def test_a_language_never_asked_falls_back_to_every_hint(self) -> None: + """A skipped language question offers the tree default — every family's last hint.""" + plan = apply_skips(_full_plan(convention_exists=True), [("skipper", "language")]) + answers = SessionAnswers() + + result = _run_survey( + plan, + answers, + [ + "n", # Add codemanifest usages? + "n", # Add codemanifest annotations? + "n", # Configure a build agent? + "n", # Create Dockerfile? + "", # Docker image → the tree default (the swift family's last) + "n", # Configure a pipeline agent? + "n", # Add tools? + "n", # Add usages records? + ], + ) + + assert result.exit_code == 0 + assert answers.snapshot()["docker_image"]["image"] == "qarium/goga-swift-6.2:1.3" + + +class TestToolBlockPatterns: + def test_a_tool_pairs_question_offers_its_suggested_keys(self) -> None: + """A pairs record carrying keys renders the suggested-keys offer first.""" + plan = _minimal_plan( + _declaration("my-tool", Question(id="kv", kind="pairs", prompt="Service keys", keys=["API_KEY", "MODEL"])) + ) + answers = SessionAnswers(tools=["my-tool"]) + + result = _run_survey(plan, answers, ["python", "y", "secret", "gpt-4", "n"]) + + assert result.exit_code == 0 + assert answers.snapshot()["my-tool"]["kv"] == {"API_KEY": "secret", "MODEL": "gpt-4"} + assert "Suggested keys:" in result.output + assert "API_KEY" in result.output + + +# --- Logic tests — a tool-declared skip of a core child collapses the branch --- + + +class TestSkippedCoreChildren: + """Every core child skip target collapses its ask — the sibling path stands. + + A child absent from the post-skip section is never asked; the remaining + children of the section still are, and only the asked children record. + """ + + @pytest.mark.parametrize( + ("skips", "inputs", "absent_prompt", "expected_snapshot"), + [ + pytest.param( + [("skipper", "build.agent")], + ["python", "n", "n", "y", "n", "n", "", "n", "n", "n"], + "Build agent", + {"language": "python", "docker_image": {"image": _PYTHON_LAST_HINT_1_3}}, + id="build-agent", + ), + pytest.param( + [("skipper", "build.env")], + ["python", "n", "n", "y", "claude", "n", "", "n", "n", "n"], + "Build environment variables", + { + "language": "python", + "build": {"agent": "claude"}, + "docker_image": {"image": _PYTHON_LAST_HINT_1_3}, + }, + id="build-env", + ), + pytest.param( + [("skipper", "codemanifest.usages")], + ["python", "n", "n", "n", "", "n", "n", "n"], + "Add codemanifest usages?", + {"language": "python", "docker_image": {"image": _PYTHON_LAST_HINT_1_3}}, + id="codemanifest-usages", + ), + pytest.param( + [("skipper", "codemanifest.annotations")], + ["python", "n", "n", "n", "", "n", "n", "n"], + "Add codemanifest annotations?", + {"language": "python", "docker_image": {"image": _PYTHON_LAST_HINT_1_3}}, + id="codemanifest-annotations", + ), + pytest.param( + [("skipper", "docker_image.image")], + ["python", "n", "n", "n", "y", "", "", "n", "n", "n"], + "Built image name", + { + "language": "python", + "docker_image": {"dockerfile": ".goga/Dockerfile", "base_image": _PYTHON_LAST_HINT_1_3}, + }, + id="docker-image-dockerfile-branch", + ), + pytest.param( + [("skipper", "docker_image.image")], + ["python", "n", "n", "n", "n", "n", "n", "n"], + "Docker image", + {"language": "python"}, + id="docker-image-pull-branch", + ), + pytest.param( + [("skipper", "docker_image.base_image")], + ["python", "n", "n", "n", "n", "", "n", "n", "n"], + "Available images:", + {"language": "python", "docker_image": {"image": "my-app:latest"}}, + id="base-image-pull-branch", + ), + ], + ) + def test_a_skipped_child_is_never_asked( + self, + skips: list[tuple[str, str]], + inputs: list[str], + absent_prompt: str, + expected_snapshot: dict, + ) -> None: + """The absent child is neither asked nor recorded; the snapshot matches.""" + plan = apply_skips(_full_plan(convention_exists=True), skips) + answers = SessionAnswers() + + result = _run_survey(plan, answers, inputs) + + assert result.exit_code == 0, result.output + assert absent_prompt not in result.output + assert answers.snapshot() == expected_snapshot diff --git a/tests/onboarding/test_integration.py b/tests/onboarding/test_integration.py index edf035ad..6f1b15fa 100644 --- a/tests/onboarding/test_integration.py +++ b/tests/onboarding/test_integration.py @@ -129,6 +129,9 @@ def test_init_full_session_with_invited_tool(self, install_tool: _InstallTool) - cfg = yaml.safe_load(Path(".goga/config.yml").read_text(encoding="utf-8")) assert cfg["language"] == "python" assert cfg["tools"] == {"my-tool": "latest"} + # The offered image default follows the selected language's family + # (the tag tracks the installed goga minor line). + assert cfg["image"].startswith("qarium/goga-python-3.14:") assert Path(".goga/tools/my-tool/service.yml").exists() assert "(tool: my-tool)" in result.output From beb337b578a0ab68518a347e1f0102e8607d616e Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 22:49:19 +0000 Subject: [PATCH 030/205] fix: address code review findings --- goga/onboarding/generator/generator.py | 47 ++++++++++++-- tests/onboarding/generator/test_generator.py | 67 ++++++++++++++++++++ tests/onboarding/test_integration.py | 37 +++++++++++ 3 files changed, 147 insertions(+), 4 deletions(-) diff --git a/goga/onboarding/generator/generator.py b/goga/onboarding/generator/generator.py index 527d4160..d20a1ebf 100644 --- a/goga/onboarding/generator/generator.py +++ b/goga/onboarding/generator/generator.py @@ -179,9 +179,32 @@ def _build_config_document(snapshot: dict) -> dict: return data +def _contained_file_name(file: str) -> bool: + """Check whether a buffered file name stays inside the tool config directory. + + Args: + file: The buffered file name of one config file. + + Returns: + True when the name is a non-empty relative path without ``..`` + segments — a name the write path can join under the tool's own + directory without escaping it. + """ + return ( + isinstance(file, str) + and bool(file) + and not Path(file).is_absolute() + and ".." not in Path(file).parts + ) + + def _write_tool_configs(contributions: list[ToolContribution]) -> list[CreatedFile]: """Write every buffered tool config file and collect the report entries. + A buffered name that leaves the tool's config directory, and a file whose + write or serialization fails, is dropped with a warning naming the tool — + a tool failure never fails the session. + Args: contributions: The committed contributions, in enumeration order. @@ -195,11 +218,24 @@ def _write_tool_configs(contributions: list[ToolContribution]) -> list[CreatedFi tool_dir = Path(".goga") / "tools" / contribution.tool for file, data in contribution.files: - tool_dir.mkdir(parents=True, exist_ok=True) + if not _contained_file_name(file): + logger.warning( + "rejected the config file of tool %s: %s", + contribution.tool, + f"the file name must stay inside the tool's config directory, got {file!r}", + ) + continue + path = tool_dir / file - with path.open("w", encoding="utf-8") as f: - yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False) + try: + text = yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False) + tool_dir.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: + f.write(text) + except Exception as reason: + logger.warning("the config file %s of tool %s is not written: %s", file, contribution.tool, reason) + continue files.append(CreatedFile(path=str(path), tool=contribution.tool)) @@ -319,7 +355,10 @@ def generate_tool_configs(self, contributions: list[ToolContribution]) -> None: """Generate the tool config files from the committed contributions. The buffered data is written verbatim, without interpretation — the - engine is the single write path of the tool configs. + engine is the single write path of the tool configs. A buffered name + that leaves the tool's config directory, and a file whose write or + serialization fails, is dropped with a warning naming the tool — the + session continues. Args: contributions: The committed contributions, in enumeration order. diff --git a/tests/onboarding/generator/test_generator.py b/tests/onboarding/generator/test_generator.py index 20035e53..9642089b 100644 --- a/tests/onboarding/generator/test_generator.py +++ b/tests/onboarding/generator/test_generator.py @@ -256,6 +256,73 @@ def test_generate_tool_configs_with_attribution(self) -> None: assert files[-1].path == ".goga/tools/my-tool/service.yml" +class TestToolFileSoftness: + """The tool config write path never fails the session — drops with a warning.""" + + def test_escaping_file_names_rejected_with_warning(self, caplog: pytest.LogCaptureFixture) -> None: + """Absolute and ``..`` names never leave the tool directory; the valid file still lands.""" + answers = SessionAnswers() + answers.record("language", "python") + + contribution = ToolContribution(tool="my-tool", invited=True, answers={}) + contribution.write_config("/etc/cron.d/escape.yml", {"a": 1}) + contribution.write_config("../../escape.yml", {"b": 2}) + contribution.write_config("service.yml", {"c": 3}) + + with caplog.at_level(logging.WARNING): + files = FileGenerator().generate(answers, [contribution]) + + assert [f.path for f in files] == [".goga/config.yml", ".goga/tools/my-tool/service.yml"] + assert not Path("/etc/cron.d/escape.yml").exists() + assert not (Path.cwd().parent.parent / "escape.yml").exists() + + rejected = [record.message for record in caplog.records if "rejected the config file" in record.message] + assert len(rejected) == 2 + assert all("my-tool" in message for message in rejected) + + def test_unserializable_payload_dropped_with_warning(self, caplog: pytest.LogCaptureFixture) -> None: + """A payload yaml cannot serialize drops its file only — the session output stands.""" + deep: dict = {} + current = deep + for _ in range(50_000): + current["n"] = {} + current = current["n"] + + answers = SessionAnswers() + answers.record("language", "python") + + contribution = ToolContribution(tool="my-tool", invited=True, answers={}) + contribution.write_config("bad.yml", deep) + contribution.write_config("service.yml", {"a": 1}) + + with caplog.at_level(logging.WARNING): + files = FileGenerator().generate(answers, [contribution]) + + assert [f.path for f in files] == [".goga/config.yml", ".goga/tools/my-tool/service.yml"] + assert not Path(".goga/tools/my-tool/bad.yml").exists() + assert any( + "my-tool" in record.message and "bad.yml" in record.message for record in caplog.records + ) + + def test_unwritable_target_dropped_with_warning(self, caplog: pytest.LogCaptureFixture) -> None: + """A tool directory path occupied by a regular file fails softly — nothing crashes.""" + Path(".goga/tools").mkdir(parents=True) + Path(".goga/tools/my-tool").write_text("not a directory\n", encoding="utf-8") + + answers = SessionAnswers() + answers.record("language", "python") + + contribution = ToolContribution(tool="my-tool", invited=True, answers={}) + contribution.write_config("service.yml", {"a": 1}) + + with caplog.at_level(logging.WARNING): + files = FileGenerator().generate(answers, [contribution]) + + assert [f.path for f in files] == [".goga/config.yml"] + assert Path(".goga/tools/my-tool").read_text(encoding="utf-8") == "not a directory\n" + assert any("my-tool" in record.message for record in caplog.records) + + class TestStagedCommit: """The staged-commit story end to end — the cross-entity negative trace.""" diff --git a/tests/onboarding/test_integration.py b/tests/onboarding/test_integration.py index 6f1b15fa..e1682a4e 100644 --- a/tests/onboarding/test_integration.py +++ b/tests/onboarding/test_integration.py @@ -160,6 +160,43 @@ def amend_boom(context: Any) -> None: assert not Path(".goga/tools/my-tool").exists() assert any(_TOOL in record.message for record in caplog.records) + def test_bad_buffered_config_file_never_changes_exit_code( + self, + install_tool: _InstallTool, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A buffered escape name and an unserializable payload drop their files — the session still exits 0.""" + + def amend_bad_files(context: Any) -> None: + if not context.invited: + return + deep: dict = {} + current = deep + for _ in range(50_000): + current["n"] = {} + current = current["n"] + context.answer("tools", {_TOOL: "latest"}) + context.write_config("../../escape.yml", {"a": 1}) + context.write_config("bad.yml", deep) + context.write_config("service.yml", {"token_source": "env"}) + + install_tool(amend=amend_bad_files) + + with caplog.at_level(logging.WARNING): + result = CliRunner().invoke( + init_cli, + ["-t", _TOOL], + input="\n".join(_FULL_SESSION_INPUTS) + "\n", + ) + + assert result.exit_code == 0, result.output + assert Path(".goga/config.yml").is_file() + assert Path(".goga/tools/my-tool/service.yml").is_file() + assert not Path(".goga/tools/my-tool/bad.yml").exists() + assert not (Path.cwd().parent.parent / "escape.yml").exists() + assert "(tool: my-tool)" in result.output + assert any(_TOOL in record.message for record in caplog.records) + def test_skip_of_base_image_collapses_dockerfile_branch(self, install_tool: _InstallTool) -> None: """A tool-declared skip of the base image collapses the FROM — no Dockerfile, no config field.""" From bfc7ae6e9a4f649d3ebe9bf328283c5c788a879f Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 22:59:16 +0000 Subject: [PATCH 031/205] fix: address code review findings --- goga/onboarding/generator/generator.py | 2 +- goga/onboarding/survey/plan.py | 5 +++-- tests/onboarding/generator/test_generator.py | 16 ++++++++++++++++ tests/onboarding/survey/test_plan.py | 10 ++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/goga/onboarding/generator/generator.py b/goga/onboarding/generator/generator.py index d20a1ebf..3133e8b8 100644 --- a/goga/onboarding/generator/generator.py +++ b/goga/onboarding/generator/generator.py @@ -230,7 +230,7 @@ def _write_tool_configs(contributions: list[ToolContribution]) -> list[CreatedFi try: text = yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False) - tool_dir.mkdir(parents=True, exist_ok=True) + path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8") as f: f.write(text) except Exception as reason: diff --git a/goga/onboarding/survey/plan.py b/goga/onboarding/survey/plan.py index 41d13283..addaa08d 100644 --- a/goga/onboarding/survey/plan.py +++ b/goga/onboarding/survey/plan.py @@ -89,8 +89,9 @@ def assemble_session_plan(core: QuestionGroup, declarations: list[ToolDeclaratio sections followed by the tool blocks, and the participating tools in block order. """ - children: list[Question | QuestionGroup] = list(core.children) - reserved = {child.id for child in core.children} + core_children = core.children or [] + children: list[Question | QuestionGroup] = list(core_children) + reserved = {child.id for child in core_children} tools: list[str] = [] for declaration in declarations: diff --git a/tests/onboarding/generator/test_generator.py b/tests/onboarding/generator/test_generator.py index 9642089b..cc82ea81 100644 --- a/tests/onboarding/generator/test_generator.py +++ b/tests/onboarding/generator/test_generator.py @@ -255,6 +255,22 @@ def test_generate_tool_configs_with_attribution(self) -> None: assert files[-1].tool == "my-tool" assert files[-1].path == ".goga/tools/my-tool/service.yml" + def test_generate_tool_configs_nested_file_name_creates_subdirectories(self) -> None: + """A relative multi-segment name is contained — its parent directories are created.""" + answers = SessionAnswers() + answers.record("language", "python") + + contribution = ToolContribution(tool="my-tool", invited=True, answers={}) + contribution.write_config("svc/service.yml", {"token_source": "env"}) + + files = FileGenerator().generate(answers, [contribution]) + + assert yaml.safe_load(Path(".goga/tools/my-tool/svc/service.yml").read_text(encoding="utf-8")) == { + "token_source": "env" + } + assert files[-1].tool == "my-tool" + assert files[-1].path == ".goga/tools/my-tool/svc/service.yml" + class TestToolFileSoftness: """The tool config write path never fails the session — drops with a warning.""" diff --git a/tests/onboarding/survey/test_plan.py b/tests/onboarding/survey/test_plan.py index 0c236f70..0fc9adf5 100644 --- a/tests/onboarding/survey/test_plan.py +++ b/tests/onboarding/survey/test_plan.py @@ -146,6 +146,16 @@ def test_reserved_names_derive_from_the_received_core(self) -> None: assert _child_ids(plan.root) == ["language", "tools"] assert plan.tools == ["tools"] + def test_structural_core_root_assembles_as_empty(self) -> None: + """A core with ``children`` of None — a legal structural node — carries no core sections.""" + core = QuestionGroup(id="core") + named = _declaration("tools", Question(id="token", kind="input", prompt="Token")) + + plan = assemble_session_plan(core, [named]) + + assert _child_ids(plan.root) == ["tools"] + assert plan.tools == ["tools"] + def test_the_core_tree_is_never_mutated(self) -> None: """Assembly builds a fresh root over fresh containers; frozen records shared.""" language = Question(id="language", kind="choice", prompt="Language") From b7bbe54efb85613fe760afb7177fd2ee2e73e685 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 14 Sep 2026 23:14:05 +0000 Subject: [PATCH 032/205] fix: address code review findings --- goga/onboarding/survey/CODEMANIFEST | 6 +- goga/onboarding/survey/questionnaire.py | 88 +++++++++++- tests/onboarding/survey/test_questionnaire.py | 127 +++++++++++++++++- 3 files changed, 210 insertions(+), 11 deletions(-) diff --git a/goga/onboarding/survey/CODEMANIFEST b/goga/onboarding/survey/CODEMANIFEST index e9fc5701..f7085661 100644 --- a/goga/onboarding/survey/CODEMANIFEST +++ b/goga/onboarding/survey/CODEMANIFEST @@ -97,7 +97,11 @@ Annotations: | absent version reads as latest - usages — a confirm-gated repeated record collection: group, dependency name, git repository, optional ref and root; the answer - nests as {group: {dep: {git, ref, root}}} + nests as {group: {dep: {git, ref, root}}}; the inputs follow the + structural rules of the project config loader — a group or + dependency name with a path separator, an absolute or escaping + root, and a whitespace-only git re-ask; a whitespace-only ref or + root reads as absent - Answer values nest as mappings mirroring the project config schema - The review section is not part of the tree — a future additive core section diff --git a/goga/onboarding/survey/questionnaire.py b/goga/onboarding/survey/questionnaire.py index 26653cc2..3963255f 100644 --- a/goga/onboarding/survey/questionnaire.py +++ b/goga/onboarding/survey/questionnaire.py @@ -10,6 +10,7 @@ from __future__ import annotations import logging +from pathlib import PurePosixPath import click @@ -77,6 +78,77 @@ def _language_hints(base_image: Question, language: str | None) -> tuple[list[st return family, family[-1] +def _usages_segment(value: str) -> str: + """Validate a usages group or dependency name at the prompt. + + The project config loader rejects a ````/```` key that + is a traversal segment or carries a path separator — the record + loop re-asks these shapes so the written config always loads. + + Args: + value: The entered group or dependency name. + + Returns: + The validated name, unchanged. + + Raises: + click.BadParameter: When the name is ``.``/``..`` or contains a + path separator — click re-asks the prompt. + """ + if value in (".", "..") or "/" in value or "\\" in value: + raise click.BadParameter("a plain name without '/', '\\' or '..' segments") + return value + + +def _usages_root(value: str) -> str: + """Validate a usages root path at the prompt. + + The project config loader rejects an absolute root and a root with + a ``..`` segment. The entered value is stripped and backslash- + normalized to the canonical forward-slash form the loader itself + would produce; a whitespace-only input reads as absent. + + Args: + value: The entered root path. + + Returns: + The normalized relative path; an empty string for an absent + root. + + Raises: + click.BadParameter: When the root is absolute or contains a + ``..`` segment — click re-asks the prompt. + """ + normalized = value.strip().replace("\\", "/") + path = PurePosixPath(normalized) + if path.is_absolute() or ".." in path.parts: + raise click.BadParameter("a relative path without '..' segments") + return normalized + + +def _non_empty(value: str) -> str: + """Validate a required free-text value at the prompt. + + A whitespace-only entry would serialize into the config as a value + the loader rejects (``git`` must be a non-empty string after the + strip) — the prompt re-asks it. + + Args: + value: The entered text. + + Returns: + The stripped text. + + Raises: + click.BadParameter: When the entry strips to nothing — click + re-asks the prompt. + """ + stripped = value.strip() + if not stripped: + raise click.BadParameter("a non-empty value") + return stripped + + class Questionnaire: """The interactive survey engine of the session. @@ -560,19 +632,25 @@ def _survey_usages(self) -> None: entry). The records accumulate as ``{group: {dep: {git, ref?, root?}}}`` — a later record of the same group merges under the group key. + + The inputs are structurally validated at the prompt: a group or + dependency name carrying a path separator, an absolute or + escaping root, and a whitespace-only git re-ask — the shapes + the project config loader rejects, so the written config + always loads. A whitespace-only ref or root reads as absent. """ if not click.confirm("Add usages records?", default=False): return records: dict[str, dict[str, dict[str, str]]] = {} while True: - group = click.prompt("Usage group") - dependency = click.prompt("Dependency name") - entry: dict[str, str] = {"git": click.prompt("Git URL")} - ref = click.prompt("Ref (optional)", default="") + group = click.prompt("Usage group", value_proc=_usages_segment) + dependency = click.prompt("Dependency name", value_proc=_usages_segment) + entry: dict[str, str] = {"git": click.prompt("Git URL", value_proc=_non_empty)} + ref = click.prompt("Ref (optional)", default="").strip() if ref: entry["ref"] = ref - root = click.prompt("Root (optional)", default="") + root = click.prompt("Root (optional)", default="", value_proc=_usages_root) if root: entry["root"] = root records.setdefault(group, {})[dependency] = entry diff --git a/tests/onboarding/survey/test_questionnaire.py b/tests/onboarding/survey/test_questionnaire.py index 9a8d76cc..5463204b 100644 --- a/tests/onboarding/survey/test_questionnaire.py +++ b/tests/onboarding/survey/test_questionnaire.py @@ -13,10 +13,13 @@ from __future__ import annotations import logging +from pathlib import Path import click import pytest +import yaml from click.testing import CliRunner, Result +from goga.config import load_project_config from goga.onboarding.participation import ToolDeclaration from goga.onboarding.questions import Question, QuestionGroup, SessionAnswers from goga.onboarding.survey import ( @@ -539,13 +542,13 @@ def test_usages_record_loop_accumulates_nested_records(self) -> None: "n", # Configure a pipeline agent? "n", # Add tools? "y", # Add usages records? - "goga/hooks", # Usage group + "goga-hooks", # Usage group "goga-lint", # Dependency name "https://github.com/qarium/goga-lint", # Git URL "", # Ref (optional) "", # Root (optional) "y", # Add another usage record? - "goga/hooks", # the same group merges under its key + "goga-hooks", # the same group merges under its key "goga-viewer", # Dependency name "https://github.com/qarium/goga-viewer", # Git URL "0.1.0", # Ref (optional) @@ -556,12 +559,126 @@ def test_usages_record_loop_accumulates_nested_records(self) -> None: assert result.exit_code == 0 assert answers.snapshot()["usages"] == { - "goga/hooks": { + "goga-hooks": { "goga-lint": {"git": "https://github.com/qarium/goga-lint"}, "goga-viewer": {"git": "https://github.com/qarium/goga-viewer", "ref": "0.1.0"}, } } + def test_usages_inputs_the_config_loader_rejects_re_ask(self) -> None: + """A separator name, an absolute or escaping root, and a whitespace git re-ask.""" + answers = SessionAnswers() + + result = _run_survey( + _full_plan(convention_exists=True), + answers, + [ + "python", # Language + "n", # Add codemanifest usages? + "n", # Add codemanifest annotations? + "n", # Configure a build agent? + "n", # Create Dockerfile? + "", # Docker image → the last hint default + "n", # Configure a pipeline agent? + "n", # Add tools? + "y", # Add usages records? + "my/org", # Usage group — a separator name re-asks + "goga-hooks", # the accepted Usage group + "..", # Dependency name — a traversal segment re-asks + "goga-lint", # the accepted Dependency name + " ", # Git URL — a whitespace-only entry re-asks + "https://github.com/qarium/goga-lint", # the accepted Git URL + "", # Ref (optional) + "/docs", # Root (optional) — an absolute root re-asks + "../docs", # an escaping root re-asks + "docs", # the accepted Root + "n", # Add another usage record? + ], + ) + + assert result.exit_code == 0 + assert result.output.count("Error:") == 5 + assert answers.snapshot()["usages"] == { + "goga-hooks": { + "goga-lint": {"git": "https://github.com/qarium/goga-lint", "root": "docs"} + } + } + + def test_whitespace_only_usages_ref_and_root_read_as_absent(self) -> None: + """A whitespace-only optional ref or root omits the entry — no load failure.""" + answers = SessionAnswers() + + result = _run_survey( + _full_plan(convention_exists=True), + answers, + [ + "python", # Language + "n", # Add codemanifest usages? + "n", # Add codemanifest annotations? + "n", # Configure a build agent? + "n", # Create Dockerfile? + "", # Docker image → the last hint default + "n", # Configure a pipeline agent? + "n", # Add tools? + "y", # Add usages records? + "goga-hooks", # Usage group + "goga-lint", # Dependency name + "https://github.com/qarium/goga-lint", # Git URL + " ", # Ref (optional) — reads as absent + " ", # Root (optional) — reads as absent + "n", # Add another usage record? + ], + ) + + assert result.exit_code == 0 + assert answers.snapshot()["usages"] == { + "goga-hooks": {"goga-lint": {"git": "https://github.com/qarium/goga-lint"}} + } + + def test_recorded_usages_pass_the_project_config_loader( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The usages records the survey accepts load through the project config loader.""" + answers = SessionAnswers() + + result = _run_survey( + _full_plan(convention_exists=True), + answers, + [ + "python", # Language + "n", # Add codemanifest usages? + "n", # Add codemanifest annotations? + "n", # Configure a build agent? + "n", # Create Dockerfile? + "", # Docker image → the last hint default + "n", # Configure a pipeline agent? + "n", # Add tools? + "y", # Add usages records? + "goga-hooks", # Usage group + "goga-lint", # Dependency name + " https://github.com/qarium/goga-lint ", # Git URL — recorded stripped + " 0.1.0 ", # Ref (optional) — recorded stripped + "docs\\sub", # Root (optional) — normalized to forward slashes + "n", # Add another usage record? + ], + ) + + assert result.exit_code == 0 + config_dir = tmp_path / ".goga" + config_dir.mkdir() + (config_dir / "config.yml").write_text( + yaml.safe_dump({"language": "python", "usages": answers.snapshot()["usages"]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + config = load_project_config() + + dep = config.usages["goga-hooks"]["goga-lint"] + assert dep.git == "https://github.com/qarium/goga-lint" + assert dep.ref == "0.1.0" + assert dep.root == "docs/sub" + def test_custom_annotations_append_to_the_prefill(self) -> None: """Accepting the annotations collection appends to the convention prefill.""" answers = SessionAnswers() @@ -658,7 +775,7 @@ def test_usages_record_with_a_root_entry(self) -> None: "n", # Configure a pipeline agent? "n", # Add tools? "y", # Add usages records? - "goga/hooks", # Usage group + "goga-hooks", # Usage group "goga-lint", # Dependency name "https://github.com/qarium/goga-lint", # Git URL "0.1.0", # Ref (optional) @@ -669,7 +786,7 @@ def test_usages_record_with_a_root_entry(self) -> None: assert result.exit_code == 0 assert answers.snapshot()["usages"] == { - "goga/hooks": { + "goga-hooks": { "goga-lint": { "git": "https://github.com/qarium/goga-lint", "ref": "0.1.0", From d2d734bb1df74b05959cf1dfe0f14e083b1ca5f6 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Tue, 15 Sep 2026 00:18:52 +0000 Subject: [PATCH 033/205] fix: address acceptance audit findings in manifests and adapter tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acceptance audit (contracts & coverage) of the onboarding-refctoring branch: - survey CODEMANIFEST: ask_group gains the implemented prefix parameter, ask_question return type gains the unaskable-None case - pipeline CODEMANIFEST: run_pipeline_container hosts gains | None (defaults stay in annotation prose — the DSL signature grammar cannot express tuple defaults); void-return labels normalized to _: None (also version) - tests: direct boundary tests for the run_pipeline_container environment adapters (_check_docker, _read_git_config, _allocate_port) — 11 new tests; run_pipeline_container.py coverage 88% -> 99% Gates: goga lint 0 errors (76 cells); 5513 tests pass; ruff clean. --- goga/commands/pipeline/CODEMANIFEST | 8 +-- goga/onboarding/survey/CODEMANIFEST | 14 +++- goga/version/CODEMANIFEST | 2 +- tests/commands/pipeline/test_run_helpers.py | 79 +++++++++++++++++++++ 4 files changed, 95 insertions(+), 8 deletions(-) diff --git a/goga/commands/pipeline/CODEMANIFEST b/goga/commands/pipeline/CODEMANIFEST index 6d538d26..b849f1c2 100644 --- a/goga/commands/pipeline/CODEMANIFEST +++ b/goga/commands/pipeline/CODEMANIFEST @@ -330,7 +330,7 @@ Annotations: | - Do not pass the topic name into the container — the container sees the branch through the mounted project -"run_pipeline_container(name: str, config: ProjectConfig, extra_env: tuple[str, ...], proxy: str | None, hosts: dict[str, str], clean: bool, update: bool, workflow: str | None, no_workflow: bool, skip: tuple[str, ...], parallel: int | None) -> exit_code: int": +"run_pipeline_container(name: str, config: ProjectConfig, extra_env: tuple[str, ...], proxy: str | None, hosts: dict[str, str] | None, clean: bool, update: bool, workflow: str | None, no_workflow: bool, skip: tuple[str, ...], parallel: int | None) -> exit_code: int": location: run_pipeline_container.py annotations: | Host-side docker launcher for the run form. Launches the goga Docker @@ -349,8 +349,8 @@ Annotations: | container env-file. `hosts`: resolved host→IP dict (CLI entries merged on top of config.pipeline.hosts by the caller; CLI wins on host-key - conflict). Each entry becomes a docker run --add-host HOST:IP - flag. + conflict; default None — no extra hosts). Each entry becomes + a docker run --add-host HOST:IP flag. `clean`: when True, wipe the persistent afm state host directory before launch via `clean_pipeline_runtime_dir`. `update`: when True, refresh the image via `docker_update` before @@ -794,7 +794,7 @@ Annotations: | basename use the ORIGINAL pipeline_name; only the host path segment is sanitized -"clean_pipeline_runtime_dir(pipeline_runtime_dir: Path) -> none: None": +"clean_pipeline_runtime_dir(pipeline_runtime_dir: Path) -> _: None": location: run_pipeline_container.py annotations: | Recursively wipe the persistent afm state directory and recreate it diff --git a/goga/onboarding/survey/CODEMANIFEST b/goga/onboarding/survey/CODEMANIFEST index f7085661..7edf5e94 100644 --- a/goga/onboarding/survey/CODEMANIFEST +++ b/goga/onboarding/survey/CODEMANIFEST @@ -217,24 +217,32 @@ Annotations: | applies only when a Dockerfile path was given; the image defaults follow the Dockerfile branch - A skipped subtree is never asked - "ask_question(question: Question) -> value: str | bool | dict[str, str]": | + "ask_question(question: Question) -> value: str | bool | dict[str, str] | None": | Ask one simple question of its kind. `question`: the question record - `value`: the answer value of the kind + `value`: the answer value of the kind; None when the question is + unaskable — an unknown kind or a missing parameterization — + announced with a warning naming the question path, never + asked, never recorded Requirements: - Render the prompt, the offered choices or keys, and the default of the record; a free-form input is accepted where the kind allows it - "ask_group(group: QuestionGroup) -> value: dict": | + "ask_group(group: QuestionGroup, prefix: str | None = None) -> value: dict": | Ask one group — its children in order. `group`: the group node + `prefix`: the record-path prefix of the group — the tool id for a + tool block, the dotted parent path for a nested group; None + addresses the children by their local names `value`: the mapping of the children's answers keyed by child ids Requirements: - Section headings and explanatory text precede the children; the confirm-gated collections ask their gate first + - An unaskable child is skipped — absent from the mapping, never + recorded --- diff --git a/goga/version/CODEMANIFEST b/goga/version/CODEMANIFEST index 9906c7a4..34b2c650 100644 --- a/goga/version/CODEMANIFEST +++ b/goga/version/CODEMANIFEST @@ -240,7 +240,7 @@ Annotations: | Apply the `convention` practice for docstring style and intra-package imports. -"ensure_version_match(image_version: str | None) -> none: None": +"ensure_version_match(image_version: str | None) -> _: None": location: version.py annotations: | Apply the outcome matrix of the host–image version consistency check. diff --git a/tests/commands/pipeline/test_run_helpers.py b/tests/commands/pipeline/test_run_helpers.py index e86c707e..4d3d122a 100644 --- a/tests/commands/pipeline/test_run_helpers.py +++ b/tests/commands/pipeline/test_run_helpers.py @@ -11,10 +11,19 @@ ``goga.runtime.paths`` (tested in ``tests/runtime/test_paths.py``) and are no longer defined in this module. These tests verify the renamed facades delegate correctly and clean idempotently; they have no docker dependency. + +The environment-adapter boundary tests cover the three private adapters +colocated in the same module — ``_check_docker``, ``_read_git_config``, and +``_allocate_port`` — whose internal logic the launcher tests bypass by +monkeypatching the adapters at the import point (mirroring the direct-test +precedent for the build module's copies in ``tests/commands/test_build.py``). +The subprocess boundary is mocked per the `convention` practice; no docker or +git binary is required. """ from __future__ import annotations +import subprocess import sys from pathlib import Path from unittest import mock @@ -175,3 +184,73 @@ def test_tolerates_concurrent_removal(self, tmp_path: Path) -> None: with mock.patch.object(_rpc_mod.shutil, "rmtree", side_effect=FileNotFoundError): clean_pipeline_runtime_dir(runtime_dir) # does not raise assert runtime_dir.exists() + + +# --- Environment-adapter boundary tests --- + + +class TestCheckDocker: + def test_true_when_docker_version_exits_zero(self) -> None: + """A successful `docker --version` probe reports docker available.""" + with mock.patch.object(_rpc_mod.subprocess, "run") as run: + run.return_value = subprocess.CompletedProcess(args=[], returncode=0) + assert _rpc_mod._check_docker() is True + run.assert_called_once_with(["docker", "--version"], capture_output=True, text=True, check=False) + + def test_false_when_probe_exits_nonzero(self) -> None: + """A failing `docker --version` probe (nonzero exit) reports unavailable.""" + with mock.patch.object(_rpc_mod.subprocess, "run") as run: + run.return_value = subprocess.CompletedProcess(args=[], returncode=1) + assert _rpc_mod._check_docker() is False + + @pytest.mark.parametrize("error", [FileNotFoundError, PermissionError, OSError]) + def test_false_when_binary_unlaunchable(self, error: type[BaseException]) -> None: + """A missing or unlaunchable docker binary reports unavailable, never raises.""" + with mock.patch.object(_rpc_mod.subprocess, "run", side_effect=error): + assert _rpc_mod._check_docker() is False + + +class TestReadGitConfig: + def test_returns_four_identity_entries_when_configured(self) -> None: + """A fully configured git identity yields the author/committer env pairs.""" + name = subprocess.CompletedProcess(args=[], returncode=0, stdout="Goga Dev\n") + email = subprocess.CompletedProcess(args=[], returncode=0, stdout="goga@example.com\n") + with mock.patch.object(_rpc_mod.subprocess, "run", side_effect=[name, email]) as run: + result = _rpc_mod._read_git_config() + assert result == { + "GIT_AUTHOR_NAME": "Goga Dev", + "GIT_AUTHOR_EMAIL": "goga@example.com", + "GIT_COMMITTER_NAME": "Goga Dev", + "GIT_COMMITTER_EMAIL": "goga@example.com", + } + assert [call.args[0] for call in run.call_args_list] == [ + ["git", "config", "user.name"], + ["git", "config", "user.email"], + ] + + @pytest.mark.parametrize(("name_stdout", "email_stdout"), [("", "goga@example.com\n"), ("Goga Dev\n", "")]) + def test_empty_when_identity_incomplete(self, name_stdout: str, email_stdout: str) -> None: + """A half-configured identity (name or email missing) yields no env pairs.""" + name = subprocess.CompletedProcess(args=[], returncode=0, stdout=name_stdout) + email = subprocess.CompletedProcess(args=[], returncode=0, stdout=email_stdout) + with mock.patch.object(_rpc_mod.subprocess, "run", side_effect=[name, email]): + assert _rpc_mod._read_git_config() == {} + + def test_empty_when_git_binary_missing(self) -> None: + """An absent git binary yields no env pairs, never raises.""" + with mock.patch.object(_rpc_mod.subprocess, "run", side_effect=FileNotFoundError): + assert _rpc_mod._read_git_config() == {} + + +class TestAllocatePort: + def test_returns_port_in_valid_range(self) -> None: + """The allocated port is an int inside the TCP port range.""" + port = _rpc_mod._allocate_port() + assert isinstance(port, int) + assert 1 <= port <= 65535 + + def test_successive_calls_allocate_valid_ports(self) -> None: + """Repeated allocations (list/card launches in sequence) stay valid.""" + first, second = _rpc_mod._allocate_port(), _rpc_mod._allocate_port() + assert 1 <= first <= 65535 + assert 1 <= second <= 65535 From 817091f957788f1096db2f2eb64c7b7617866d0a Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Tue, 15 Sep 2026 22:57:41 +0300 Subject: [PATCH 034/205] fix: relocate onboarding tool-contexts usage to the domain facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool-author documentation of the onboarding hook events lived at the participation leaf with no importers, leaving the facade — the mandated addressing point of the domain — without a hook-events practice. Move it to goga/onboarding/.usages following the registering-statuses facade pattern; content unchanged apart from the domain header line. --- goga/onboarding/{participation => }/.usages/tool-contexts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename goga/onboarding/{participation => }/.usages/tool-contexts.md (98%) diff --git a/goga/onboarding/participation/.usages/tool-contexts.md b/goga/onboarding/.usages/tool-contexts.md similarity index 98% rename from goga/onboarding/participation/.usages/tool-contexts.md rename to goga/onboarding/.usages/tool-contexts.md index 86bb0ed7..263b7810 100644 --- a/goga/onboarding/participation/.usages/tool-contexts.md +++ b/goga/onboarding/.usages/tool-contexts.md @@ -1,4 +1,4 @@ -# Onboarding contexts — goga/onboarding/participation +# Onboarding contexts — goga/onboarding ## Domain From a8e492d24a8f871af4d93f32ac83c8b3f81007c9 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Wed, 16 Sep 2026 20:43:20 +0300 Subject: [PATCH 035/205] feat: delete goga history from gitignore --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index f9d798c2..bac2b86d 100644 --- a/.gitignore +++ b/.gitignore @@ -223,6 +223,3 @@ __marimo__/ docs/plans/ docs/design/ docs/superpowers/ - -# Goga -.goga/history \ No newline at end of file From 42128ca8f514763fa7f38f9a54c322669f66d0bf Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Wed, 16 Sep 2026 20:44:14 +0300 Subject: [PATCH 036/205] feat: add onboarding-refactoring topic to history --- .../history/2026/onboarding-refctoring/adr.md | 38 + .../2026/onboarding-refctoring/arch.md | 1719 +++++++++++++ .../onboarding-refctoring/completed/plan.md | 1449 +++++++++++ .../2026/onboarding-refctoring/design.md | 2182 +++++++++++++++++ .../2026/onboarding-refctoring/plan.md | 4 +- .../history/2026/onboarding-refctoring/prd.md | 138 ++ .../2026/onboarding-refctoring/task.md | 126 + 7 files changed, 5654 insertions(+), 2 deletions(-) create mode 100644 .goga/history/2026/onboarding-refctoring/adr.md create mode 100644 .goga/history/2026/onboarding-refctoring/arch.md create mode 100644 .goga/history/2026/onboarding-refctoring/completed/plan.md create mode 100644 .goga/history/2026/onboarding-refctoring/design.md create mode 100644 .goga/history/2026/onboarding-refctoring/prd.md create mode 100644 .goga/history/2026/onboarding-refctoring/task.md diff --git a/.goga/history/2026/onboarding-refctoring/adr.md b/.goga/history/2026/onboarding-refctoring/adr.md new file mode 100644 index 00000000..e672e47c --- /dev/null +++ b/.goga/history/2026/onboarding-refctoring/adr.md @@ -0,0 +1,38 @@ +# Tool onboarding: two hooks actions, a single "question → answer" mechanism, mediated config writes + +**Status:** accepted + +The `goga init` session opens to invited tools (`-t/--tool`) through two soft actions of the hooks platform — `onboarding/declare_session` (the declaration of questions and skips before the survey) and `onboarding/amend_config` (amendments and writes after all answers are collected, before `config.yml` is written) — and all of a tool's specialized participation reduces to a single mechanism: identifiable session questions and answers by id. The hooks platform is not reworked (C-4): an invitation is a marker in the per-tool context, not a delivery filter. Image hints (the pull image and the Dockerfile base image) receive the tag `N.M`, derived from the installed goga version via `host_goga_version()` — the same reading point as the host↔image compatibility check; an unreadable version is a clean session error without a traceback. This eliminates the drift of the hardcoded `:1.3` without manual tag maintenance in every minor release. + +## Decisions + +1. **Catalog actions.** The `onboarding` domain declares two addresses, both `error_class=soft`: `declare_session` — moment one (before the survey); `amend_config` — moment two (after the survey collects the answers, before the write). Each subscription is independent. The engine itself asks the tool question blocks — questions are declarative data; the platform never calls a hook to survey. +2. **Invitation.** The platform delivers the emission to every subscriber of the address; each tool context carries the invitation marker: an invited tool receives the active surface; a non-invited tool receives the "not invited" marker, and its hook must return immediately (a contract rule). An invited tool that subscribes to nothing participates silently. +3. **Question space.** The core defines 8 ids: `language`, `convention`, `codemanifest`, `build` (build_agent + build_env, i.e. `build.task_executor`), `docker_image`, `pipeline`, `tools`, `usages`. `review` is a future additive id (this task does not add the review survey). Tool questions are qualified by identity: `.` (a duplicate within a tool rejects the declaration with a warning — the registrar pattern). Question kinds: `choice`, `input`, `confirm`, `pairs` (repeated key→value collection with proposed keys), `group` (one nesting level with simple children; a group's answer is a mapping). +4. **The core survey extends** (confirm-gated, within the existing patterns): `tools` — "Add goga tools to the project dependencies?" → repeated collection of name+version pairs; `usages` — repeated record collection per the schema: group → dependency name → git repository (optional ref/root per the config schema). Order: language → convention → codemanifest → build → docker_image → pipeline → tools → usages → tool question blocks. +5. **Skips.** A skip address is a dot-path into the question tree; core child names match the schema field names (`build.task_executor.env`); a tool's own question or group takes no prefix, another tool's takes `.`. Skipping a node skips the entire subtree; inside `pairs`, individual pairs are not addressable. The engine applies skips after the entire declaration, order-independently; an unknown path is a no-op with a warning. The survey never asks a skipped question; the tool tolerates the missing answer. +6. **Amendments — the single mechanism `answer(id, value)`.** A tool re-answers or updates an existing id; mappings merge recursively, scalars and lists are replaced. Substituting a user's answer is silent (a tool's lawful right). Tool conflicts resolve last-wins in delivery order (the alphabet of tool identities). No special registration members exist: `tools`/`usages` are ordinary ids; a tool's own entry lands under its identity (the platform assigns identities); version values follow the four-form grammar; a missing version reads as `latest`. +7. **Tool config writes go only through the API.** The engine serializes YAML and writes `.goga/tools//.yml`; writing the same file again rewrites it. Writes are buffered; the final created-files list combines the engine's files and the tools' files with attribution (automatically, with no "report file" member). A tool writing files directly, bypassing the API, acts outside the session contract. +8. **Failure resilience is staged per tool.** A tool's contribution (amendments + files) commits only after its moment-two hook completes successfully; a failure at any step discards the contribution entirely, emits a warning with the tool name and the reason, and the session continues with exit code 0. An existing `.goga/config.yml` (the `` branch) is never rewritten; the related events are not emitted. +9. **Answer isolation.** `answers` holds nested mappings (groups are mappings; dotted keys never appear): a tool sees its own unprefixed questions and the core questions; other tools' answers are unreadable — tools coordinate through merges of shared sections, never through another tool's data. +10. **Documentation and the example.** The author contract lives in `docs/features/init/hooks.md` (modeled on the domain pages; it currently holds the "no hook actions" stub), with cross-references from `tools/hooks.md`; registrations appear in `goga hooks` automatically. The example tool is the test fixture package `goga_tool_*`, exercising the scenario exclusively through the public surface. + +## Deviations from the PRD (deliberately accepted) + +- **R4.1 "import is soft":** a broken package import is the hooks platform's single fatal case (the session fails with a clean error naming the package); every other participation failure is soft. The platform invariant outweighs the PRD wording. +- **R4.2 "the config is always created":** skipping any question is allowed (R2.3, literally); if a required schema field (`language`) is empty at write time and no amendment restores it, the session fails with a clean error naming the field. +- **R2.4 "registrations":** the design implements registrations as ordinary answers at the `tools`/`usages` ids rather than special surface members (one mechanism); the core survey thereby gains two new sections. + +## Open Questions (design stage) + +- The exact names and signatures of the context members (`invited`, `declare_*`, `skip`, `answers`, `answer`, `write_config` — working aliases of this ADR). +- The composition and wording of the core `tools`/`usages` prompts. +- Whether a tool may write another tool's keys into `tools` (merge mechanically allows it; the contract does not propose it — the design decides whether to validate). + +## Considered Options + +- **One action, the emission delivered twice with different contexts** — rejected: the hook would branch on the moment, blending two contracts (q1). +- **Filtered event delivery to invited tools only** — rejected: a platform rework that C-4 forbids (q3). +- **Addressing amendments by schema fields** — rejected in favor of addressing by question ids: one mechanism covering re-answers and updates (q10). +- **Special members `register_tool`/`register_dep`** — rejected: they duplicate `answer` with merge (q17–q18). +- **Dotted keys in `answers`** — rejected: nested mappings without path parsing; the dot-path survives only in skip addresses (q21–q22). diff --git a/.goga/history/2026/onboarding-refctoring/arch.md b/.goga/history/2026/onboarding-refctoring/arch.md new file mode 100644 index 00000000..9b89a147 --- /dev/null +++ b/.goga/history/2026/onboarding-refctoring/arch.md @@ -0,0 +1,1719 @@ +# Architecture plan — Extensible `goga init` onboarding: tool participation via hooks actions and the dynamic image tag + +## Topic + +**Extensible `goga init` onboarding** — tool participation via hooks actions and the + dynamic image tag. +Plan path: `.goga/history/2026/onboarding-refctoring/arch.md` (the output of `goga history path -f arch.md`). +Normative inputs: `task.md` (decisions 1–12, S1–S14) and `adr.md`; stage decisions: q1=q2=q3=q6=A, +the `minor_version` rename, and map/distribution corrections (q7–q9, q16, q18, q20). + +## Implementation Order + +| # | Cell | Status | Order rationale | +|---|---|---|---| +| 1 | `goga/version` | modify | leaf without Imports; the onboarding facade depends on it | +| 2 | `goga/hooks/catalog` | modify | leaf without Imports; the hooks facade and delivery depend on it | +| 3 | `goga/hooks` (facade) | modify | depends on the existing leaves catalog/registry/dispatch/tools (unchanged) | +| 4 | `goga/onboarding/questions` | create | leaf without Imports — the session data model | +| 5 | `goga/onboarding/participation` | create | depends on questions (No. 4) and goga/hooks (No. 3) | +| 6 | `goga/onboarding/survey` | create | depends on questions (No. 4) and participation (No. 5, F1: `ToolDeclaration`) | +| 7 | `goga/onboarding/generator` | create | depends on questions (No. 4) and participation (No. 5) | +| 8 | `goga/onboarding` (facade) | modify | depends on the four leaves (No. 4–7), goga/version (No. 1), and goga/config (exists) | +| 9 | `goga/commands/init` | modify | depends on the onboarding facade (No. 8) and goga/scaffold (exists) | + +The graph has no cycles; the order runs leaves → root. + +## Artifacts + +### 1. `goga/version` — CODEMANIFEST *(modify)* + +**Diff — add** (to the existing Body, after the `host_goga_version` block): + +```yaml +"minor_version(version: str) -> minor: str": + location: version.py + annotations: | + Derive the minor line of a version string — the N.M form consumers use + to present values that must match the installed minor (image tag hints). + + `version`: version string (release segments, possibly with + dev/pre/post/local tails) + `minor`: the minor line N.M + + Apply the `convention` practice for docstring style and the + pure-function discipline. + + Algorithm: + 1. Reduce `version` to its leading release segments: the first numeric + segment is the major, the optional second numeric segment is the minor + 2. Treat a missing minor segment as 0 + 3. Return the two segments joined by a dot — the minor line + 4. An argument with no leading numeric major segment raises ValueError + + Requirements: + - Pure function — deterministic, no I/O, no logging + - Richer tails reduce silently: 1.2.1.dev3, 1.2.0rc1, 1.2.0.post1, + 1.2.0+local all reduce to the 1.2 line + + Constraints: + - Do not read the installed version here — the caller owns the metadata + boundary; this routine receives it as `version` + - Do not validate that segments form a real released version — shape + recognition only, mirroring `resolve_version` +``` + +Nothing changes and nothing is deleted: `Usages`/`Annotations` (global), `resolve_version`, +`resolve_relative_spec`, `compare_versions`, `host_goga_version`, `version_check_enabled`, +`ensure_version_match`, the footer — verbatim. + +**Usage file — create:** `goga/version/.usages/minor-line.md` + +```md +# Minor line of a version — goga/version + +## Domain + +Deriving the minor line (N.M) of a version string. Target audience: features +that present values matching the installed minor — image tag hints, +compatibility labels — and need the same minor the host↔image comparison +uses. + +## Public API + + from goga.version import minor_version, host_goga_version + +- `minor_version(version: str) -> str` — the N.M line of `version`. A missing + minor segment reads as 0; dev/pre/post/local tails are discarded; an + undeterminable major segment raises ValueError. +- `host_goga_version() -> str` — the installed goga version; the single + reading point. Propagates the metadata exception when undeterminable. + +## Ready-to-use pattern + +### Offer a hint matching the installed minor + +Read once, derive, format at the consumer: + +```python +from goga.version import host_goga_version, minor_version + +version = host_goga_version() # may raise when metadata is unreadable — handle at the caller +tag = minor_version(version) # "1.3.2" -> "1.3" +image_hint = f"qarium/goga-python-3.12:{tag}" +``` + +- `minor_version` is pure — the caller owns the metadata boundary and passes + the string; the routine never reads, prints, or exits. +- A hint built from the returned line agrees with the (major, minor) + host↔image check by construction. + +## Notes for the consumer + +- Do not parse the version string at the call site — this routine owns the + reduction. +- An unreadable installed version is the caller's error to translate into a + clean message. +``` + +### 2. `goga/hooks/catalog` — CODEMANIFEST *(modify)* + +**Diff — add** to the `declared_actions` → `Requirements` block (after the existing item about `statuses`, +which stays verbatim): + +```yaml + - The catalog carries the onboarding session-declaration action — the + record domain="onboarding", name="declare_session", error_class="soft": + a failing hook of the action is skipped with a warning and the sequence + continues + - The catalog carries the onboarding config-amendment action — the record + domain="onboarding", name="amend_config", error_class="soft": a failing + hook of the action is skipped with a warning and the sequence continues +``` + +Nothing changes and nothing is deleted. The cell has no usage files (and gains none). + +### 3. `goga/hooks` — CODEMANIFEST *(facade, modify)* + +**Diff — Imports:** add `wrap_context` and `build_hook_arguments` to the existing import record from +`goga/hooks/dispatch` (which already carries `emit_hook_event`); add a new import record from +`goga/hooks/tools` with the type `enumerate_tool_packages`: + +```yaml +Imports: + - Types: + - declared_actions + From: goga/hooks/catalog + - Types: + - HookRegistry + - ToolHooks + From: goga/hooks/registry + - Types: + - emit_hook_event + - wrap_context + - build_hook_arguments + From: goga/hooks/dispatch + - Types: + - enumerate_tool_packages + From: goga/hooks/tools +``` + +**Diff — Annotations (global block):** append one sentence to the end of the existing facade +characterization (the existing wording stays verbatim): + +```yaml + It additionally re-exports the delivery primitives and the + installed-package enumeration — for domains that orchestrate per-tool + delivery themselves and need each hook's outcome or the installed + identities. +``` + +**Diff — Body:** add after the existing embeddings: + +```yaml +->wrap_context: {} +->build_hook_arguments: {} +->enumerate_tool_packages: {} +``` + +**Usage file — create:** `goga/hooks/.usages/per-tool-delivery.md` + +```md +# hooks — delivering per tool with staged control + +How a goga domain delivers an action to its subscribed hooks per tool, when +the plain emission is not enough — the domain must know each tool's outcome +(staged contributions, compensating rollback). For domain maintainers inside +goga. + +## When to use + +Use `emit_hook_event` when the domain only hands the context over — the +emission is fire-and-forget and collects nothing after the event. Use this +pattern when a tool's contribution is committed only after its hooks succeed; +per-hook outcomes are out of reach through the emission, so the domain drives +the delivery loop itself over the public primitives. + +## The public primitives + + from goga.hooks import HookRegistry, wrap_context, build_hook_arguments + +- `HookRegistry()` — the run registry; `build_once()` assembles it once per + run. +- `registry.subscriptions_for(domain, action)` — the address's + subscriptions, in enumeration order. +- `registry.self_context(tool)` — the isolated context of one tool. +- `wrap_context(view)` — the delivery view of your context: reads and calls + pass through, attribute assignment is blocked. +- `build_hook_arguments(hook, proxy, self_context)` — the keyword arguments + for the call; only names the hook declared receive values. + +## The pattern + +```python +registry = HookRegistry() +registry.build_once() + +groups: dict[str, list] = {} +for sub in registry.subscriptions_for("", ""): + groups.setdefault(sub.tool, []).append(sub) + +for tool, subs in groups.items(): + proxy = wrap_context(build_the_context_for(tool)) # your per-tool view + try: + for sub in subs: + sub.hook(**build_hook_arguments(sub.hook, proxy, registry.self_context(tool))) + except Exception as reason: + logger.warning("tool skipped", extra={"tool": tool, "action": "", "reason": reason}) + discard(tool) # the tool's whole contribution + continue + commit(tool) # only after every hook of the tool succeeded +``` + +## Rules the pattern keeps + +- Deliver to every subscriber of the address — never filter delivery by + invitation or any other criterion; a tool's eligibility lives in its + context (a marker the hook checks), not in delivery. +- Treat a failure per the action's error class recorded in the catalog — + soft: warn naming the tool, the action, and the reason, then continue with + the next tool. The single fatal case (a broken package import) surfaces at + `build_once`. +- One registry per run — build it once and share it across your checkpoints. +- Do not deliver a hook any value it did not declare — + `build_hook_arguments` is the single projection. +``` + +### 4. `goga/onboarding/questions` — CODEMANIFEST *(create)* + +```yaml +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the declarative question-and-answer model of the + onboarding session: the question records of every kind, the nesting + groups, and the session answer space. Data and pure answer operations + only — no interactivity, no filesystem, no tool delivery. The question + records are immutable; the answer space is the single mutable + accumulator of one run. Use relative imports. + +--- + +"Question(id: str, kind: str, prompt: str, choices: list[str] | None = None, default: str | bool | None = None, keys: list[str] | None = None)": + location: questions.py + annotations: | + One declarative question record — the survey unit of the session. + + `id`: the local name of the question within its parent — unique among + the siblings of its tree position + `kind`: the question kind — one of choice, input, confirm, pairs + `prompt`: the user-facing prompt text + `choices`: the offered values; set for the choice kind + `default`: the preselected value or the input default; a bool for the + confirm kind + `keys`: the proposed keys of the repeated key-value collection; set for + the pairs kind + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The record carries data only — rendering the question and validating + the answer value belong to the survey engine + - The kind fixes the parameterization: `choices` for choice, `default` + for input and confirm, `keys` for pairs + - The answer value of each kind: choice and input — a string, confirm — + a boolean, pairs — a mapping of strings + properties: + "id -> str": | + The local name of the question within its parent. + "kind -> str": | + The question kind — choice, input, confirm, or pairs. + "prompt -> str": | + The user-facing prompt text. + "choices -> list[str] | None": | + The offered values of the choice kind. + "default -> str | bool | None": | + The preselected value or the input default; a bool for confirm. + "keys -> list[str] | None": | + The proposed keys of the pairs kind. + +"QuestionGroup(id: str, prompt: str | None = None, children: list[Question | QuestionGroup] | None = None)": + location: questions.py + annotations: | + One nesting node of the question tree — a section whose answer is the + mapping of its children's answers. + + `id`: the local name of the group within its parent — unique among the + siblings of its tree position + `prompt`: the optional section heading; a purely structural node carries + none + `children`: the nested questions and groups, in survey order + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The tree path of a node — the ids from the root to the node joined by + dots — addresses the node in skip requests and answer paths + - The answer value of a group is a nested mapping keyed by child ids — + never a flat dotted key + - A group declared by a tool is limited to one nesting level with + simple children; deeper nesting belongs to the core survey structure + properties: + "id -> str": | + The local name of the group within its parent. + "prompt -> str | None": | + The optional section heading of the group. + "children -> list[Question | QuestionGroup] | None": | + The nested questions and groups, in survey order. + +"SessionAnswers()": + location: answers.py + annotations: | + The answer space of one session — the question-to-value mapping shared + by the survey, the tool participation, and the file generation. + + Apply the `convention` practice for the code style and intra-package + imports. + + Requirements: + - Created empty; the structure is nested mappings keyed by question + ids — groups hold mappings, no dotted keys are ever stored + - The single mutable accumulator of the run — every answer, core and + tool, lands here exactly once + methods: + "record(id: str, value: str | bool | dict) -> _: None": | + Record the user's answer collected by the survey. + + `id`: the dot-path of the answered question in the plan tree + `value`: the answer value of the question kind + + Algorithm: + 1. Resolve `id` segment by segment, creating the intermediate + mappings of the traversed groups + 2. Set the value at the leaf name + + Requirements: + - Recording replaces — a later record at the same path overwrites the + earlier value; merging belongs to amendments + "amend(id: str, value: str | bool | dict) -> _: None": | + Apply one amendment of a tool contribution at the addressed location. + + `id`: the dot-path of the addressed entry + `value`: the amendment value + + Algorithm: + 1. Resolve `id` segment by segment, creating the intermediate + mappings of the traversed groups + 2. An existing mapping at the leaf merges recursively with `value`; + a scalar or a list replaces; an absent leaf is created + + Requirements: + - Mappings merge recursively; scalars and lists replace + - Amendments apply in delivery order — a later amendment wins at + every conflicting leaf + - Substituting a user's answer is a tool's lawful right — the + amendment applies silently + "view_for(tool: str) -> view: dict": | + The isolated answer view of one tool. + + `tool`: the tool identity + `view`: the core answers plus the tool's own answers under their + local names + + Algorithm: + 1. Take the core section of the space + 2. Add the tool's own section re-keyed by local names, without the + tool prefix + + Requirements: + - The answers of other tools are never present — coordination goes + through amendments of shared sections, not through reading foreign + data + - The view is a snapshot — amendments applied after the call do not + appear in it + "snapshot() -> view: dict": | + The full answer space for generation. + + `view`: the complete nested structure — the core and every committed + tool section + + Requirements: + - Reflects the committed state at the call moment — generation runs + after the tool contributions are committed + +--- + +Author: Goga +CreatedAt: 14/09/26 +Description: | + Declarative question-and-answer model of the onboarding session — + question records, nesting groups, and the session answer space. +``` + +**Usage file — create:** `goga/onboarding/questions/.usages/question-records.md` + +```md +# Question records — goga/onboarding/questions + +## Domain + +The declarative question-and-answer model of the onboarding session: +question records of every kind, nesting groups, and the session answer +space. Target audience: cells that build or survey a question tree, and +tool package authors whose session questions are declared as these +records. + +## Public API + + from goga.onboarding import Question, QuestionGroup, SessionAnswers + +- `Question(id, kind, prompt, choices=None, default=None, keys=None)` — one + simple question. Kinds: `choice` (answer — a string from `choices`), + `input` (answer — a free-form string), `confirm` (answer — a bool), + `pairs` (answer — a mapping of strings; `keys` proposes the keys). +- `QuestionGroup(id, prompt=None, children=None)` — one nesting level; the + group's answer is the mapping of its children's answers. +- `SessionAnswers` — the answer space of one run: `record`, `amend`, + `view_for`, `snapshot`. + +## Ready-to-use patterns + +### Declare a question of each kind + +```python +from goga.onboarding import Question, QuestionGroup + +language = Question(id="language", kind="choice", prompt="Project language", + choices=["python", "golang"], default="python") +image = Question(id="image", kind="input", prompt="Image name") +setup = Question(id="setup", kind="confirm", prompt="Configure the tool?", default=False) +env = Question(id="env", kind="pairs", prompt="Environment variables", + keys=["API_URL", "TOKEN"]) +``` + +### Declare a group + +```python +block = QuestionGroup(id="reporting", prompt="Reporting settings", + children=[setup, env]) +``` + +A group carries one nesting level with simple children; its answer is a +mapping keyed by child ids. + +### Address answers + +Paths join ids with dots (`reporting.env`); stored answers are nested +mappings — groups hold mappings, no dotted keys. `record` replaces at the +path; `amend` merges mappings recursively and replaces scalars and lists +(last applied wins); `view_for(tool)` returns the core plus the tool's own +answers under local names — other tools' answers are never visible; +`snapshot` returns the committed whole for generation. + +## Notes for the consumer + +- Question records are immutable value objects — build them fresh, never + mutate. +- The `id` is local to its parent; uniqueness matters among siblings of the + same tree position. +``` + +### 5. `goga/onboarding/participation` — CODEMANIFEST *(create)* + +```yaml +Imports: + - Types: + - Question + - QuestionGroup + - SessionAnswers + Usages: + - question-records + From: goga/onboarding/questions + - Types: + - HookRegistry + - wrap_context + - build_hook_arguments + - enumerate_tool_packages + Usages: + - per-tool-delivery + - registering-hooks + From: goga/hooks + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the tool participation in the onboarding session: the + invitation, the two onboarding action moments delivered per tool with + staged control, the tool declaration and contribution surfaces, and the + isolated answer views. The per-tool delivery composes `wrap_context` + and `build_hook_arguments` per the `per-tool-delivery` practice. A + failure of one tool never cancels another tool or the session; the + single fatal case is a broken package import. Every warning names the + tool, the action, and the reason. Use relative imports. + +--- + +"ToolDeclaration(tool: str, invited: bool)": + location: declaration.py + annotations: | + The moment-one surface delivered to one tool — the session declaration + context and its buffer. + + `tool`: the tool identity assigned by the platform + `invited`: the invitation marker — False marks a subscribed tool the + session did not invite + + Apply the `convention` practice for the code style and intra-package + imports. + Use the `question-records` practice for the declaration records. + Use the `registering-hooks` practice for the hook signature and the + failure handling behind the action. + + Requirements: + - A hook of a non-invited tool returns immediately — the False marker + is the contract rule; no member is called + - The declared questions and skips are buffered; the engine reads them + after the delivery of the moment completes + - A hook is never called to survey — the engine asks the declared + questions itself + properties: + "tool -> str": | + The tool identity of the owning tool. + "invited -> bool": | + The invitation marker of the session. + "questions -> list[Question | QuestionGroup]": | + The declared questions and groups, in declaration order. + "skips -> list[str]": | + The declared skip paths, in declaration order. + methods: + "declare(item: Question | QuestionGroup) -> _: None": | + Declare one question or one group of the tool's block. + + `item`: the question record or the one-level group + + Requirements: + - A group of a tool is limited to one nesting level with simple + children + - The local names are the tool's own — the engine qualifies them + with the tool identity + "skip(path: str) -> _: None": | + Declare one skip request. + + `path`: the raw path — unprefixed for the core tree or the tool's + own block, prefixed with a tool identity for another tool's block + + Requirements: + - The engine resolves and applies every declared skip as one set + after the whole declaration; individual pairs inside a pairs + question are not addressable + +"ToolContribution(tool: str, invited: bool, answers: dict)": + location: contribution.py + annotations: | + The moment-two surface delivered to one tool and the staged buffer of + its contribution — the amendments and the config files. + + `tool`: the tool identity assigned by the platform + `invited`: the invitation marker of the session + `answers`: the isolated answer view of the tool — the core answers plus + its own under local names + + Apply the `convention` practice for the code style and intra-package + imports. + Use the `registering-hooks` practice for the hook signature and the + failure handling behind the action. + + Requirements: + - A hook of a non-invited tool returns immediately + - The contribution is staged — the engine applies the buffered + amendments and writes the buffered files only after every hook of + the tool of this moment completed without failure + - The view carries nothing of the other tools — coordination goes + through amendments of shared sections + properties: + "tool -> str": | + The tool identity of the owning tool. + "invited -> bool": | + The invitation marker of the session. + "answers -> dict": | + The isolated answer view — the core and the tool's own answers. + "amendments -> list[tuple[str, str | bool | dict]]": | + The buffered amendments — the path and the value, in call order. + "files -> list[tuple[str, dict]]": | + The buffered config files — the file name and the data, in call + order. + methods: + "answer(id: str, value: str | bool | dict) -> _: None": | + Buffer one amendment of the collected configuration. + + `id`: the dot-path of the addressed entry + `value`: the amendment value + + Requirements: + - Merge semantics apply at the addressed location — mappings merge + recursively, scalars and lists replace + - Substituting a user's answer is silent — a lawful right of the + tool + - The registrations in the tools and usages sections are ordinary + amendments at their ids + "write_config(file: str, data: dict) -> _: None": | + Buffer one config file of the tool. + + `file`: the file name inside the tool's config directory + `data`: the serializable mapping of the file + + Requirements: + - The engine serializes and writes the file into the tool's config + directory; writing the same file again replaces it + - A tool never writes its config files itself — the engine API is + the single write path + +"ToolParticipation(invited: list[str])": + location: participation.py + annotations: | + The mediator of the tool participation — both onboarding action moments + delivered per tool with staged control. + + `invited`: the invited tool identities, deduplicated, in flag order + + Apply the `convention` practice for the code style and intra-package + imports. + Use the `per-tool-delivery` practice for the delivery loop. + Use the `registering-hooks` practice for the registration contract + behind the actions. + properties: + "invited -> list[str]": | + The invited tool identities, deduplicated, in flag order. + methods: + "collect_declarations() -> declarations: list[ToolDeclaration]": | + Deliver the moment one — the session declaration action. + + `declarations`: the declarations of the surviving tools, in + enumeration order + + Algorithm: + 1. Build the run registry — `HookRegistry` — once for the whole + session; a broken package import is a clean error naming the + package — the single fatal case + 2. Warn for every invited identity that is not among the installed + tool packages — resolved via `enumerate_tool_packages` — naming + it; the session continues without its block + 3. Deliver the declaration action to every subscriber per tool, in + enumeration order: an invited tool receives an active surface, a + subscribed tool without an invitation receives the not-invited + marker + 4. A failing hook of a tool drops that tool's whole declaration — a + warning, the session continues; the other tools stand + 5. Return the declarations of the surviving tools + + Requirements: + - An invited tool without a subscription to the action participates + silently — no block, no warning + "collect_contributions(answers: SessionAnswers) -> contributions: list[ToolContribution]": | + Deliver the moment two — the config amendment action — and commit the + surviving contributions. + + `answers`: the session answer space after the survey + `contributions`: the committed contributions, in enumeration order + + Algorithm: + 1. Deliver the amendment action to every subscriber per tool, in + enumeration order, each tool with its isolated answer view + 2. A failing hook of a tool discards its whole contribution — the + amendments and the files together — with a warning; the session + continues; the other tools stand + 3. Commit every surviving contribution: apply its buffered + amendments to `answers` in delivery order; collect its buffered + files + 4. Return the committed contributions + + Requirements: + - The committed amendments apply in delivery order — the enumeration + order of the tool identities; a conflicting leaf is won by the + last applied amendment + - The file buffer of a failed tool is discarded together with its + amendments + +--- + +Author: Goga +CreatedAt: 14/09/26 +Description: | + Tool participation in the onboarding session — the invitation, the two + onboarding action moments delivered per tool, the staged contributions, + and the isolated answer views. +``` + +**Usage files — create:** + +`goga/onboarding/participation/.usages/session-participation.md` + +```md +# Session participation — goga/onboarding/participation + +## Domain + +Mediating the tool participation in one onboarding session: the invitation +set, the declaration moment before the survey, the amendment moment after +it, and the staged commit of the surviving contributions. Target +audience: the session orchestrator. + +## Public API + + from goga.onboarding import ToolParticipation + +- `ToolParticipation(invited)` — the mediator of one session; `invited` is + the deduplicated list of tool names from the command line, in flag + order. +- `collect_declarations() -> list[ToolDeclaration]` — deliver + `onboarding/declare_session` per tool. Warns for every invited name not + among the installed tool packages; a failing hook of a tool drops that + tool's whole declaration; an invited tool without a subscription + participates silently. +- `collect_contributions(answers) -> list[ToolContribution]` — deliver + `onboarding/amend_config` per tool after the survey. A failing hook + discards the tool's whole contribution (amendments and files); the + surviving contributions are committed: amendments apply to `answers` in + delivery order, files are collected for generation. + +## Ready-to-use pattern + +### Run both moments around the survey + +```python +from goga.onboarding import SessionAnswers, ToolParticipation + +participation = ToolParticipation(invited=["my-tool", "viewer"]) +declarations = participation.collect_declarations() # moment one — before the survey +# ... assemble the plan, run the survey into answers ... +contributions = participation.collect_contributions(answers) # moment two — after +``` + +## Notes for the consumer + +- One registry per session — build and both deliveries share it; a broken + package import is the single fatal case (a clean error naming the + package). +- Tool failures are soft — every warning names the tool, the action, and + the reason; the session and the other tools continue. +- The returned contributions carry the committed file buffers — hand them + to the artifact generation. +``` + +`goga/onboarding/participation/.usages/tool-contexts.md` + +```md +# Onboarding contexts — goga/onboarding/participation + +## Domain + +What a tool package receives inside a `goga init` session and the member +contract of the two onboarding actions. Target audience: authors of +`goga_tool_*` packages that need project configuration. + +## Subscribing + +Register hooks for the two actions in the package facade — the tool +identity is assigned by goga from the package name: + +```python +def register_hooks(hooks): + hooks.subscribe("onboarding", "declare_session", "declare", declare_session) + hooks.subscribe("onboarding", "amend_config", "amend", amend_config) +``` + +A hook declares `context` (and optionally `self`) by name; values land by +name. Subscribing to one action only is fine — the moments are +independent. + +## Moment one — declare_session(context) + +Declare the tool's questions and skips as data; the engine asks them +itself after the core questions, under a heading with the tool's name. + +```python +from goga.onboarding import Question, QuestionGroup + +def declare_session(context): + if not context.invited: + return # contract rule: return immediately + context.declare(Question(id="token", kind="input", prompt="Service token")) + context.declare(QuestionGroup(id="reporting", prompt="Reporting", + children=[Question(id="enabled", kind="confirm", + prompt="Enable reporting?", default=False)])) + context.skip("docker_image.base_image") # unprefixed — core tree or own block +``` + +- `context.invited` — False means the session did not invite this tool: + return immediately, call nothing. +- `context.declare(item)` — a `Question` or a one-level `QuestionGroup`; + local names, the engine qualifies them with the tool identity. A + repeated local name is rejected with a warning; the rest of the + declaration stands. +- `context.skip(path)` — unprefixed for the core tree or the tool's own + block, `.`-prefixed for another tool's block. A skip removes the + whole subtree; unknown paths are a no-op with a warning. + +## Moment two — amend_config(context) + +Read the isolated answers and buffer the contribution. + +```python +def amend_config(context): + if not context.invited: + return + if context.answers.get("reporting", {}).get("enabled"): + context.answer("pipeline.env", {"REPORT_URL": "https://example.com"}) + context.answer("tools", {"my-tool": "latest"}) + context.write_config("service.yml", {"token_source": "env", "interval": 60}) +``` + +- `context.answers` — the core answers plus this tool's own answers under + local names; other tools' answers are never visible. +- `context.answer(id, value)` — buffer an amendment: mappings merge + recursively, scalars and lists replace; substituting a user's answer is + silent; registrations in `tools`/`usages` are ordinary amendments. +- `context.write_config(file, data)` — buffer a config file; the engine + serializes YAML and writes `.goga/tools//`; the same file + written again is replaced. + +## Failure behavior + +- An exception in a hook drops the tool's whole contribution with a + warning naming the tool and the reason; `goga init` continues and exits + 0. +- A broken package import is the single fatal case — a clean session + error naming the package. +``` + +### 6. `goga/onboarding/survey` — CODEMANIFEST *(create)* + +```yaml +Imports: + - Types: + - Question + - QuestionGroup + - SessionAnswers + Usages: + - question-records + From: goga/onboarding/questions + - Types: + - ToolDeclaration + From: goga/onboarding/participation + +Usages: + convention: .goga/usages/conventions.md + click: .goga/usages/cooks/click.md + image_defaults: | + The default Docker image hints depend on the selected language and carry + the current minor tag supplied at runtime (the image-tag input of + the core tree) — never a hardcoded tag. For languages with predefined + images, display the suggestions; default to the last entry. Accept + arbitrary user input for the image name. Language → image family + mapping (the tag completes the name): + - python: qarium/goga-python-{3.10-3.14}:{tag} + - golang: qarium/goga-golang-{1.23, 1.24, 1.25, 1.26}:{tag} + - javascript: qarium/goga-node-{22, 24}:{tag} + - kotlin: qarium/goga-kotlin-{2.0, 2.1, 2.2, 2.3}:{tag} + - swift: qarium/goga-swift-{6.0, 6.1, 6.2}:{tag} + agent_env_defaults: | + Map each agent to a list of environment variable keys for prompting. + Display the keys to the user; collect corresponding values. + Agent → env key mapping: + - claude: ANTHROPIC_BASE_URL, ANTHROPIC_DEFAULT_HAIKU_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL, ANTHROPIC_MODEL + - codex: CODEX_MODEL + - cursor: CURSOR_MODEL + - opencode: OPENCODE_MODEL, OPENCODE_VARIANT + - qwen: OPENAI_BASE_URL, OPENAI_MODEL + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the survey of the onboarding session: the core question + tree, the assembly of the session plan with the tool question blocks, + the application of skip requests, and the interactive run. The survey is + interactive on the host through the `click` practice; core sections are + conditional on the filesystem state. Questions are declarative data — + the engine asks them itself; a tool hook is never called to survey. Use + relative imports. + +--- + +"core_questions(image_tag: str, project_name: str | None, convention_exists: bool) -> tree: QuestionGroup": + location: core.py + annotations: | + Build the core question tree of the session — the eight core sections + in survey order. + + `image_tag`: the current minor tag for image hints (the `image_defaults` + practice completes image names with it) + `project_name`: the git-derived project name for the built-image name + default; None offers no default + `convention_exists`: True when the base conventions file already exists + `tree`: the core tree + + Apply the `convention` practice for docstring style and intra-package + imports. + Use the `image_defaults` practice for image hints. + Use the `agent_env_defaults` practice for the env key suggestions. + + Algorithm: + 1. Compose the sections in order: language, convention, codemanifest, + build, docker_image, pipeline, tools, usages + 2. Omit the convention section when `convention_exists` is True + 3. Set the image hints from the `image_defaults` practice completed + with `image_tag`; the built-image name default follows + `project_name` + + Requirements: + - language — a choice of the supported languages + - convention — an offer to adopt the base convention; acceptance + pre-fills the codemanifest section + - codemanifest — practice usages and annotations entries + - build — the task executor: agent and env (suggested keys per + `agent_env_defaults`) + - docker_image — the Dockerfile decision, the base image of the FROM + line, and the image name; the base image applies only when a + Dockerfile path is given; the children carry the local names + image, dockerfile, base_image — the mapping into the top-level + config fields belongs to the generation + - pipeline — agent and env (suggested keys per `agent_env_defaults`) + - tools — a confirm-gated repeated collection of name and version + pairs; version values follow the four-form version grammar, an + absent version reads as latest + - usages — a confirm-gated repeated record collection: group, + dependency name, git repository, optional ref and root; the answer + nests as {group: {dep: {git, ref, root}}} + - Answer values nest as mappings mirroring the project config schema + - The review section is not part of the tree — a future additive core + section + +"assemble_session_plan(core: QuestionGroup, declarations: list[ToolDeclaration]) -> plan: SessionPlan": + location: plan.py + annotations: | + Assemble the session plan — the core tree plus the tool question + blocks in one root. + + `core`: the core tree built by `core_questions` + `declarations`: the collected declarations of the run, in enumeration + order + `plan`: the assembled plan + + Apply the `convention` practice for docstring style and intra-package + imports. + Use the `question-records` practice for the record structure. + + Algorithm: + 1. Start from the children of `core` + 2. For each declaration in enumeration order: wrap its declared + questions into one group named by the tool identity and append it + after the core children + 3. A repeated local name within one tool's declaration is rejected — + the element of the declaration is dropped with a warning naming the + tool and the reason; the surviving elements of the same declaration + stand + + Requirements: + - Deterministic — the plan children order is the core order followed by + the enumeration order of the tools + - A tool without declarations contributes no block + - Rejections never cancel the surviving elements of the same tool + +"apply_skips(plan: SessionPlan, skips: list[tuple[str, str]]) -> plan: SessionPlan": + location: plan.py + annotations: | + Apply the declared skip requests to the plan. + + `plan`: the assembled plan + `skips`: the declared skips — the declaring tool identity and the raw + path + `plan`: the plan with the skipped subtrees removed + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Resolve every path against the plan root: an unprefixed path + addresses the core tree or the declaring tool's own block; a path + prefixed with a tool identity addresses that tool's block + 2. Remove every resolved node — the whole subtree under it + 3. Apply all skips as one set — the result does not depend on the + order of application + 4. A path resolving to nothing is a no-op announced with a warning + + Requirements: + - A skipped question is never asked; the tool whose question was + skipped tolerates the missing answer + - Inside a pairs question the individual pairs are not addressable — + only the node as a whole + +"SessionPlan(root: QuestionGroup, tools: list[str])": + location: plan.py + annotations: | + The assembled survey plan — one tree with the tool blocks as groups + named by tool identity. + + `root`: the plan root — the core children followed by the tool blocks + `tools`: the participating tools in block order + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "root -> QuestionGroup": | + The plan root — the core sections followed by the tool blocks. + "tools -> list[str]": | + The participating tools in block order. + +"Questionnaire()": + location: questionnaire.py + annotations: | + The interactive survey engine of the session. + + Apply the `convention` practice for the code style and intra-package + imports. + Use the `click` practice for prompting, confirmation, choices, and the + repeated collections. + Use the `image_defaults` practice to render image hints. + Use the `agent_env_defaults` practice to render env key suggestions. + methods: + "run(plan: SessionPlan, answers: SessionAnswers) -> _: None": | + Run the whole survey of one plan into the answer space. + + `plan`: the assembled plan with skips applied + `answers`: the session answer space receiving the collected values + + Algorithm: + 1. Display the session header and the wizard description + 2. Survey the core sections of `plan` in order + 3. Survey every tool block after the core, in block order, under an + attribution heading naming the tool + 4. Record every collected value into `answers` at its plan path + + Requirements: + - The core survey keeps its conditional patterns: the base image + applies only when a Dockerfile path was given; the image defaults + follow the Dockerfile branch + - A skipped subtree is never asked + "ask_question(question: Question) -> value: str | bool | dict[str, str]": | + Ask one simple question of its kind. + + `question`: the question record + `value`: the answer value of the kind + + Requirements: + - Render the prompt, the offered choices or keys, and the default of + the record; a free-form input is accepted where the kind allows it + "ask_group(group: QuestionGroup) -> value: dict": | + Ask one group — its children in order. + + `group`: the group node + `value`: the mapping of the children's answers keyed by child ids + + Requirements: + - Section headings and explanatory text precede the children; the + confirm-gated collections ask their gate first + +--- + +Author: Goga +CreatedAt: 14/09/26 +Description: | + Survey of the onboarding session — the core question tree, the session + plan assembly with skip requests, and the interactive run. +``` + +**Usage file — create:** `goga/onboarding/survey/.usages/survey-run.md` + +```md +# Survey run — goga/onboarding/survey + +## Domain + +Assembling the onboarding survey plan and running it interactively: the +core question tree, the tool question blocks, the skip requests, and the +click-driven survey. Target audience: the session orchestrator that builds +a plan and collects answers into the answer space. + +## Public API + + from goga.onboarding import core_questions, assemble_session_plan, apply_skips, Questionnaire + +- `core_questions(image_tag, project_name, convention_exists) -> QuestionGroup` + — the eight core sections in survey order; image hints carry + `image_tag`; the convention section is omitted when the file exists. +- `assemble_session_plan(core, declarations) -> SessionPlan` — one root: + core children, then one group per declaring tool in enumeration order. + A repeated local name within a tool drops that element with a warning; + the rest of the declaration stands. +- `apply_skips(plan, skips) -> SessionPlan` — removes the addressed + subtrees; unprefixed paths address the core tree or the declaring + tool's own block, `.`-prefixed paths address that tool's block; + unknown paths are a no-op with a warning; pairs are addressed only as a + whole. +- `Questionnaire().run(plan, answers)` — the interactive survey: session + header, core sections, tool blocks with attribution headings; values are + recorded into the answer space at their plan paths. + +## Ready-to-use pattern + +### Build the plan and run the survey + +```python +from goga.onboarding import ( + SessionAnswers, Questionnaire, apply_skips, assemble_session_plan, core_questions, +) + +core = core_questions(image_tag="1.3", project_name="my-app", convention_exists=False) +plan = assemble_session_plan(core, declarations) # declarations: from tool participation +plan = apply_skips(plan, skips) # skips: (tool, raw path) pairs +answers = SessionAnswers() +Questionnaire().run(plan, answers) +``` + +## Notes for the consumer + +- Skips are applied to the assembled plan as one set — order-independent; + run the survey only after `apply_skips`. +- The core survey keeps its conditional patterns: the Dockerfile branch + decides the image questions; a confirm-gated collection asks its gate + first. +- Questions are declarative data — the engine asks them; nothing calls a + tool hook to survey. +``` + +### 7. `goga/onboarding/generator` — CODEMANIFEST *(create)* + +```yaml +Imports: + - Types: + - SessionAnswers + Usages: + - question-records + From: goga/onboarding/questions + - Types: + - ToolContribution + Usages: + - session-participation + From: goga/onboarding/participation + +Usages: + convention: .goga/usages/conventions.md + yaml: | + Use yaml.dump() to generate .goga/config.yml and the tool config files. + PyYAML library. Set default_flow_style=False for human-readable output. + lang_conventions: | + Download base language conventions from the qarium/goga-lang-conventions repository (branch 0.0.x). + URL template: https://raw.githubusercontent.com/qarium/goga-lang-conventions/refs/heads/0.0.x/{language}/project.md + The language identifier maps directly to the URL path segment (no mapping layer). + Save the downloaded file to .goga/usages/conventions.md. + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the artifact generation of the session: the project + config, the Dockerfile, the base conventions download, and the tool + config files — written from the committed answer space and the committed + tool contributions. An existing .goga/config.yml is never rewritten — + whoever created it first wins; the guarantee lives here, not only at the + caller. Use relative imports. + +--- + +"FileGenerator()": + location: generator.py + annotations: | + The artifact generator of the session. + + Apply the `convention` practice for the code style and intra-package + imports. + Use the `yaml` practice for every YAML serialization. + Use the `question-records` practice for the answer space structure. + Use the `session-participation` practice for the committed + contributions. + methods: + "generate(answers: SessionAnswers, contributions: list[ToolContribution]) -> files: list[CreatedFile]": | + Generate every artifact of the session and report the created files. + + `answers`: the committed answer space of the session + `contributions`: the committed contributions, in enumeration order + `files`: the created files with attribution — an engine file carries + a None tool, a tool file carries the tool identity + + Algorithm: + 1. An existing .goga/config.yml skips the config and the Dockerfile + generation — the file is never rewritten + 2. Otherwise: create the Dockerfile when the answers carry a + Dockerfile path — FROM its base image; then generate the project + config + 3. Generate the tool config files of `contributions` + 4. Return the created files with attribution, in generation order + + Requirements: + - The return value is the single source of the final file report + "generate_goga_config(answers: SessionAnswers) -> _: None": | + Generate .goga/config.yml from the answer snapshot. + + `answers`: the committed answer space + + Algorithm: + 1. Take the snapshot of `answers` + 2. An empty required language field is a clean error of the session + naming the field + 3. Download the base conventions file per the `lang_conventions` + practice when the codemanifest usages carry the conventions entry + — a download failure is a clean error with the URL and the cause + 4. Create the .goga/ directory when missing + 5. Serialize the YAML document per the `yaml` practice, preserving + the field order and rendering the annotations as a literal block + + Snapshot → YAML field mapping: + - language → language (top level) + - docker_image.image → image (top level) + - docker_image.dockerfile → dockerfile (top level, omitted when absent) + - docker_image.base_image → the Dockerfile FROM line only — never + emitted to the config + - build.task_executor.agent → build.task_executor.agent (omitted when absent) + - build.task_executor.env → build.task_executor.env (omitted when absent or empty) + - pipeline.agent / pipeline.env → the pipeline block (same omission rules) + - codemanifest.usages / codemanifest.annotations → the codemanifest block + - tools → tools (omitted when absent or empty) + - usages → usages (omitted when absent or empty) + - convention and the confirm gates are presentational — their answers + are never carried into the config (the convention acceptance + pre-fills codemanifest; a gate only gates its collection) + + Requirements: + - Field order in the document: language, image, dockerfile, build, + pipeline, codemanifest, tools, usages + - The build and pipeline blocks appear only when they carry content + - The written file passes the core schema loader of the project + config + "generate_tool_configs(contributions: list[ToolContribution]) -> _: None": | + Generate the tool config files from the committed contributions. + + `contributions`: the committed contributions, in enumeration order + + Algorithm: + 1. For every contribution, for every buffered file in call order: + serialize the data per the `yaml` practice and write it into the + tool's config directory under the buffered file name + 2. Writing the same file name again replaces the file + + Requirements: + - The engine is the single write path of the tool configs — the + buffered data is written verbatim, without interpretation + +"CreatedFile(path: str, tool: str | None)": + location: generator.py + annotations: | + One entry of the final file report — a created file with its + attribution. + + `path`: the created file path relative to the project root + `tool`: the tool identity for a tool file; None for an engine file + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "path -> str": | + The created file path relative to the project root. + "tool -> str | None": | + The tool identity of the file; None for an engine file. + +--- + +Author: Goga +CreatedAt: 14/09/26 +Description: | + Artifact generation of the onboarding session — the project config, the + Dockerfile, the base conventions, the tool configs, and the final file + report. +``` + +**Usage file — create:** `goga/onboarding/generator/.usages/artifact-generation.md` + +```md +# Artifact generation — goga/onboarding/generator + +## Domain + +Writing the onboarding session artifacts from the committed answer space +and the committed tool contributions: `.goga/config.yml`, the Dockerfile, +the base conventions file, and the tool config files. Target audience: the +session orchestrator. + +## Public API + + from goga.onboarding import FileGenerator + +- `FileGenerator().generate(answers, contributions) -> list[CreatedFile]` — + every artifact in generation order with attribution (`CreatedFile.tool` + is None for engine files, the tool identity for tool files). An existing + `.goga/config.yml` skips the config and Dockerfile generation — never + rewritten, whoever created it first wins. +- `generate_goga_config(answers)` — the project config from the answer + snapshot: field order language, image, dockerfile, build, pipeline, + codemanifest, tools, usages; empty build/pipeline blocks omitted; + annotations rendered as a literal block. An empty required `language` is + a clean session error naming the field; the conventions download failure + is a clean error with the URL and the cause. +- `generate_tool_configs(contributions)` — every buffered tool file + serialized as YAML into `.goga/tools//`; the same file name + written again replaces the file. + +## Ready-to-use pattern + +### Generate after the survey and the committed contributions + +```python +from goga.onboarding import FileGenerator + +files = FileGenerator().generate(answers, contributions) +for entry in files: + if entry.tool is None: + print(f"created {entry.path}") + else: + print(f"created {entry.path} (tool: {entry.tool})") +``` + +## Notes for the consumer + +- Call `generate` once, after the tool contributions are committed — the + snapshot is read at that moment. +- The return value is the single source of the final file report — render + it with the tool attribution. +- The written config must pass the project config loader — the mapping + above is normative. +``` + +### 8. `goga/onboarding` — CODEMANIFEST *(facade, modify)* + +**Diff — full document replacement** (the facade becomes a domain facade; the old body is deleted). +Deleted elements of the old manifest: the `InitAnswers`, `GogaConfigAnswers`, `Questionnaire`, +`FileGenerator`, and `InitLogic` blocks; the `click`, `yaml`, `lang_conventions`, `image_defaults`, +and `agent_env_defaults` practices (they move into the survey/generator leaves); the import of +`resolve_project_name` from `goga/config` stays. The new document in full: + +```yaml +Imports: + - Types: + - Question + - QuestionGroup + - SessionAnswers + From: goga/onboarding/questions + - Types: + - Questionnaire + - SessionPlan + - core_questions + - assemble_session_plan + - apply_skips + Usages: + - survey-run + From: goga/onboarding/survey + - Types: + - ToolParticipation + - ToolDeclaration + - ToolContribution + Usages: + - session-participation + From: goga/onboarding/participation + - Types: + - FileGenerator + - CreatedFile + Usages: + - artifact-generation + From: goga/onboarding/generator + - Types: + - minor_version + - host_goga_version + Usages: + - minor-line + From: goga/version + - Types: + - resolve_project_name + From: goga/config + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell is the facade of the onboarding domain: it owns the session + orchestration and re-exports the public session API of the leaf cells — + the question-and-answer model, the survey, the tool participation, and + the artifact generation. Consumers address the domain through this + facade only. Use relative imports. + +--- + +"InitLogic(questionnaire: Questionnaire, generator: FileGenerator, participation: ToolParticipation)": + location: logic.py + annotations: | + The orchestrator of one initialization session. + + `questionnaire`: the survey engine + `generator`: the artifact generator + `participation`: the tool participation mediator + + Apply the `convention` practice for the code style and intra-package + imports. + Use the `minor-line` practice for reading the installed version and + deriving the image tag. + Use the `session-participation` practice for the two tool moments. + Use the `survey-run` practice for the plan assembly and the survey. + Use the `artifact-generation` practice for the generation and the file + report. + methods: + "run() -> exit_code: int": | + Run the whole session. + + `exit_code`: 0 on success, nonzero on a session error + + Algorithm: + 1. An existing .goga/config.yml ends the session — no question is + asked, no tool event is delivered, no artifact is written + 2. Read the installed goga version — `host_goga_version` — and derive + its minor line — `minor_version` — for the image hints; an + unreadable version is a clean session error without a traceback + 3. Deliver the declaration moment via `ToolParticipation` — it warns + for every invited identity that is not among the installed tool + packages + 4. Build the core tree — `core_questions`, with `image_tag` from + step 2, the project name from `resolve_project_name`, and + convention_exists read from the existence of + .goga/usages/conventions.md; assemble the plan with the collected + declarations; flatten every declaration's buffered skips into + (tool identity, raw path) pairs and apply them via `apply_skips` + 5. Run the survey into the answer space via `Questionnaire` + 6. Deliver the amendment moment and commit the surviving tool + contributions via `ToolParticipation` + 7. Generate the artifacts via `FileGenerator` and render the final + file report with the attribution + 8. Return 0 + + Requirements: + - A tool failure never changes the exit code — the softness of the + tool moments is theirs + - A session error is one clean message — a broken package import, an + unreadable version, an empty required field at generation — never + a traceback + +->Question: {} +->QuestionGroup: {} +->SessionAnswers: {} +->SessionPlan: {} +->Questionnaire: {} +->core_questions: {} +->assemble_session_plan: {} +->apply_skips: {} +->ToolParticipation: {} +->ToolDeclaration: {} +->ToolContribution: {} +->FileGenerator: {} +->CreatedFile: {} + +--- + +Author: Goga +CreatedAt: 14/09/26 +Description: | + Facade of the onboarding domain — the initialization session + orchestration and the public session API. +``` + +**Usage file — rewrite:** `goga/onboarding/.usages/onboarding-usage.md` + +```md +# Project Onboarding — goga/onboarding + +## Domain + +Interactive initialization of a goga project: one session that surveys the +core configuration and the invited tool questions, applies the tool +amendments, and writes the project artifacts. Target audience: the init +command and embedding code. + +## Facade + +Import all types directly from `goga.onboarding`: + +```python +from goga.onboarding import ( + CreatedFile, FileGenerator, InitLogic, Question, QuestionGroup, + Questionnaire, SessionAnswers, SessionPlan, ToolParticipation, + apply_skips, assemble_session_plan, core_questions, +) +``` + +## Usage + +### Run a session with invited tools + +```python +from goga.onboarding import FileGenerator, InitLogic, Questionnaire, ToolParticipation + +logic = InitLogic( + questionnaire=Questionnaire(), + generator=FileGenerator(), + participation=ToolParticipation(invited=["my-tool", "viewer"]), +) +exit_code = logic.run() +``` + +**Returns:** exit code (0 — success, nonzero — a session error). + +**Session flow:** an existing `.goga/config.yml` ends the session +immediately — no questions, no tool events, no artifacts; otherwise the +session reads the installed version (clean error when unreadable), +collects the tool declarations, surveys the core tree and the tool blocks +with attribution, collects and commits the tool contributions, generates +`.goga/config.yml`, the Dockerfile, and the tool configs, and reports the +created files with tool attribution. + +### Behavior guarantees + +- A failing tool is soft: its contribution is discarded with a warning + naming the tool and the reason; the session continues and returns 0. +- An invited but not installed tool name is a warning; the session + continues. +- Session errors are single clean messages without a traceback: a broken + package import (named), an unreadable installed version, an empty + required `language` at generation (named). +- The image hints carry the minor tag of the installed goga version. + +## Notes for the consumer + +- Onboarding is filesystem-conditional: an existing `.goga/config.yml` is + never rewritten — whoever created it first wins. +- Pass the deduplicated invited names in flag order to + `ToolParticipation`; without invitations the session contains no tool + blocks and matches the plain behavior. +``` + +### 9. `goga/commands/init` — CODEMANIFEST *(modify)* + +**Diff — Imports:** add the type `ToolParticipation` to the existing import record from `goga/onboarding`: + +```yaml + - Types: + - InitLogic + - Questionnaire + - FileGenerator + - ToolParticipation + Usages: + - onboarding-usage + From: goga/onboarding +``` + +**Diff — Body:** replace the `init` routine block in full with: + +```yaml +"init(tpl: str | None, upgrade: bool, ref: str | None, tools: tuple[str, ...]) -> exit_code: int": + location: init.py + annotations: | + CLI wrapper for the initialization command. Integrates two independent + domains — onboarding and scaffold — and owns the mode routing, + execution order, already-initialized guard, and the tool invitation + flag. Delegates execution to `InitLogic` (onboarding) and `Scaffold` + (scaffold). + + `tpl`: optional positional — git URL of a copier template, optionally with a ref fragment (url.git#ref) + `upgrade`: when True, run template migration only (no onboarding) + `ref`: explicit git ref overriding the URL fragment (`tpl`) or the migration target ref (`upgrade`) + `tools`: the invited tool names from the repeated -t/--tool flag; an empty tuple when absent + `exit_code`: 0 on success, nonzero on error, already-initialized, or invalid argument combination + + Use the `onboarding-usage` practice for the session API and the + invitation semantics. + Use the `scaffold-usage` practice for the Scaffold API. + + Algorithm: + 1. Validate `ref` placement: if `ref` is not None and `tpl` is None and not `upgrade` -> emit + "--ref requires or --upgrade" and return nonzero (ref is meaningful only with a template + source — primary generation or migration target) + 2. Determine mode: if `upgrade` and `tpl` are both given -> emit " and --upgrade are mutually + exclusive (--upgrade updates existing state tied to a specific repository)" and return nonzero; + otherwise `upgrade` -> UPGRADE; `tpl` is not None -> SCAFFOLD_THEN_ONBOARDING; otherwise + BARE_ONBOARDING + 3. Validate the invitation flag: if `tools` is non-empty and the mode is UPGRADE -> emit a message + stating that -t/--tool requires an onboarding session and --upgrade runs none; return nonzero + 4. Deduplicate `tools` preserving the flag order — one invitation per name, one block per tool + 5. Already-initialized guard: if BARE_ONBOARDING and the .goga/ directory exists -> emit + "Project already initialized" and return nonzero (the guard does NOT fire when `tpl` is given) + 6. Dispatch: + - UPGRADE: construct `Scaffold`; return Scaffold.upgrade(`ref`) + - SCAFFOLD_THEN_ONBOARDING: construct `Scaffold`; sc = Scaffold.generate(`tpl`, `ref`); if sc + nonzero return sc; otherwise construct `InitLogic`(`Questionnaire`, `FileGenerator`, + `ToolParticipation`(`tools`)) and return its run() + - BARE_ONBOARDING: construct `InitLogic`(`Questionnaire`, `FileGenerator`, + `ToolParticipation`(`tools`)) and return its run() + + Requirements: + - The invitation acts in both modes that run onboarding (bare and template-given); a repeated name + deduplicates into one block + - The command passes the names through as opaque data — installation checks and warnings belong + to the onboarding domain + - scaffold runs before onboarding when `tpl` is given (template may bring .goga/ artefacts that + onboarding then skips) + - the already-initialized marker is the .goga/ directory, not a specific file + + Constraints: + - Do not combine --upgrade with or with a non-empty `tools` — both combinations are rejected + with a nonzero exit and a clear message + - Do not run onboarding in UPGRADE mode + - Do not fire the already-initialized guard when `tpl` is given + - Do not accept a bare --ref (no , no --upgrade) + - The command delegates execution — it does not implement onboarding, invitation, or copier logic + itself +``` + +The rest (Usages: `click`, `conventions`; the global Annotations; the footer) — verbatim. + +**Usage file — update:** `goga/commands/init/.usages/init.md` — the syntax +`goga init [] [-t ]... [--upgrade] [--ref ]`; the `-t/--tool` option +(repeatable, dedup preserving the flag order, acts in the bare and `` modes, +rejected with `--upgrade` with a nonzero exit); extend the mode rows, examples, and +exit codes accordingly; the material is aligned with `onboarding-usage` (the +invitation semantics belong to the onboarding domain). + +## Dependency Map + +``` +goga/version ──(minor_version, host_goga_version)───────────────────────────▶ goga/onboarding (InitLogic) +goga/config ──(resolve_project_name)────────────────────────────────────────▶ goga/onboarding (InitLogic) + +goga/hooks/catalog ──(declared_actions)──────────────────▶ goga/hooks (facade) +goga/hooks/registry ──(HookRegistry, ToolHooks)───────────▶ goga/hooks (facade) +goga/hooks/dispatch ──(emit_hook_event, wrap_context, + build_hook_arguments)──────────────▶ goga/hooks (facade) +goga/hooks/tools ──(enumerate_tool_packages)───────────▶ goga/hooks (facade) + +goga/hooks ──(wrap_context, build_hook_arguments, HookRegistry, + enumerate_tool_packages)────────────────────▶ goga/onboarding/participation + +goga/onboarding/questions ──(Question, QuestionGroup, SessionAnswers)──▶ survey, participation, generator, onboarding facade +goga/onboarding/participation ──(ToolDeclaration)──▶ survey +goga/onboarding/participation ──(ToolContribution)──▶ generator + +goga/onboarding (facade) ──(re-export of the 13 types + InitLogic)──▶ questions, survey, participation, generator +goga/onboarding ──(InitLogic, Questionnaire, FileGenerator, ToolParticipation)──▶ goga/commands/init +goga/scaffold ──(Scaffold)───────────────────────────────────────────▶ goga/commands/init (unchanged) +``` + +The dependency direction is fixed and one-way; the graph has no reverse edges or cycles. +Consumers of the domain capabilities address the facades (`goga/hooks`, `goga/onboarding`); +inside the domains, the leaves connect directly. + +## Verification Checklist + +After materializing each artifact: + +- [ ] `goga lint` — 0 errors across all project cells (DSL syntax, reference closure, import rules). +- [ ] `goga schema` — the tree assembles; the new cells `goga/onboarding/{questions,survey,participation,generator}` are present; the `goga/onboarding` facade re-exports the 13 types + `InitLogic`; `goga/version` contains `minor_version`. +- [ ] `goga hooks` — the `onboarding/declare_session` and `onboarding/amend_config` records (soft) are visible. +- [ ] Cell `goga/onboarding/questions`: immutable question records; `SessionAnswers` — merge/last-wins/isolation per the tests in `tests/onboarding/questions/`. +- [ ] Cell `goga/onboarding/participation`: per-tool delivery (without `emit_hook_event` for the onboarding addresses), staged discarding of a failing tool's contribution, warnings (name/action/reason), fatal only on a broken import; tests in `tests/onboarding/participation/` (+ a `goga_tool_*` fixture package through the public surface). +- [ ] Cell `goga/onboarding/survey`: plan assembly (a duplicate id — element rejection), skips (subtree, order independence, no-op for an unknown path), block attribution; tests in `tests/onboarding/survey/`. +- [ ] Cell `goga/onboarding/generator`: config.yml passes `load_project_config`; the generator never rewrites an existing config.yml; an empty `language` — a clean error naming the field; tool configs in `.goga/tools//`; the final list with attribution; tests in `tests/onboarding/generator/`. +- [ ] Facade `goga/onboarding`: `InitLogic.run` — the existing-config guard (no questions/events/artifacts), the `N.M` tag from the installed version, clean errors without a traceback; tests in `tests/onboarding/`. +- [ ] `goga/commands/init`: `-t` repeatable (deduplication), `-t`+`--upgrade` — a nonzero exit with a message; without `-t` — the existing behavior; tests in `tests/commands/test_init.py` per the `cli-commands` convention. +- [ ] The existing behavior/contracts/tests of the hooks, scaffold, and config platforms — unchanged (additivity). +- [ ] Author-contract documentation: `docs/features/init/hooks.md` (modeled on the domain + hooks pages: action declaration, moment 1/2, the non-invited tool contract, + answer isolation, staged failure resilience) + cross-references from + `docs/features/tools/hooks.md`; the material matches `.usages/tool-contexts.md`. +- [ ] Final acceptance: S1–S14 from task.md (see [CELL_ASSEMBLY_REPORT]; all 14 are covered). diff --git a/.goga/history/2026/onboarding-refctoring/completed/plan.md b/.goga/history/2026/onboarding-refctoring/completed/plan.md new file mode 100644 index 00000000..eaa677b2 --- /dev/null +++ b/.goga/history/2026/onboarding-refctoring/completed/plan.md @@ -0,0 +1,1449 @@ +# Plan: `onboarding-refctoring` — extensible `goga init` onboarding (tool participation via hooks actions + dynamic image tag) + +## Purpose + +Materialize the contracts of the topic "Extensible `goga init` onboarding: tool +participation via hooks actions and the dynamic image tag" into code: the four new +onboarding leaf cells (questions, participation, survey, generator), the +rewritten onboarding domain facade with the new `InitLogic`, the CLI `-t/--tool` +invitation flag, the `minor_version` tag routine, the two onboarding catalog +records, and the three hooks-facade re-exports. + +After implementation the package provides: +- a declarative question-and-answer model (`Question`, `QuestionGroup`, + `SessionAnswers`) shared by every session participant; +- per-tool participation in the session through the two hooks actions + (`onboarding/declare_session`, `onboarding/amend_config`) delivered per tool + with staged control and isolated answer views; +- a survey engine that asks the declarative records (core sections + tool + blocks) and records answers at plan dot-paths; +- artifact generation from the committed answer space (Dockerfile, config.yml, + conventions download, tool configs) with attribution; +- image hints completed with the runtime minor tag — never a hardcoded tag; +- `goga init [-t name]...` with dedup, invitation validation, and opaque + passthrough. + +The most important gaps between contract and code: none of the four leaf cells +exists yet (only CODEMANIFESTs); the old flat `InitAnswers`/`GogaConfigAnswers` +model, the old per-field `Questionnaire.ask_*` engine, and the old +`FileGenerator.generate(answers)` API must be replaced (modules deleted, logic +ported); `goga.hooks` does not yet re-export the delivery primitives; the +catalog carries no onboarding records; `minor_version` does not exist; the +`init` command has no `-t` flag. + +Overall implementation strategy: dependency order leaves → root +(version → hooks catalog → hooks facade → questions → participation → survey → +generator → onboarding facade → commands/init), TDD per coding task, full +suite + `goga lint` at the end. The old modules are deleted in the facade +rewrite task — never earlier (the old facade imports them until then). + +## Context + +### Contract Surface + +**Entity: `minor_version(version: str) -> minor: str`** +- Type: function (Routine) +- Declared `location`: `goga/version/version.py` +- Facade obligation: must be importable from `goga.version` +- Properties/Methods: none (routine) +- Semantic requirements from descriptions: reduce a version string to its `N.M` + line; reuse the module-private `_release_segments` reducer (version.py:71); + a missing minor segment is treated as `"0"`; an argument with no leading + numeric major raises `ValueError` (message from the shared reducer); pure + function — deterministic, no I/O, no logging; richer tails reduce silently + (`1.2.1.dev3`, `1.2.0rc1`, `1.2.0.post1`, `1.2.0+local` → `1.2`); do not + read the installed version — the caller owns the metadata boundary +- Imported dependencies: none +- Annotation context: `convention` practice (docstring style, pure-function + discipline); mirrors `resolve_version`'s shape recognition + +**Entity: `declared_actions()` data change (+2 records)** +- Type: data change in `goga/hooks/catalog/catalog.py` (`_DECLARED_ACTIONS`) +- Facade obligation: re-exported through `goga.hooks` (already present — + `from .catalog import declared_actions`) +- Semantic requirements: add + `Action(domain="onboarding", name="declare_session", error_class="soft")` + and `Action(domain="onboarding", name="amend_config", error_class="soft")`; + published records are never rewritten (the statuses record is untouched); + ordering stays domain-then-name (`onboarding/amend_config`, + `onboarding/declare_session`, `statuses/register_statuses`); the catalog + stays supported-data only +- Annotation context: catalog contract Requirements already list all three + records — the code must now match + +**Entity: `goga.hooks` facade re-exports (+3)** +- Type: re-export (embeddings `->wrap_context: {}`, `->build_hook_arguments: {}`, `->enumerate_tool_packages: {}`) +- Declared `location`: `goga/hooks/__init__.py` +- Facade obligation: `wrap_context`, `build_hook_arguments` importable from + `goga.hooks` (source `goga/hooks/dispatch`), `enumerate_tool_packages` + importable from `goga.hooks` (source `goga/hooks/tools`); all three in + `__all__` +- Semantic requirements: both sub-facades already export them + (dispatch/__init__.py, tools/__init__.py verified); importing `goga.hooks` + imports no `goga_tool_*` package and enumerates nothing (facade docstring + invariant holds); no local name shadows the imports; the existing + `declared_actions` re-export is untouched + +**Entity: `Question(id, kind, prompt, choices=None, default=None, keys=None)`** +- Type: class (Entity, frozen dataclass `kw_only=True`) +- Declared `location`: `goga/onboarding/questions/questions.py` +- Facade obligation: must be importable from `goga.onboarding.questions (and + re-exported by `goga.onboarding`) +- Properties: `id -> str` (local name, unique among siblings), `kind -> str` + (choice | input | confirm | pairs), `prompt -> str`, `choices -> + list[str] | None`, `default -> str | bool | None`, `keys -> list[str] | None` +- Semantic requirements: the record carries data only — rendering and answer + validation belong to the survey engine; the kind fixes the parameterization + (`choices` for choice, `default` for input/confirm, `keys` for pairs); + answer values: choice/input — string, confirm — boolean, pairs — mapping of + strings; no validation at construction (kinds are checked at ask time) +- Annotation context: `convention` data-model rules + +**Entity: `QuestionGroup(id, prompt=None, children=None)`** +- Type: class (Entity, frozen dataclass `kw_only=True`) +- Declared `location`: `goga/onboarding/questions/questions.py` +- Facade obligation: importable from `goga.onboarding.questions` (and `goga.onboarding`) +- Properties: `id -> str`, `prompt -> str | None` (None for a purely structural + node), `children -> list[Question | QuestionGroup] | None` +- Semantic requirements: the tree path (ids from root joined by dots) addresses + the node in skip requests and answer paths; a group's answer value is a + nested mapping keyed by child ids — never a flat dotted key; a tool-declared + group is limited to one nesting level with simple children + +**Entity: `SessionAnswers(tools: list[str] | None = None)`** +- Type: class (Entity — the single mutable accumulator of one run) +- Declared `location`: `goga/onboarding/questions/answers.py` +- Facade obligation: importable from `goga.onboarding.questions` (and `goga.onboarding`) +- Methods: + - `record(id: str, value: str | bool | dict) -> None` — resolve `id` + segment by segment creating intermediate mappings; set the value at the + leaf; recording REPLACES (never merges) + - `amend(id: str, value: str | bool | dict) -> None` — same walk; an + existing mapping at the leaf merges recursively with `value`; scalars and + lists replace; an absent leaf is created; silent + - `view_for(tool: str) -> view: dict` — core section (every top-level key + except the reserved tool-section names given at construction) plus the + tool's own section re-keyed by local names without the tool prefix; a deep + copy — a snapshot; other tools never present + - `snapshot() -> view: dict` — deepcopy of the complete nested structure +- Semantic requirements: created empty — `tools` reserves the top-level keys + without creating them; nested mappings only, no dotted keys ever stored; + amendments apply in delivery order — a later amendment wins at every + conflicting leaf; substituting a user's answer is silent +- Edge semantics: `record` over an existing scalar with a deeper path replaces + the scalar with an intermediate mapping (the survey is authoritative); + `view_for` of a tool without a recorded section → core-only view; a local + name colliding with a core key wins in THAT tool's view only (update order) + +**Entity: `ToolDeclaration(tool: str, invited: bool)`** +- Type: class (Entity — moment-one surface + buffer) +- Declared `location`: `goga/onboarding/participation/declaration.py` +- Facade obligation: importable from `goga.onboarding.participation` (and `goga.onboarding`) +- Properties: `tool -> str`, `invited -> bool`, `questions -> + list[Question | QuestionGroup]` (declaration order), `skips -> list[str]` +- Methods: `declare(item)` — buffer one question or one-level group; a group + whose children contain a `QuestionGroup` is refused with a logged warning + naming the tool and the reason, the element is NOT buffered (structural + violations are warnings, never exceptions); `skip(path)` — append the raw + path string, no resolution here +- Semantic requirements: a hook of a non-invited tool returns immediately — no + member is called; buffered data is read by the engine after delivery; a hook + is never called to survey + +**Entity: `ToolContribution(tool: str, invited: bool, answers: dict)`** +- Type: class (Entity — moment-two surface + staged buffer) +- Declared `location`: `goga/onboarding/participation/contribution.py` +- Facade obligation: importable from `goga.onboarding.participation` (and `goga.onboarding`) +- Properties: `tool -> str`, `invited -> bool`, `answers -> dict` (the isolated + view), `amendments -> list[tuple[str, str | bool | dict]]` (call order), + `files -> list[tuple[str, dict]]` (call order) +- Methods: `answer(id, value)` — buffer one amendment; `write_config(file, + data)` — buffer one config file; the engine serializes and writes — a tool + never writes its config files itself; writing the same file again replaces + at write time +- Semantic requirements: the contribution is staged — buffered amendments and + files apply only after every hook of the tool completed without failure; the + view carries nothing of the other tools + +**Entity: `ToolParticipation(invited: list[str])`** +- Type: class (Entity — mediator of both onboarding action moments) +- Declared `location`: `goga/onboarding/participation/participation.py` +- Facade obligation: importable from `goga.onboarding.participation` (and `goga.onboarding`) +- Properties: `invited -> list[str]` (deduplicated, flag order — defensive + dedup in the constructor via `list(dict.fromkeys(invited))`) +- Methods: + - `collect_declarations() -> list[ToolDeclaration]` — build the run registry + once (`HookRegistry()`; `build_once()`; `ImportError` propagates — the + single fatal case); warn for every invited identity not among + `enumerate_tool_packages()` naming it; group + `registry.subscriptions_for("onboarding", "declare_session")` per tool + preserving enumeration order; per tool: `surface = ToolDeclaration(tool, + invited=tool in self._invited)`, `proxy = wrap_context(surface)`, per + subscription `hook(**build_hook_arguments(hook, proxy, + registry.self_context(tool)))`; any `Exception` from a hook of the tool → + the whole declaration is discarded with a warning naming tool, action, + reason; return surviving declarations in enumeration order + - `collect_contributions(answers: SessionAnswers) -> list[ToolContribution]` + — same delivery over `subscriptions_for("onboarding", "amend_config")` + with `surface = ToolContribution(tool, invited, answers=answers.view_for(tool))`; + a failing hook discards the tool's whole contribution (amendments AND + files) with a warning; commit pass in enumeration order: + `answers.amend(path, value)` per buffered amendment; return the committed + contributions +- Semantic requirements: every warning names the tool, the action, and the + reason; an invited tool without a subscription participates silently; a + subscribed tool without an invitation receives the not-invited marker (the + hook returns immediately — the marker is never filtered by the platform); + `_registry` is built lazily and shared by both moments +- Imported dependencies: `Question`, `QuestionGroup`, `SessionAnswers` + + `question-records` usage (from `goga/onboarding/questions`); `HookRegistry`, + `wrap_context`, `build_hook_arguments`, `enumerate_tool_packages` + + `per-tool-delivery`, `registering-hooks` usages (from `goga/hooks`) + +**Entity: `core_questions(image_tag: str, project_name: str | None, convention_exists: bool) -> tree: QuestionGroup`** +- Type: function (Routine) +- Declared `location`: `goga/onboarding/survey/core.py` +- Facade obligation: importable from `goga.onboarding.survey` (and `goga.onboarding`) +- Semantic requirements: build the eight sections in survey order — `language` + (choice, order python/golang/kotlin/swift/javascript), `convention` (only + when `convention_exists` is False; a confirm `adopt` with default False), + `codemanifest` (usages pairs + annotations input), `build` (agent choice + + env pairs), `docker_image` (dockerfile input default `.goga/Dockerfile`, + base_image input with the tag-completed hints embedded in the prompt and + default = LAST hint, image input with default `f"{project_name}:latest"` or + no default when the name is None), `pipeline` (agent choice + env pairs), + `tools` (pairs: name → version; empty version reads as latest; the four + grammar forms documented in the prompt), `usages` (structural group, no + declarable children — the engine drives the record loop); return + `QuestionGroup(id="core", children=sections)` — the root id is never + addressed in answers; the tag is never hardcoded (completed from + `image_tag` via the `image_defaults` practice mapping) +- Imported dependencies: `Question`, `QuestionGroup` (from questions cell) + +**Entity: `assemble_session_plan(core: QuestionGroup, declarations: list[ToolDeclaration]) -> plan: SessionPlan`** +- Type: function (Routine) +- Declared `location`: `goga/onboarding/survey/plan.py` +- Facade obligation: importable from `goga.onboarding.survey` (and `goga.onboarding`) +- Semantic requirements: `children = list(core.children)`; `reserved = {child.id + for child in core.children}` (derived from the RECEIVED core — no hardcoded + name list); per declaration in enumeration order: empty `questions` → no + block; `declaration.tool in reserved` → warning naming the tool and the + reserved name, the whole block is dropped (fix q2); local-name dedup within + the declaration (a repeated id drops THAT element with a warning naming the + tool and the reason; survivors stand); append `QuestionGroup(id=tool, + prompt=f"--- Tool: {tool} ---", children=survivors)` and `tools.append(tool)`; + return `SessionPlan(root=QuestionGroup(id="session", children=children), + tools=tools)` — a fresh root, the core tree is never mutated; a tool whose + every element was dropped still gets its (empty) block appended + +**Entity: `apply_skips(plan: SessionPlan, skips: list[tuple[str, str]]) -> plan: SessionPlan`** +- Type: function (Routine) +- Declared `location`: `goga/onboarding/survey/plan.py` +- Facade obligation: importable from `goga.onboarding.survey` (and `goga.onboarding`) +- Semantic requirements: resolve every raw path against the ORIGINAL root: + `segments[0]` a tool identity in `plan.tools` → address is the full path; + ELIF `segments[0]` a core section id → address is the path from the root; + ELIF `segments[0]` a local name of the DECLARING tool's own block → address + is `[tool] + segments`; ELSE → warning no-op; existence is checked against + the original tree only (a descendant of an already-skipped node resolves and + is silently absorbed — set semantics, order-independent); rebuild new + `QuestionGroup`s along removed branches, share frozen originals on + unmodified branches; return a NEW `SessionPlan` with the same `tools` list + (an emptied block stays); a path into a pairs question has no children to + resolve → no-op warning; `core_section_ids` derived as + `set(c.id for c in root.children) - set(plan.tools)` + +**Entity: `SessionPlan(root: QuestionGroup, tools: list[str])`** +- Type: class (Entity, data record) +- Declared `location`: `goga/onboarding/survey/plan.py` +- Facade obligation: importable from `goga.onboarding.survey` (and `goga.onboarding`) +- Properties: `root -> QuestionGroup` (core children followed by tool blocks), + `tools -> list[str]` (participating tools in block order) + +**Entity: `Questionnaire()`** +- Type: class (Entity — the interactive survey engine) +- Declared `location`: `goga/onboarding/survey/questionnaire.py` +- Facade obligation: importable from `goga.onboarding.survey` (and `goga.onboarding`) +- Methods: + - `run(plan: SessionPlan, answers: SessionAnswers) -> None` — echo the + session header (`=== Goga Project Initialization ===` + wizard + description, ported from the old `ask`); iterate `plan.root.children` in + order; membership in `plan.tools` distinguishes tool blocks (echo the + block prompt as the attribution heading, then `ask_group` with prefix = + the tool id; suppress emptied blocks) from core sections + (`survey_core_section`); records land at plan dot-paths (`"{tool}.{local}"` + for tool answers); `click.Abort` propagates + - `ask_question(question: Question) -> value` — choice → + `click.prompt(prompt, type=click.Choice(choices))`; input → + `click.prompt(prompt, default=default)` (None default → required); + confirm → `click.confirm(prompt, default=default or False)`; pairs → + proposed-keys confirm + per-key prompts, then an add-another loop of + arbitrary key/value prompts (return `{}` when nothing collected); ELSE + (unknown kind or missing parameterization such as a choice without + `choices`) → `logger.warning` naming the question path (first segment is + the tool identity) and the reason; the question is skipped — not asked, + not recorded; the survey continues (tier 1 soft) + - `ask_group(group: QuestionGroup, prefix: str | None = None) -> dict` — + echo the group prompt as heading; children in order; recurse into + groups. The optional `prefix` (the tool id) qualifies the record paths + (`"{tool}.{local}"`); with the default `None` the declared + one-argument call shape of the CODEMANIFEST + (`ask_group(group) -> value: dict`) stays valid — matching the design's + `ask_group(group, prefix=None)` +- Core-section conditional patterns (the engine's own logic — port the old + per-field ask methods here): confirm gates are presentational (asked, drive + control flow, NEVER recorded); only the children PRESENT in the post-skip + section are asked — a skipped child is never asked; a branch whose driving + question is absent collapses to the remaining path: + - `language` → choice ask + - `convention` → confirm gate; accept → prefill + `({"conventions": ".goga/usages/conventions.md"}, "Use \`conventions\` for + code writing rules and testing.")` for codemanifest; reject → `(None, None)` + - `codemanifest` → usages pairs (prefill entries offered first), annotations + input (prefill text) + - `build` → confirm gate; accept → agent choice then env pairs (suggested + keys from `agent_env_defaults[agent]` prompted first, then arbitrary + additions — the old `_collect_agent_env` behavior); reject → nothing + recorded + - `docker_image` → IF the dockerfile question is absent (skipped) → no + gate, the pull branch directly (image ask with hint presentation when + base_image is present, else plain free-form); ELSE confirm gate + ("Create Dockerfile?"): accept → dockerfile input → base_image ask only + when present → image ask (plain label, record default); reject → the pull + branch; a skipped `base_image` collapses the FROM — never asked, never + recorded + - `pipeline` → confirm gate, same shape as build + - `tools` → confirm gate; accept → pairs loop (name prompt, version prompt; + empty input → "latest") + - `usages` → confirm gate; accept → record loop per record (group, + dependency name, git URL, optional ref, optional root; empty → omitted); + accumulate `{group: {dep: {git, ref?, root?}}}` (a later record of the + same group merges under the group key); record the accumulated mapping at + `"usages"` +- Imported dependencies: `Question`, `QuestionGroup`, `SessionAnswers`, + `ToolDeclaration`; practices `click`, `image_defaults`, `agent_env_defaults` + +**Entity: `FileGenerator()`** +- Type: class (Entity — the artifact generator, new snapshot-driven API) +- Declared `location`: `goga/onboarding/generator/generator.py` +- Facade obligation: importable from `goga.onboarding.generator` (and `goga.onboarding`) +- Methods: + - `generate(answers: SessionAnswers, contributions: list[ToolContribution]) + -> files: list[CreatedFile]` — `Path(".goga/config.yml").is_file()` → + skip the config and Dockerfile generation (jump to tool configs — the + guarantee lives here, not only at the caller); ELSE: snapshot; Dockerfile + written ONLY when BOTH `docker_image.dockerfile` AND + `docker_image.base_image` are present (`FROM {base_image}\n`, + `mkdir(parents=True, exist_ok=True)`, `CreatedFile(path, None)`); a + skipped `base_image` collapses the branch — no Dockerfile and the config + `dockerfile` field is omitted; then `generate_goga_config`; then + `generate_tool_configs`; return the created files in generation order + (Dockerfile, conventions.md when downloaded, config.yml, tool files) + - `generate_goga_config(answers) -> None` — snapshot; `language` empty → + clean `ValueError` naming the field (the single required-field check); + conventions entry (when `codemanifest.usages` carries the `"conventions"` + key) → download per `lang_conventions` (`requests.get(url, timeout=30)`), + failure → clean error with the URL and the cause, config.yml NOT created; + write `.goga/usages/conventions.md` first; `mkdir .goga`; assemble the + ordered document per the mapping table; `yaml.dump(default_flow_style=False, + allow_unicode=True, sort_keys=False)`; field order: language, image, + dockerfile, build, pipeline, codemanifest, tools, usages + - `generate_tool_configs(contributions) -> None` — per contribution, per + buffered `(file, data)` in call order → `.goga/tools//`; + a repeated file name replaces +- Snapshot → YAML field mapping (normative): `language` → language; + `docker_image.image` → image; `docker_image.dockerfile` → dockerfile + (omitted when absent — and absent when the Dockerfile was not written); + `docker_image.base_image` → the Dockerfile FROM line only, NEVER emitted to + the config; `build.{agent,env}` → `build.task_executor.{agent,env}` (nested + under `task_executor`); `pipeline.{agent,env}` → the flat pipeline block; + `codemanifest.{usages,annotations}` → the codemanifest block (annotations + via the `_LiteralStr` literal-block representer); `tools` → top-level tools + (dict[str,str]); `usages` → the nested records (dict[str, dict[str, + DepConfig]], `git` required, `ref`/`root` optional); confirm-gate answers + never carried (never recorded in the first place); no entry for tool + sections — their data reaches `.goga/tools//` through `write_config` +- Imported dependencies: `SessionAnswers`, `ToolContribution`; practices + `yaml`, `lang_conventions` + +**Entity: `CreatedFile(path: str, tool: str | None)`** +- Type: class (Entity, frozen dataclass) +- Declared `location`: `goga/onboarding/generator/generator.py` +- Facade obligation: importable from `goga.onboarding.generator` (and `goga.onboarding`) +- Properties: `path -> str` (relative to the project root), `tool -> str | + None` (None for an engine file) + +**Entity: `InitLogic(questionnaire: Questionnaire, generator: FileGenerator, participation: ToolParticipation)`** +- Type: class (Entity — orchestrator, rewritten) +- Declared `location`: `goga/onboarding/logic.py` +- Facade obligation: importable from `goga.onboarding` +- Methods: `run() -> exit_code: int` — the eight steps: + 1. existing `.goga/config.yml` → `return 0` immediately (no prompts, no + events, no artifacts) + 2. `version = host_goga_version()` — `PackageNotFoundError` → one clean + message, `return 1`; `tag = minor_version(version)` — `ValueError` → + clean message, `return 1` (defensive) + 3. `declarations = self._participation.collect_declarations()` — + `ImportError` → one clean message naming the package, `return 1` + 4. `project_name = resolve_project_name()` (tolerant, `None` on failure); + `convention_exists = Path(".goga/usages/conventions.md").is_file()`; + `core = core_questions(tag, project_name, convention_exists)`; + `plan = assemble_session_plan(core, declarations)`; + `skips = [(d.tool, p) for d in declarations for p in d.skips]`; + `plan = apply_skips(plan, skips)` + 5. `answers = SessionAnswers(tools=plan.tools)`; + `self._questionnaire.run(plan, answers)` — `click.Abort` → `return 1` + (quiet); unexpected `Exception` → one clean message, `return 1` + 6. `contributions = self._participation.collect_contributions(answers)` + 7. `files = self._generator.generate(answers, contributions)`; render the + report `created {path}` / `created {path} (tool: {tool})` + 8. `return 0` — tool failures never change the exit code +- Error tiers (cross-cutting): tool failures → `logger.warning`, element/tool + drops, exit code untouched; session errors (broken import, unreadable + version, empty language, download failure) → ONE `click.echo("Error: …", + err=True)` + exit 1, never a traceback; user aborts (`click.Abort`) → + exit 1, quiet +- Facade re-exports (13 embeddings, in order): `Question`, `QuestionGroup`, + `SessionAnswers`, `SessionPlan`, `Questionnaire`, `core_questions`, + `assemble_session_plan`, `apply_skips`, `ToolParticipation`, + `ToolDeclaration`, `ToolContribution`, `FileGenerator`, `CreatedFile` + +**Entity: `init(tpl, upgrade, ref, tools)` (changed)** +- Type: function (CLI command, `goga/commands/init/init.py`) +- Facade obligation: exposed as the `goga init` click command +- Signature change: `tools: tuple[str, ...]` via `@click.option("-t", + "--tool", "tools", multiple=True, ...)` +- Semantic requirements (algorithm steps 1–6): ref placement check (ported) → + `--ref requires or --upgrade`; mode resolution (ported; `` + + `--upgrade` mutual exclusion) → `UPGRADE | SCAFFOLD_THEN_ONBOARDING | + BARE_ONBOARDING`; invitation validation — `tools` non-empty AND mode + UPGRADE → `-t/--tool requires an onboarding session and --upgrade runs + none`, exit 1; dedup preserving flag order (`list(dict.fromkeys(tools))` — + the tuple→list conversion happens here); already-initialized guard + (BARE_ONBOARDING only, ported); dispatch — UPGRADE: `Scaffold().upgrade(ref)`; + both onboarding modes: `InitLogic(Questionnaire(), FileGenerator(), + ToolParticipation(invited=deduped))` → `ctx.exit(logic.run())` +- Constraints: the command passes names through as opaque data — no + installation checks; delegates execution; scaffold before onboarding when + `tpl` is given + +### Interaction Diagram and Data Flows + +Verbatim from the design document — the runtime composition every coding +task of this plan contributes to: + +``` +CLI: goga init [-t name]... [] [--upgrade] [--ref r] + └─ init (goga/commands/init) ── validates flags, dedups tools + ├─ Scaffold (tpl modes; unchanged) + └─ InitLogic(Questionnaire, FileGenerator, ToolParticipation(tools)) + │ + │ 1. guard: existing .goga/config.yml → return 0 + │ 2. host_goga_version → minor_version → tag + │ 3. ToolParticipation.collect_declarations ──── moment one + │ ├─ HookRegistry.build_once ── enumerate_tool_packages + │ │ └─ goga_tool_* facades: register_hooks(hooks) + │ │ subscribe("onboarding","declare_session",…) + │ ├─ per tool: wrap_context(ToolDeclaration) + + │ │ build_hook_arguments(hook, view, self_context) + │ │ → hook(context[, self]) → context.declare/.skip + │ └─ declarations: list[ToolDeclaration] + │ 4. resolve_project_name (goga/config), conventions.md check + │ core_questions(tag, name, exists) → core tree + │ assemble_session_plan(core, declarations) → SessionPlan + │ apply_skips(plan, (tool, path) pairs) → SessionPlan + │ 5. SessionAnswers(tools=plan.tools) + │ Questionnaire.run(plan, answers) ── click survey + │ 6. ToolParticipation.collect_contributions(answers) ─ moment two + │ ├─ per tool: view_for(tool) → ToolContribution(view) + │ │ wrap_context + build_hook_arguments → hook(context) + │ │ → context.answer / context.write_config + │ └─ commit: answers.amend(...) per contribution; files kept + │ 7. FileGenerator.generate(answers, contributions) + │ ├─ Dockerfile (FROM base_image) when dockerfile path + │ ├─ generate_goga_config: snapshot → .goga/config.yml + │ │ └─ conventions download (lang_conventions) + │ └─ generate_tool_configs → .goga/tools// + │ 8. file report with attribution → exit 0 +``` + +Data flows (verbatim from the design document): + +- **Invitation flow**: CLI `-t` names → dedup (order-preserving) → + `ToolParticipation(invited)` → per-tool `invited` marker on both + surfaces → hook-side early return when False. +- **Declaration flow**: hook buffers `Question`/`QuestionGroup` + + skip paths → `ToolDeclaration.questions/.skips` → plan blocks named by + tool identity → skips `(tool, raw_path)` → `apply_skips`. +- **Answer flow**: `Questionnaire.run` records at plan paths → nested + mappings in `SessionAnswers` → `view_for(tool)` isolates per tool → + amendments `answer(id, value)` → committed via `amend` (recursive merge) + → `snapshot()` → config mapping. +- **Tag flow**: `host_goga_version()` → `minor_version()` → `"N.M"` → + `core_questions(image_tag)` → completed hints in the tree → prompts. +- **File flow**: committed `ToolContribution.files` + + snapshot → `FileGenerator.generate` → `list[CreatedFile]` → report. + +Runtime construction order: `ToolParticipation` and `Questionnaire` +and `FileGenerator` are constructed by `init` and injected into +`InitLogic`; `SessionAnswers` is constructed inside `InitLogic.run` after +`apply_skips` (it needs `plan.tools`); `HookRegistry` is constructed once +inside `ToolParticipation` and shared by both moments. + +### Re-exports + +- `->minor_version` — source: `goga/version/version.py` (local type in the + version cell); facade obligation: importable from `goga.version`; add to + `__all__` in `goga/version/__init__.py` +- `->wrap_context`, `->build_hook_arguments` — source: `goga/hooks/dispatch` + (Imports entry, sub-facade verified to export both); facade obligation: + importable from `goga.hooks` +- `->enumerate_tool_packages` — source: `goga/hooks/tools` (Imports entry, + sub-facade verified); facade obligation: importable from `goga.hooks` +- The 13 onboarding embeddings (see `InitLogic` above) — sources: + `goga/onboarding/questions` (3), `goga/onboarding/survey` (5), + `goga/onboarding/participation` (3), `goga/onboarding/generator` (2); + facade obligation: importable from `goga.onboarding`, in the embedding + order of the CODEMANIFEST; the facade docstring states the domain-facade + role + +### Usages Context + +- `convention` (`.goga/usages/conventions.md`) — mandatory code conventions: + relative imports, dataclasses `kw_only=True`, docstring discipline, module + logger per file, REPL/test infrastructure, `pytest tests/ -x` / `ruff check + /` commands. Relevant to EVERY task of this plan. +- `click` (`.goga/usages/cooks/click.md`) — the prompting cookbook: + `click.prompt` / `click.confirm` / `click.Choice`, repeated collections. + Relevant to the `Questionnaire` engine and the `init` command. +- `image_defaults` (inline in `goga/onboarding/survey/CODEMANIFEST`) — + language → image-family mapping, tag completed at runtime, suggestions + displayed, default = last entry, free-form accepted: + python `qarium/goga-python-{3.10-3.14}`, golang + `qarium/goga-golang-{1.23-1.26}`, javascript `qarium/goga-node-{22,24}`, + kotlin `qarium/goga-kotlin-{2.0-2.3}`, swift `qarium/goga-swift-{6.0-6.2}`. + Relevant to `core_questions` (builds the hints) and `Questionnaire` + (renders them). +- `agent_env_defaults` (inline, survey) — agent → env key mapping (claude: + ANTHROPIC_BASE_URL, ANTHROPIC_DEFAULT_HAIKU_MODEL, + ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL, + ANTHROPIC_MODEL; codex: CODEX_MODEL; cursor: CURSOR_MODEL; opencode: + OPENCODE_MODEL, OPENCODE_VARIANT; qwen: OPENAI_BASE_URL, OPENAI_MODEL). + Relevant to `core_questions` and the engine's build/pipeline env pairs. +- `yaml` (inline, generator) — `yaml.dump(default_flow_style=False)` for + config and tool files; `sort_keys=False, allow_unicode=True`; annotations + via the `_LiteralStr` representer (ported from the old generator). +- `lang_conventions` (inline, generator) — URL template + `https://raw.githubusercontent.com/qarium/goga-lang-conventions/refs/heads/0.0.x/{language}/project.md`; + save to `.goga/usages/conventions.md`; `requests.get(url, timeout=30)`; + failure → clean error with URL and cause. + +### Imported Usages + +- `minor-line` from `goga/version` (`goga/version/.usages/minor-line.md`) — + reading the installed version and deriving the tag; used by the `InitLogic` + task. Status: current, no changes needed. +- `question-records` from `goga/onboarding/questions` + (`goga/onboarding/questions/.usages/question-records.md`) — the record + structure and the answer addressing rules; imported by the survey, + participation, and generator tasks. Status: current. +- `per-tool-delivery`, `registering-hooks` from `goga/hooks` + (`goga/hooks/.usages/{per-tool-delivery,registering-hooks}.md`) — the + staged per-tool delivery loop and the registration contract; imported by + the participation tasks. Status: current. +- `session-participation` from `goga/onboarding/participation` + (`goga/onboarding/participation/.usages/session-participation.md`) — the + two tool moments; used by the generator and `InitLogic` tasks. Status: + current. +- `tool-contexts` from `goga/onboarding/participation` + (`goga/onboarding/participation/.usages/tool-contexts.md`) — the hook + signature pattern (`context` first, optional `self`); used by the + participation tests. Status: current. +- `survey-run` from `goga/onboarding/survey` + (`goga/onboarding/survey/.usages/survey-run.md`) — the plan assembly and + the survey; used by the `InitLogic` task. Status: current. +- `artifact-generation` from `goga/onboarding/generator` + (`goga/onboarding/generator/.usages/artifact-generation.md`) — the + generation and the file report; used by the `InitLogic` task. Status: + current. +- `onboarding-usage` from `goga/onboarding` + (`goga/onboarding/.usages/onboarding-usage.md`) — the session API and the + invitation semantics; used by the `init` command task. Status: current. +- `scaffold-usage` from `goga/scaffold` — the Scaffold API; used by the + `init` command task. Status: current (unchanged). + +### Local Usages + +No new local usage files are planned. The design stage already created and +updated every usage file referenced by the contracts (`minor-line.md`, +`per-tool-delivery.md`, `question-records.md`, `session-participation.md`, +`tool-contexts.md`, `survey-run.md`, `artifact-generation.md`, +`onboarding-usage.md`, `init.md` — all verified current in the design +review). Implementation tasks must keep the code consistent with them but do +not create or modify usage files. + +### External Dependencies + +- `click` — CLI framework: the survey prompting (`prompt`, `confirm`, + `Choice`, `Abort`) and the `goga init` command (`@click.option(multiple=True)`) +- `requests` — the conventions download (`requests.get(url, timeout=30)`, + `requests.RequestException`) +- `PyYAML` (`yaml`) — config and tool file serialization, the `_LiteralStr` + literal-block representer +- `importlib.metadata` — `host_goga_version` (already in version.py; + `PackageNotFoundError` handling in `InitLogic`) +- Tools: `pytest` (with `CliRunner`), `ruff`, `goga lint` + +## Facts + +- The DSL graph has 76 cells, `goga lint` exits 0 — the baseline is green; + the plan's changes are additive to the graph (new leaf cells + facade + re-exports). +- `_release_segments(version)` already exists at `goga/version/version.py:71` + (module-private, returns `(major, minor | None)`, raises `ValueError` on no + leading numeric major) — `minor_version` reuses it, does not duplicate it. +- `wrap_context` is at `goga/hooks/dispatch/delivery.py:28` (resolves + attribute reads and bound methods; writes blocked — `declare`/`skip`/ + `answer`/`write_config` are method calls and pass through); + `build_hook_arguments` at `delivery.py:69` (only a declared `context` and + optional `self` receive values). +- `HookRegistry` (`goga/hooks/registry/state.py`) provides `build_once()`, + `subscriptions_for(domain, action)`, `self_context(tool)`; + `subscribe` resolves the address through `declared_actions` + (registration.py:99) — without the two catalog records every onboarding + subscription is rejected as «unknown action». +- `goga/hooks/dispatch/__init__.py` and `goga/hooks/tools/__init__.py` + already export `wrap_context`, `build_hook_arguments`, + `enumerate_tool_packages` — the facade re-export is a pure wiring change. +- Old modules to port from (then delete): `goga/onboarding/questionnaire.py` + (486 lines: `_IMAGE_MAP`, `_LANGUAGES` order python/golang/kotlin/swift/ + javascript, `_AGENT_ENV_MAP`, `_AGENTS`, `_collect_agent_env`, prompt + texts, docker branch), `goga/onboarding/generator.py` (174 lines: + `_LiteralStr` + representer, `_CONVENTION_URL_TEMPLATE`, download logic, + `_build_block`), `goga/onboarding/answers.py` (26 lines, to delete without + porting — replaced by `SessionAnswers`). +- Old facade `goga/onboarding/__init__.py` exports `GogaConfigAnswers`, + `InitAnswers` — the surface must never expose them again after the rewrite. +- The old `init.py` (104 lines) already carries the ref-placement and mode + logic to port verbatim; it lacks only the `tools` parameter. +- Python conventions: type hints mandatory; `snake_case` functions/methods, + `PascalCase` classes; relative intra-package imports; module docstrings in + the established style; one module logger per file; ruff line-length 120, + mccabe max-complexity 10. +- Test infrastructure: pytest `testpaths=["tests"]`; `tests/conftest.py` + autouse `_isolate_home`; `tests/onboarding/conftest.py` provides + `_clean_cwd` (applies to nested test dirs automatically); the repo CWD + contains its own `.goga/` — every filesystem test needs `_clean_cwd`; + tool-package simulation pattern lives in `tests/hooks/conftest.py` + (`sys.modules` injection + enumeration monkeypatch); `tests/hooks/` has + per-cell subpackages (catalog/, dispatch/, registry/, tools/) plus + `test_facade.py`. +- The project-config loader (`goga/config/project/loader.py`) validates the + written config: `tools` is `dict[str, str]` (loader.py:253); `usages` is + `dict[str, dict[str, DepConfig]]` with `git` required and `ref`/`root` + optional strings (loader.py:365-370) — the mapping table matches. +- Runtime construction order: `ToolParticipation`, `Questionnaire`, + `FileGenerator` are constructed by `init` and injected into `InitLogic`; + `SessionAnswers` is constructed inside `InitLogic.run` after `apply_skips` + (it needs `plan.tools`); `HookRegistry` is constructed once inside + `ToolParticipation` and shared by both moments. +- Reverse dependencies of the changed cells are unaffected: `goga/version` + consumers (docker, commands/upgrade, commands/install) use existing names; + `goga/hooks` consumers (history/statuses, commands/hooks) use existing + re-exports; all changes are additive. +- The `review` core section is explicitly out of scope — do not add it. + +## Gap Analysis + +- Missing contract entities: + - `minor_version` — not implemented (version.py lacks it) + - `Question`, `QuestionGroup`, `SessionAnswers` — cells have only + CODEMANIFEST, no code + - `ToolDeclaration`, `ToolContribution`, `ToolParticipation` — no code + - `core_questions`, `assemble_session_plan`, `apply_skips`, `SessionPlan`, + new `Questionnaire` — no code + - new-API `FileGenerator`, `CreatedFile` — no code + - new `InitLogic` (3-collaborator constructor, 8-step run) — old 2- + collaborator version present +- Missing facade exposure: + - `goga/version/__init__.py`: `minor_version` absent from imports/`__all__` + - `goga/hooks/__init__.py`: `wrap_context`, `build_hook_arguments`, + `enumerate_tool_packages` absent + - `goga/onboarding/__init__.py`: exports the wrong surface + (`GogaConfigAnswers`, `InitAnswers`); the 13 embeddings + `InitLogic` + missing +- Incorrect `location` placement: none — all planned files match the + CODEMANIFEST `location`s; the leaf-cell packages + (`goga/onboarding/{questions,participation,survey,generator}/`) exist as + directories with CODEMANIFEST only. +- API mismatches: + - old `FileGenerator.generate(answers: InitAnswers) -> None` vs new + `generate(answers: SessionAnswers, contributions: list[ToolContribution]) + -> list[CreatedFile]` + - old `Questionnaire.ask()` family vs new `run(plan, answers)` + + `ask_question` + `ask_group` + - old `init(tpl, upgrade, ref)` vs new `init(tpl, upgrade, ref, tools)` +- Behavioral mismatches: + - no tool participation exists at all (no onboarding catalog records, no + delivery) + - image hints carry the tag only via the old wizard's per-language lists — + the new tag threading (`host_goga_version` → `minor_version` → + `core_questions`) does not exist + - no `usages`/`tools` survey sections in the old wizard (NEW user-facing + sections) +- Existing code that can be reused: + - `_release_segments` (version.py) — direct reuse + - old questionnaire data + prompt texts + `_collect_agent_env` — port into + survey cell + - old generator `_LiteralStr`, URL template, download logic, `_build_block` + — port into generator cell + - old init.py validation order — port verbatim, extend + - hooks platform (registry, dispatch, tools sub-cells) — consume via the + facade, no changes +- Test coverage gaps: all 33 scenarios of the design's Test Stack Trace; + existing `tests/onboarding/test_answers.py`, `test_generator.py`, + `test_questionnaire.py` test the old API and are deleted with the modules + (cases ported); `tests/commands/test_init.py` imports the deleted modules + at collection level (`goga.onboarding.answers`, `goga.onboarding.questionnaire`) + and is adapted to the facade imports in Task 17; + `tests/onboarding/test_logic.py` and + `test_integration.py` are rewritten; `tests/version/test_version.py`, + `tests/hooks/catalog/test_catalog.py`, `tests/hooks/test_facade.py` are + extended. +- Missing visibility in workspace or git: the four leaf-cell directories are + untracked (`?? goga/onboarding/{generator,participation,questions,survey}/`) + — CODEMANIFESTs only; `goga/hooks/.usages/per-tool-delivery.md`, + `goga/version/.usages/minor-line.md`, `goga/commands/init/.usages/` + init.md updates are uncommitted but present (no action needed by this plan). + +--- + +## Tasks + +> **Package ordering rule**: coding tasks for each package are completed before starting the next. Within each coding task, contract tests are written first (TDD workflow). + +### Task 1: `minor_version` routine in the version cell (TDD coding) + +The version cell (leaf, `goga/version/`) gains the routine +`minor_version(version: str) -> minor: str` at `location: version.py` — the +`N.M` line derivation consumed by the onboarding image hints. The routine is +a pure function reusing the module-private `_release_segments` reducer that +already exists at `goga/version/version.py:71` (`(major, minor | None)`, +`ValueError` on no leading numeric major) — mirror `resolve_version`'s shape +recognition, do not duplicate the reducer. Also add the facade re-export: +`minor_version` importable from `goga.version`, added to `__all__` in +`goga/version/__init__.py` (alphabetical position maintained by the linter's +isort). Extend `tests/version/test_version.py`. + +**Usages relevant to this task:** +- `convention`: docstring style in the established version.py pattern; the + pure-function discipline (no side effects, deterministic output, no + logging); `kw` conventions for tests; run tests with `pytest tests/version/test_version.py -v`. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): in `tests/version/test_version.py` add — `from goga.version import minor_version` succeeds; `"minor_version" in goga.version.__all__`; `callable(minor_version)` +- [x] **Code**: implement `minor_version(version: str) -> str` in `goga/version/version.py` placed after `host_goga_version`, algorithm: (1) `major, minor_seg = _release_segments(version)`; (2) `minor = minor_seg if minor_seg is not None else "0"`; (3) `return f"{major}.{minor}"` — `ValueError` propagates from the reducer +- [x] **Code**: add `from .version import … minor_version` and the `__all__` entry in `goga/version/__init__.py` +- [x] **Interface verification**: `pytest tests/version/test_version.py -v` — the contract tests pass +- [x] **Logic tests**: add `test_minor_version_reduces_to_minor_line` — assertions: `minor_version("1.3.2") == "1.3"`; `minor_version("1.2.1.dev3") == "1.2"`; `minor_version("1.2.0rc1") == "1.2"`; `minor_version("1.2.0.post1") == "1.2"`; `minor_version("1.2.0+local") == "1.2"`; `minor_version("2") == "2.0"` (missing minor → 0). Add `test_minor_version_no_major_raises` — `with pytest.raises(ValueError): minor_version("latest")` (a `match` was added for the repo's PT011 lint rule) +- [x] **Debugging**: `pytest tests/version/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [x] **Contract re-verification**: facade importable; signature `(version: str) -> str`; pure (no I/O, no logging); `goga version` CLI behavior untouched +- [x] **Lint**: `ruff check goga/version/` — fix formatting if necessary + +### Task 2: onboarding action records in the hooks catalog (TDD coding) + +The catalog cell (leaf, `goga/hooks/catalog/`) gains two records in the +`_DECLARED_ACTIONS` constant of `catalog.py`: +`Action(domain="onboarding", name="declare_session", error_class="soft")` +and `Action(domain="onboarding", name="amend_config", error_class="soft")`. +This is the additive catalog extension that makes every tool subscription of +the two onboarding actions acceptable at `HookRegistrar.subscribe` +(registration.py:99 resolves addresses through `declared_actions()`) — +without it the whole feature dies at registration. The statuses record is +untouched; published records are never rewritten; the catalog stays +supported-data only. Extend `tests/hooks/catalog/test_catalog.py`. + +**Usages relevant to this task:** +- `convention`: the data-model rules; test conventions. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests**: the existing catalog surface tests in `tests/hooks/catalog/test_catalog.py` must keep passing after the change (facade `declared_actions` importable, record shape unchanged) — run them first to establish the baseline +- [x] **Code**: append the two `Action(...)` records to `_DECLARED_ACTIONS` in `goga/hooks/catalog/catalog.py` (order in the constant is irrelevant — `declared_actions()` sorts by `(domain, name)`) +- [x] **Interface verification**: `pytest tests/hooks/catalog/ -v` — the baseline still passes +- [x] **Logic tests**: add `test_catalog_carries_onboarding_actions` — assertions: `records = declared_actions()`; `("onboarding", "declare_session", "soft")` and `("onboarding", "amend_config", "soft")` are among `{(r.domain, r.name, r.error_class) for r in records}`; `[(r.domain, r.name) for r in records] == sorted((r.domain, r.name) for r in records)`; the statuses record still present +- [x] **Debugging**: `pytest tests/hooks/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: `declared_actions()` returns a new list per call; records frozen; `goga hooks` inspection output gains exactly the two onboarding rows (additive only) +- [x] **Lint**: `ruff check goga/hooks/catalog/` — fix formatting if necessary + +### Task 3: hooks facade re-exports of the delivery primitives (infrastructure) + +The hooks facade (`goga/hooks/__init__.py`) re-exports three names so that +domains orchestrating per-tool delivery address the platform through the +facade only: `wrap_context` and `build_hook_arguments` (from +`goga/hooks/dispatch`, already exported by its sub-facade) and +`enumerate_tool_packages` (from `goga/hooks/tools`, already exported). Both +the participation cell and `goga/hooks/.usages/per-tool-delivery.md` import +`from goga.hooks import HookRegistry, wrap_context, build_hook_arguments` — +a missing re-export is an ImportError of the whole onboarding domain. No +name collision exists; the `declared_actions` re-export is untouched; +importing `goga.hooks` must keep importing no `goga_tool_*` package and +enumerating nothing. Extend `tests/hooks/test_facade.py`. + +**Usages relevant to this task:** +- `convention`: relative intra-package imports (`from .dispatch import …`). + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): in `tests/hooks/test_facade.py` add `test_hooks_facade_reexports_delivery_primitives` — assertions: `goga.hooks.wrap_context is goga.hooks.dispatch.wrap_context`; `goga.hooks.build_hook_arguments is goga.hooks.dispatch.build_hook_arguments`; `goga.hooks.enumerate_tool_packages is goga.hooks.tools.enumerate_tool_packages`; `{"wrap_context", "build_hook_arguments", "enumerate_tool_packages"} <= set(goga.hooks.__all__)` +- [x] **Code**: in `goga/hooks/__init__.py` add `from .dispatch import build_hook_arguments, emit_hook_event, wrap_context` (replacing the single-name import) and `from .tools import enumerate_tool_packages`; extend `__all__` with the three names +- [x] Verify facade accessibility: `pytest tests/hooks/test_facade.py -v` — all pass, including the pre-existing facade invariants (no import side effects) +- [x] Lint: `ruff check goga/hooks/__init__.py` — fix formatting if necessary + +### Task 4: questions cell structure (infrastructure) + +Create the package structure of the new leaf cell +`goga/onboarding/questions/` (the directory exists with CODEMANIFEST only — +untracked). The cell owns the declarative question-and-answer model: data +and pure answer operations only — no interactivity, no filesystem, no tool +delivery. Create the two `location` module files with module docstrings and +the package facade `__init__.py` with the domain docstring (no exports yet — +the entity tasks add them). Create the test subpackage +`tests/onboarding/questions/` (`__init__.py` empty). The +`tests/onboarding/conftest.py` `_clean_cwd` fixture applies automatically to +nested dirs. + +**Usages relevant to this task:** +- `convention`: module docstrings in the established style (see + `goga/hooks/catalog/catalog.py` header); relative imports; test + infrastructure layout. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] Create `goga/onboarding/questions/questions.py` — module docstring naming the cell entities (`Question`, `QuestionGroup` at `location: questions.py`), no code yet +- [x] Create `goga/onboarding/questions/answers.py` — module docstring naming `SessionAnswers` at `location: answers.py`, no code yet +- [x] Create `goga/onboarding/questions/__init__.py` — domain docstring (the cell owns the declarative question-and-answer model of the onboarding session); empty `__all__: list[str] = []` placeholder to be filled by the entity tasks +- [x] Create `tests/onboarding/questions/__init__.py` (empty) +- [x] Verify importability: `python -c "import goga.onboarding.questions"` — exits 0 +- [x] Lint: `ruff check goga/onboarding/questions/` — passes + +### Task 5: `Question` and `QuestionGroup` records (TDD coding) + +Implement the two immutable declarative records of the questions cell at +`location: goga/onboarding/questions/questions.py` and expose them through +the cell facade. Frozen dataclasses, `kw_only=True`, fields exactly per the +signatures; `None` only for explicit absence (`choices`/`default`/`keys` on +`Question`; `prompt`/`children` on `QuestionGroup`). NO validation in the +records — kinds are checked at ask time, not at construction; no methods, no +properties beyond the fields (data-only discipline). A `QuestionGroup` with +`children=None` is a structural node carrying no prompt. + +**Usages relevant to this task:** +- `convention`: dataclass rules — `@dataclass(frozen=True, kw_only=True)` in + the `catalog.py` `Action` style; attribute docstrings; do not log (pure + records never log). +- `question-records` (imported from the questions cell itself — + `goga/onboarding/questions/.usages/question-records.md`): the record + structure and the answer addressing rules; construct with keyword + arguments, e.g. `Question(id="token", kind="input", prompt="Service token")`. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): create `tests/onboarding/questions/test_questions.py` — `from goga.onboarding.questions import Question, QuestionGroup` succeeds; both in `__all__`; keyword construction `Question(id="token", kind="input", prompt="Service token")` works; frozen (assigning a field raises `dataclasses.FrozenInstanceError`); `QuestionGroup(id="g", children=None)` constructible without `prompt` +- [x] **Code**: implement `Question(id: str, kind: str, prompt: str, choices: list[str] | None = None, default: str | bool | None = None, keys: list[str] | None = None)` and `QuestionGroup(id: str, prompt: str | None = None, children: list[Question | QuestionGroup] | None = None)` in `goga/onboarding/questions/questions.py` — frozen `kw_only` dataclasses, field docstrings per the CODEMANIFEST property annotations +- [x] **Code**: export both from `goga/onboarding/questions/__init__.py` (`from .questions import Question, QuestionGroup`, `__all__` entries) +- [x] **Interface verification**: `pytest tests/onboarding/questions/test_questions.py -v` — contract tests pass +- [x] **Logic tests**: positive — `Question(id="q", kind="choice", prompt="Pick", choices=["a", "b"], default="a")` exposes all fields with the given values; a group round-trips `children=[Question(id="x", kind="input", prompt="X")]`; negative — positional construction is refused (`kw_only`); edge — defaults are `None` for `choices`/`default`/`keys`/`prompt`/`children` +- [x] **Debugging**: `pytest tests/onboarding/questions/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: no methods or computed properties beyond the fields; no validation raising at construction; hashable/immutable value objects +- [x] **Lint**: `ruff check goga/onboarding/questions/` — fix formatting if necessary + +### Task 6: `SessionAnswers` accumulator (TDD coding) + +Implement the single mutable accumulator of one run at `location: +goga/onboarding/questions/answers.py` and expose it through the cell facade. +Constructor `SessionAnswers(tools: list[str] | None = None)` creates an +empty space; `tools` reserves the top-level keys of the tool sections +WITHOUT creating them (`_tool_sections = frozenset(tools or ())`, +`_data = {}`). Four methods: `record` (segment walk creating intermediate +dicts; set leaf — REPLACE, never merge; a leaf collision with an existing +scalar mid-path is replaced by a mapping — the survey is the authoritative +writer), `amend` (same walk; at the leaf an existing dict AND a dict value → +recursive per-key merge; otherwise plain assignment; silent — no warning; +delivery order is the caller's responsibility), `view_for(tool)` (deepcopy +of the core items — every top-level key except the reserved names — updated +with a deepcopy of the tool's own section re-keyed by local names; a +local-name collision with a core key wins in THAT tool's view only; +an absent own section → core-only view), `snapshot()` (`deepcopy(_data)`). +No dotted keys are ever stored as literal keys — the path is split. + +**Usages relevant to this task:** +- `convention`: type hints mandatory (`dict`, `str | bool | dict` value + types); no logging (the accumulator is total, raises nothing). +- `question-records` (`goga/onboarding/questions/.usages/question-records.md`): + the answer addressing rules — plan dot-paths (`"build.agent"`, + `"my-tool.token"`), nested mappings keyed by question ids. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): create `tests/onboarding/questions/test_answers.py` — `from goga.onboarding.questions import SessionAnswers` succeeds; in `__all__`; `SessionAnswers()` and `SessionAnswers(tools=["my-tool"])` both construct empty (`snapshot() == {}`) +- [x] **Code**: implement `SessionAnswers` with `record`, `amend`, `view_for`, `snapshot` per the semantics above (walk helper may be a module-private function) +- [x] **Code**: export `SessionAnswers` from `goga/onboarding/questions/__init__.py` +- [x] **Interface verification**: `pytest tests/onboarding/questions/test_answers.py -v` — contract tests pass +- [x] **Logic tests** (from the design, transfer verbatim): `test_record_creates_nested_mappings` — `answers.record("build.agent", "claude")` → `snapshot() == {"build": {"agent": "claude"}}`; `test_amend_merges_mappings_replaces_scalars` — setup `answers.record("pipeline", {"agent": "codex", "env": {"A": "1"}})`, input `answers.amend("pipeline", {"env": {"B": "2"}, "agent": "claude"})` → `snapshot() == {"pipeline": {"agent": "claude", "env": {"A": "1", "B": "2"}}}`; `test_view_for_isolates_and_flattens` — setup `SessionAnswers(tools=["my-tool", "viewer"])` with `language`/`my-tool.token`/`viewer.flag` recorded, `view_for("my-tool")` → `{"language": "python", "token": "t0"}`, `"viewer" not in view`, mutating the view does not touch the space (`answers.snapshot()["my-tool"]["token"] == "t0"`); `test_view_for_unknown_tool_returns_core_only` — `view_for("not-declared")` → core only; edge — `record("a.b", 1)` then `record("a.b.c", 2)` replaces the scalar with a mapping +- [x] **Debugging**: `pytest tests/onboarding/questions/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: reserved names come from the constructor param (fix q1 — no hardcoded list); merge happens only when BOTH sides are mappings; every returned view is a deep copy +- [x] **Lint**: `ruff check goga/onboarding/questions/` — fix formatting if necessary + +### Task 7: participation cell structure (infrastructure) + +Create the package structure of the new leaf cell +`goga/onboarding/participation/` (directory exists with CODEMANIFEST only). +The cell owns the tool participation: the invitation, the two onboarding +action moments delivered per tool with staged control, the surfaces, and +the isolated answer views. Create the three `location` module files with +module docstrings and the facade. Create the test subpackage +`tests/onboarding/participation/`. + +**Usages relevant to this task:** +- `convention`: module docstrings; relative imports; test layout. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] Create `goga/onboarding/participation/declaration.py` — module docstring naming `ToolDeclaration` +- [x] Create `goga/onboarding/participation/contribution.py` — module docstring naming `ToolContribution` +- [x] Create `goga/onboarding/participation/participation.py` — module docstring naming `ToolParticipation` +- [x] Create `goga/onboarding/participation/__init__.py` — domain docstring; empty `__all__` placeholder +- [x] Create `tests/onboarding/participation/__init__.py` (empty) +- [x] Verify importability: `python -c "import goga.onboarding.participation"` — exits 0 +- [x] Lint: `ruff check goga/onboarding/participation/` — passes + +### Task 8: `ToolDeclaration` and `ToolContribution` surfaces (TDD coding) + +Implement the two delivery surfaces at `location: +goga/onboarding/participation/{declaration.py, contribution.py}` and expose +them through the cell facade. Both are the hook-facing objects wrapped by +`wrap_context` (attribute reads resolve; writes are blocked; the buffer +methods are calls and pass through). `ToolDeclaration(tool: str, invited: +bool)` — properties `tool`, `invited`, `questions` (declaration order), +`skips`; `declare(item)` enforces the one-level rule: a `QuestionGroup` +whose `children` contain a `QuestionGroup` is refused with a +`logger.warning` naming the tool and the reason ("a tool group is limited +to one nesting level with simple children"), the element is NOT buffered, +delivery continues (structural violations are warnings, never exceptions); +accepted items append to `questions`. `skip(path)` appends the raw string — +no resolution here. `ToolContribution(tool: str, invited: bool, answers: +dict)` — properties `tool`, `invited`, `answers`, `amendments`, `files`; +`answer(id, value)` appends `(id, value)` keeping call order; +`write_config(file, data)` appends `(file, data)` — a later same name +replaces at write time, not here. Both carry a module logger +(`logger = logging.getLogger(__name__)`). + +**Usages relevant to this task:** +- `convention`: dataclass/buffer style; one module logger per file; warnings + carry the tool name and the reason. +- `question-records` (`goga/onboarding/questions/.usages/question-records.md`): + the declaration records — `Question` or a one-level `QuestionGroup`. +- `registering-hooks` (`goga/hooks/.usages/registering-hooks.md`): the hook + signature (`context` first, optional `self`) and the failure handling + behind the actions — the surfaces are what a hook receives as `context`. +- `per-tool-delivery` (`goga/hooks/.usages/per-tool-delivery.md`): the staged + delivery loop the surfaces participate in. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): create `tests/onboarding/participation/test_declaration.py` and `test_contribution.py` — `from goga.onboarding.participation import ToolDeclaration, ToolContribution` succeeds; both in `__all__`; keyword construction `ToolDeclaration(tool="t", invited=True)` / `ToolContribution(tool="t", invited=True, answers={})` works; buffers start empty +- [x] **Code**: implement `ToolDeclaration` in `declaration.py` (fields `tool`, `invited`; buffered `questions: list`, `skips: list` — mutable lists on a non-frozen dataclass or equivalent) and `ToolContribution` in `contribution.py` (fields `tool`, `invited`, `answers`; buffered `amendments`, `files`) +- [x] **Code**: export both from `goga/onboarding/participation/__init__.py` +- [x] **Interface verification**: `pytest tests/onboarding/participation/ -v` — contract tests pass +- [x] **Logic tests**: positive — `declare(Question(id="token", kind="input", prompt="Token"))` buffers it in order; `skip("build.env")` buffers the raw string; `answer("tools", {"t": "1.0"})` and `write_config("service.yml", {"a": 1})` buffer tuples in call order (a same-named file buffered twice keeps BOTH entries — replacement happens at write time). Negative/edge: `test_declare_rejects_nested_group_with_warning` — `surface.declare(QuestionGroup(id="deep", children=[QuestionGroup(id="inner")]))` with caplog at WARNING → `surface.questions == []` AND `any("one nesting level" in r.message for r in caplog.records)` AND `any("t" in r.message for r in caplog.records)` (two independent `any()` joined by `and`) +- [x] **Debugging**: `pytest tests/onboarding/participation/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: `declare` never raises; the one-level Requirement is enforced at the surface (single point); buffers readable via the properties after delivery +- [x] **Lint**: `ruff check goga/onboarding/participation/` — fix formatting if necessary + +### Task 9: `ToolParticipation` mediator (TDD coding) + +Implement the mediator of both onboarding action moments at `location: +goga/onboarding/participation/participation.py` and expose it through the +cell facade. Constructor `ToolParticipation(invited: list[str])` — defensive +dedup preserving flag order (`_invited = list(dict.fromkeys(invited))`), +`_registry = None` (built lazily by `_ensure_registry()` — `HookRegistry()` ++ `build_once()`; `ImportError` from a broken package import propagates — +the single fatal case). `collect_declarations()`: warn for every invited +identity not among `{pkg.tool for pkg in enumerate_tool_packages()}` +("invited tool %s is not installed; continuing without its block"); group +`registry.subscriptions_for("onboarding", "declare_session")` per +`subscription.tool` preserving enumeration order; per tool build the surface +(`invited=tool in self._invited`), `proxy = wrap_context(surface)`, call +`sub.hook(**build_hook_arguments(sub.hook, proxy, registry.self_context(tool)))` +per subscription; any `Exception` → `logger.warning` naming tool, action +("onboarding.declare_session"), reason; the whole declaration of that tool +is discarded; return the surviving surfaces in enumeration order. +`collect_contributions(answers)`: identical delivery over +`subscriptions_for("onboarding", "amend_config")` with +`ToolContribution(tool, invited, answers=answers.view_for(tool))`; a +failure discards amendments AND files together; then a commit pass in +enumeration order — `answers.amend(path, value)` per buffered amendment; +return the committed contributions. Import the platform names from the +facade: `from goga.hooks import HookRegistry, build_hook_arguments, +enumerate_tool_packages, wrap_context` (enabled by Task 3). + +Test setup pattern (from the design's General Setup): fake installed +packages via monkeypatched `goga.hooks.enumerate_tool_packages` (or +`packages_distributions`) + hooks registered directly through +`HookRegistrar`/a fake facade module injected via `sys.modules` — the +existing `tests/hooks/conftest.py` pattern. Caplog at WARNING for the +warning-path assertions. + +**Usages relevant to this task:** +- `convention`: one module logger; warnings name the tool, the action, and + the reason. +- `per-tool-delivery` (`goga/hooks/.usages/per-tool-delivery.md`): the + staged delivery loop — commit only after every hook of the tool + succeeded; delivery is NEVER filtered by invitation (the marker travels + to the hook). +- `registering-hooks` (`goga/hooks/.usages/registering-hooks.md`): the + registration envelope behind the two actions. +- `tool-contexts` (`goga/onboarding/participation/.usages/tool-contexts.md`): + the hook signature pattern the fake hooks in tests follow (`context` + first, optional `self`). +- `question-records`: the declaration records the hooks buffer. +- `session-participation` + (`goga/onboarding/participation/.usages/session-participation.md`): the + two moments' composition this class realizes. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): create `tests/onboarding/participation/test_participation.py` — `from goga.onboarding.participation import ToolParticipation` succeeds; in `__all__`; `ToolParticipation(invited=["a", "a", "b"]).invited == ["a", "b"]` (dedup, flag order); `collect_declarations`/`collect_contributions` callable +- [x] **Code**: implement `ToolParticipation` with `_ensure_registry`, `collect_declarations`, `collect_contributions` per the semantics above +- [x] **Code**: export `ToolParticipation` from `goga/onboarding/participation/__init__.py` +- [x] **Interface verification**: `pytest tests/onboarding/participation/test_participation.py -v` — contract tests pass +- [x] **Logic tests** (from the design, transfer verbatim): `test_collect_declarations_delivers_invitation_marker` — fake `goga_tool_my-tool` subscribed `("onboarding", "declare_session", "d1", hook)`, `ToolParticipation(invited=["my-tool"])` → one declaration, `tool == "my-tool"`, `invited is True`, `questions[0].id == "token"`; `test_collect_contributions_commits_in_order` — two fake tools alpha/beta (alpha first) each buffering `context.answer("tools", {name: version})` → `[c.tool for c in contributions] == ["alpha", "beta"]` and `answers.snapshot()["tools"] == {"alpha": "1.0", "beta": "2.0"}`; `test_collect_declarations_warns_for_uninstalled_invited` — no packages, `invited=["ghost"]`, caplog → `declarations == []` and a warning naming "ghost"; `test_failing_hook_drops_whole_declaration` — tools `bad` (raises `RuntimeError("boom")`) and `good` (declares one) → `[d.tool for d in declarations] == ["good"]`, warning carries "bad" and "boom"; `test_noninvited_subscribed_tool_is_marked_and_silent` — fake tool subscribed to BOTH actions, hooks record `self.saw_invited = context.invited` and return immediately when not invited, `ToolParticipation(invited=["other"])` → `declarations == []`, `[c.tool for c in contributions] == ["my-tool"]` with empty `amendments`/`files`, captured marker is False, `"my-tool" not in answers.snapshot()`, `not caplog.records` (silent — not a warning); moment-two failure — a tool whose `amend_config` hook buffers then raises → `contributions == []`, `"tools" not in answers.snapshot()` (participation side only; the generate side is covered in Task 16) +- [x] **Debugging**: `pytest tests/onboarding/participation/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: `_registry` built once and shared by both moments; delivery order is enumeration order; the invitation marker is never filtered platform-side; `ImportError` propagates uncaught +- [x] **Lint**: `ruff check goga/onboarding/participation/` — fix formatting, apply decomposition if necessary + +### Task 10: survey cell structure (infrastructure) + +Create the package structure of the new leaf cell +`goga/onboarding/survey/` (directory exists with CODEMANIFEST only). The +cell owns the survey: the core question tree, the plan assembly, the skip +application, and the interactive run. Create the three `location` module +files with module docstrings and the facade. Create the test subpackage +`tests/onboarding/survey/`. + +**Usages relevant to this task:** +- `convention`: module docstrings; relative imports; test layout. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] Create `goga/onboarding/survey/core.py` — module docstring naming `core_questions` +- [x] Create `goga/onboarding/survey/plan.py` — module docstring naming `assemble_session_plan`, `apply_skips`, `SessionPlan` +- [x] Create `goga/onboarding/survey/questionnaire.py` — module docstring naming `Questionnaire` +- [x] Create `goga/onboarding/survey/__init__.py` — domain docstring; empty `__all__` placeholder +- [x] Create `tests/onboarding/survey/__init__.py` (empty) +- [x] Verify importability: `python -c "import goga.onboarding.survey"` — exits 0 +- [x] Lint: `ruff check goga/onboarding/survey/` — passes + +### Task 11: `core_questions` tree builder (TDD coding) + +Implement the core tree builder at `location: goga/onboarding/survey/core.py` +and expose it through the cell facade. Port the data of the old wizard from +`goga/onboarding/questionnaire.py` (do not rewrite from scratch): +`_IMAGE_MAP` → the `image_defaults` mapping (families per the practice), +`_LANGUAGES = ["python", "golang", "kotlin", "swift", "javascript"]`, +`_AGENT_ENV_MAP` → the `agent_env_defaults` mapping, `_AGENTS = +list(_AGENT_ENV_MAP)`. Signature `core_questions(image_tag: str, +project_name: str | None, convention_exists: bool) -> QuestionGroup`. +Sections in order: (1) `language` — choice of `_LANGUAGES`; (2) `convention` +— only when `not convention_exists`: `QuestionGroup(id="convention", +prompt="--- Base Convention ---", children=[Question(id="adopt", kind="confirm", +prompt="Download base convention", default=False)])`; (3) `codemanifest` — +usages pairs + annotations input (defaults supplied at ask time when the +convention gate was accepted — engine-side prefill, NOT tree defaults); +(4) `build` — agent choice + env pairs; (5) `docker_image` — dockerfile +input (default `.goga/Dockerfile`), base_image input (prompt embeds the +completed hint list, default = LAST entry), image input (default +`f"{project_name}:latest"` or absent when `project_name is None`); (6) +`pipeline` — agent choice + env pairs; (7) `tools` — pairs question with the +four-form grammar documented in the prompt; (8) `usages` — structural +`QuestionGroup(id="usages", prompt="--- Usages ---")` with no declarable +children. Return `QuestionGroup(id="core", children=sections)`. The +completed hints are data of the tree (embedded in the `base_image` prompt + +default) — the ENGINE renders them; the tag is never hardcoded. The +`tools`/`usages` sections are NEW user-facing sections — keep their prompt +texts aligned with the created-files list of +`goga/commands/init/.usages/init.md` (`.goga/config.yml`, +`.goga/usages/conventions.md`, the Dockerfile, `.goga/tools//`) +— a design instruction carried into this task. + +**Usages relevant to this task:** +- `convention`: docstring style; pure builder (no I/O). +- `image_defaults` (inline practice in the survey CODEMANIFEST): the + language → family mapping; complete each name with `image_tag`; default = + last entry; free-form accepted (kind stays `input`). +- `agent_env_defaults` (inline practice): the agent → env key mapping for + the `keys` parameterization of the env pairs questions. +- `question-records`: the record structure the builder emits. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): create `tests/onboarding/survey/test_core.py` — `from goga.onboarding.survey import core_questions` succeeds; in `__all__`; `core_questions("1.3", "my-app", False)` returns a `QuestionGroup` with `id == "core"` +- [x] **Code**: port the mapping data and implement `core_questions` in `goga/onboarding/survey/core.py` per the section list above +- [x] **Code**: export `core_questions` from `goga/onboarding/survey/__init__.py` +- [x] **Interface verification**: `pytest tests/onboarding/survey/test_core.py -v` — contract tests pass +- [x] **Logic tests**: `test_core_questions_builds_eight_sections_with_tag` — assertions: `[child.id for child in core.children] == ["language", "convention", "codemanifest", "build", "docker_image", "pipeline", "tools", "usages"]`; the `base_image` question's prompt contains `"qarium/goga-python-3.14:1.3"` and its default equals the last completed hint; the `image` question default == `"my-app:latest"`; `core_questions("1.3", None, True)` drops the `convention` section (first section is `language`); edge — `project_name=None` → the `image` default is `None` (absent) +- [x] **Debugging**: `pytest tests/onboarding/survey/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: the root id `"core"` is never addressed in answers (sections are the top-level keys); kind of `base_image`/`image` stays `input` (free-form); the tag threads from the single argument — no hardcoded `1.3` +- [x] **Lint**: `ruff check goga/onboarding/survey/` — fix formatting if necessary + +### Task 12: plan layer — `SessionPlan`, `assemble_session_plan`, `apply_skips` (TDD coding) + +Implement the plan layer at `location: goga/onboarding/survey/plan.py` and +expose the three names through the cell facade. `SessionPlan(root, tools)` +is a data record (frozen `kw_only` dataclass). `assemble_session_plan(core, +declarations)` — the algorithm with the fix-q2 guard (reserved names +derived from the RECEIVED core's children; a colliding tool identity drops +the whole block with a warning naming the tool and the reserved name; the +tool keeps its amendment rights) and the local-name dedup (a repeated id +drops THAT element with a warning; survivors stand; a fully-dropped tool +still gets its empty block). `apply_skips(plan, skips)` — the three-way +resolution rule (prefixed / core / own-block) against the ORIGINAL root, +set semantics (order-independent, descendants of removed nodes silently +absorbed), rebuild with new groups along changed branches sharing frozen +originals, a NEW `SessionPlan` with the same `tools` list, no-op warnings +for unresolvable paths. Module logger for the warnings. + +**Usages relevant to this task:** +- `convention`: pure transformers; one module logger; warnings name the + tool and the reason. +- `question-records`: the record structure and the tree-path addressing + (ids joined by dots). +- `survey-run` (`goga/onboarding/survey/.usages/survey-run.md`): the + reserved-names note of `assemble_session_plan` and the plan semantics. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): create `tests/onboarding/survey/test_plan.py` — `from goga.onboarding.survey import SessionPlan, assemble_session_plan, apply_skips` succeeds; all three in `__all__`; `assemble_session_plan(core, [])` returns a `SessionPlan` whose root children equal the core children and `tools == []` +- [x] **Code**: implement `SessionPlan`, `assemble_session_plan`, `apply_skips` in `goga/onboarding/survey/plan.py` per the algorithms above (a `_resolve_path` helper and a `_rebuild_without` helper are natural internal decomposition) +- [x] **Code**: export the three names from `goga/onboarding/survey/__init__.py` +- [x] **Interface verification**: `pytest tests/onboarding/survey/test_plan.py -v` — contract tests pass +- [x] **Logic tests** (from the design, transfer verbatim): `test_assemble_session_plan_orders_blocks_and_drops_repeats` — core with `language`, `tools`; declarations of `my-tool` (two questions both id `token`), `viewer` (one), `empty-tool` (none) → root children ids `["language", "tools", "my-tool", "viewer"]`, `plan.tools == ["my-tool", "viewer"]`, the my-tool block has exactly one `token` child; `test_assemble_reserved_name_drops_block` — core with a `tools` section; a declaration of a tool with identity `tools` → `plan.tools == []`, root children `["tools"]` (core only), a warning naming "tools" in caplog; `test_apply_skips_prefixed_own_and_unknown` — plan with core `language` + `build` and blocks `my-tool` (group `reporting` with `enabled`) and `viewer` (`opt`); skips `[("my-tool", "reporting.enabled"), ("viewer", "my-tool.reporting.enabled"), ("viewer", "language"), ("my-tool", "no.such.path")]` → `language` absent, `build` present, both blocks present (`my-tool` emptied group), `"enabled"` not reachable under the my-tool block, a warning naming "no.such.path"; edge — applying the same plan's skips in reversed order yields the same result (order independence) +- [x] **Debugging**: `pytest tests/onboarding/survey/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: the core tree is never mutated (records frozen, fresh containers); an emptied block stays in the plan; `core_section_ids` derived, not hardcoded +- [x] **Lint**: `ruff check goga/onboarding/survey/` — fix formatting, apply decomposition if necessary + +### Task 13: `Questionnaire` survey engine (TDD coding) + +Implement the interactive engine at `location: +goga/onboarding/survey/questionnaire.py` and expose it through the cell +facade. Port the prompt texts and the interactive patterns of the old +`goga/onboarding/questionnaire.py` (session header, language choice, base +convention gate with prefill, codemanifest usages/annotations, agent +gates, `_collect_agent_env`, docker branch with hints, image name) — the +old `ask`/`ask_*` per-field methods become the engine's core-section +patterns; `ask_goga_config`'s config-exists short-circuit is NOT ported +(that guard moved to `InitLogic`/`FileGenerator`). API: `run(plan, +answers)` — header echo, core sections via the conditional patterns, tool +blocks after the core under the attribution heading (suppress emptied +blocks; a group with `prompt=None` still renders the heading from the block +id); records land at plan dot-paths. `ask_question(question)` — the four +kinds via click plus the ELSE soft-skip branch (unknown kind or missing +parameterization → warning naming the question path, not asked, not +recorded, survey continues). `ask_group(group, prefix=None)` — heading +echo, children in order, recursion (the optional prefix keeps the +contract's one-argument call shape). The core-section rule: only the children +present in the post-skip section are asked; the confirm gates are +presentational (never recorded); the docker_image branch collapses per the +rule (skipped `dockerfile` → the pull branch directly; skipped `base_image` +→ the FROM is never recorded/asked). The usages record loop accumulates +`{group: {dep: {git, ref?, root?}}}` and records it at `"usages"`. +`click.Abort` propagates. Test with `CliRunner` (the existing +`tests/onboarding/test_questionnaire.py` pattern — port the old cases into +`tests/onboarding/survey/test_questionnaire.py`). + +**Usages relevant to this task:** +- `click` (`.goga/usages/cooks/click.md`): `click.prompt` / + `click.confirm` / `click.Choice`; the repeated key-value collection + pattern; `_collect_agent_env` is the template for the gated env pairs. +- `convention`: one module logger (the ELSE branch warns); type hints. +- `image_defaults` (inline practice): render the hint lines of the + `base_image` prompt, default the last, accept free-form. +- `agent_env_defaults` (inline practice): prompt the suggested keys of the + selected agent first, then arbitrary additions. +- `question-records`: the records the engine asks; the answer-value types + per kind. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): create `tests/onboarding/survey/test_questionnaire.py` — `from goga.onboarding.survey import Questionnaire` succeeds; in `__all__`; `Questionnaire()` constructs with no arguments; `run`, `ask_question`, `ask_group` callable +- [x] **Code**: implement `Questionnaire` in `goga/onboarding/survey/questionnaire.py` — `run`, `ask_question`, `ask_group`, and the private core-section patterns (`_survey_language`, `_survey_convention`, `_survey_codemanifest`, `_survey_build`, `_survey_docker_image`, `_survey_pipeline`, `_survey_tools`, `_survey_usages` — or an equivalent internal decomposition under mccabe 10) +- [x] **Code**: export `Questionnaire` from `goga/onboarding/survey/__init__.py` +- [x] **Interface verification**: `pytest tests/onboarding/survey/test_questionnaire.py -v` — contract tests pass +- [x] **Logic tests** (from the design, transfer verbatim): `test_questionnaire_records_core_and_tool_answers` — plan from a minimal core (`language` choice) + `my-tool` block (input `token`); `answers = SessionAnswers(tools=["my-tool"])`; CliRunner inputs `["python", "t0"]` → `answers.snapshot() == {"language": "python", "my-tool": {"token": "t0"}}`; `test_unknown_kind_is_skipped_with_warning` — a `my-tool` block with `Question(id="bad", kind="text", prompt="Weird")` and `Question(id="ok", kind="input", prompt="Token")`; input `["t0"]`; caplog → `answers.snapshot() == {"my-tool": {"ok": "t0"}}`, a warning naming "my-tool.bad", `"Weird" not in result.output`; port the old questionnaire test cases (convention gate accept/reject prefill, agent gates, docker branch hints, image default) adapted to the plan/answers API +- [x] **Debugging**: `pytest tests/onboarding/survey/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: gates never record; a skipped subtree is never asked; tool answers nest under the reserved tool key; a hook is never called to survey (the engine asks the buffered records) +- [x] **Lint**: `ruff check goga/onboarding/survey/` — fix formatting, apply decomposition if necessary + +### Task 14: generator cell structure (infrastructure) + +Create the package structure of the new leaf cell +`goga/onboarding/generator/` (directory exists with CODEMANIFEST only; the +OLD module `goga/onboarding/generator.py` — a file — coexists until the +facade rewrite deletes it; Python resolves `goga.onboarding.generator` to +the package once it has `__init__.py`, so create the facade only when the +entity code is ready in Task 15 — this task creates the module file and the +test subpackage, and the facade together with Task 15's first code step). +Create `goga/onboarding/generator/generator.py` with its module docstring +and `tests/onboarding/generator/__init__.py`. + +**Usages relevant to this task:** +- `convention`: module docstrings; test layout. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] Create `goga/onboarding/generator/generator.py` — module docstring naming `FileGenerator` and `CreatedFile` at `location: generator.py` +- [x] Create `tests/onboarding/generator/__init__.py` (empty) +- [x] Verify no import shadowing breakage: `pytest tests/onboarding/ -x --co -q` — the OLD tests still collect and pass (the old `generator.py` module is untouched; the new package has no `__init__.py` yet, so `goga.onboarding.generator` still resolves to the old file) +- [x] Lint: `ruff check goga/onboarding/generator/` — passes + +### Task 15: `FileGenerator.generate` + `generate_goga_config` + `CreatedFile` (TDD coding) + +Implement the artifact generator core at `location: +goga/onboarding/generator/generator.py` and expose `FileGenerator` + +`CreatedFile` through the cell facade (`goga/onboarding/generator/__init__.py` +created NOW — from this point `goga.onboarding.generator` resolves to the +package). Port from the old `goga/onboarding/generator.py`: the +`_LiteralStr` class + `yaml.add_representer` registration, the +`_CONVENTION_URL_TEMPLATE`, the requests download with timeout 30 and the +clean error with URL and cause, the `_build_block` shape, the field order. +New API: `generate(answers, contributions) -> list[CreatedFile]` — the +existing-config guard (`Path(".goga/config.yml").is_file()` → skip the +config and Dockerfile generation, jump to tool configs); snapshot; the +Dockerfile written ONLY when BOTH `docker_image.dockerfile` AND +`docker_image.base_image` are present (`FROM {base_image}\n`, +`CreatedFile(path, None)`); then `generate_goga_config(answers)` (the +conventions download writes `.goga/usages/conventions.md` → `CreatedFile` +BEFORE the config serialization); then `generate_tool_configs` +(implemented fully in this task; Task 16 verifies it in isolation and +adds the cross-entity negative trace of the design). +`generate_goga_config(answers)` — snapshot; empty `language` → +`ValueError` naming the field; the conventions entry check; `mkdir .goga`; +the mapping table (see Contract Surface — build nests under +`task_executor`, pipeline stays flat, `base_image` NEVER emitted, +`dockerfile` omitted when absent, blocks omitted when empty, annotations as +a `_LiteralStr` literal block); `yaml.dump(default_flow_style=False, +allow_unicode=True, sort_keys=False)`. `CreatedFile(path, tool)` — frozen +`kw_only` dataclass. + +**Usages relevant to this task:** +- `yaml` (inline practice in the generator CODEMANIFEST): + `yaml.dump(default_flow_style=False)`; `sort_keys=False, + allow_unicode=True`; the literal-block representer for annotations. +- `lang_conventions` (inline practice): the URL template, the target path, + `requests.get(url, timeout=30)`, the failure semantics (clean error with + URL + cause; config.yml NOT created on failure). +- `question-records`: the answer-space structure the snapshot yields. +- `session-participation`: the committed contributions shape. +- `convention`: docstring style; the module logger is NOT needed for the + happy path (errors are exceptions, not warnings). + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): create `tests/onboarding/generator/test_generator.py` — `from goga.onboarding.generator import CreatedFile, FileGenerator` succeeds; both in `__all__`; `FileGenerator()` constructs; `CreatedFile(path="p", tool=None)` exposes both fields; `generate`/`generate_goga_config`/`generate_tool_configs` callable on the instance +- [x] **Code**: port `_LiteralStr` + representer and the URL template; implement `CreatedFile`, `FileGenerator.generate`, `FileGenerator.generate_goga_config`, and `FileGenerator.generate_tool_configs` (per-contribution loop writing `.goga/tools//` in call order, a repeated name replacing) in `goga/onboarding/generator/generator.py` +- [x] **Code**: create `goga/onboarding/generator/__init__.py` — domain docstring + `from .generator import CreatedFile, FileGenerator` + `__all__` +- [x] **Interface verification**: `pytest tests/onboarding/generator/ -v` — contract tests pass; `python -c "from goga.onboarding.generator import FileGenerator"` — exits 0 +- [x] **Logic tests** (from the design, transfer verbatim): `test_generate_writes_dockerfile_then_config` — `_clean_cwd`; answers with `docker_image = {"dockerfile": ".goga/Dockerfile", "base_image": "qarium/goga-python-3.13:1.3", "image": "my-app:latest"}`, `language = "python"`, no conventions entry, contributions `[]` → `Path(".goga/Dockerfile").read_text() == "FROM qarium/goga-python-3.13:1.3\n"`; `cfg = yaml.safe_load(...)` → `cfg["language"] == "python"`, `cfg["image"] == "my-app:latest"`, `cfg["dockerfile"] == ".goga/Dockerfile"`, `"base_image" not in cfg`; `[f.path for f in files] == [".goga/Dockerfile", ".goga/config.yml"]`, all `f.tool is None`; `test_generate_empty_language_is_clean_error` — snapshot without `language` → `with pytest.raises(ValueError, match="language")`, `not Path(".goga/config.yml").exists()`; `test_conventions_download_failure_names_url` — answers with `language="python"` and `codemanifest={"usages": {"conventions": ".goga/usages/conventions.md"}}`, `requests.get` monkeypatched to raise `requests.ConnectionError("down")` → `with pytest.raises(RuntimeError, match="https://raw.githubusercontent.com/.*/python/project.md")`, `not Path(".goga/config.yml").exists()`; edge — existing config.yml → generate skips config/Dockerfile and returns only tool-file entries +- [x] **Debugging**: `pytest tests/onboarding/generator/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: the written file passes the project-config loader (`from goga.config import load config schema` — the core schema loader); field order language, image, dockerfile, build, pipeline, codemanifest, tools, usages; `base_image` never in the config +- [x] **Lint**: `ruff check goga/onboarding/generator/` — fix formatting if necessary + +### Task 16: tool-config generation with attribution (TDD coding) + +Complete the tool-config write path of the generator cell: the staged-commit +story end to end. `generate_tool_configs(contributions)` iterates the +committed contributions in enumeration order and their buffered `(file, +data)` in call order, serializes per the `yaml` practice, and writes +`.goga/tools//` — a repeated file name replaces; every entry +returns `CreatedFile(path, tool)` with the tool identity (attribution). +Task 15 implemented the method fully; this task verifies it in isolation +and adds the cross-entity negative trace of the design. + +**Usages relevant to this task:** +- `yaml` (inline practice): the tool file serialization. +- `session-participation` (`goga/onboarding/participation/.usages/session-participation.md`): + the committed contributions — buffers of `write_config` calls. +- `convention`: test conventions; `_clean_cwd` for filesystem tests. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests**: `from goga.onboarding.generator import FileGenerator`; `generate_tool_configs([])` is a no-op returning `None`; the method exists on the facade-exported class +- [x] **Code**: ensure `generate_tool_configs` matches the contract (per-contribution, per-buffer loops; `.goga/tools//`; replace on repeat; `CreatedFile(path, tool)` appended by `generate`) +- [x] **Interface verification**: `pytest tests/onboarding/generator/ -v` — all pass +- [x] **Logic tests** (from the design, transfer verbatim): `test_generate_tool_configs_with_attribution` — `_clean_cwd`; `answers = SessionAnswers()` with `answers.record("language", "python")` and no docker_image; a committed contribution of `my-tool` with files `[("service.yml", {"token_source": "env"}), ("service.yml", {"interval": 60})]` → `yaml.safe_load(Path(".goga/tools/my-tool/service.yml").read_text()) == {"interval": 60}` (later buffer wins); the last `CreatedFile` has `tool == "my-tool"` and `path == ".goga/tools/my-tool/service.yml"`; `test_failing_hook_discards_files_with_amendments` — `_clean_cwd`; a fake tool subscribed to `amend_config` whose hook buffers `answer("tools", …)` and `write_config("x.yml", …)` then raises; `answers.record("language", "python")` in the setup (the generator's required-field gate would otherwise fire before the assertions); run `collect_contributions(answers)` then `FileGenerator().generate(answers, [])` → `contributions == []`, `"tools" not in answers.snapshot()`, `not Path(".goga/tools").exists()`, config.yml written from the recorded core +- [x] **Debugging**: `pytest tests/onboarding/generator/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: the engine is the single write path of tool configs — data written verbatim, no interpretation; attribution None for engine files, identity for tool files +- [x] **Lint**: `ruff check goga/onboarding/generator/` — fix formatting if necessary + +### Task 17: onboarding facade rewrite — `InitLogic`, 13 re-exports, old-module deletion (TDD coding) + +Rewrite the onboarding domain facade (`goga/onboarding/`): the new +`InitLogic` at `location: logic.py` (constructor gains `participation: +ToolParticipation` — three injected collaborators), the facade +`__init__.py` re-exporting the 13 embedded types + `InitLogic` in the +embedding order of the CODEMANIFEST, and the deletion of the old modules +`goga/onboarding/answers.py`, `goga/onboarding/questionnaire.py`, +`goga/onboarding/generator.py` together with their old test files +`tests/onboarding/test_answers.py`, `tests/onboarding/test_generator.py`, +`tests/onboarding/test_questionnaire.py` (their cases were ported into the +leaf test layout in Tasks 5–16). The facade must never expose +`InitAnswers`/`GogaConfigAnswers` again. `InitLogic.run()` implements the +eight steps (see Contract Surface — guard, version/tag, moment one, +plan assembly with `resolve_project_name` from `goga/config` and the +conventions check, survey, moment two, generation + report, `return 0`) and +the three error tiers (tool failures soft — already warned inside the +collaborators; session errors — ONE `click.echo(f"Error: {exc}", err=True)` ++ `logger.error`, exit 1, never a traceback; `click.Abort` — exit 1, +quiet). Rewrite `tests/onboarding/test_logic.py` for the new constructor +and API. + +**Usages relevant to this task:** +- `convention`: dependency-injection style of the old logic.py; relative + imports (`from .generator import FileGenerator`, `from .participation + import ToolParticipation`, `from .questions import SessionAnswers`, + `from .survey import Questionnaire, apply_skips, assemble_session_plan, + core_questions`); one module logger. +- `minor-line` (`goga/version/.usages/minor-line.md`): reading the + installed version and deriving the tag (`host_goga_version` → + `minor_version`). +- `session-participation`: the two tool moments. +- `survey-run`: the plan assembly and the survey. +- `artifact-generation`: the generation and the file report. +- `onboarding-usage` (`goga/onboarding/.usages/onboarding-usage.md`): the + facade import list this task realizes (13 embeddings + `InitLogic`). + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): rewrite `tests/onboarding/test_logic.py` — `from goga.onboarding import InitLogic` plus the 13 re-exported names succeeds; all 14 in `__all__`; `InitLogic(questionnaire, generator, participation)` requires three positional/keyword collaborators; `goga.onboarding` no longer exports `InitAnswers`/`GogaConfigAnswers` (`not hasattr(goga.onboarding, "InitAnswers")`) +- [x] **Code**: rewrite `goga/onboarding/logic.py` — the new `InitLogic` with the eight-step `run()` per the Contract Surface algorithm +- [x] **Code**: rewrite `goga/onboarding/__init__.py` — the domain-facade docstring; imports from the four leaves + `InitLogic` from `.logic`; `__all__` with the 13 embeddings in CODEMANIFEST embedding order followed by `InitLogic` (mirror the same order in the import block) +- [x] **Code**: delete `goga/onboarding/answers.py`, `goga/onboarding/questionnaire.py`, `goga/onboarding/generator.py`; delete `tests/onboarding/test_answers.py`, `tests/onboarding/test_generator.py`, `tests/onboarding/test_questionnaire.py` +- [x] **Code**: adapt `tests/commands/test_init.py` to the deletion — replace its module-level imports of the deleted modules (`from goga.onboarding.answers import GogaConfigAnswers, InitAnswers`; `from goga.onboarding.questionnaire import Questionnaire`) with facade imports (`from goga.onboarding import FileGenerator, InitLogic, Questionnaire, ToolParticipation`); rewrite the tests that drive the real `InitLogic` (the `mock_q.ask` / `InitAnswers` stubbing of `test_init_cli_command`) into the `mock.patch.object(_cmd_init_module, "InitLogic", ...)` pattern used by the file's other tests — the exit-code propagation they verify is unchanged. Also drop the stale `GogaConfigAnswers` mention from the `_run_goga_config` docstring in `tests/config/test_resolve_project_name_flows.py` (a leftover docstring reference, not an import) +- [x] **Interface verification**: `pytest tests/onboarding/test_logic.py -v` — contract tests pass; `pytest tests/commands/ --co -q` — collects cleanly; `grep -r "InitAnswers\|GogaConfigAnswers" goga/ tests/` returns no hits +- [x] **Logic tests** (from the design, transfer verbatim): `test_existing_config_ends_session_silently` — `_clean_cwd` with `.goga/config.yml` pre-created; stubbed collaborators asserting no calls → `run() == 0`, neither `questionnaire.run` nor `participation.collect_declarations` nor `generator.generate` called; `test_unreadable_version_is_clean_error` — `host_goga_version` monkeypatched to raise `PackageNotFoundError("goga")` → `run() == 1`, "Error:" in output, "Traceback" not in output; `test_broken_package_import_is_clean_session_error` — enumeration monkeypatched to a package whose facade raises on import (platform-wrapped `ImportError`) → `run() == 1`, "Error:" and "goga_tool_broken" in output, "Traceback" not in output; edge — zero invited tools and no installed packages → the session degrades to the plain behavior (`declarations == []`, `contributions == []`, core-only survey) +- [x] **Debugging**: `pytest tests/onboarding/ -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: the facade import order matches the embeddings; no old names anywhere; the `InitLogic` error tiers hold (one message, no traceback, exit codes 0/1) +- [x] **Lint**: `ruff check goga/onboarding/` — fix formatting if necessary + +### Task 18: `init` command — `-t/--tool` invitation flag (TDD coding) + +Extend the CLI command at `location: goga/commands/init/init.py` with the +repeatable `-t/--tool` option: `@click.option("-t", "--tool", "tools", +multiple=True, help=...)` (help text mirroring `init.md`). The command +signature becomes `init(tpl, upgrade, ref, tools: tuple[str, ...])`. +Validation order (contract algorithm 1–6): ref placement check (ported +verbatim) → mode resolution (ported; mutual exclusion) → invitation +validation (`tools` non-empty AND mode UPGRADE → message `-t/--tool +requires an onboarding session and --upgrade runs none`, exit 1) → dedup +preserving flag order (`list(dict.fromkeys(tools))` — the tuple→list +conversion happens HERE; the facade signature +`ToolParticipation(invited: list[str])` receives a list) → +already-initialized guard (BARE_ONBOARDING only, ported) → dispatch +(UPGRADE: `Scaffold().upgrade(ref)`; both onboarding modes: +`InitLogic(Questionnaire(), FileGenerator(), ToolParticipation(invited= +deduped))` → `ctx.exit(logic.run())`). The command passes names through as +opaque data — no installation checks. Port the existing ref/mode/guard +logic verbatim from the current init.py; do not restructure it. The +module-level imports of `tests/commands/test_init.py` were already +adapted to the facade in Task 17 — this task only extends the file with +the `-t/--tool` tests. + +**Usages relevant to this task:** +- `click` (`.goga/usages/cooks/click.md`): `@click.option(multiple=True)`; + the command wrapper conventions. +- `onboarding-usage` (`goga/onboarding/.usages/onboarding-usage.md`): the + session API and the invitation semantics; the message text of the + rejection. +- `scaffold-usage` (`goga/scaffold/.usages/scaffold-usage.md`): the + `Scaffold` API for the UPGRADE dispatch. +- `conventions` (`.goga/usages/conventions.md`): the command's code style. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] **Contract tests** (expected to fail at this stage): extend `tests/commands/test_init.py` — `runner.invoke(init_cli, ["--help"])` shows `-t, --tool`; the command accepts repeated `-t`; `-t` with `--upgrade` rejected +- [x] **Code**: add the `tools` option and the parameter to `init` in `goga/commands/init/init.py`; insert the invitation-validation step and the dedup step in the contract order; wire `ToolParticipation` into both onboarding dispatch branches +- [x] **Interface verification**: `pytest tests/commands/test_init.py -v` — contract tests pass; the pre-existing mode/ref/guard tests still pass (ported logic untouched) +- [x] **Logic tests** (from the design, transfer verbatim): `test_init_rejects_tools_with_upgrade` — `runner.invoke(init_cli, ["--upgrade", "-t", "my-tool"])` → exit 1, `"-t/--tool requires an onboarding session" in result.output`; `test_init_dedup_preserves_flag_order` — stubbed `InitLogic` capturing the constructed `ToolParticipation`; `["-t", "b", "-t", "a", "-t", "b"]` → `captured.invited == ["b", "a"]`; edge — `-t` with `` allowed (SCAFFOLD_THEN_ONBOARDING carries the invitation into the session) +- [x] **Debugging**: `pytest tests/commands/test_init.py -x` — fix implementation code until all tests pass +- [x] **Contract re-verification**: validation order matches the contract algorithm 1–6; opaque passthrough (no installation checks in the command); `--upgrade` never runs onboarding +- [x] **Lint**: `ruff check goga/commands/init/` — fix formatting if necessary + +### Task 19: Integration tests — end-to-end session with invited tools + +Cross-entity verification of the whole feature through the CLI: invitation +→ dedup → both moments → survey → amendments → generation → attributed +report. Rewrite `tests/onboarding/test_integration.py` (the old file tests +the old API). Setup pattern: `_clean_cwd`; fake installed +`goga_tool_my-tool` package (enumeration monkeypatched; `register_hooks` +subscribes both actions) — the `tests/hooks/conftest.py` `sys.modules` +injection pattern; `CliRunner` inputs walking the whole survey; caplog at +WARNING. + +**Usages relevant to this task:** +- `onboarding-usage`: the full session API composition the test drives. +- `session-participation`: the fake tool's hook bodies (declare/amend + contexts). +- `tool-contexts` (`goga/onboarding/participation/.usages/tool-contexts.md`): + the hook signature pattern for the fakes. +- `artifact-generation`: the expected artifacts and the report format. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [x] Rewrite `tests/onboarding/test_integration.py` with the shared fake-tool fixtures (declare + amend subscribed; declares a `token` input; buffers `answer("tools", {"my-tool": "latest"})` and `write_config("service.yml", {...})`) +- [x] Test cross-entity interaction: `test_init_full_session_with_invited_tool` — `runner.invoke(init_cli, ["-t", "my-tool", "-t", "my-tool"])` with the full survey inputs → `result.exit_code == 0`; `cfg = yaml.safe_load(Path(".goga/config.yml").read_text())` → `cfg["tools"] == {"my-tool": "latest"}`; `Path(".goga/tools/my-tool/service.yml").exists()`; `"(tool: my-tool)" in result.output` +- [x] Test edge case: `test_tool_failure_never_changes_exit_code` — the full-session setup with the tool's `amend_config` hook raising instead of contributing → `result.exit_code == 0`; `not Path(".goga/tools/my-tool").exists()`; a warning naming the tool in caplog +- [x] Test edge case: `test_skip_of_base_image_collapses_dockerfile_branch` — fake tool whose `declare_session` hook calls only `context.skip("docker_image.base_image")`; CliRunner inputs: language `python`, every confirm gate `n` except the Dockerfile gate `y`, dockerfile path default (empty input), built image name `my-app:latest` → `result.exit_code == 0`; `"Base image" not in result.output`; `cfg["image"] == "my-app:latest"` and `"dockerfile" not in cfg` and `"base_image" not in cfg`; `not Path(".goga/Dockerfile").exists()` +- [x] Run validation: `pytest tests/onboarding/ -x` then the full suite `pytest tests/ -x` — all pass (137 onboarding, 5471 total) +- [x] Final platform check: `goga lint` — 0 errors (76+ cells; the four new cells join the graph) + +--- + +## Validation Commands + +- `pytest tests/version/ -v`: version cell tests (`minor_version`) +- `pytest tests/hooks/ -v`: hooks catalog + facade re-export tests +- `pytest tests/onboarding/questions/ -v`: questions cell tests +- `pytest tests/onboarding/participation/ -v`: participation cell tests +- `pytest tests/onboarding/survey/ -v`: survey cell tests +- `pytest tests/onboarding/generator/ -v`: generator cell tests +- `pytest tests/onboarding/ -v`: onboarding domain (logic + integration) +- `pytest tests/commands/ -v`: init CLI tests +- `pytest tests/ -x`: Run all tests (full suite) +- `ruff check goga/`: Lint check (line-length 120, mccabe 10) +- `goga lint`: Facade/contract graph check — must stay 0 errors +- `python -c "from goga.onboarding import InitLogic, Question, QuestionGroup, SessionAnswers, SessionPlan, Questionnaire, core_questions, assemble_session_plan, apply_skips, ToolParticipation, ToolDeclaration, ToolContribution, FileGenerator, CreatedFile"`: Verify that all facade entities are importable +- `python -c "from goga.hooks import HookRegistry, wrap_context, build_hook_arguments, enumerate_tool_packages"`: Verify the hooks facade re-exports +- `python -c "from goga.version import minor_version"`: Verify the version facade re-export + +--- + +## Completion Criteria + +- [x] Every contract entity is implemented in the correct `location` +- [x] Every contract entity is accessible from the facade +- [x] Properties and methods match the declared API +- [x] Descriptions are reflected in behavior +- [x] Contract dependencies are met +- [x] Re-exports are accessible from the facade +- [x] Every coding task followed the TDD workflow (contract tests → code → verification → logic tests → debugging → re-verification → lint) +- [x] Contract tests and logic tests cover facade, API, and behavior within each coding task +- [x] Integration tests exist where cross-entity scenarios require them +- [x] No package boundary was expanded +- [x] `CODEMANIFEST` files were not modified (contract is read-only) +- [x] All validation commands pass +- [x] Every Usages entry is mentioned in at least one task (Phase 2 calibration) +- [x] The old modules `goga/onboarding/{answers,questionnaire,generator}.py` and their test files are deleted; `InitAnswers`/`GogaConfigAnswers` appear nowhere +- [x] The written `.goga/config.yml` passes the project-config loader +- [x] The image tag is never hardcoded — it threads `host_goga_version` → `minor_version` → `core_questions` diff --git a/.goga/history/2026/onboarding-refctoring/design.md b/.goga/history/2026/onboarding-refctoring/design.md new file mode 100644 index 00000000..74e5a923 --- /dev/null +++ b/.goga/history/2026/onboarding-refctoring/design.md @@ -0,0 +1,2182 @@ +# Design Document: `onboarding-refctoring` + +Complete architectural specification for materializing the contracts of the +topic "Extensible `goga init` onboarding: tool participation via hooks actions +and the dynamic image tag" into code. Source contracts: the CODEMANIFEST set +materialized by the apply-architecture stage (9 cells) plus the two +approved fixes of this design stage (see Applied Fixes). + +--- + +## Contract Changes + +### Changed CODEMANIFEST Files + +- `goga/version/CODEMANIFEST`: new routine `minor_version` (after + `host_goga_version`); usage file `minor-line.md` created. +- `goga/hooks/catalog/CODEMANIFEST`: +2 Requirements records in + `declared_actions` — `onboarding/declare_session` (soft), + `onboarding/amend_config` (soft). +- `goga/hooks/CODEMANIFEST` (facade): Imports +`wrap_context`, + `build_hook_arguments` (from `goga/hooks/dispatch`) and + `enumerate_tool_packages` (from `goga/hooks/tools`); +3 embeddings; + global Annotations +1 sentence on the delivery re-exports; usage file + `per-tool-delivery.md` created. +- `goga/onboarding/questions/CODEMANIFEST` (create): `Question`, + `QuestionGroup` (questions.py), `SessionAnswers` (answers.py); usage + `question-records.md`. +- `goga/onboarding/participation/CODEMANIFEST` (create): `ToolDeclaration` + (declaration.py), `ToolContribution` (contribution.py), + `ToolParticipation` (participation.py); usages `session-participation.md`, + `tool-contexts.md`. +- `goga/onboarding/survey/CODEMANIFEST` (create): `core_questions` (core.py), + `assemble_session_plan`, `apply_skips`, `SessionPlan` (plan.py), + `Questionnaire` (questionnaire.py); usage `survey-run.md`. +- `goga/onboarding/generator/CODEMANIFEST` (create): `FileGenerator`, + `CreatedFile` (generator.py); usage `artifact-generation.md`. +- `goga/onboarding/CODEMANIFEST` (facade, rewritten): `InitLogic` (logic.py) + +13 re-export embeddings; old entities removed. +- `goga/commands/init/CODEMANIFEST`: `init` gains `tools: tuple[str, ...]`, + invitation validation, dedup step, `ToolParticipation` wiring; usage + `init.md` rewritten. + +### New Entities + +- `minor_version(version: str) -> minor: str` — goga/version/version.py. +- `Question`, `QuestionGroup`, `SessionAnswers` — + goga/onboarding/questions/{questions.py, answers.py}. +- `ToolDeclaration`, `ToolContribution`, `ToolParticipation` — + goga/onboarding/participation/{declaration.py, contribution.py, participation.py}. +- `core_questions`, `assemble_session_plan`, `apply_skips`, `SessionPlan`, + `Questionnaire` — goga/onboarding/survey/{core.py, plan.py, questionnaire.py}. +- `FileGenerator` (new API), `CreatedFile` — goga/onboarding/generator/generator.py. + +### Changed Entities + +- `InitLogic` — constructor gains `participation: ToolParticipation`; `run` + rewritten around the two tool moments, the plan assembly, and the dynamic + image tag. +- `init` (goga/commands/init/init.py) — `-t/--tool` repeatable option, + validation order extended, dedup, `ToolParticipation` construction. +- `declared_actions` data — two onboarding records (code change in + `_DECLARED_ACTIONS`). +- `goga.hooks` facade — three additional re-exports. +- `goga.version` facade — `minor_version` re-export. + +### Deleted Entities + +- `InitAnswers`, `GogaConfigAnswers` (old goga/onboarding/answers.py) — + replaced by the declarative `SessionAnswers` space; the flat answer + container dissolves into nested mappings. +- Old `Questionnaire.ask/ask_goga_config/ask_*` per-field methods + (goga/onboarding/questionnaire.py) — the engine is rebuilt around + `run(plan, answers)` + `ask_question` + `ask_group`; per-field ask logic + is ported into the engine's core-section survey. +- Old `FileGenerator.generate(answers: InitAnswers)` / + `generate_goga_config(config: GogaConfigAnswers)` — replaced by the + snapshot-driven API. +- Files `goga/onboarding/answers.py`, `goga/onboarding/questionnaire.py`, + `goga/onboarding/generator.py` are deleted; their contracts live in the + leaf cells. + +### Usages and Annotations Changes + +- New usage files: `minor-line.md`, `per-tool-delivery.md`, + `question-records.md`, `session-participation.md`, `tool-contexts.md`, + `survey-run.md`, `artifact-generation.md`. +- Rewritten: `onboarding-usage.md` (domain facade), `init.md` (CLI with + `-t`). +- New header practices: survey gains inline `image_defaults` (tag-completed + hints) and `agent_env_defaults` (agent → env keys); generator gains + inline `yaml` and `lang_conventions` (moved from the old facade header). + +## Applied Fixes + +### Fixed CODEMANIFEST Defects + +Both fixes approved by the user during this stage (file dialog q1, q2). + +- `goga/onboarding/questions/CODEMANIFEST`: `SessionAnswers()` → + `SessionAnswers(tools: list[str] | None = None)`; Requirements restate + "Created empty — the space holds no answers; `tools` reserves the + top-level keys of the tool sections"; `view_for` algorithm step 1 now + reads "every top-level key except the reserved tool-section names" + (reason: semantic gap — the core/tool distinction was not derivable by + the object; defect type: unoperationalizable algorithm). +- `goga/onboarding/survey/CODEMANIFEST`: `assemble_session_plan` gains + algorithm step 4 and Requirements bullet — a tool identity colliding + with a section name of the `core` tree drops the tool's whole block with + a warning; core section names are reserved; the tool keeps its amendment + rights (reason: edge-case gap — `goga_tool_tools` → identity `tools` + would silently merge the core `tools` section with the tool block; + defect type: ambiguous plan-root naming). + +Matching usage updates: `question-records.md` (constructor param), +`survey-run.md` (reserved names). + +--- + +## Entity Interaction and Data Flow + +### Interaction Diagram + +``` +CLI: goga init [-t name]... [] [--upgrade] [--ref r] + └─ init (goga/commands/init) ── validates flags, dedups tools + ├─ Scaffold (tpl modes; unchanged) + └─ InitLogic(Questionnaire, FileGenerator, ToolParticipation(tools)) + │ + │ 1. guard: existing .goga/config.yml → return 0 + │ 2. host_goga_version → minor_version → tag + │ 3. ToolParticipation.collect_declarations ──── moment one + │ ├─ HookRegistry.build_once ── enumerate_tool_packages + │ │ └─ goga_tool_* facades: register_hooks(hooks) + │ │ subscribe("onboarding","declare_session",…) + │ ├─ per tool: wrap_context(ToolDeclaration) + + │ │ build_hook_arguments(hook, view, self_context) + │ │ → hook(context[, self]) → context.declare/.skip + │ └─ declarations: list[ToolDeclaration] + │ 4. resolve_project_name (goga/config), conventions.md check + │ core_questions(tag, name, exists) → core tree + │ assemble_session_plan(core, declarations) → SessionPlan + │ apply_skips(plan, (tool, path) pairs) → SessionPlan + │ 5. SessionAnswers(tools=plan.tools) + │ Questionnaire.run(plan, answers) ── click survey + │ 6. ToolParticipation.collect_contributions(answers) ─ moment two + │ ├─ per tool: view_for(tool) → ToolContribution(view) + │ │ wrap_context + build_hook_arguments → hook(context) + │ │ → context.answer / context.write_config + │ └─ commit: answers.amend(...) per contribution; files kept + │ 7. FileGenerator.generate(answers, contributions) + │ ├─ Dockerfile (FROM base_image) when dockerfile path + │ ├─ generate_goga_config: snapshot → .goga/config.yml + │ │ └─ conventions download (lang_conventions) + │ └─ generate_tool_configs → .goga/tools// + │ 8. file report with attribution → exit 0 +``` + +### Data Flows + +- **Invitation flow**: CLI `-t` names → dedup (order-preserving) → + `ToolParticipation(invited)` → per-tool `invited` marker on both + surfaces → hook-side early return when False. +- **Declaration flow**: hook buffers `Question`/`QuestionGroup` + + skip paths → `ToolDeclaration.questions/.skips` → plan blocks named by + tool identity → skips `(tool, raw_path)` → `apply_skips`. +- **Answer flow**: `Questionnaire.run` records at plan paths → nested + mappings in `SessionAnswers` → `view_for(tool)` isolates per tool → + amendments `answer(id, value)` → committed via `amend` (recursive merge) + → `snapshot()` → config mapping. +- **Tag flow**: `host_goga_version()` → `minor_version()` → `"N.M"` → + `core_questions(image_tag)` → completed hints in the tree → prompts. +- **File flow**: committed `ToolContribution.files` + + snapshot → `FileGenerator.generate` → `list[CreatedFile]` → report. + +### Entity Dependencies + +Implementation order (leaves → root, matching the schema): + +1. `goga/version` (leaf) — `minor_version` reuses module-private + `_release_segments`. +2. `goga/hooks/catalog` (leaf) — two data records. +3. `goga/hooks` facade — three re-exports. +4. `goga/onboarding/questions` (leaf) — no imports. +5. `goga/onboarding/participation` — imports questions + goga/hooks. +6. `goga/onboarding/survey` — imports questions + participation + (`ToolDeclaration` only). +7. `goga/onboarding/generator` — imports questions + participation + (`ToolContribution` only). +8. `goga/onboarding` facade — imports all four leaves + goga/version + + goga/config. +9. `goga/commands/init` — imports the onboarding facade + goga/scaffold. + +Construction order at runtime: `ToolParticipation` and `Questionnaire` +and `FileGenerator` are constructed by `init` and injected into +`InitLogic`; `SessionAnswers` is constructed inside `InitLogic.run` after +`apply_skips` (it needs `plan.tools`); `HookRegistry` is constructed once +inside `ToolParticipation` and shared by both moments. + +--- + +## Code Stack Trace + +### Trace: `minor_version(version)` + +#### Chain +1. **Input**: caller `InitLogic.run` step 2 passes the string returned by + `host_goga_version()`; tests pass literals. +2. **Step**: `_release_segments(version)` (module-private, already in + version.py:71) reduces to `(major, minor | None)` — regex + `_RELEASE_PREFIX_RE` strips dev/pre/post/local tails → checkpoint: the + reducer exists at the same `location: version.py`; reuse, do not + duplicate (constraint: "mirroring `resolve_version`"). +3. **Step**: `minor = minor_seg if minor_seg is not None else "0"` → + checkpoint: "Treat a missing minor segment as 0" — matches + `compare_versions`'s convention (`"1" ≡ "1.0"`). +4. **Step**: `return f"{major}.{minor}"` → checkpoint: pure string join, + no I/O — the practice `convention` pure-function discipline. +5. **Output**: `"N.M"` string; `ValueError` propagates from + `_release_segments` for an argument with no leading numeric major. + +#### Checkpoint Summary +- reuse of `_release_segments`: passed (same module, same location). +- error contract: passed (ValueError, message from the shared reducer). + +### Trace: `declared_actions()` (changed data) + +#### Chain +1. **Input**: no arguments; called by `HookRegistrar.subscribe` + (registration.py:99) and `emit_hook_event` (emit.py:72) on every + registration/emission. +2. **Step**: `_DECLARED_ACTIONS` gains + `Action(domain="onboarding", name="declare_session", error_class="soft")` + and `Action(domain="onboarding", name="amend_config", error_class="soft")` + → checkpoint: frozen dataclasses, catalog constant stays supported-data + only; the pair is unique; error_class is exactly `soft`. +3. **Step**: `sorted(..., key=(domain, name))` yields + `onboarding/amend_config`, `onboarding/declare_session`, + `statuses/register_statuses` → checkpoint: ordering rule unchanged; the + `goga hooks` inspection output gains the two rows — additive only. +4. **Output**: new list per call; registration of the onboarding addresses + becomes acceptable (`subscribe` no longer rejects + "unknown action onboarding.declare_session"). + +#### Checkpoint Summary +- additive catalog extension: passed (published records are never + rewritten; the statuses record is untouched). + +### Trace: `goga.hooks` facade import (changed) + +#### Chain +1. **Input**: `from goga.hooks import HookRegistry, ToolHooks, + emit_hook_event, wrap_context, build_hook_arguments, + enumerate_tool_packages` (consumer: goga/onboarding/participation). +2. **Step**: facade `__init__.py` adds `from .dispatch import + build_hook_arguments, wrap_context` and `from .tools import + enumerate_tool_packages`; `__all__` gains the three names → checkpoint: + both sub-facades already export them (dispatch/__init__.py, + tools/__init__.py verified); no tool package is imported and nothing is + enumerated at import time (facade docstring invariant holds). +3. **Step**: name-collision check — no local names shadow the imports; + `declared_actions` re-export untouched. +4. **Output**: the delivery primitives and the enumeration are consumable + through the facade only (participation's `From: goga/hooks` resolves). + +#### Checkpoint Summary +- export availability: passed. +- import-side-effect invariant: passed (importing goga.hooks imports no + `goga_tool_*`). + +### Trace: `Question(...)` / `QuestionGroup(...)` constructors + +#### Chain +1. **Input**: keyword construction (`kw_only=True` per the `convention` + practice data-model rules), e.g. + `Question(id="token", kind="input", prompt="Service token")`. +2. **Step**: frozen dataclasses with fields exactly as the signatures; + `choices`/`default`/`keys` default to `None` (absence semantics) → + checkpoint: convention rule "dataclasses, kw_only=True"; `None` only + for explicit absence — the optionality here IS absence of the + parameterization. +3. **Step**: no validation in the records ("the record carries data only — + rendering and validating the answer value belong to the survey + engine") → checkpoint: kinds are checked at ask time, not at + construction. +4. **Output**: immutable value objects; `QuestionGroup.children` defaults + to `None`, a structural node carries no prompt. + +#### Checkpoint Summary +- data-only discipline: passed (no methods, no properties beyond fields). + +### Trace: `SessionAnswers.record(id, value)` + +#### Chain +1. **Input**: the survey passes the plan dot-path (`"build.agent"`, + `"my-tool.token"`) and the answer value + (`str | bool | dict`). +2. **Step**: split `id` on `"."`; walk `self._data` creating intermediate + `dict`s for traversed groups → checkpoint: a leaf collision with an + existing scalar mid-path is replaced by a mapping (record wins — the + survey is the authoritative writer of the run). +3. **Step**: set `value` at the leaf name → checkpoint: "Recording + replaces" — assignment, never merge. +4. **Output**: nested mapping; `snapshot()` reflects it; no return value. + +#### Checkpoint Summary +- dotted-key ban: passed — the dotted path is split, never stored as a + literal key. + +### Trace: `SessionAnswers.amend(id, value)` + +#### Chain +1. **Input**: committed contribution buffer entry `(path, value)` from + `ToolParticipation.collect_contributions` step 3. +2. **Step**: same segment walk creating intermediates. +3. **Step**: at the leaf — existing `dict` AND `value` is `dict` → + recursive merge (`merge(old[k], new[k])` per key; keys of `value` + win/replace per the same rule); otherwise (scalar, list, absent, or + type mismatch) → plain assignment → checkpoint: the exact rule + "mappings merge recursively; scalars and lists replace" — merge happens + only when BOTH sides are mappings. +4. **Step**: delivery order is the caller's responsibility + ("a later amendment wins at every conflicting leaf" — later assignment + overwrites). +5. **Output**: mutated space; silent (no warning — "substituting a user's + answer is a tool's lawful right"). + +#### Checkpoint Summary +- merge asymmetry handled: passed (scalar-under-mapping and + mapping-under-scalar both fall to replace). + +### Trace: `SessionAnswers.view_for(tool)` + +#### Chain +1. **Input**: `tool` identity from `ToolParticipation` (a subscribing tool + of `amend_config`). +2. **Step**: `view = deepcopy({k: v for k, v in self._data.items() if k + not in self._tool_sections})` → checkpoint: core = top-level keys minus + the reserved names given at construction (`tools` param, fix q1); the + survey records tool blocks exactly under those names. +3. **Step**: `own = self._data.get(tool)`; if a mapping, `view.update(deepcopy(own))` + → checkpoint: own local names land at the top level ("without the tool + prefix"); on a local-name collision with a core key the tool's own + value wins (update order) — the tool shadows only its own view; an + absent own section (no block, fully skipped, or non-declaring tool) + yields the core-only view. +4. **Step**: deep copy everywhere → checkpoint: "The view is a snapshot — + amendments applied after the call do not appear in it" and a hook + mutating the view cannot touch the space. +5. **Output**: `dict` delivered as `ToolContribution.answers`. + +#### Checkpoint Summary +- isolation of other tools: passed (excluded by the reserved set). +- snapshot immutability: passed (deepcopy). + +### Trace: `SessionAnswers.snapshot()` + +#### Chain +1. **Input**: `FileGenerator.generate_goga_config` step 1 (after the + contributions are committed). +2. **Step**: `deepcopy(self._data)` — the complete nested structure: core + sections and every committed tool section. +3. **Output**: `dict`; generation maps core fields and ignores tool + sections (their data reaches `.goga/tools//` through + `write_config`, not through config.yml) → checkpoint: mapping table has + no entry for tool sections — intentional. + +### Trace: `ToolDeclaration` delivery (moment one, per tool) + +#### Chain +1. **Input**: `ToolParticipation.collect_declarations` groups + `registry.subscriptions_for("onboarding", "declare_session")` by + `subscription.tool` preserving enumeration order. +2. **Step**: `surface = ToolDeclaration(tool=..., invited=tool in + self._invited)`; `proxy = wrap_context(surface)` → checkpoint: + `wrap_context` (delivery.py:28) resolves attribute reads and bound + methods; writes blocked — `declare`/`skip` are METHOD calls, they pass. +3. **Step**: per subscription `arguments = build_hook_arguments(hook, + proxy, registry.self_context(tool))`; `hook(**arguments)` → checkpoint: + only a declared `context` (and optional `self`) receives values + (delivery.py:69); the hook signature pattern from + `tool-contexts.md` works verbatim. +4. **Step**: the hook checks `context.invited`; False → immediate return + (empty buffer — "A tool without declarations contributes no block"); + True → `context.declare(...)`, `context.skip(...)` buffer into the + surface. +5. **Step**: any `Exception` from a hook of the tool → the whole + declaration of that tool is discarded with a warning; the delivery + continues with the next tool → checkpoint: staged control per the + `per-tool-delivery` practice (commit only after every hook of the tool + succeeded). +6. **Output**: surviving declarations in enumeration order. + +#### Checkpoint Summary +- read-only context + callable members: passed. +- never called to survey: passed (the engine asks the buffered records + itself later). + +### Trace: `ToolDeclaration.declare(item)` / `.skip(path)` + +#### Chain +1. **Input**: a `Question`, or a one-level `QuestionGroup` with simple + children; a raw skip path string. +2. **Step**: `declare` validates the one-level rule — a group whose + `children` contain a `QuestionGroup` is refused with a logged warning + naming the tool and the reason; the element is not buffered → + checkpoint: the Requirement lives on `declare`; enforcement at the + surface, single point. +3. **Step**: accepted items append to `questions`; `skip` appends the raw + string — no resolution here ("the engine resolves and applies every + declared skip as one set"). +4. **Output**: buffered lists readable via the properties after delivery. + +### Trace: `ToolContribution` delivery (moment two, per tool) + +#### Chain +1. **Input**: `collect_contributions(answers)` after the survey; the same + registry; subscriptions of `onboarding/amend_config` grouped per tool. +2. **Step**: `view = answers.view_for(tool)`; `surface = + ToolContribution(tool=..., invited=..., answers=view)` → checkpoint: + type flow — `view_for -> dict` matches `ToolContribution.answers: dict`. +3. **Step**: `wrap_context` + `build_hook_arguments` + call, identical to + moment one; the hook reads `context.answers`, buffers + `context.answer(path, value)` and `context.write_config(file, data)`. +4. **Step**: failure → the tool's whole contribution (amendments AND + files) discarded with a warning; other tools stand. +5. **Step**: commit pass in enumeration order: `answers.amend(path, + value)` per buffered amendment; the contribution object (with its file + buffer) is returned for generation. +6. **Output**: `list[ToolContribution]` — the committed contributions. + +#### Checkpoint Summary +- staged commit: passed (buffer → all hooks of the tool succeed → + commit). +- file buffers: passed (returned, not written here — generation owns the + write path). + +### Trace: `core_questions(image_tag, project_name, convention_exists)` + +#### Chain +1. **Input**: `InitLogic.run` step 4 — tag from step 2, name from + `resolve_project_name()` (`str | None`), flag from + `Path(".goga/usages/conventions.md").is_file()`. +2. **Step**: build the section list in survey order: `language`, + `convention` (only when the file is absent), `codemanifest`, `build`, + `docker_image`, `pipeline`, `tools`, `usages` → checkpoint: eight + sections with convention; omission rule exact. +3. **Step**: image hints — the `image_defaults` mapping completed with + `image_tag` (e.g. `qarium/goga-python-3.12:1.3`): the completed list is + embedded in the prompt text of the `base_image` question and its + default is the LAST entry; the `image` question carries the + built-branch default `f"{project_name}:latest"` or no default when the + name is `None` → checkpoint: kind stays `input` (free-form accepted, + matching the old `click.prompt(default=images[-1])` behavior); the tag + is never hardcoded. +4. **Output**: `QuestionGroup(id="core", children=[sections])` — the root + id is never addressed in answers (sections are the top-level keys). + +#### Checkpoint Summary +- tag threading: passed (single source, `minor_version` output). +- free-form image input preserved: passed. + +### Trace: `assemble_session_plan(core, declarations)` + +#### Chain +1. **Input**: the core tree + surviving declarations in enumeration + order. +2. **Step**: `children = list(core.children)`; `tools: list[str] = []`; + `reserved = {child.id for child in core.children}`. +3. **Step**: per declaration — empty `questions` → skip ("contributes no + block"); `declaration.tool in reserved` → warning naming the tool and + the reserved name, skip the whole block (fix q2) → checkpoint: the + guard compares against the RECEIVED core's children — no hardcoded + name list. +4. **Step**: local-name dedup within the declaration: walk + `declaration.questions` in order tracking seen ids; a repeated id drops + THAT element with a warning naming the tool and the reason; survivors + stand → checkpoint: element-level rejection only. +5. **Step**: append `QuestionGroup(id=declaration.tool, prompt=ATTRIBUTION_HEADING, + children=survivors)`; `tools.append(declaration.tool)` → checkpoint: + the block prompt doubles as the attribution heading the engine echoes. +6. **Output**: `SessionPlan(root=QuestionGroup(id="session", + children=children), tools=tools)` — a fresh root; the core tree is + never mutated (records are frozen). + +#### Checkpoint Summary +- determinism: passed (core order then enumeration order). +- immutability of inputs: passed (fresh containers; shared frozen + records). + +### Trace: `apply_skips(plan, skips)` + +#### Chain +1. **Input**: the assembled plan; `[(tool, raw_path), ...]` flattened by + `InitLogic` from the declarations' buffers. +2. **Step**: resolve every raw path against the ORIGINAL root: + segments = `raw_path.split(".")`; IF `segments[0]` is a tool identity + in `plan.tools` → the address is the full path from the root; ELSE IF + `segments[0]` resolves among the core section ids → the address is the + path from the root; ELSE IF `segments[0]` matches a local name of the + DECLARING `tool`'s own block → the address is `tool` + the path; ELSE → + warning no-op → checkpoint: the three-way rule of the contract + (prefixed / core / own-block) is fully deterministic; a path under a + pairs question simply has no children to resolve → no-op warning + ("only the node as a whole"). +3. **Step**: existence is checked against the original tree only — a + descendant of an already-skipped node resolves in the original, so the + set removal absorbs it silently (no spurious warning) → checkpoint: + "apply all skips as one set — order-independent". +4. **Step**: rebuild — construct new `QuestionGroup`s along removed + branches, dropping resolved nodes; unmodified branches share the + frozen originals. +5. **Output**: a NEW `SessionPlan` (same `tools` list — an emptied block + stays; the engine suppresses its empty heading and its section simply + never records). + +#### Checkpoint Summary +- order independence: passed (set semantics against the original). +- immutability: passed (new plan, no mutation of the input). + +### Trace: `Questionnaire.run(plan, answers)` + +#### Chain +1. **Input**: the final plan; the empty `SessionAnswers(tools=plan.tools)`. +2. **Step**: session header echoes (`=== Goga Project Initialization ===` + + wizard description — ported from the old `ask`). +3. **Step**: iterate `plan.root.children` in order; membership in + `plan.tools` distinguishes tool blocks from core sections → checkpoint: + assembly guarantees tool blocks come after all core children. +4. **Step**: core sections — the engine's conditional patterns (see + Algorithm Design: `Questionnaire`): the confirm gates are + presentational (asked, drive control flow, NEVER recorded); the + collected values record at their plan paths. +5. **Step**: tool blocks — echo the block's prompt (the attribution + heading), then `ask_group(block)`; records land at + `"{tool}.{local}"` paths → checkpoint: answers of a tool nest under + the reserved tool-section key, exactly the key `view_for` later + strips. +6. **Output**: populated answer space; `click.Abort` propagates to + `InitLogic` (exit 1, quiet). + +#### Checkpoint Summary +- "nothing calls a tool hook to survey": passed — the engine asks the + buffered records; hooks are already done. + +### Trace: `FileGenerator.generate(answers, contributions)` + +#### Chain +1. **Input**: the committed space; the committed contributions. +2. **Step**: `Path(".goga/config.yml").is_file()` → skip steps 2 (Dockerfile + and config) — the guarantee lives here, not only at the caller → + checkpoint: double guard with `InitLogic` step 1 is intentional. +3. **Step**: snapshot = `answers.snapshot()`; `docker_image.dockerfile` AND + `docker_image.base_image` both present → write the Dockerfile + (`FROM {base_image}` + newline, `mkdir(parents=True, exist_ok=True)`) → + `CreatedFile(path, None)`; a skipped `base_image` collapses the branch — + no Dockerfile is written and the config `dockerfile` field is omitted. +4. **Step**: `generate_goga_config(answers)` — inside it, the conventions + download writes `.goga/usages/conventions.md` (→ `CreatedFile(..., + None)`) before the config serialization (→ `CreatedFile(".goga/config.yml", + None)`). +5. **Step**: `generate_tool_configs(contributions)` — per contribution, + per buffered `(file, data)` in call order → `.goga/tools//` + (→ `CreatedFile(path, tool)`); a repeated file name replaces. +6. **Output**: `list[CreatedFile]` in generation order: Dockerfile, + conventions.md (when downloaded), config.yml, tool files. + +#### Checkpoint Summary +- generation order: passed (Dockerfile before config — the config names + the image built from it). +- attribution: passed (None for engine files, identity for tool files). + +### Trace: `FileGenerator.generate_goga_config(answers)` + +#### Chain +1. **Step**: snapshot; `language = snapshot.get("language")` — empty → + clean `ValueError` naming the field (the single required-field check). +2. **Step**: conventions entry — `codemanifest.usages` carries the + `"conventions"` key → download per `lang_conventions` (URL template, + `requests.get(url, timeout=30)`); failure → clean error with the URL + and the cause (ported verbatim from the old generator; config.yml is + NOT created on failure). +3. **Step**: `mkdir .goga`; assemble the ordered document per the mapping + table: `language`; `image` ← `docker_image.image`; `dockerfile` ← + `docker_image.dockerfile` (omitted when absent); `build` ← + `{"task_executor": {agent?, env?}}` from `build.{agent,env}` (omitted + when empty); `pipeline` ← `{agent?, env?}` flat block (same rules); + `codemanifest` ← `{usages?, annotations?}` (`_LiteralStr` literal + block); `tools` ← top-level tools section (omitted when absent/empty); + `usages` ← the nested records (omitted when absent/empty) → checkpoint + against `goga/config/project/loader.py`: `tools` is `dict[str, str]` + (loader.py:253), `usages` is `dict[str, dict[str, DepConfig]]` with + `git` required and `ref`/`root` optional strings (loader.py:365-370) — + the survey's `{group: {dep: {git, ref, root}}}` nesting matches; build + nests under `task_executor` while pipeline stays flat — matches the old + writer and the loader. +4. **Step**: `yaml.dump(data, default_flow_style=False, allow_unicode=True, + sort_keys=False)` — field order preserved by dict insertion order. +5. **Output**: `.goga/config.yml` passing the core schema loader. + +#### Checkpoint Summary +- mapping table vs config schema: passed (verified field by field). +- confirm gates / convention answers never carried: passed (never + recorded in the first place — the design's gate rule). + +### Trace: `InitLogic.run()` + +#### Chain +1. **Step**: existing `.goga/config.yml` → `return 0` immediately (no + prompts, no events, no artifacts). +2. **Step**: `version = host_goga_version()` — `PackageNotFoundError` → + one clean message, `return 1`; `tag = minor_version(version)` — + `ValueError` → clean message, `return 1` (defensive; practically + unreachable for metadata versions). +3. **Step**: `declarations = self._participation.collect_declarations()` + — `ImportError` (broken package import, named by the platform wrapper) + → one clean message, `return 1`; warnings for soft failures already + logged inside. +4. **Step**: `project_name = resolve_project_name()` (tolerant, `None` on + failure — never raises); `convention_exists = + Path(".goga/usages/conventions.md").is_file()`; `core = + core_questions(tag, project_name, convention_exists)`; `plan = + assemble_session_plan(core, declarations)`; `skips = [(d.tool, p) for + d in declarations for p in d.skips]`; `plan = apply_skips(plan, skips)`. +5. **Step**: `answers = SessionAnswers(tools=plan.tools)`; + `self._questionnaire.run(plan, answers)` — `click.Abort` → `return 1` + (quiet); unexpected `Exception` → one clean message, `return 1`. +6. **Step**: `contributions = + self._participation.collect_contributions(answers)`. +7. **Step**: `files = self._generator.generate(answers, contributions)`; + render the report: `created {path}` / `created {path} (tool: {tool})`. +8. **Output**: `return 0` — tool failures never change the exit code. + +#### Checkpoint Summary +- error tiers: passed (see Cross-cutting Concerns). +- type flow across all six imported leaves: passed (every boundary + checked in the traces above). + +### Trace: `init(tpl, upgrade, ref, tools)` (goga/commands/init) + +#### Chain +1. **Input**: click passes `tpl: str | None`, `upgrade: bool`, + `ref: str | None`, `tools: tuple[str, ...]` (`@click.option("-t", + "--tool", "tools", multiple=True, ...)`). +2. **Step**: ref placement check (ported verbatim) → exit 1 message + `--ref requires or --upgrade`. +3. **Step**: mode resolution (ported verbatim; `` + `--upgrade` + mutual exclusion) → `UPGRADE | SCAFFOLD_THEN_ONBOARDING | + BARE_ONBOARDING`. +4. **Step**: invitation validation — `tools` non-empty AND mode + `UPGRADE` → message `-t/--tool requires an onboarding session and + --upgrade runs none`, exit 1 → checkpoint: contract algorithm step 3; + message text matches `init.md`. +5. **Step**: dedup preserving flag order — `list(dict.fromkeys(tools))`. +6. **Step**: already-initialized guard (BARE_ONBOARDING only) → ported. +7. **Step**: dispatch — UPGRADE: `Scaffold().upgrade(ref)`; the two + onboarding modes: `InitLogic(Questionnaire(), FileGenerator(), + ToolParticipation(invited=deduped))` → `ctx.exit(logic.run())` → + checkpoint: tuple→list conversion happens at the dedup step; the + facade signature `ToolParticipation(invited: list[str])` receives a + list. +8. **Output**: `ctx.exit(code)`. + +#### Checkpoint Summary +- validation order matches the contract algorithm 1–6: passed. +- opaque passthrough (no installation checks in the command): passed. + +--- + +## Algorithm Design + +### `minor_version` + +**Responsibility**: reduce a version string to its `N.M` line — the same +reduction the host↔image comparison uses. + +**Algorithm:** +``` +1. major, minor_seg = _release_segments(version) # shared reducer; ValueError on no major +2. minor = minor_seg if minor_seg is not None else "0" +3. return f"{major}.{minor}" +``` + +**Errors:** +- `ValueError` (from the reducer) → propagates; the caller (`InitLogic`) + translates into a clean session message. + +**Edge Cases:** +- `"1.2.1.dev3"`, `"1.2.0rc1"`, `"1.2.0.post1"`, `"1.2.0+local"` → all + `"1.2"` (tails discarded by the reducer). +- `"2"` → `"2.0"`. + +### `Question`, `QuestionGroup` + +**Responsibility**: immutable declarative records of the survey. + +**Algorithm:** frozen dataclasses, `kw_only=True`, fields per the +signatures; no behavior. + +**Edge Cases:** none — validation belongs to the engine. + +### `SessionAnswers` + +**Responsibility**: the single mutable accumulator of one run. + +**Algorithm:** +``` +construct(tools=None): + _data = {}; _tool_sections = frozenset(tools or ()) + +record(id, value): + walk segments of id, creating dicts; set leaf = value (replace) + +amend(id, value): + walk segments of id, creating dicts + leaf existing dict AND value dict → recursive merge (per key: same rule) + otherwise → replace/create + +view_for(tool): + core = deepcopy of _data items whose key not in _tool_sections + own = _data.get(tool); if mapping → core.update(deepcopy(own)) + return core + +snapshot(): + return deepcopy(_data) +``` + +**Errors:** none raised — the accumulator is total. + +**Edge Cases:** +- `record` over an existing scalar with a deeper path → the scalar is + replaced by the intermediate mapping (the survey is authoritative). +- `view_for` of a tool without a recorded section → core-only view. +- local name of the tool colliding with a core key in ITS view → the + tool's own value wins (update order); only that tool's view is + affected. + +### `ToolDeclaration` + +**Responsibility**: moment-one surface + buffer of one tool. + +**Algorithm:** +``` +declare(item): + IF item is QuestionGroup and any(child is QuestionGroup for child in item.children): + logger.warning("rejected declaration of tool %s: a tool group is limited to one nesting level with simple children", tool) + return # element dropped, delivery continues + questions.append(item) + +skip(path): + skips.append(path) # raw; resolution belongs to apply_skips +``` + +**Errors:** structural violations are warnings, never exceptions (a +raise would kill the tool's whole declaration — disproportionate for one +bad element; the contract reserves exceptions for real failures). + +**Edge Cases:** +- non-invited tool: the hook returns before any call — the buffer stays + empty and the tool contributes no block. + +### `ToolContribution` + +**Responsibility**: moment-two surface + staged buffer. + +**Algorithm:** +``` +answer(id, value): amendments.append((id, value)) # call order kept +write_config(file, data): files.append((file, data)) # a later same name replaces at write time +``` + +**Edge Cases:** `answers` is the isolated deep-copied view — a hook +mutating it harms only itself. + +### `ToolParticipation` + +**Responsibility**: the mediator of both onboarding action moments. + +**Algorithm:** +``` +construct(invited): + _invited = list(dict.fromkeys(invited)) # defensive dedup, flag order + _registry = None + +collect_declarations(): + _ensure_registry() # HookRegistry(); build_once() + # ImportError propagates — the single fatal case + for name in _invited: + IF name not in {pkg.tool for pkg in enumerate_tool_packages()}: + logger.warning("invited tool %s is not installed; continuing without its block", name) + groups = per-tool grouping of subscriptions_for("onboarding", "declare_session") + preserving enumeration order + declarations = [] + for tool, subs in groups: + surface = ToolDeclaration(tool=tool, invited=tool in _invited) + proxy = wrap_context(surface) + try: + for sub in subs: + sub.hook(**build_hook_arguments(sub.hook, proxy, _registry.self_context(tool))) + except Exception as reason: + logger.warning("declaration of tool %s dropped on onboarding.declare_session: %s", tool, reason) + continue # whole declaration discarded + declarations.append(surface) + return declarations + +collect_contributions(answers): + _ensure_registry() + groups = per-tool grouping of subscriptions_for("onboarding", "amend_config") + contributions = [] + for tool, subs in groups: # enumeration order + surface = ToolContribution(tool=tool, invited=tool in _invited, + answers=answers.view_for(tool)) + proxy = wrap_context(surface) + try: + for sub in subs: + sub.hook(**build_hook_arguments(sub.hook, proxy, _registry.self_context(tool))) + except Exception as reason: + logger.warning("contribution of tool %s discarded on onboarding.amend_config: %s", tool, reason) + continue # amendments AND files discarded together + contributions.append(surface) + for surface in contributions: # commit pass, delivery order + for path, value in surface.amendments: + answers.amend(path, value) + return contributions +``` + +**Errors:** +- broken package import → `ImportError` propagates from `build_once` → + `InitLogic` renders one clean message naming the package, exit 1. +- hook failure → warning naming tool, action, reason; the tool drops out + of that moment; the session continues. + +**Edge Cases:** +- invited tool without a subscription to the action → absent from + `groups` → silent, no block, no warning. +- subscribed tool without an invitation → surface `invited=False` → the + hook returns immediately; empty buffer → no block, but its moment-two + surface is still built (a non-invited tool also returns immediately). +- `collect_contributions` without a prior `collect_declarations` → + `_ensure_registry` builds lazily (defensive; the session flow always + calls moment one first). + +### `core_questions` + +**Responsibility**: the core tree — eight sections in survey order with +the tag-completed hints. + +**Algorithm:** +``` +sections = [] +1. language: Question(id="language", kind="choice", prompt=..., choices=_LANGUAGES) + # order preserved from the old wizard: python, golang, kotlin, swift, javascript +2. IF not convention_exists: + convention: QuestionGroup(id="convention", prompt="--- Base Convention ---", + children=[Question(id="adopt", kind="confirm", prompt="Download base convention", default=False)]) +3. codemanifest: QuestionGroup(children=[ + Question(id="usages", kind="pairs", prompt="Codemanifest usages (name → path)"), + Question(id="annotations", kind="input", prompt="Codemanifest annotations")]) + # defaults for both are supplied at ask time when the convention gate was accepted + # (engine-side prefill — see Questionnaire) +4. build: QuestionGroup(children=[ + Question(id="agent", kind="choice", prompt="Build agent", choices=_AGENTS), + Question(id="env", kind="pairs", prompt="Build env (KEY → value)")]) +5. docker_image: QuestionGroup(children=[ + Question(id="dockerfile", kind="input", prompt="Dockerfile path", default=".goga/Dockerfile"), + Question(id="base_image", kind="input", + prompt="Base image (FROM)\nAvailable images:\n - hint1\n - hint2…", + default=), + Question(id="image", kind="input", prompt="Built image name", + default=f"{project_name}:latest" if project_name is not None else None)]) +6. pipeline: QuestionGroup(children=[ + Question(id="agent", kind="choice", prompt="Pipeline agent", choices=_AGENTS), + Question(id="env", kind="pairs", prompt="Pipeline env (KEY → value)")]) +7. tools: Question(id="tools", kind="pairs", + prompt="Tools (name → version; empty version reads as latest; forms N / N.M / N.M.K / N.x / N.M.x)") +8. usages: QuestionGroup(id="usages", prompt="--- Usages ---") + # no declarable children — the engine drives the record loop (see Questionnaire) +return QuestionGroup(id="core", children=sections) +``` + +Hints: the completed list (practice mapping × `image_tag`) is embedded in +the `base_image` prompt with the last entry as its default — the values +are data of the tree; the ENGINE renders them per the `image_defaults` +practice (list the lines, default the last, accept free-form). + +**Edge Cases:** +- `project_name is None` → the `image` default is absent → the built-image + prompt is required (click re-prompts on empty). +- unknown language at the engine (impossible — choice constrains) → no + hints: plain free-form prompt. + +### `assemble_session_plan` + +**Responsibility**: one root — the core children plus the tool blocks. + +**Algorithm:** +``` +children = list(core.children); tools = [] +reserved = {child.id for child in core.children} +for declaration in declarations: # enumeration order + IF not declaration.questions: continue # no block + IF declaration.tool in reserved: + logger.warning("tool %s skipped: its identity collides with the reserved section name %s", + declaration.tool, declaration.tool) + continue # whole block dropped (fix q2) + seen = set(); survivors = [] + for item in declaration.questions: + IF item.id in seen: + logger.warning("element %s of tool %s dropped: repeated local name in the declaration", + item.id, declaration.tool) + continue + seen.add(item.id); survivors.append(item) + children.append(QuestionGroup(id=declaration.tool, + prompt=f"--- Tool: {declaration.tool} ---", + children=survivors)) + tools.append(declaration.tool) +return SessionPlan(root=QuestionGroup(id="session", children=children), tools=tools) +``` + +**Edge Cases:** +- a tool whose every element was dropped by the repeated-name rule → an + empty block is still appended with its heading; the engine suppresses + empty headings; no answers record under it. (The block exists — the + tool declared; only its duplicated elements were rejected.) + +### `apply_skips` + +**Responsibility**: remove the addressed subtrees as one set. + +**Algorithm:** +``` +blocks = {tool: [child.id for child in block_group.children] for tool in plan.tools} +targets = [] +for tool, raw in skips: + segs = raw.split(".") + IF segs[0] in plan.tools: address = segs # prefixed path + ELIF segs[0] in core_section_ids: address = segs # core path + ELIF tool in blocks and segs[0] in blocks[tool]: + address = [tool] + segs # own-block local path + ELSE: + logger.warning("skip of tool %s is a no-op: %s resolves to nothing", tool, raw) + continue + IF resolves(plan.root, address): targets.append(address) + ELSE: warning no-op (as above) +new_root = rebuild_without(plan.root, targets) # fresh groups along changed branches +return SessionPlan(root=new_root, tools=plan.tools) +``` + +`core_section_ids` = ids of the plan-root children that are NOT tool +blocks (`set(c.id for c in root.children) - set(plan.tools)`) — derived, +not hardcoded. + +**Edge Cases:** +- descendant of a removed node → resolves in the original, silently + absorbed by the set removal. +- path into a pairs question (`"build.env.SOME_KEY"`) → no children to + resolve → no-op warning ("only the node as a whole"). +- skip emptying a whole block → the block stays (empty); the engine + suppresses the heading; the tool's section never records. + +### `Questionnaire` + +**Responsibility**: the interactive engine — asks the declarative records, +owns the core conditional patterns, records at plan paths. + +**Algorithm:** +``` +run(plan, answers): + echo session header + wizard description + tool_ids = set(plan.tools) + for section in plan.root.children: + IF section.id in tool_ids: + IF section.children: # suppress emptied blocks + ask_group(section, prefix=section.id) + ELSE: + survey_core_section(section, answers) + # recording: every collected leaf records at its walked path + +# Core-section rule: the patterns ask only the children present in the +# post-skip section — a skipped child is never asked; a branch whose driving +# question is absent collapses to the remaining path. +survey_core_section(section, answers): # the conditional patterns + language → ask_question(choice) + convention → confirm gate ("Download base convention?"): + accept → prefill = ({"conventions": ".goga/usages/conventions.md"}, + "Use `conventions` for code writing rules and testing.") + reject → prefill = (None, None) + # gate answer NOT recorded (presentational) + codemanifest → usages pairs (default/prefill: prefill usages dict entries offered first), + annotations input (default/prefill: prefill text) + build → confirm gate ("Configure a build agent?"): + accept → the remaining children in order — agent choice, + then env pairs (each asked only when present; + suggested keys from agent_env_defaults[agent] prompted first, + then arbitrary KEY → value additions — old _collect_agent_env behavior) + reject → nothing recorded (block omitted at generation) + docker_image → IF the dockerfile question is absent (skipped) → no gate; + the pull branch directly: image ask with the hint + presentation when base_image is present (hint lines of + its prompt, default = last hint), else plain free-form + (default = the image record default when it carries one) + confirm gate ("Create Dockerfile?") otherwise: + accept → dockerfile input (default .goga/Dockerfile) + → base_image ask only when present + (record at docker_image.base_image) + → image ask, plain label, default = record default + (project:latest) → record at docker_image.image + reject → the pull branch as above (the hints of base_image + when present, else plain free-form) + pipeline → confirm gate ("Configure a pipeline agent?"): same shape as build + tools → confirm gate ("Register tools?"): + accept → pairs loop: name prompt, version prompt + (empty input → "latest"; the four forms documented in the + prompt; grammar enforcement belongs to the install consumer) + usages → confirm gate ("Register usages?"): + accept → record loop per record: group, dependency name, git URL, + optional ref, optional root (empty → omitted); + accumulate {group: {dep: {git, ref?, root?}}} (later record + of the same group merges under the group key) + → record the accumulated mapping at "usages" + +ask_question(question): + choice → click.prompt(question.prompt, type=click.Choice(question.choices)) + input → click.prompt(question.prompt, default=question.default) # None default → required + confirm → click.confirm(question.prompt, default=question.default or False) + pairs → IF question.keys: confirm("Set proposed keys?") → prompt each proposed key + then confirm("Add another?") loop → arbitrary key/value prompts + return the accumulated dict (None → {} when nothing collected) + ELSE → an unknown kind or a missing required parameterization (a choice + without choices): logger.warning naming the question path (its + first segment is the tool identity) and the reason; the question + is skipped — not asked, not recorded; the survey continues (tier 1) + +ask_group(group, prefix=None): + echo group.prompt as the heading (attribution for tool blocks) + for child in group.children: + value = ask_question(child) if Question else ask_group(child, …) + record at the walked path +``` + +**Errors:** `click.Abort` (Ctrl-C / empty required prompt) propagates — +`InitLogic` exits 1 quietly. + +**Edge Cases:** +- a tool block group with `prompt=None` (structural) → the engine still + renders the attribution heading from the block id. +- free-form image input: any string accepted; the hints are suggestions + only (old behavior preserved). +- a skipped child of a core section is never asked — the pattern asks the + remaining children only; the docker_image branch collapses per the + core-section rule (skipped `dockerfile` → the pull branch directly; + skipped `base_image` → the FROM is never recorded). +- an unknown question kind or a missing required parameterization (a + choice without `choices`) from a tool — a warning naming the question + path; the question is skipped and never recorded; the survey continues + (tier 1 soft, per the cross-cutting table's "bad declaration element"). + +### `FileGenerator` + +**Responsibility**: write every artifact; the single write path of the +tool configs; never rewrite an existing config.yml. + +**Algorithm:** +``` +generate(answers, contributions): + files = [] + IF Path(".goga/config.yml").is_file(): files_from_config = skip → jump to tool configs + ELSE: + snap = answers.snapshot() + IF snap["docker_image"]["dockerfile"] present AND snap["docker_image"]["base_image"] present: + write Dockerfile "FROM {base_image}\n" at that path; files.append(CreatedFile(path, None)) + # a Dockerfile without a FROM cannot be composed — a skipped base_image + # collapses the branch; the config dockerfile field is emitted only + # when the Dockerfile was written + # generate_goga_config: + IF language empty → clean ValueError naming "language" + IF codemanifest usages carry "conventions": + download per lang_conventions; on failure → clean error with URL and cause + write .goga/usages/conventions.md; files.append(CreatedFile(path, None)) + assemble the ordered document per the mapping table; yaml.dump to .goga/config.yml + files.append(CreatedFile(".goga/config.yml", None)) + for contribution in contributions: + for file, data in contribution.files: # call order + write yaml to .goga/tools/{contribution.tool}/{file} + files.append(CreatedFile(path, contribution.tool)) + return files +``` + +**Errors:** +- empty required `language` → `ValueError("required field language is empty")` + → `InitLogic` renders one clean message, exit 1. +- conventions download failure → clean error with URL + cause; config.yml + is NOT written on failure (old contract preserved). + +**Edge Cases:** +- existing config.yml → only tool configs generate (the caller normally + ends the session earlier; this guard protects direct consumers). +- `dockerfile` present without `base_image` (the base question was + skipped) → no Dockerfile is written and the config omits the + `dockerfile` field — a FROM line cannot be composed. +- same tool file buffered twice → the later buffer wins (replace). +- `usages` records with omitted ref/root → the loader's optional fields; + `git` required by the survey loop (empty git re-prompted). + +### `InitLogic` + +**Responsibility**: orchestrate one session end to end. + +**Algorithm:** the eight steps of the CODEMANIFEST, elaborated in the +trace above. Constructor stores the three collaborators (dependency +injection unchanged in style from the old logic.py). + +**Errors:** the three tiers (see Cross-cutting Concerns). Every session +error is ONE `click.echo(f"Error: {exc}", err=True)` + `logger.error` — +no traceback ever reaches the user. + +**Edge Cases:** +- zero invited tools and no installed tool packages → both moments are + no-ops over an empty subscription list → the session degrades exactly + to the plain behavior (`declarations == []`, `contributions == []`). + +### `init` (goga/commands/init) + +**Responsibility**: CLI wrapper — flag validation, mode routing, guard, +tool passthrough. + +**Algorithm:** steps 1–6 of the CODEMANIFEST (see trace). The `-t/--tool` +option: `multiple=True`, help text mirroring `init.md`. + +**Edge Cases:** +- `-t a -t b -t a` → invited `["a", "b"]` (dedup preserving first-seen + order). +- `-t` with `--upgrade` → rejected before any filesystem work. +- `-t` with `` → allowed (SCAFFOLD_THEN_ONBOARDING carries the + invitation into the session). + +--- + +## Cross-cutting Concerns + +- **Error handling** — three tiers, strictly separated: + 1. *Tool failures* (a hook raising, a bad declaration element, an + unknown skip path, an uninstalled invited name): soft — + `logger.warning` naming the tool, the action, and the reason; the + element/tool drops out; the session continues; the exit code is + untouched. + 2. *Session errors* (broken package import, unreadable installed + version, empty required language, conventions download failure): one + clean `click.echo("Error: …", err=True)` + exit 1; never a + traceback. + 3. *User aborts* (`click.Abort` — Ctrl-C, empty required prompt): + exit 1, quiet (ported from the old `InitLogic.run`). +- **Logging**: stdlib `logging`, one module logger per file + (`logger = logging.getLogger(__name__)`); warnings carry structured + extras where the old code used them; pure records and `minor_version` + never log. +- **Validation**: registration envelope (registrar, existing); the + one-level declaration rule (at `declare`); repeated local names + + reserved block names (at `assemble_session_plan`); skip paths (at + `apply_skips`, no-op warnings); the question kind and its parameterization + — at ask time, soft (a warning naming the question path; the question is + skipped, never recorded); the four-form version grammar — NOT + re-validated at survey time (prompt documents the forms; `resolve_version` + at the install consumer owns the grammar); the written config passes the + project-config loader. +- **Caching**: none — one registry per session (`build_once` idempotent); + no cross-run state (platform invariant). +- **Concurrency**: single-threaded interactive CLI; no thread-safety + requirements. + +## Usages Analysis + +### `convention` (.goga/usages/conventions.md) +- **What it provides**: mandatory code conventions — relative imports, + dataclasses `kw_only=True`, docstring discipline, logging, REPL/test + infrastructure. +- **Where used**: every changed/created cell (global Annotations + type + annotations). +- **Why chosen**: project-wide practice. +- **How exactly**: frozen `kw_only` dataclasses for records; relative + intra-package imports (`from .questions import Question` inside the + leaves; `from .questions import …` in the facade); module docstrings in + the established style (see old generator.py / delivery.py). + +### `click` (.goga/usages/cooks/click.md) +- **What it provides**: the prompting cookbook for the interactive survey. +- **Where used**: `Questionnaire` (survey cell) — prompting, confirmation, + choices, repeated collections. +- **Why chosen**: the survey is interactive on the host. +- **How exactly**: `click.prompt` / `click.confirm` / `click.Choice` per + the old questionnaire.py patterns (`_collect_agent_env` is the template + for the gated env pairs). + +### `image_defaults` (inline, survey) +- **What it provides**: language → image-family mapping; tag completed at + runtime; suggestions displayed; default = last entry; free-form + accepted. +- **Where used**: `core_questions` (builds the completed hints), + `Questionnaire` (renders them). +- **Why chosen**: the hints must match the installed minor. +- **How exactly**: hints embedded in the `base_image` prompt + default; + the pull branch reuses the same completed values. + +### `agent_env_defaults` (inline, survey) +- **What it provides**: agent → env key mapping for prompting. +- **Where used**: `Questionnaire` (build/pipeline env pairs). +- **Why chosen**: parity with the old `_AGENT_ENV_MAP`. +- **How exactly**: at pairs-ask time, the engine prompts the suggested + keys of the selected agent, then offers arbitrary additions. + +### `yaml` (inline, generator) +- **What it provides**: `yaml.dump(default_flow_style=False)` for config + and tool files. +- **Where used**: `generate_goga_config`, `generate_tool_configs`. +- **Why chosen**: human-readable output, field order preserved. +- **How exactly**: `sort_keys=False, allow_unicode=True`; annotations via + the `_LiteralStr` representer (ported). + +### `lang_conventions` (inline, generator) +- **What it provides**: the conventions download URL template and target + path. +- **Where used**: `generate_goga_config` step 3. +- **Why chosen**: unchanged behavior from the old generator. +- **How exactly**: `requests.get(url, timeout=30)`; failure → clean error + with URL and cause. + +### Imported Usages +- `minor-line` from `goga/version` — reading the installed version and + deriving the tag; used by `InitLogic` annotations. Path: + `goga/version/.usages/minor-line.md`. +- `survey-run` / `session-participation` / `artifact-generation` from the + three leaves — used by `InitLogic` annotations (the facade documents the + session API composition). Paths: `goga/onboarding/{survey,participation,generator}/.usages/*.md`. +- `question-records` from `goga/onboarding/questions` — imported by + survey, participation, generator; the record structure and the answer + addressing rules. Path: `goga/onboarding/questions/.usages/question-records.md`. +- `per-tool-delivery`, `registering-hooks` from `goga/hooks` — imported + by participation; the staged delivery loop and the registration + contract. Paths: `goga/hooks/.usages/{per-tool-delivery,registering-hooks}.md`. +- `onboarding-usage` from `goga/onboarding` — imported by + goga/commands/init; the session API and the invitation semantics. Path: + `goga/onboarding/.usages/onboarding-usage.md`. +- `scaffold-usage` from `goga/scaffold` — imported by goga/commands/init + (unchanged). + +## `.usages/` Update + +### Cell: `goga/onboarding/questions` +- **`question-records.md`** → current after this stage's edit (the + `SessionAnswers(tools=None)` entry updated). No further changes needed. + +### Cell: `goga/onboarding/survey` +- **`survey-run.md`** → current after this stage's edit (reserved-names + note added to `assemble_session_plan`). No further changes needed. + +### Cell: `goga/onboarding/participation` +- **`session-participation.md`**, **`tool-contexts.md`** → current (match + the contracts verbatim; the tool-context hook examples align with the + delivery primitives). + +### Cell: `goga/onboarding/generator` +- **`artifact-generation.md`** → current. + +### Cell: `goga/onboarding` (facade) +- **`onboarding-usage.md`** → current (the facade import list matches the + 13 embeddings + `InitLogic`). + +### Cell: `goga/version` +- **`minor-line.md`** → current. + +### Cell: `goga/hooks` +- **`per-tool-delivery.md`** → current (imports `from goga.hooks import + HookRegistry, wrap_context, build_hook_arguments` — enabled by this + change). + +### Cell: `goga/commands/init` +- **`init.md`** → current (syntax with `-t`, modes table, exit codes, + anti-patterns). + +## Test Stack Trace + +### General Setup + +- pytest; click testing via `CliRunner` for the command and the survey + (existing project pattern — see tests/onboarding/test_questionnaire.py). +- `tmp_path` + `monkeypatch.chdir(tmp_path)` for every filesystem test + (the repo CWD contains its own `.goga/` — see tests/onboarding/conftest.py + `_clean_cwd`). +- Tool-package simulation: monkeypatch + `goga.hooks.enumerate_tool_packages` (or `packages_distributions`) and + register hooks directly through `HookRegistrar`/a fake facade module + via `sys.modules` injection — the existing tests/hooks/conftest.py + pattern. +- Caplog at WARNING for the warning-path assertions. + +### Source File Registry + +- goga/version/version.py, goga/hooks/catalog/catalog.py, + goga/hooks/__init__.py +- goga/onboarding/questions/{questions.py, answers.py, __init__.py} +- goga/onboarding/participation/{declaration.py, contribution.py, + participation.py, __init__.py} +- goga/onboarding/survey/{core.py, plan.py, questionnaire.py, __init__.py} +- goga/onboarding/generator/{generator.py, __init__.py} +- goga/onboarding/{logic.py, __init__.py} +- goga/commands/init/init.py +- Test layout: tests/onboarding/{questions, survey, participation, + generator}/test_*.py; tests/onboarding/test_logic.py (facade), + tests/onboarding/test_integration.py (end-to-end); old + test_answers.py / test_generator.py / test_questionnaire.py are + deleted with the old modules (their cases are ported into the new + layout); tests/version/test_version.py and tests/hooks/* extended. + +--- + +### Positive Tests + +#### `test_minor_version_reduces_to_minor_line` + +**Setup**: none (pure function). + +**Input**: `minor_version("1.3.2")`. + +**Trace**: +``` +minor_version("1.3.2") + → _release_segments("1.3.2") # ("1", "3") + → minor = "3" + → f"1.3" returned +``` + +**Assertions**: +``` +minor_version("1.3.2") == "1.3" +minor_version("1.2.1.dev3") == "1.2" +minor_version("1.2.0rc1") == "1.2" +minor_version("1.2.0.post1") == "1.2" +minor_version("1.2.0+local") == "1.2" +minor_version("2") == "2.0" +``` + +**Sufficiency**: pins the tag derivation every image hint consumes; a +regression here desyncs the host↔image minor agreement by construction. + +#### `test_catalog_carries_onboarding_actions` + +**Setup**: none. + +**Input**: `declared_actions()`. + +**Trace**: +``` +declared_actions() + → sorted(_DECLARED_ACTIONS, key=(domain, name)) + → [("onboarding","amend_config","soft"), ("onboarding","declare_session","soft"), + ("statuses","register_statuses","soft")] +``` + +**Assertions**: +``` +records = declared_actions() +("onboarding", "declare_session", "soft") in {(r.domain, r.name, r.error_class) for r in records} +("onboarding", "amend_config", "soft") in {…} +[ (r.domain, r.name) for r in records ] == sorted((r.domain, r.name) for r in records) +``` + +**Sufficiency**: without the records, every tool subscription of the two +onboarding actions is rejected as "unknown action" — the whole feature +dies at registration. + +#### `test_hooks_facade_reexports_delivery_primitives` + +**Setup**: none. + +**Input**: `import goga.hooks`. + +**Trace**: +``` +import goga.hooks + → from .dispatch import build_hook_arguments, wrap_context, emit_hook_event + → from .tools import enumerate_tool_packages + → __all__ contains the names +``` + +**Assertions**: +``` +goga.hooks.wrap_context is goga.hooks.dispatch.wrap_context +goga.hooks.build_hook_arguments is goga.hooks.dispatch.build_hook_arguments +goga.hooks.enumerate_tool_packages is goga.hooks.tools.enumerate_tool_packages +{"wrap_context", "build_hook_arguments", "enumerate_tool_packages"} <= set(goga.hooks.__all__) +``` + +**Sufficiency**: the participation cell (and `per-tool-delivery.md`) +addresses the platform through the facade only — a missing re-export is +an ImportError in the whole onboarding domain. + +#### `test_record_creates_nested_mappings` + +**Setup**: `answers = SessionAnswers()`. + +**Input**: `answers.record("build.agent", "claude")`. + +**Trace**: +``` +record("build.agent", "claude") + → segments ["build", "agent"]; create _data["build"] = {} + → _data["build"]["agent"] = "claude" +``` + +**Assertions**: +``` +answers.snapshot() == {"build": {"agent": "claude"}} +``` + +**Sufficiency**: the no-dotted-keys invariant — the space must hold +nested mappings, or every downstream consumer (view, snapshot mapping) +breaks. + +#### `test_amend_merges_mappings_replaces_scalars` + +**Setup**: `answers.record("pipeline", {"agent": "codex", "env": {"A": "1"}})`. + +**Input**: `answers.amend("pipeline", {"env": {"B": "2"}, "agent": "claude"})`. + +**Trace**: +``` +amend → leaf _data["pipeline"] is dict AND value is dict → recursive merge + env: dict+dict → merge → {"A": "1", "B": "2"} + agent: str → replace → "claude" +``` + +**Assertions**: +``` +answers.snapshot() == {"pipeline": {"agent": "claude", "env": {"A": "1", "B": "2"}}} +``` + +**Sufficiency**: the amendment semantics are the tools' only write path +into shared sections — a wrong merge rule silently corrupts user answers. + +#### `test_view_for_isolates_and_flattens` + +**Setup**: `answers = SessionAnswers(tools=["my-tool", "viewer"])`; +`answers.record("language", "python")`; +`answers.record("my-tool.token", "t0")`; +`answers.record("viewer.flag", True)`. + +**Input**: `view = answers.view_for("my-tool")`. + +**Trace**: +``` +view_for("my-tool") + → core = {"language": "python"} # my-tool and viewer keys excluded + → own = {"token": "t0"} → core.update → {"language": "python", "token": "t0"} + → deepcopy +``` + +**Assertions**: +``` +view == {"language": "python", "token": "t0"} +"viewer" not in view and "flag" not in view # other tool invisible +view["token"] = "mutated"; answers.snapshot()["my-tool"]["token"] == "t0" # snapshot copy +``` + +**Sufficiency**: the isolation guarantee of the platform — a tool reading +another tool's answers (or mutating the space through its view) would be +a cross-tool data leak. + +#### `test_collect_declarations_delivers_invitation_marker` + +**Setup**: fake installed package `goga_tool_my-tool` (enumeration +monkeypatched) whose `register_hooks` subscribes +`("onboarding", "declare_session", "d1", hook)`; `ToolParticipation(invited=["my-tool"])`. + +**Input**: `declarations = participation.collect_declarations()`. + +**Trace**: +``` +collect_declarations() + → registry.build_once() → enumeration → registration → one subscription + → groups {"my-tool": [sub]} + → surface = ToolDeclaration(tool="my-tool", invited=True) + → wrap_context + build_hook_arguments → hook(context=proxy) + → hook: context.invited is True → context.declare(Question(id="token", …)) + → [surface] +``` + +**Assertions**: +``` +len(declarations) == 1 +declarations[0].tool == "my-tool" and declarations[0].invited is True +declarations[0].questions[0].id == "token" +``` + +**Sufficiency**: the invitation marker is the eligibility contract of +moment one — a broken marker either shows questions of an uninvited tool +or hides an invited one's. + +#### `test_collect_contributions_commits_in_order` + +**Setup**: two fake tools `goga_tool_alpha`, `goga_tool_beta` (alpha +enumerates first) each buffering `context.answer("tools", {"alpha": +"1.0"})` / `{"beta": "2.0"}` via `amend_config`; answers space with the +core `tools` section absent. + +**Input**: `contributions = participation.collect_contributions(answers)`. + +**Trace**: +``` +collect_contributions(answers) + → groups alpha, beta (enumeration order) + → surfaces built with view_for(tool); hooks buffer amendments + → commit: amend("tools", {"alpha": "1.0"}) then amend("tools", {"beta": "2.0"}) + → dict+dict → merge → {"alpha": "1.0", "beta": "2.0"} +``` + +**Assertions**: +``` +[c.tool for c in contributions] == ["alpha", "beta"] +answers.snapshot()["tools"] == {"alpha": "1.0", "beta": "2.0"} +``` + +**Sufficiency**: delivery order is the tiebreaker for conflicting leaves +— the determinism of the commit pass is the whole point of staged +contributions. + +#### `test_core_questions_builds_eight_sections_with_tag` + +**Setup**: none (pure builder). + +**Input**: `core_questions(image_tag="1.3", project_name="my-app", +convention_exists=False)`. + +**Trace**: +``` +core_questions("1.3", "my-app", False) + → sections language, convention, codemanifest, build, docker_image, pipeline, tools, usages + → base_image prompt contains "qarium/goga-python-3.14:1.3"; default is the last hint + → image default "my-app:latest" +``` + +**Assertions**: +``` +[child.id for child in core.children] == ["language","convention","codemanifest","build","docker_image","pipeline","tools","usages"] +base = the docker_image child with id "base_image" +"qarium/goga-python-3.14:1.3" in base.prompt and base.default == "qarium/goga-python-3.14:1.3" +image child default == "my-app:latest" +# convention_exists=True drops the section: +[child.id for child in core_questions("1.3", None, True).children][0] == "language" and "convention" not in … +``` + +**Sufficiency**: the dynamic tag is the feature's namesake — a +hardcoded `1.3` string here is exactly the regression the topic exists +to remove. + +#### `test_assemble_session_plan_orders_blocks_and_drops_repeats` + +**Setup**: core tree with sections `language`, `tools`; declaration of +`my-tool` with questions `[Question(id="token"), Question(id="token")]`; +declaration of `viewer` with one question; declaration of `empty-tool` +with no questions. + +**Input**: `plan = assemble_session_plan(core, [d_my, d_viewer, d_empty])`. + +**Trace**: +``` +assemble → children = core children + my-tool: second "token" dropped (warning) → block [token] + viewer: block [q] + empty-tool: no block +→ root children: language, tools, my-tool, viewer; tools ["my-tool", "viewer"] +``` + +**Assertions**: +``` +[child.id for child in plan.root.children] == ["language", "tools", "my-tool", "viewer"] +plan.tools == ["my-tool", "viewer"] +my_block.children[0].id == "token" and len(my_block.children) == 1 +``` + +**Sufficiency**: plan determinism + element-level rejection — the survey +and every skip path derive from this ordering. + +#### `test_questionnaire_records_core_and_tool_answers` + +**Setup**: plan from a minimal core (`language` choice) + `my-tool` block +(input `token`); `answers = SessionAnswers(tools=["my-tool"])`; +`runner = CliRunner()` with inputs `["python", "t0"]`. + +**Input**: `Questionnaire().run(plan, answers)` under the runner. + +**Trace**: +``` +run → header → language section → ask_question(choice) → "python" → record("language", "python") + → my-tool block → heading → ask_question(input) → "t0" → record("my-tool.token", "t0") +``` + +**Assertions**: +``` +answers.snapshot() == {"language": "python", "my-tool": {"token": "t0"}} +``` + +**Sufficiency**: the path-addressing contract — tool answers must nest +under the reserved tool key, or isolation and generation both miss. + +#### `test_generate_writes_dockerfile_then_config` + +**Setup**: `_clean_cwd`; answers with +`docker_image = {"dockerfile": ".goga/Dockerfile", "base_image": +"qarium/goga-python-3.13:1.3", "image": "my-app:latest"}`, `language = +"python"`, no conventions entry; contributions `[]`. + +**Input**: `files = FileGenerator().generate(answers, contributions)`. + +**Trace**: +``` +generate → snapshot → dockerfile present → write "FROM qarium/goga-python-3.13:1.3\n" + → no conventions key → mkdir .goga → yaml.dump +→ files [CreatedFile(".goga/Dockerfile", None), CreatedFile(".goga/config.yml", None)] +``` + +**Assertions**: +``` +Path(".goga/Dockerfile").read_text() == "FROM qarium/goga-python-3.13:1.3\n" +cfg = yaml.safe_load(Path(".goga/config.yml").read_text()) +cfg["language"] == "python" and cfg["image"] == "my-app:latest" +cfg["dockerfile"] == ".goga/Dockerfile" and "base_image" not in cfg +[f.path for f in files] == [".goga/Dockerfile", ".goga/config.yml"] and all(f.tool is None for f in files) +``` + +**Sufficiency**: the FROM-line mapping ("never emitted to the config") and +the generation order are normative for build/pipeline consumers. + +#### `test_generate_tool_configs_with_attribution` + +**Setup**: `_clean_cwd`; `answers = SessionAnswers()` with the surveyed +core recorded: `answers.record("language", "python")` and no docker_image; +a committed contribution of `my-tool` with files +`[("service.yml", {"token_source": "env"}), ("service.yml", {"interval": +60})]` (same name twice — later wins). + +**Input**: `FileGenerator().generate(answers, [contribution])`. + +**Trace**: +``` +generate → config written (minimal answers) → tool configs: + .goga/tools/my-tool/service.yml ← {"interval": 60} (second buffer replaced the first) +→ CreatedFile(".goga/tools/my-tool/service.yml", "my-tool") +``` + +**Assertions**: +``` +yaml.safe_load(Path(".goga/tools/my-tool/service.yml").read_text()) == {"interval": 60} +last = files[-1]; last.tool == "my-tool" and last.path == ".goga/tools/my-tool/service.yml" +``` + +**Sufficiency**: the engine is the single write path of tool configs; +attribution feeds the final report. + +#### `test_init_full_session_with_invited_tool` + +**Setup**: `_clean_cwd`; fake `goga_tool_my-tool` installed (declare + +amend subscribed; declares `token` input, buffers `answer("tools", +{"my-tool": "latest"})` and `write_config("service.yml", {...})`); +CliRunner inputs for the whole survey. + +**Input**: `runner.invoke(init_cli, ["-t", "my-tool", "-t", "my-tool"])`. + +**Trace**: +``` +init(tools=("my-tool","my-tool")) + → ref/mode checks pass → dedup ["my-tool"] → no .goga/ → BARE_ONBOARDING + → InitLogic(Questionnaire(), FileGenerator(), ToolParticipation(["my-tool"])).run() + → declarations [token] → plan → survey (core + tool block) → contributions committed + → files: config.yml (+ tools entry), .goga/tools/my-tool/service.yml → report → 0 +``` + +**Assertions**: +``` +result.exit_code == 0 +cfg = yaml.safe_load(Path(".goga/config.yml").read_text()); cfg["tools"] == {"my-tool": "latest"} +Path(".goga/tools/my-tool/service.yml").exists() +"(tool: my-tool)" in result.output +``` + +**Sufficiency**: the end-to-end acceptance of the feature — invitation, +dedup, both moments, amendment into config, tool file, attributed report. + +--- + +### Negative Tests + +#### `test_minor_version_no_major_raises` + +**Setup**: none. + +**Input**: `minor_version("latest")`. + +**Trace**: +``` +minor_version("latest") → _release_segments → no numeric major → ValueError +``` + +**Assertions**: +``` +with pytest.raises(ValueError): minor_version("latest") +``` + +**Sufficiency**: the error contract mirrors `resolve_version`'s shape +recognition — silent garbage would corrupt the tag. + +#### `test_collect_declarations_warns_for_uninstalled_invited` + +**Setup**: no tool packages installed (enumeration → `[]`); +`ToolParticipation(invited=["ghost"])`; caplog WARNING. + +**Input**: `collect_declarations()`. + +**Trace**: +``` +build_once → no packages → no subscriptions + → "ghost" not among installed → warning naming it + → groups empty → [] +``` + +**Assertions**: +``` +declarations == [] +any("ghost" in rec.message for rec in caplog.records) is True +``` + +**Sufficiency**: the invitation is opaque data at the command — the +warning is the only feedback a typo'd `-t` name ever gets. + +#### `test_failing_hook_drops_whole_declaration` + +**Setup**: fake tools `good` and `bad` subscribed to `declare_session`; +`bad`'s hook raises `RuntimeError("boom")` before declaring; `good` +declares one question; caplog. + +**Input**: `collect_declarations()`. + +**Trace**: +``` +groups bad, good (enumeration order) + bad → hook raises → warning ("bad", declare_session, boom) → dropped + good → block stands +→ [good_declaration] +``` + +**Assertions**: +``` +[d.tool for d in declarations] == ["good"] +any("bad" in r.message and "boom" in r.message for r in caplog.records) +``` + +**Sufficiency**: one tool's crash must never cancel another tool or the +session — the core softness guarantee. + +#### `test_failing_hook_discards_files_with_amendments` + +**Setup**: fake tool subscribed to `amend_config` whose hook buffers +`answer("tools", …)` and `write_config("x.yml", …)` then raises; +`_clean_cwd`; `answers = SessionAnswers()` with the surveyed core recorded: +`answers.record("language", "python")` (no docker_image — no Dockerfile). + +**Input**: `collect_contributions(answers)` then `FileGenerator().generate(answers, [])`. + +**Trace**: +``` +hook buffers then raises → contribution discarded (amendments AND files) +→ [] returned; answers untouched; generate succeeds (config.yml written +from the recorded core) — nothing of the tool is written +``` + +**Assertions**: +``` +contributions == [] +"tools" not in answers.snapshot() +not Path(".goga/tools").exists() +``` + +**Sufficiency**: staged commit means a failed tool leaves NO trace — +half-committed state (files without amendments or vice versa) would +corrupt the project. + +#### `test_broken_package_import_is_clean_session_error` + +**Setup**: enumeration monkeypatched to a package whose facade raises on +import (`call_register_hooks` → platform-wrapped `ImportError`); +`_clean_cwd`. + +**Input**: `InitLogic(...).run()` (or the CLI invocation). + +**Trace**: +``` +collect_declarations → build_once → ImportError("package goga_tool_broken failed to import: …") + → propagates out of collect_declarations + → InitLogic catches → click.echo("Error: …") → return 1 +``` + +**Assertions**: +``` +result.exit_code == 1 +"Error:" in result.output and "goga_tool_broken" in result.output +"Traceback" not in result.output +``` + +**Sufficiency**: the single fatal case must be a named clean message — +a traceback here is the canonical session-error violation. + +#### `test_generate_empty_language_is_clean_error` + +**Setup**: `_clean_cwd`; answers snapshot without `language`; +contributions `[]`. + +**Input**: `FileGenerator().generate(answers, [])`. + +**Trace**: +``` +generate_goga_config → language empty → ValueError("language") + → InitLogic tier renders one message, exit 1 +``` + +**Assertions**: +``` +with pytest.raises(ValueError, match="language"): FileGenerator().generate(answers, []) +not Path(".goga/config.yml").exists() +``` + +**Sufficiency**: the required-field gate is the last line of defense for +config validity; the file must not appear on failure. + +#### `test_conventions_download_failure_names_url` + +**Setup**: `_clean_cwd`; answers with `language="python"` and +`codemanifest={"usages": {"conventions": ".goga/usages/conventions.md"}}`; +`requests.get` monkeypatched to raise `requests.ConnectionError("down")`. + +**Input**: `FileGenerator().generate(answers, [])`. + +**Trace**: +``` +conventions key present → url = …/python/project.md → get raises + → clean error f"Failed to download convention from {url}: down" + → config.yml NOT written +``` + +**Assertions**: +``` +with pytest.raises(RuntimeError, match="https://raw.githubusercontent.com/.*/python/project.md"): + FileGenerator().generate(answers, []) +not Path(".goga/config.yml").exists() +``` + +**Sufficiency**: network failure at onboarding must be diagnosable (URL + +cause) and non-destructive (no half-config). + +#### `test_init_rejects_tools_with_upgrade` + +**Setup**: CliRunner; a directory with `.goga/scaffold.yml` present or +not (irrelevant — rejected before any work). + +**Input**: `runner.invoke(init_cli, ["--upgrade", "-t", "my-tool"])`. + +**Trace**: +``` +init(upgrade=True, tools=("my-tool",)) + → ref None → mode UPGRADE (no tpl) → tools non-empty + UPGRADE + → echo "-t/--tool requires an onboarding session and --upgrade runs none" → exit 1 +``` + +**Assertions**: +``` +result.exit_code == 1 +"-t/--tool requires an onboarding session" in result.output +``` + +**Sufficiency**: the new flag combination's guard — running upgrade with +a silently dropped invitation would be the confusing alternative. + +--- + +### Edge Case Tests + +#### `test_init_dedup_preserves_flag_order` + +**Setup**: CliRunner; stubbed InitLogic capturing the constructed +ToolParticipation. + +**Input**: `["-t", "b", "-t", "a", "-t", "b"]`. + +**Trace**: +``` +tools ("b","a","b") → dedup ["b","a"] → ToolParticipation(invited=["b","a"]) +``` + +**Assertions**: +``` +captured.invited == ["b", "a"] +``` + +**Sufficiency**: one invitation per name, one block per tool, first-seen +order — the plan ordering derives from it. + +#### `test_view_for_unknown_tool_returns_core_only` + +**Setup**: `SessionAnswers(tools=["my-tool"])` with core + `my-tool` +answers recorded. + +**Input**: `view_for("not-declared")`. + +**Trace**: own section absent → core only. + +**Assertions**: +``` +view == {"language": "python"} # no "not-declared" key, no tool sections +``` + +**Sufficiency**: a tool subscribing only to `amend_config` (no moment +one) still gets a lawful view. + +#### `test_assemble_reserved_name_drops_block` + +**Setup**: core tree with section `tools`; declaration of a tool with +identity `tools` and one question; caplog. + +**Input**: `assemble_session_plan(core, [declaration])`. + +**Trace**: +``` +"tools" in reserved → warning naming the tool and the reserved name → no block +``` + +**Assertions**: +``` +plan.tools == [] and [c.id for c in plan.root.children] == ["tools"] # core only +any("tools" in r.message for r in caplog.records) +``` + +**Sufficiency**: the collision fix (q2) — without the guard, two `tools` +children merge answers of the core section and the tool block. + +#### `test_apply_skips_prefixed_own_and_unknown` + +**Setup**: plan with core `language` + `build` sections and blocks +`my-tool` (`reporting` group with `enabled`) and `viewer` (`opt`); +skips `[("my-tool", "reporting.enabled"), ("viewer", "my-tool.reporting.enabled"), +("viewer", "language"), ("my-tool", "no.such.path")]`. + +**Input**: `plan2 = apply_skips(plan, skips)` (caplog). + +**Trace**: +``` +("my-tool","reporting.enabled") → own-block: address my-tool.reporting.enabled → removed +("viewer","my-tool.reporting.enabled") → prefixed → already removed? resolves in ORIGINAL → removed (same node) +("viewer","language") → core → language section removed +("my-tool","no.such.path") → unresolvable → warning no-op +``` + +**Assertions**: +``` +ids of plan2.root.children: "language" absent, "build" present, "my-tool" present (emptied group), "viewer" present +"enabled" not reachable under my-tool block +any("no.such.path" in r.message for r in caplog.records) +``` + +**Sufficiency**: the three-way resolution rule + set semantics + +absorbed-descendant silence — the most intricate rule of the plan layer. + +#### `test_existing_config_ends_session_silently` + +**Setup**: `_clean_cwd` with `.goga/config.yml` pre-created (content +irrelevant); stubbed collaborators asserting no calls. + +**Input**: `InitLogic(Questionnaire(), FileGenerator(), +ToolParticipation([])).run()`. + +**Trace**: +``` +run → Path(".goga/config.yml").is_file() → return 0 immediately +``` + +**Assertions**: +``` +result == 0 +questionnaire.run not called; participation.collect_declarations not called; generator.generate not called +``` + +**Sufficiency**: "whoever created it first wins" — after a copier +template brings a config, onboarding must be a silent no-op (no events, +no prompts). + +#### `test_unreadable_version_is_clean_error` + +**Setup**: `_clean_cwd`; `host_goga_version` monkeypatched to raise +`PackageNotFoundError("goga")`. + +**Input**: `InitLogic(...).run()`. + +**Trace**: +``` +run → host_goga_version raises → clean message → return 1 +``` + +**Assertions**: +``` +result == 1 and "Error:" in output and "Traceback" not in output +``` + +**Sufficiency**: the metadata boundary tier — an uninstalled-host +scenario must not traceback. + +#### `test_tool_failure_never_changes_exit_code` + +**Setup**: full-session setup of the positive end-to-end test, with the +tool's `amend_config` hook raising instead of contributing. + +**Input**: CLI invocation with `-t my-tool`. + +**Trace**: +``` +moment two → hook raises → contribution discarded with warning → generate (no tool files) → 0 +``` + +**Assertions**: +``` +result.exit_code == 0 +not Path(".goga/tools/my-tool").exists() +warning named the tool in caplog +``` + +**Sufficiency**: "the softness of the tool moments is theirs" — the +session's success is independent of any tool's failure. + +#### `test_declare_rejects_nested_group_with_warning` + +**Setup**: `surface = ToolDeclaration(tool="t", invited=True)`; a group +whose children contain another group; caplog. + +**Input**: `surface.declare(QuestionGroup(id="deep", children=[QuestionGroup(id="inner")]))`. + +**Trace**: +``` +declare → one-level rule violated → warning naming the tool → not buffered +``` + +**Assertions**: +``` +surface.questions == [] +any("one nesting level" in r.message for r in caplog.records) +and any("t" in r.message for r in caplog.records) +``` + +**Sufficiency**: the tree-shape bound of tool declarations — a deeper +tree would break the engine's simple survey and the answer nesting rules. + +#### `test_noninvited_subscribed_tool_is_marked_and_silent` + +**Setup**: fake installed `goga_tool_my-tool` subscribed to BOTH actions; +the hook bodies record the marker into their `self` context +(`self.saw_invited = context.invited`) and would declare a question only +when `context.invited` (the contract early return); `ToolParticipation(invited=["other"])`; +`answers = SessionAnswers()`; caplog at WARNING. + +**Input**: `declarations = p.collect_declarations()`; then +`contributions = p.collect_contributions(answers)`. + +**Trace**: +``` +build_once → one subscription per action → groups {"my-tool": […]} + moment one: surface = ToolDeclaration(tool="my-tool", invited=False) + → hook reads context.invited → False → immediate return, no member called + → empty buffer → no block in the plan (assemble skips it) + moment two: surface = ToolContribution(tool="my-tool", invited=False, + answers=core-only view) → same immediate return → empty buffers +``` + +**Assertions**: +``` +declarations == [] # empty buffer → contributes no block +[c.tool for c in contributions] == ["my-tool"] +contributions[0].amendments == [] and contributions[0].files == [] +the captured self-context marker is False +"my-tool" not in answers.snapshot() # nothing recorded, nothing amended +not caplog.records # silent participation — not a warning +``` + +**Sufficiency**: the False marker is the eligibility contract of both +moments — a regression to an always-True marker leaks an uninvited tool's +questions into every session, and no other test of the design would +catch it (delivery is never filtered by invitation per the +`per-tool-delivery` practice). + +#### `test_skip_of_base_image_collapses_dockerfile_branch` + +**Setup**: `_clean_cwd`; fake installed `goga_tool_my-tool` (subscribed, +invited) whose `declare_session` hook calls only +`context.skip("docker_image.base_image")`; CliRunner inputs walking the +survey: language `python`, every confirm gate `n` except the Dockerfile +gate `y`, dockerfile path default (empty input), built image name +`my-app:latest`. + +**Input**: `runner.invoke(init_cli, ["-t", "my-tool"])`. + +**Trace**: +``` +collect_declarations → skip buffered ("my-tool", "docker_image.base_image") + → apply_skips removes the base_image question from the docker_image section + → survey: docker gate accepted → dockerfile asked (default) → base_image + NOT asked (absent from the tree) → image asked plain (default my-app:latest) + → snapshot: docker_image = {dockerfile, image} — no base_image + → generate: dockerfile present BUT base_image absent → no Dockerfile; + config omits the dockerfile field → report → 0 +``` + +**Assertions**: +``` +result.exit_code == 0 +"Base image" not in result.output +cfg = yaml.safe_load(Path(".goga/config.yml").read_text()) +cfg["image"] == "my-app:latest" and "dockerfile" not in cfg and "base_image" not in cfg +not Path(".goga/Dockerfile").exists() +``` + +**Sufficiency**: pins the partial-skip semantics of the docker_image +branch — the contract invariant "a skipped subtree is never asked" plus +the FROM-composition rule; without it an implementer either asks the +removed question or composes a Dockerfile with no base image. + +#### `test_unknown_kind_is_skipped_with_warning` + +**Setup**: plan with a `my-tool` block carrying +`Question(id="bad", kind="text", prompt="Weird")` and +`Question(id="ok", kind="input", prompt="Token")`; +`answers = SessionAnswers(tools=["my-tool"])`; `runner = CliRunner()` with +input `["t0"]`; caplog at WARNING. + +**Input**: `Questionnaire().run(plan, answers)` under the runner. + +**Trace**: +``` +run → my-tool block → ask_group → ask_question(bad): + kind "text" matches no branch → warning naming "my-tool.bad" → skipped, not recorded + → ask_question(ok): input → "t0" → record("my-tool.ok", "t0") +``` + +**Assertions**: +``` +answers.snapshot() == {"my-tool": {"ok": "t0"}} +any("my-tool.bad" in r.message for r in caplog.records) +"Weird" not in result.output +``` + +**Sufficiency**: the soft principle for a bad declaration element — +without the defined branch the survey dies as a tier-2 session error, +contradicting the cross-cutting tier table. + +--- + +## Additional Instructions for the Implementation Agent + +- Delete `goga/onboarding/answers.py`, `goga/onboarding/questionnaire.py`, + `goga/onboarding/generator.py` together with creating the leaf cells — + the facade must never expose `InitAnswers`/`GogaConfigAnswers` again; + port (not rewrite from scratch) the old prompt texts and the + `_collect_agent_env`, `_IMAGE_MAP` (now completed with the tag), + `_AGENT_ENV_MAP`, `_LiteralStr`, conventions-download logic into the + new modules. +- Re-export surface of the facade `goga/onboarding/__init__.py`: the 13 + embedded types + `InitLogic`, in the embedding order of the + CODEMANIFEST; docstring states the domain-facade role. +- Implementation order: the dependency order of the Design (version → + catalog → hooks facade → questions → participation → survey → + generator → onboarding facade → commands/init); after each cell run + its tests, at the end run `goga lint` (must stay 0 errors) and the + full pytest suite. +- The `usages`/`tools` core sections are NEW user-facing survey sections + (the old wizard had neither) — keep prompt texts aligned with + `init.md`'s created-files list. +- `goga hooks` output gains the two onboarding rows automatically from + the catalog — no CLI change. +- Do not add the `review` section (a future additive core section, + explicitly out of scope). diff --git a/.goga/history/2026/onboarding-refctoring/plan.md b/.goga/history/2026/onboarding-refctoring/plan.md index e87e0cdd..eaa677b2 100644 --- a/.goga/history/2026/onboarding-refctoring/plan.md +++ b/.goga/history/2026/onboarding-refctoring/plan.md @@ -2,8 +2,8 @@ ## Purpose -Materialize the contracts of the topic «Расширяемый онбординг `goga init`: участие -тулз через hooks-действия и динамический тег образов» into code: the four new +Materialize the contracts of the topic "Extensible `goga init` onboarding: tool +participation via hooks actions and the dynamic image tag" into code: the four new onboarding leaf cells (questions, participation, survey, generator), the rewritten onboarding domain facade with the new `InitLogic`, the CLI `-t/--tool` invitation flag, the `minor_version` tag routine, the two onboarding catalog diff --git a/.goga/history/2026/onboarding-refctoring/prd.md b/.goga/history/2026/onboarding-refctoring/prd.md new file mode 100644 index 00000000..a6014064 --- /dev/null +++ b/.goga/history/2026/onboarding-refctoring/prd.md @@ -0,0 +1,138 @@ +# Extensible goga onboarding: tool embedding and compatible images + +## Problem + +**P-1. Onboarding is closed to tools.** +The user runs `goga init` in a project where goga tools (`goga_tool_*`) that need their own project configuration are installed. The onboarding session is closed: the core fixes the set of questions, and the answer-collection mechanics is a fixed set of fields; a tool can neither add its own questions to the session nor write its own configuration from the answers. Tool setup is divorced from initialization: the user configures the tool manually after init (editing files blind), and the tool author cannot offer a guided setup — a risk of incomplete or incorrect configuration. + +**P-2. Image tag drift in the hints.** +Onboarding offers images with a hardcoded tag (`:1.3`) unrelated to the actual installed goga version (1.3.x). When goga moves to a new minor, the hints start offering images that fail the host↔image compatibility check on (major, minor) at the first container run — a broken first run, or manual tag maintenance in every release. + +## Users + +**U1. The user initializing a project** (primary). A developer running `goga init` — standalone or after scaffolding from a template. Context: first run in the project; goga tools may be installed in the environment. Goal: obtain a fully configured working project in one session — the standard config plus the invited tools' configs — and an image compatible with the installed goga. Expectations: one coherent session; sensible defaults; nothing breaks at the first container run after init; a tool failure does not take down the initialization. + +**U2. The tool-package author (`goga_tool_*`)**. Publishes an extension that needs project configuration. Goal: embed the tool's questions into the `goga init` session and, from the answers, write its own config and specific registrations into the project config. Expectations: a documented, predictable extension contract (analogous to the existing hooks registration contract); no goga code changes required; predictable handling of the tool's failures. + +**U3. The goga maintainer** (secondary, for P-2). Goal: keep the onboarding image hints correct across minor-version changes, without manually editing hardcoded tags in every release. + +## Goals + +**G-1. A single setup session.** The `goga init` user configures the standard goga config and the invited tools' configuration in one session — with no manual setup after initialization. + +**G-2. An extension contract for tool authors.** The `goga_tool_*` author embeds the tool's questions into the `goga init` session and, from the answers, writes its own config plus specific registrations into the project config (itself into `tools`, dependencies into `usages`) — without extending the standard schema with arbitrary keys and without goga code changes, relying on a documented contract. + +**G-3. Always compatible image suggestions.** Onboarding offers only images compatible with the installed goga version (the current minor's tag), without manual tag maintenance in every minor release. + +## User Experience + +### Entry point + +`goga init` (in a project without `.goga/`), `goga init ` (scaffold, then onboarding), `goga init --upgrade` (template migration, no onboarding — unchanged). A new repeatable tool-invitation flag: `-t/--tool `. + +### Main scenario (fresh project, the session creates the config) + +1. **The session header** and the wizard description — as today. +2. **Tool invitation (before the survey starts).** Every tool explicitly named via `-t` and installed participates through its share of the event context: it declares its questions (declarative data) and/or the skipping of any session questions. Only explicitly named tools receive invitations; unnamed tools receive none and stay silent. The session never re-confirms an explicitly named tool — the flag is itself the explicit invitation. +3. **The core survey** (minus the questions declared for skipping): language → base convention (when the file is absent) → codemanifest usages/annotations → build-agent → Dockerfile → image → env → pipeline-agent → pipeline-env. The image hints carry the installed goga's current minor tag; the user may enter any image name. +4. **Tool question blocks.** The session engine asks each participating tool's declared questions — after the core questions, in the deterministic tool-enumeration order, with explicit attribution ("questions from tool X"). +5. **Amendments before the write.** After the survey collects the answers and before the write, each participating tool may modify the configuration object under assembly: values within the standard config schema plus registrations of itself into `tools` and of dependencies into `usages`; the tool writes its own config by its own rules. +6. **The write and the summary.** The session creates `.goga/config.yml` (the core structure plus the tools' registrations), the Dockerfile (when chosen), and the tools' configs. The user sees the list of created files and warnings about skipped tools. Exit code — success. + +### Alternative scenarios + +- **Without `-t`** (or none of the named tools is installed): no tool question blocks — the session matches today's behavior. +- **`-t X`, tool X not installed:** an actionable warning naming the tool; the session continues without X's block. +- **`goga init `, the template brought `.goga/config.yml`:** behavior as today — the session skips config creation and the related questions entirely; it also skips the config-related tool events (tools do not participate in this branch); the existing file is never rewritten. +- **A repeated bare `goga init` in an initialized project:** the refusal "Project already initialized" (as today). +- **`-t` together with `--upgrade`:** a flag-combination validation error with a nonzero exit (this mode has no onboarding). + +### Failures (soft) + +An invited tool failing at any participation moment (a broken import, an exception in the declarations, in the amendments, or while writing its config): the session skips the tool's block with a warning (tool name + reason), continues, and the remaining tools and the core complete as intended. The standard `.goga/config.yml` is created from the core answers and the surviving tools' amendments. Initialization completes successfully. + +### Feedback, interruption, retry + +The session's progress is transparent: section headers, attribution of tool blocks, warnings about failures and uninstalled invited tools, and the final list of created files. User interruption behaves as today. After a successful write, a repeated bare init is impossible (the guard). The created artifacts are ordinary project files; no special rollback mechanics is introduced. + +## Requirements + +### Tool invitation + +- **R1.1.** `goga init` accepts a repeatable option `-t/--tool `; the invitation acts in both modes that run onboarding (bare and ``). +- **R1.2.** Without `-t`, no tool question blocks exist — the session matches today's. +- **R1.3.** Only explicitly named tools receive invitations; unnamed installed tools receive none and ask no questions. +- **R1.4.** A named but not installed tool — a warning naming it; the session continues without its block; a repeated name deduplicates (the session asks the block once). +- **R1.5.** `-t` with `--upgrade` — a flag-combination validation error with a nonzero exit and an actionable message. + +### Tool participation in the session (the two-moment model) + +- **R2.1.** The core questions come first (minus those declared for skipping), then the participating tools' question blocks — in the deterministic tool-enumeration order, with attribution of the tool name. +- **R2.2.** A tool declares its questions as declarative data at invitation (before the survey starts); the session engine asks them in its block; the answers are available to the tool for building its configuration. +- **R2.3.** At invitation, a tool may declare the skipping of any session questions (the core's and other tools'); the survey never shows a question declared for skipping and never requests its answer. +- **R2.4.** After the survey collects the answers and before the write, a participating tool may modify the configuration object under assembly: values within the standard config schema plus registrations of itself into `tools` and of dependencies into `usages`; arbitrary new keys never enter the standard schema. +- **R2.5.** A tool writes its own config from its answers and by its own rules. +- **R2.6.** A documented extension contract provides the tool's capabilities without goga code changes; the onboarding-action registrations are visible in the `goga hooks` inspection. + +### The configuration object + +- **R3.1.** The session collects the configuration as a fillable "question → value" mapping (core and tool questions) and serializes it into `.goga/config.yml` per the core structure (including the tools' registrations). +- **R3.2.** An existing `.goga/config.yml`: the session skips the config's creation and its related questions; it skips the config-related tool events; it never rewrites the file. + +### Failure resilience + +- **R4.1.** The session handles a failure of an invited tool (import, declarations, amendments, writing its config) as soft: it skips the tool's block, prints a warning with the name and the reason, and continues. +- **R4.2.** The standard `.goga/config.yml` is created from the core answers and the surviving tools' amendments regardless of the failed tools. +- **R4.3.** The final exit code is success whenever the core completes successfully, regardless of tool failures. + +### Images + +- **R5.1.** The image hints (the ready image for pull and the Dockerfile base image) carry the tag equal to the installed goga's current minor version (e.g., 1.3.x → `:1.3`). +- **R5.2.** The minor comes from the installed goga distribution's version-reading point — the same one the host↔image compatibility check uses. +- **R5.3.** The user may enter an arbitrary image name — the hints never restrict the input. +- **R5.4.** An unreadable goga version — a clean session error with an actionable message (no traceback). + +### Feedback + +- **R6.1.** The session shows the attribution of tool blocks, warnings about failures and about uninstalled invited tools; it reports the created files. + +## Constraints + +- **C-1. The init modes are immutable.** The routing of bare / ``+onboarding / `--upgrade`, and the "already initialized" guard (`.goga/` exists → bare refusal), stay as is. +- **C-2. The existing config.yml is untouchable.** The "whoever created it first wins" rule holds: an existing `.goga/config.yml` is never rewritten; this branch skips the config-related tool events. +- **C-3. The standard config keeps a typed schema.** `.goga/config.yml` stays on the core schema (including the `tools`/`usages` sections); tools add no arbitrary keys. +- **C-4. Extension goes only through the hooks platform.** Tool embedding uses the existing platform model (additive catalog extension, records never rewritten; registration; delivery with an error class; the `goga hooks` inspection); tools run at the installation trust level — without isolation or sandboxing; existing subscriptions and the CLI keep working. +- **C-5. The interactive CLI channel.** The entire session is an interactive CLI on the host, before building and running containers; no tool participation exists outside this channel. +- **C-6. Image version compatibility.** The host↔image check on (major, minor) exists and does not change; the image suggestions must satisfy it. +- **C-7. Extension without goga patches.** A tool author implements the embedding solely in their package per a documented contract (modeled on the existing author-contract documentation). + +## Scope + +### In Scope + +- The config-collection mechanics as a fillable "question → value" mapping in the `goga init` session (an internal questionnaire rework; the output structure of `.goga/config.yml` — the core schema — is preserved). +- The onboarding action in the hooks catalog and the individual tool contexts: invitation, declaring one's own questions, declaring question skips, amending the object before the write, registrations in `tools`/`usages`, writing the tool's own config. +- The repeatable option `-t/--tool ` (including validation of the combination with `--upgrade`, warnings about uninstalled tools, deduplication). +- The image-hints tag (the ready image and the Dockerfile base image) derived from the installed goga's current minor version. +- Soft handling of tool failures with warnings; the guarantee that the standard config is created. +- Documentation of the extension contract for tool authors and an update of the init/hooks documentation; visibility of the new registrations in `goga hooks`. + +### Out of Scope + +- Separate tool onboarding sessions outside `goga init` (explicitly rejected). +- An open `.goga/config.yml` schema with arbitrary keys; merging or rewriting an existing config.yml. +- Runtime updates of the image tag in existing projects (the change concerns only the onboarding hints). +- A rework of the hooks platform (delivery, registration, isolation models) — only additive catalog records are allowed. +- A non-interactive/CI onboarding mode with answers from a file. +- The `goga install` post-install hooks and the home config `~/.goga/config.yml`. + +## Success Criteria + +- **S1.** With `goga init -t ` (the tool installed, the project without `.goga/`), the tool's question block appears in the same session after the core questions with attribution; on completion, a valid `.goga/config.yml` (core schema, including the tool's registrations in `tools`/`usages`) and the tool's own config exist — with no manual setup after init. +- **S2.** Without `-t`, the session contains no tool questions; the result matches today's behavior. +- **S3.** A tool author implements the embedding without goga code changes, solely per the documented contract (the contract documentation exists; the example tool works exclusively through the public surface). +- **S4.** A failing tool: init completes successfully (exit code 0), the standard config is created, the output carries a warning with the tool name and the reason; the other invited tools are configured. +- **S5.** `-t` with an uninstalled tool: a warning; the session continues; exit code 0. +- **S6.** An existing `.goga/config.yml` (the `` branch): the file is unchanged, no config questions are asked, the related tool events never fire. +- **S7.** With goga version N.M.* installed, the image hints carry the tag `:N.M`; after a minor goga upgrade, the hints automatically show the new minor without code changes. +- **S8.** The image offered by default passes the host↔image compatibility check on (major, minor) at the first container run. diff --git a/.goga/history/2026/onboarding-refctoring/task.md b/.goga/history/2026/onboarding-refctoring/task.md new file mode 100644 index 00000000..2eca270a --- /dev/null +++ b/.goga/history/2026/onboarding-refctoring/task.md @@ -0,0 +1,126 @@ +# Extensible `goga init` onboarding: tool participation via hooks actions and the dynamic image tag + +## Current State + +- **`goga/onboarding`** — the session is closed to tools: `Questionnaire` asks a fixed set of `ask_*` methods (click), and the answers are collected directly into `GogaConfigAnswers`; a tool can neither add questions to the session nor write its configuration. Image tags are hardcoded (`:1.3`) in the cell manifest's `image_defaults` practice and drift from the actual installed goga version. +- **`goga/hooks`** — the platform is ready for additive extension: `catalog` (`Action` records — data, not discovery), `registry` (the single registry build, isolated `ToolContext`s, the `by_tool` inspection), `dispatch` (`emit_hook_event` delivery, per-tool contexts via `context_for`, the error class per action), `tools` (the `goga_tool_*` enumeration, `HookRegistrar` with envelope validation). The catalog carries no `onboarding` domain actions. +- **`goga/commands/init`** — the CLI wrapper: routing of bare / ``+onboarding / `--upgrade`, the "already initialized" guard on `.goga/`; no tool-invitation option exists. +- **`goga/version`** — `host_goga_version()` exists (the single host-version reading point, uses `importlib.metadata`); no `N.M` tag derivation for hints exists. The four-form version grammar is already implemented in `resolve_version` (N.x, N.M.x, N(.M)(.K), latest). +- **`goga/config/project`** — the `ProjectConfig` schema already contains `tools: dict[str, str]` (four-form version strings) and `usages: dict[str, dict[str, DepConfig(git, ref, root)]]`; the loader validates the structure. The schema is closed to tools' arbitrary keys. +- **Documentation** — `docs/features/init/hooks.md` — the "no hook actions" stub; the author contract for onboarding participation is undocumented. + +## Description + +Extend the `goga init` session with invited-tool participation and eliminate the image-tag drift. The ADR decisions (`adr.md`) are normative for the entire task — the "Decisions" sections 1–10, the "Deviations from the PRD" (R4.1, R4.2, R2.4), and the introductory decisions (the CLI `-t/--tool`, the `N.M` image tag): + +1. **Two soft catalog actions** in the `onboarding` domain: `declare_session` — moment one (before the survey; declaring questions and skips), `amend_config` — moment two (after the survey collects every answer, before the config write; amendments and writes). Subscriptions are independent; the engine itself asks the tool question blocks — questions are declarative data, and the platform never calls a hook to survey. +2. **Invitation via a marker in the per-tool context**, not a delivery filter: the emission reaches every subscriber of the address; the invited tool's context carries the active surface, the non-invited tool's — the "not invited" marker (its hook must return immediately — a contract rule). An invited but unsubscribed tool participates silently. +3. **A single question space**: 8 core ids — `language`, `convention`, `codemanifest`, `build` (build_agent + build_env, i.e. `build.task_executor`), `docker_image`, `pipeline`, `tools`, `usages`; tool questions — `.` (a duplicate id within a tool rejects the declaration with a warning — the registrar pattern). Question kinds: `choice`, `input`, `confirm`, `pairs` (repeated key→value collection with proposed keys), `group` (one nesting level; children are simple kinds; a group's answer is a mapping). `review` — a future additive id, not added in this task. +4. **The extended core survey** (confirm-gated, within the existing patterns): `tools` — a confirmation → repeated collection of name+version pairs; `usages` — repeated record collection per the schema group → dependency name → git repository (optional ref/root per the `DepConfig` schema). Order: language → convention → codemanifest → build → docker_image → pipeline → tools → usages → tool question blocks (deterministic enumeration order, with attribution of the tool name). +5. **Skips** — a dot-path into the question tree; core child names match the schema fields (e.g. `build.task_executor.env`); a tool's own question or group takes no prefix, another tool's takes `.`. Skipping a node skips the subtree; inside `pairs`, individual pairs are not addressable. The engine applies skips after the entire declaration, order-independently; an unknown path is a no-op with a warning. The survey never asks a skipped question; the tool tolerates the missing answer. +6. **Amendments — the single mechanism `answer(id, value)`**: re-answer or update an existing id; mappings merge recursively, scalars and lists are replaced. Substituting a user's answer is silent (a tool's lawful right). Tool conflicts resolve last-wins in delivery order (the alphabet of tool identities). The `tools`/`usages` registrations are ordinary answers at the ids (no special registration members exist); a tool's own entry lands under its identity; version values follow the four-form grammar; a missing version reads as `latest`. +7. **Tool config writes go only through the engine's API**: the engine serializes YAML and writes `.goga/tools//.yml`; writing the same file again rewrites it. Writes are buffered; the final created-files list combines the engine's files and the tools' files with attribution (automatically, with no "report file" member). A tool writing files directly, bypassing the API, acts outside the session contract. +8. **Failure resilience is staged per tool**: a tool's contribution (amendments + files) commits only after its moment-two hook completes successfully; a failure at any step discards the contribution entirely, emits a warning with the tool name and the reason, and the session continues with exit code 0. An existing `.goga/config.yml` (the `` branch) is never rewritten; the related events are not emitted. A broken package import is the single fatal case: a clean session error naming the package, without a traceback. +9. **Answer isolation**: `answers` holds nested mappings (groups are mappings; dotted keys never appear); a tool sees its own unprefixed questions and the core questions; other tools' answers are unreadable — tools coordinate through merges of shared sections. An empty required schema field (`language`) at write time, unrestored by an amendment, is a clean session error naming the field. +10. **The `N.M` image tag** from `host_goga_version()` — the same reading point the host↔image compatibility check uses; the ready-image (pull) and Dockerfile base-image hints carry the current minor's tag; an unreadable version is a clean session error without a traceback. +11. **CLI**: the repeatable option `-t/--tool ` in both modes that run onboarding (bare and ``); `-t` with `--upgrade` — a flag-combination validation error with a nonzero exit; deduplication of repeated names (the session asks the block once); a named but not installed tool — a warning naming it, the session continues without its block. +12. **Documentation and the example**: the author contract — `docs/features/init/hooks.md` (modeled on the existing domain hooks pages), cross-references from `docs/features/tools/hooks.md`; registrations appear in `goga hooks` automatically. The example tool is the test fixture package `goga_tool_*`, exercising the scenario exclusively through the public surface. + +## Scope + +**In scope:** + +- Two additive `Action` records in the hooks catalog (`onboarding/declare_session`, `onboarding/amend_config`, both soft). +- The repeatable `-t/--tool` option of the init command: combination validation with `--upgrade`, deduplication, warnings about uninstalled tools. +- The "question → answer" mechanism in onboarding: the id space (8 core + `.`), the kinds choice/input/confirm/pairs/group, the survey order, the attribution of tool blocks. +- Extending the core survey with the `tools` and `usages` sections (per the `ProjectConfig`/`DepConfig` schema). +- Skipping questions by dot-paths with subtree semantics and order-independent application. +- Amendments via `answer(id, value)`: mapping merge, replacement of scalars/lists, last-wins by delivery order; the tools' registrations in `tools`/`usages` as ordinary answers. +- Writing tool configs through the engine's API into `.goga/tools//.yml` with buffering and automatic attribution in the final file list. +- Staged failure resilience per tool; fatal only on a broken package import; the guarantee that the standard config is created from the core answers and the surviving tools' amendments. +- Answer isolation (nested mappings, no dotted keys); a clean session error on an empty required `language` field at write time. +- The `N.M` image-hints tag derived from `host_goga_version()`; a clean error on an unreadable version. +- Documentation of the author contract (`docs/features/init/hooks.md`, cross-references from `tools/hooks.md`). +- The example tool — the `goga_tool_*` test fixture package via the public surface; tests in the project's mirrored structure. + +**Out of scope:** + +- The `review` survey (a future additive core id). +- A non-interactive/CI onboarding mode with answers from a file. +- A rework of the hooks platform (delivery, registration, isolation models) — only additive catalog records. +- An open `.goga/config.yml` schema with tools' arbitrary keys; merging or rewriting an existing `config.yml`. +- Runtime updates of the image tag in existing projects (the change concerns only the onboarding hints). +- The `goga install` post-install hooks and the home config `~/.goga/config.yml`. +- Separate tool onboarding sessions outside `goga init`. +- Special registration members (`register_tool`/`register_dep`) — rejected by the ADR in favor of `answer`. + +## Acceptance Criteria + +- **S1.** `goga init -t ` (the tool installed, the project without `.goga/`): the tool's question block appears in the same session after the core questions with attribution; on completion, a valid `.goga/config.yml` (core schema, including the tool's registrations in `tools`/`usages`) and the tool's own config in `.goga/tools//` exist — with no manual setup after init. +- **S2.** Without `-t`, the session contains no tool questions; the result matches today's behavior. +- **S3.** The extension-contract documentation exists (`docs/features/init/hooks.md` + cross-references); the example tool works exclusively through the public surface, without goga code changes. +- **S4.** A failing tool (an exception in the declaration, the amendments, or writing its config): init completes successfully (exit code 0), the standard config is created, the output carries a warning with the tool name and the reason, and the tool's contribution is discarded entirely (staged); the other invited tools are configured. +- **S5.** `-t` with an uninstalled tool: a warning naming it; the session continues; exit code 0. +- **S6.** An existing `.goga/config.yml` (the `` branch): the file is unchanged, no config questions are asked, the related tool events are never emitted. +- **S7.** With goga version `N.M.*` installed, the image hints (pull and the Dockerfile base image) carry the tag `:N.M`; after a minor goga upgrade, the hints show the new minor without code changes. +- **S8.** The image offered by default passes the host↔image compatibility check on (major, minor) at the first container run. +- **S9.** An unreadable goga version and a broken tool-package import — clean session errors with an actionable message (the field/package name), without a traceback; an empty required `language` field — a clean error naming the field. +- **S10.** A duplicate question id within a tool — declaration rejection with a warning (partial declarations survive); an unknown skip path — a no-op with a warning; the onboarding-action registrations are visible in the `goga hooks` inspection. +- **S11.** `-t` with `--upgrade` — a nonzero exit with an actionable message about the incompatible flags; a repeated `-t` name deduplicates (the block is asked once). +- **S12.** A skip declared by a tool suppresses the questions of the subtree (the core's, its own, and other tools'): the survey never shows the skipped question and never requests its answer; order-independent application of skips does not change the result. +- **S13.** Isolation and amendment conflicts: a participating tool never sees another tool's questions and answers (only the core's and its own); when two tools amend the same id, the last in delivery order wins (last-wins), mappings merge recursively, scalars and lists are replaced. +- **S14.** On session completion, the final created-files list is printed: the engine's files plus the tools' files with attribution of the tool name. + +## Stack + +- **Frameworks:** click — the interactive CLI survey of all question kinds (core and tools). +- **Libraries:** PyYAML — serialization of `.goga/config.yml` and the tools' configs; `importlib.metadata` (stdlib) — only through `host_goga_version()` from `goga/version`. +- **Models and logging:** dataclasses (`frozen=True`, `kw_only=True`), `logging` — per the project's conventions. +- **Infrastructure:** none — the entire session is interactive on the host, before building and running containers. +- **Testing:** pytest, pytest-cov, ruff (in `[project.optional-dependencies] test`); the example tool — a `goga_tool_*` pytest fixture package installed into the test environment. + +## External Dependencies + +| Component | Usage file | Status | +|-----------|------------|--------| +| click | `.goga/usages/cooks/click.md` | existing | +| PyYAML | the inline `yaml` practice in the `goga/onboarding` manifest | existing | + +No new external dependencies; the practice files in `.goga/usages/cooks/` are neither created nor updated. + +## Risks and Constraints + +- **C-1.** The init modes are immutable: bare / ``+onboarding / `--upgrade`, the "already initialized" guard. +- **C-2.** The existing `config.yml` is untouchable: "whoever created it first wins"; this branch skips the related tool events. +- **C-3.** The `config.yml` schema stays the core's typed schema; tools add no arbitrary keys. +- **C-4.** Extension goes only through the hooks platform (additive catalog records; the installation trust level, no sandbox); existing subscriptions and the CLI keep working. +- **C-5.** The interactive CLI channel on the host, before containers. +- **C-6.** The host↔image compatibility check on (major, minor) does not change; the image suggestions must satisfy it. +- **C-7.** Extension without goga patches — only the tool's package per a documented contract. +- **Deliberate deviations from the PRD (fixed by the ADR):** a broken import is fatal, not soft (R4.1); an empty required field at write time is a clean session error, not "the config always" (R4.2); the registrations are implemented as answers at the ids, not as special members (R2.4). +- **Determinism:** the tool-block order is the package-enumeration order; last-wins of amendments is the delivery order (the alphabet of identities). +- **Open questions of the design stage (from the ADR):** the exact names and signatures of the context members (`invited`, `declare_*`, `skip`, `answers`, `answer`, `write_config` — working aliases); the composition and wording of the core `tools`/`usages` prompts; whether writing other tools' keys into `tools` is admissible (whether to validate — the design decides). + +## Scope Estimate + +**A single task** (large). Work zones: the hooks catalog (small, additive), the onboarding session engine — the core of the work (questions/skips/amendments/staged/writes), CLI + the image tag (small), documentation + the example tool (medium). The parts are rigidly coupled by a single contract; decomposition into cells is performed by the brainstorm stage per the DSL (bottom-up). ~15–20 types/entities, high interaction complexity (merge/last-wins, staged rollback, answer isolation, hooks-platform invariants). + +## Existing Architecture + +The affected cells and connections: + +- **`goga/hooks/catalog`** — additively two `Action(domain="onboarding", …, error_class="soft")` records; catalog records are never rewritten; the catalog stays data. +- **`goga/onboarding`** — the main rework: the session engine consumes the hooks platform through the `goga/hooks` facade (the registry, delivery, per-tool contexts); the `Questionnaire`/`FileGenerator`/`InitAnswers`/`GogaConfigAnswers`/`InitLogic` contracts change to the "question → answer" mechanism; the `image_defaults` practice loses the hardcoded tag — the hints take the `N.M` tag from the version. +- **`goga/commands/init`** — the new `-t/--tool` option, combination validation, passing the invitation into the onboarding logic; the command remains the integration point of the onboarding and scaffold domains and delegates execution. +- **`goga/version`** — `host_goga_version()` — the single version-reading point (reuse it; do not duplicate the metadata reading); the four-form version grammar for the registrations — reuse `resolve_version`; the design decides where to place the `N.M` tag derivation. +- **`goga/config/project`** — the schema's consumer, unchanged: the final YAML must pass `load_project_config` (`tools: dict[str, str]`, `usages: dict[str, dict[str, DepConfig(git, ref, root)]]`). +- **`docs/features/init/`, `docs/features/tools/`** — the author-contract documentation and cross-references. +- **`tests/`** — mirrors the structure; the `goga_tool_*` fixture package for the example tool (the package enumeration reads the installed distributions). +- Dependency direction: `goga/onboarding` → `goga/hooks` (one-way, no cycles); `goga/commands/init` → `goga/onboarding`, `goga/scaffold` (the existing integration point). + +## Notes + +- The normative source of the decisions is the ADR `.goga/history/2026/onboarding-refctoring/adr.md`; the PRD `prd.md` remains in force in the part not changed by the ADR's deviations. +- Code examples are not included in the task (a stage limitation); the context members' signatures are an open question of the design. +- The contract rule for non-invited tools: the hook must return immediately on the "not invited" marker. +- A skip inside `pairs` is addressable only by the node as a whole — individual pairs are not addressable. From 398c0a1f7f6b44e4141dc4ce00d5833e8e5c35a2 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Wed, 16 Sep 2026 21:19:36 +0300 Subject: [PATCH 037/205] feat: up afm version --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 158db586..d4eca77e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG AFM_VERSION=1.0.3 +ARG AFM_VERSION=1.1.2 ARG RALPHEX_VERSION=1.6 ARG PYTHON_VERSION=3.12 ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 From 1bc4ef5f14647253504b9d93b6f910c947481e89 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Wed, 16 Sep 2026 21:19:58 +0300 Subject: [PATCH 038/205] fix: delete old gitignores --- .gitignore | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.gitignore b/.gitignore index bac2b86d..4d434ebb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,3 @@ -# goga: ralphex config managed by goga -.goga/prompts/*.txt -.goga/agents/*.txt -.goga/config - # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] From de605d0e426d0a2b74204b33f2779f96b96a9df6 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Wed, 16 Sep 2026 21:24:40 +0300 Subject: [PATCH 039/205] feat: require english file content in workflow prompts --- .goga/workflows/bugfix.yml | 3 ++- .goga/workflows/development.yml | 3 ++- .goga/workflows/epic.yml | 3 ++- .goga/workflows/patch.yml | 3 ++- .goga/workflows/refinement.yml | 3 ++- .goga/workflows/review.release.yml | 3 ++- .goga/workflows/review.yml | 3 ++- .goga/workflows/story.yml | 3 ++- .goga/workflows/sync.yml | 3 ++- .goga/workflows/task.yml | 3 ++- 10 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.goga/workflows/bugfix.yml b/.goga/workflows/bugfix.yml index bad4434c..c5d2aecd 100644 --- a/.goga/workflows/bugfix.yml +++ b/.goga/workflows/bugfix.yml @@ -1,5 +1,6 @@ prompt: | - Answer (feedbacks, proposes, questions and etc) in Russian language. + Answer (feedbacks, proposes, questions:`*.question.json` and etc) in Russian language. + Any content written to the project files is in English. memory: max_rules: 15 diff --git a/.goga/workflows/development.yml b/.goga/workflows/development.yml index 6bd0795d..76d2a4ff 100644 --- a/.goga/workflows/development.yml +++ b/.goga/workflows/development.yml @@ -1,5 +1,6 @@ prompt: | - Answer (feedbacks, proposes, questions and etc) in Russian language. + Answer (feedbacks, proposes, questions:`*.question.json` and etc) in Russian language. + Any content written to the project files is in English. memory: max_rules: 15 diff --git a/.goga/workflows/epic.yml b/.goga/workflows/epic.yml index 6b20636b..25036ed9 100644 --- a/.goga/workflows/epic.yml +++ b/.goga/workflows/epic.yml @@ -1,5 +1,6 @@ prompt: | - Answer (feedbacks, proposes, questions and etc) in Russian language. + Answer (feedbacks, proposes, questions:`*.question.json` and etc) in Russian language. + Any content written to the project files is in English. stages: propose: diff --git a/.goga/workflows/patch.yml b/.goga/workflows/patch.yml index 99a89022..45f8d9cb 100644 --- a/.goga/workflows/patch.yml +++ b/.goga/workflows/patch.yml @@ -1,5 +1,6 @@ prompt: | - Answer (feedbacks, proposes, questions and etc) in Russian language. + Answer (feedbacks, proposes, questions:`*.question.json` and etc) in Russian language. + Any content written to the project files is in English. stages: ad-hoc: diff --git a/.goga/workflows/refinement.yml b/.goga/workflows/refinement.yml index 6b20636b..25036ed9 100644 --- a/.goga/workflows/refinement.yml +++ b/.goga/workflows/refinement.yml @@ -1,5 +1,6 @@ prompt: | - Answer (feedbacks, proposes, questions and etc) in Russian language. + Answer (feedbacks, proposes, questions:`*.question.json` and etc) in Russian language. + Any content written to the project files is in English. stages: propose: diff --git a/.goga/workflows/review.release.yml b/.goga/workflows/review.release.yml index ea014485..e9655b3b 100644 --- a/.goga/workflows/review.release.yml +++ b/.goga/workflows/review.release.yml @@ -1,5 +1,6 @@ prompt: | - Answer (feedbacks, proposes, questions and etc) in Russian language. + Answer (feedbacks, proposes, questions:`*.question.json` and etc) in Russian language. + Any content written to the project files is in English. stages: code-review: diff --git a/.goga/workflows/review.yml b/.goga/workflows/review.yml index 4dd6066c..1116b7ab 100644 --- a/.goga/workflows/review.yml +++ b/.goga/workflows/review.yml @@ -1,5 +1,6 @@ prompt: | - Answer (feedbacks, proposes, questions and etc) in Russian language. + Answer (feedbacks, proposes, questions:`*.question.json` and etc) in Russian language. + Any content written to the project files is in English. stages: code-review: diff --git a/.goga/workflows/story.yml b/.goga/workflows/story.yml index 6ca6482a..b301397c 100644 --- a/.goga/workflows/story.yml +++ b/.goga/workflows/story.yml @@ -1,5 +1,6 @@ prompt: | - Answer (feedbacks, proposes, questions and etc) in Russian language. + Answer (feedbacks, proposes, questions:`*.question.json` and etc) in Russian language. + Any content written to the project files is in English. stages: define: diff --git a/.goga/workflows/sync.yml b/.goga/workflows/sync.yml index 9fc61e96..db6b58ae 100644 --- a/.goga/workflows/sync.yml +++ b/.goga/workflows/sync.yml @@ -1,2 +1,3 @@ prompt: | - Answer (feedbacks, proposes, questions and etc) in Russian language. + Answer (feedbacks, proposes, questions:`*.question.json` and etc) in Russian language. + Any content written to the project files is in English. diff --git a/.goga/workflows/task.yml b/.goga/workflows/task.yml index 2d67cd24..2ae6435f 100644 --- a/.goga/workflows/task.yml +++ b/.goga/workflows/task.yml @@ -1,5 +1,6 @@ prompt: | - Answer (feedbacks, proposes, questions and etc) in Russian language. + Answer (feedbacks, proposes, questions:`*.question.json` and etc) in Russian language. + Any content written to the project files is in English. stages: define: From abbad1ec4692d255bd0684ce2c91bb787520b517 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Wed, 16 Sep 2026 23:29:46 +0000 Subject: [PATCH 040/205] feat: add topics hooks zone cell and specs --- .goga/history/2026/add-topics-hooks/adr.md | 46 + .goga/history/2026/add-topics-hooks/arch.md | 1104 +++++++++ .goga/history/2026/add-topics-hooks/design.md | 2113 +++++++++++++++++ .goga/history/2026/add-topics-hooks/plan.md | 1853 +++++++++++++++ .goga/history/2026/add-topics-hooks/prd.md | 139 ++ .goga/history/2026/add-topics-hooks/task.md | 96 + goga/hooks/catalog/CODEMANIFEST | 27 + goga/topics/.usages/creating.md | 4 + goga/topics/.usages/todo-entry.md | 5 +- goga/topics/CODEMANIFEST | 218 +- goga/topics/hooks/.usages/checkpoints.md | 80 + goga/topics/hooks/CODEMANIFEST | 581 +++++ 12 files changed, 6224 insertions(+), 42 deletions(-) create mode 100644 .goga/history/2026/add-topics-hooks/adr.md create mode 100644 .goga/history/2026/add-topics-hooks/arch.md create mode 100644 .goga/history/2026/add-topics-hooks/design.md create mode 100644 .goga/history/2026/add-topics-hooks/plan.md create mode 100644 .goga/history/2026/add-topics-hooks/prd.md create mode 100644 .goga/history/2026/add-topics-hooks/task.md create mode 100644 goga/topics/hooks/.usages/checkpoints.md create mode 100644 goga/topics/hooks/CODEMANIFEST diff --git a/.goga/history/2026/add-topics-hooks/adr.md b/.goga/history/2026/add-topics-hooks/adr.md new file mode 100644 index 00000000..fd5ab102 --- /dev/null +++ b/.goga/history/2026/add-topics-hooks/adr.md @@ -0,0 +1,46 @@ +# Topics domain: seven hooks actions — post-fact notifications, staged amendment chain, home-path identity + +**Status:** accepted + +The `topics` domain opens its lifecycle to tool packages through seven soft actions of the existing hooks platform: five notifications (`topic_created`, `topic_published`, `topic_switched`, `topic_todo_entered`, `topic_deleted`) and two amendments (`amend_creation`, `amend_todo_entry`). Notifications ride the platform's plain fire-and-forget emission after the success of their moment; amendments ride the platform's staged per-tool delivery (the `onboarding` precedent) because R11's per-hook atomicity — "a raising hook's amendment is not applied" — is unreachable through the generic `emit_hook_event` intercept. Topic identity in every context is **slug + home path + branch**: the standalone `year` fact is deliberately replaced by the topic home path `.goga/history//`, the actionable address in the history tree (the year stays recoverable inside it). + +## Decisions + +1. **Catalog.** Seven records are added additively to the platform catalog (`goga/hooks/catalog`), all `error_class=soft`, never rewritten: `topics/topic_created`, `topics/topic_published`, `topics/topic_switched`, `topics/topic_todo_entered`, `topics/topic_deleted`, `topics/amend_creation`, `topics/amend_todo_entry`. No platform-core change (C1): registration, run registry, error classes, and `goga hooks` are reused as-is. +2. **Two mechanisms by kind.** Notification actions use plain `emit_hook_event` — fire-and-forget, one read-only context view per tool, optional per-tool `self`. Amendment actions use staged delivery over the platform's public primitives (the documented `per-tool-delivery` pattern, `onboarding/amend_config` precedent): the domain drives the subscription loop itself, because only it can commit or discard a hook's buffered amendment (q2). +3. **The amendment chain.** One shared draft holder per amendment action. Each draft is exposed read-only with exactly one amendment operation replacing its full content. Per-hook buffer: an `amend` call buffers; the buffer commits to the shared holder only when the hook call returns without raising; a raising hook's buffer is discarded, the platform-form warning `hook of tool failed on topics.: ` is emitted, and the enumeration continues in the platform order (alphabetical tools × registration order within a tool). An empty or whitespace-only amendment is rejected at commit — the last buffered value is the one checked — with the same warning form. Hooks of one tool are independent (hook granularity, not tool granularity). No amendment can cancel, redirect, or defer the operation; the domain fixes only the final draft into the artifact (amended commit message into git, amended text into `todo.md`) and reports it as the final facts of the corresponding notifications (q2). +4. **Emission placement.** Emission lives in the topics domain routines, not in the CLI cell: pipeline topic resolution (`goga pipeline -t`, the sole non-topics caller via `ensure_topic`) invokes the same routines and receives the same events. `topic_created` fires exactly once per successful creation from the unified post-success point of each of the four creation paths (quarantined no-switch, switch, publication, `ensure` fast creation); on the publication path it is deferred until the push succeeds — a rolled-back publication emits nothing at all (R3). Deletion emits per target inside the removal loop, after the target's full removal: targets fully removed before a later failure emit theirs (R6) (q5). +5. **Identity vocabulary.** Identity facts are **slug, home path, branch as entered**. The home path is derived deterministically from slug and year by the history path rules — no repository reads. Marginal manual switch onto a branch hosting no topic: identity degrades to **branch-only** (detection: `candidate.topic is None` in switch-candidate resolution — operation data, not a re-read) (q4a, q7i). +6. **Marginal corners.** `ensure --todo` on a branch hosting no topic creates the topic directory and enters a fresh todo today; it fires `amend_todo_entry` + `topic_todo_entered` with derived identity (slug normalized from the branch name, current year → home path) and fires **no** `topic_created` — directory creation is not one of R2's creation paths. `switch --todo` onto a topic-less branch keeps today's clean pre-mutation error and emits nothing (q4b, q4c). +7. **Context models** (semantics; names and shapes are design-stage): + - `topic_created` — identity; final todo when resolved; path facts (checked out, published); commit message + commit hash **iff the path builds a commit** (quarantine and publication paths; absent on switch/`ensure` paths) (q7-1). + - `topic_published` — identity; final commit message + commit hash; final todo (q7-2). + - `topic_switched` — identity (branch-only in the marginal case); **switch outcome kind**: local-checkout / created-from-remote / already-on-branch (q7-3). + - `topic_todo_entered` — identity; final written text (post-amendment). No prior text (q7-4). + - `topic_deleted` — identity (slug, home path); removal composition: local branch (name), origin twin (name, when the target had one), whether the topic directory was removed. No deleted-commit hash (q7-5). + - `amend_creation` — identity; path facts; draft commit message (iff the path builds one) + draft primary todo (when resolved); the identity-only degenerate case (switch path without a todo) is valid — the tool decides whether to act (R8). + - `amend_todo_entry` — identity; draft of the saved text. No prior text (q7-4). +8. **Facts without repository reads.** Contexts are built from the operation's own data (R13): the domain threads the values it discards today — the creation plant's commit hash (return value), the final commit message (the amended text is an argument of the plant by construction of the amendment surface), the resolved year through switching. No `git log`/ref-tree re-reads are introduced for events. +9. **Inspection and documentation.** `goga hooks` changes nothing — the records appear automatically in its tool → domain → action presentation. The stub "no hook actions" statement in `docs/features/topics/hooks.md` is replaced by the topics hooks reference (firing moment, error class, facts/drafts per action); the action lists in `docs/features/hooks/index.md`, `docs/features/hooks/hooks.md`, and `docs/features/tools/hooks.md` are synchronized; the maintainer recipe `goga/hooks/.usages/declaring-actions.md` is followed. + +## Deviations from the PRD (deliberately accepted) + +- **`year` → home path** (R2/R4/R5/R6/R8/R9 list "slug, year"): the standalone year carries little integration value; the home path is the address a tool acts on and embeds the year (q6 custom answer, confirmed q7i). +- **Commit hash added** to `topic_created`/`topic_published` beyond R2/R3's enumerated facts — additive, near-zero cost (the plant already returns it), high value for tracker linkage (q3). +- **Switch outcome kind added** to `topic_switched` beyond R4's identity list — additive; lets consumers filter idempotent-switch noise (q7-3). + +## Open Questions (design stage) + +- Sharing one `HookRegistry` instance within a command (statuses creates a fresh registry per assembly; multiple topics emissions should not multiply enumeration/import costs). +- Exact member names and signatures of the context views, drafts, and amendment operations; exact wording of warning reasons. +- The concrete shape of the home path fact (string vs structured) — semantics fixed here, shape is the design's. + +## Considered Options + +- **`emit_hook_event` with immediate amendment application (statuses pattern)** — rejected: a landed amendment before a raise would survive, breaking R11's letter (q2). +- **`emit_hook_event` + softened R11 recorded as deviation** — rejected: per-hook atomicity is the published contract; staged delivery already exists as a documented platform pattern (q2). +- **Suppressing events in the `ensure --todo` marginal corner** — rejected: a silent observability gap (q4b). +- **`topic_created` for the `ensure --todo` directory creation** — rejected: expands R2's closed catalog of creation paths (q4b). +- **Bare `year` identity fact** — rejected in favor of the home path (q6/q7). +- **Prior todo text in the todo contexts** — rejected: minimal facts; a tool keeps its own state in `self` (q7-4). +- **Strict R4 switch identity without the outcome kind** — rejected (q7-3). diff --git a/.goga/history/2026/add-topics-hooks/arch.md b/.goga/history/2026/add-topics-hooks/arch.md new file mode 100644 index 00000000..5595cca1 --- /dev/null +++ b/.goga/history/2026/add-topics-hooks/arch.md @@ -0,0 +1,1104 @@ +# [ARCHITECTURE_PLAN] + +## Topic + +**topics-hooks** — seven platform hook actions for the topics lifecycle: five post-fact notifications over the plain emission, two pre-fixation amendments over the staged per-tool delivery, home-path identity, no-read contexts. + +Plan path: `.goga/history/2026/add-topics-hooks/arch.md` (this file). + +## Implementation Order + +1. **`goga/hooks/catalog`** (MODIFIED) — first: the record data every later emission resolves against; the cell has no Imports and nothing in this plan depends on order here, but the addresses must exist before any checkpoint fires. +2. **`goga/topics/hooks`** (CREATED) — second: the topics hooks zone; depends on `goga/hooks` (facade types + practices) and `goga/history` (path composition) — both already exist and are untouched by this plan. +3. **`goga/topics`** (MODIFIED) — last: the flows that call the zone; depends on `goga/topics/hooks` (Types + the `checkpoints` practice) — designed after its dependency exists. + +## Artifacts + +### Cell 1: `goga/hooks/catalog` — MODIFIED CODEMANIFEST (diff) + +Only the `Requirements` of `declared_actions` changes: keep the first three bullets (deterministic, complete, statuses record) and the two onboarding bullets verbatim, then append seven bullets. Final current-state list: + +```yaml + Requirements: + - Deterministic — the same records in the same order on every call + - Complete — no filtering and no partial views + - The catalog carries the statuses action — the record + domain="statuses", name="register_statuses", error_class="soft": a + failing hook of the action is skipped with a warning and the command + continues + - The catalog carries the onboarding session-declaration action — the + record domain="onboarding", name="declare_session", error_class="soft": + a failing hook of the action is skipped with a warning and the sequence + continues + - The catalog carries the onboarding config-amendment action — the + record domain="onboarding", name="amend_config", error_class="soft": a + failing hook of the action is skipped with a warning and the sequence + continues + - The catalog carries the topics creation-notification action — the + record domain="topics", name="topic_created", error_class="soft": a + failing hook of the action is skipped with a warning and the command + continues + - The catalog carries the topics publication-notification action — the + record domain="topics", name="topic_published", error_class="soft": a + failing hook of the action is skipped with a warning and the command + continues + - The catalog carries the topics switch-notification action — the + record domain="topics", name="topic_switched", error_class="soft": a + failing hook of the action is skipped with a warning and the command + continues + - The catalog carries the topics todo-entry-notification action — the + record domain="topics", name="topic_todo_entered", error_class="soft": + a failing hook of the action is skipped with a warning and the command + continues + - The catalog carries the topics deletion-notification action — the + record domain="topics", name="topic_deleted", error_class="soft": a + failing hook of the action is skipped with a warning and the command + continues + - The catalog carries the topics creation-amendment action — the record + domain="topics", name="amend_creation", error_class="soft": a failing + hook of the action is skipped with a warning and the command continues + - The catalog carries the topics todo-entry-amendment action — the + record domain="topics", name="amend_todo_entry", error_class="soft": + a failing hook of the action is skipped with a warning and the command + continues +``` + +Algorithm, Constraints, `Action`, header, and footer: unchanged. + +### Cell 2: `goga/topics/hooks` — CREATED + +#### CODEMANIFEST (full file) + +````yaml +Imports: + - Types: + - HookRegistry + - emit_hook_event + - wrap_context + - build_hook_arguments + - declared_actions + Usages: + - declaring-actions + - per-tool-delivery + - registering-hooks + From: goga/hooks + - Types: + - resolve_topic_dir + Usages: + - topic-paths + From: goga/history + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and + testing in the project + + Use the `declaring-actions` practice for the emission contract of + the notification checkpoints. + Use the `per-tool-delivery` practice for the staged delivery loop of + the amendment checkpoints — its loop skeleton, primitives, and + failure handling apply with one refinement: the commit granularity + is the single hook, not the tool — the per-hook requirements of the + delivery methods take precedence over the practice's tool-grouped + commit. + Use the `registering-hooks` practice for the hook signature and the + failure handling behind every checkpoint. + Use the `topic-paths` practice for the topic directory composition + behind the home path of the identity. + + This cell owns the hooks zone of the topics domain: the identity + vocabulary of the lifecycle events, the read-only notification + contexts of the five moments, the amendment drafts of the two + pre-fixation moments with their per-hook staged delivery, and the + checkpoint surface that delivers the amendments and emits the + notifications over the platform facade. One registry per run carries + every checkpoint of a command — the checkpoints never multiply the + package enumeration. Every context is built from the operation data + the caller passes — no repository reads happen here. A failing hook + never breaks the operation: the seven topics actions are soft, a + failure is a warning naming the hook, the tool, the action, and the + reason, and the delivery continues in the platform order. No package + enumeration and no subscription state live here — the platform + carries the tool packages. Use relative imports. + +--- + +"TopicIdentity(slug: str | None, year: str, branch: str | None)": + location: identity.py + annotations: | + The identity vocabulary of every topics event — the topic slug, + its home path, and the branch as entered by the operation. + + `slug`: the normalized topic slug; None in the branch-only form — + a switch onto a branch hosting no topic + `year`: the resolved year as four digits — the composition input + of the home path; it always arrives resolved — the + constructing operation passes its year input when given, + otherwise the current year + `branch`: the branch name as entered by the operation; None only + in the deletion context, whose removal composition + carries the branch names + + Apply the `convention` practice for the data-model rules and + intra-package imports. + Use the `topic-paths` practice for the topic directory composition + behind the home path — the composition runs through + `resolve_topic_dir`. + + Requirements: + - Pure composition — the home path derives from `slug` and `year` + without repository reads and without creating anything + properties: + "slug -> str | None": | + The normalized topic slug, or None in the branch-only form. + "home_path -> str | None": | + The topic home path .goga/history// as a posix + string, composed from the slug and the year inputs; None when + the slug is None. Pure composition — nothing is read or + created. + "branch -> str | None": | + The branch name as entered by the operation, or None in the + deletion context. + +"TopicCreated(identity: TopicIdentity, checked_out: bool, published: bool, todo: str | None, commit_message: str | None, commit_hash: str | None)": + location: contexts.py + annotations: | + The read-only context of the creation notification — the final + facts of one completed creation. + + `identity`: the identity of the created topic + `checked_out`: True when the creation path checked out the fresh + branch + `published`: True when the creation path published the work + `todo`: the final todo text, or None when none resolved + `commit_message`: the final commit message — present exactly when + the creation path builds a commit, None otherwise + `commit_hash`: the hash of the built commit — present exactly when + the creation path builds a commit, None otherwise + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Read-only facts of a completed operation — a hook observes the + outcome and cannot alter it + properties: + "identity -> TopicIdentity": | + The identity of the created topic. + "checked_out -> bool": | + True when the creation path checked out the fresh branch. + "published -> bool": | + True when the creation path published the work. + "todo -> str | None": | + The final todo text, or None when none resolved. + "commit_message -> str | None": | + The final commit message — present exactly when the creation + path builds a commit, None otherwise. + "commit_hash -> str | None": | + The hash of the built commit — present exactly when the creation + path builds a commit, None otherwise. + +"TopicPublished(identity: TopicIdentity, commit_message: str, commit_hash: str, todo: str)": + location: contexts.py + annotations: | + The read-only context of the publication notification — the final + facts of one successful publication push. + + `identity`: the identity of the published topic + `commit_message`: the final commit message landed in git + `commit_hash`: the hash of the publication commit + `todo`: the final todo text landed in the publication commit + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Read-only facts of a completed operation + properties: + "identity -> TopicIdentity": | + The identity of the published topic. + "commit_message -> str": | + The final commit message landed in git. + "commit_hash -> str": | + The hash of the publication commit. + "todo -> str": | + The final todo text landed in the publication commit. + +"TopicSwitched(identity: TopicIdentity, outcome: str)": + location: contexts.py + annotations: | + The read-only context of the switch notification — the outcome of + one completed switch. + + `identity`: the identity of the switched work — the branch-only + form when the branch hosts no topic + `outcome`: the outcome kind — exactly one of local-checkout, + created-from-remote, already-on-branch + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The outcome value is exactly one of the three fixed kinds + - Read-only facts of a completed operation + properties: + "identity -> TopicIdentity": | + The identity of the switched work — the branch-only form when + the branch hosts no topic. + "outcome -> str": | + The outcome kind — local-checkout, created-from-remote, or + already-on-branch. + +"TopicTodoEntered(identity: TopicIdentity, text: str)": + location: contexts.py + annotations: | + The read-only context of the todo-entry notification — the final + text of one saved todo entry. + + `identity`: the identity of the topic whose todo was entered + `text`: the final written text — after every amendment + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - No prior text is carried — a tool keeps its own state in its own + context + - Read-only facts of a completed operation + properties: + "identity -> TopicIdentity": | + The identity of the topic whose todo was entered. + "text -> str": | + The final written text — after every amendment. + +"TopicDeleted(identity: TopicIdentity, local_branch: str | None, origin_twin: str | None, directory_removed: bool)": + location: contexts.py + annotations: | + The read-only context of the deletion notification — the removal + composition of one fully removed target. + + `identity`: the identity of the removed topic — slug and home + path; no branch fact + `local_branch`: the removed local branch name, or None when the + target had none + `origin_twin`: the removed origin twin name, or None when the + target had none + `directory_removed`: True when the topic directory was removed + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - No deleted-commit hash is carried + - Read-only facts of a completed operation + properties: + "identity -> TopicIdentity": | + The identity of the removed topic — slug and home path; no + branch fact. + "local_branch -> str | None": | + The removed local branch name, or None. + "origin_twin -> str | None": | + The removed origin twin name, or None. + "directory_removed -> bool": | + True when the topic directory was removed. + +"CreationDraft(commit_message: str | None, todo: str | None)": + location: amendments.py + annotations: | + The shared draft holder of the creation amendment — the content + the creation path is about to fix, and after the delivery the + final amended content. + + `commit_message`: the draft commit message — None on paths that + build no commit + `todo`: the draft todo text — None when none resolved + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The content changes only through the delivery commit of the + amendment checkpoint — never through a delivered view + properties: + "commit_message -> str | None": | + The draft or final commit message — None on paths that build no + commit. + "todo -> str | None": | + The draft or final todo text — None when none resolved. + +"TodoEntryDraft(text: str)": + location: amendments.py + annotations: | + The shared draft holder of the todo-entry amendment — the saved + text the entry path is about to write, and after the delivery the + final amended text. + + `text`: the saved draft text + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The content changes only through the delivery commit of the + amendment checkpoint — never through a delivered view + properties: + "text -> str": | + The saved draft or final amended text. + +"CreationAmendment(identity: TopicIdentity, checked_out: bool, published: bool, draft: CreationDraft)": + location: amendments.py + annotations: | + The creation-amendment view of one hook — the read-only surface + over the live shared draft, delivered at the pre-fixation moment + of a creation. + + `identity`: the identity of the topic being created + `checked_out`: True when the chosen path checks out the fresh + branch + `published`: True when the chosen path publishes the work + `draft`: the live shared holder the view reads through + + Apply the `convention` practice for the data-model rules and + intra-package imports. + Use the `registering-hooks` practice for the hook signature that + receives this view. + + Requirements: + - The reads pass through to the live holder — a later hook sees + the committed amendments of the earlier hooks + - The identity-only form — no commit message and no todo on the + chosen path — is valid; the tool decides whether to act + properties: + "identity -> TopicIdentity": | + The identity of the topic being created. + "checked_out -> bool": | + True when the chosen path checks out the fresh branch. + "published -> bool": | + True when the chosen path publishes the work. + "commit_message -> str | None": | + The live draft commit message — None on paths that build no + commit. + "todo -> str | None": | + The live draft todo text — None when none resolved. + methods: + "amend(commit_message: str | None, todo: str | None)": | + Buffer one amendment replacing the full draft content. + + `commit_message`: the complete new commit message — None keeps + the field structurally absent + `todo`: the complete new todo text — None keeps the field + structurally absent + + Requirements: + - The call buffers into the buffer of this hook alone and + changes nothing until the delivery commits it + - The replacement is whole — a field left out is returned as + None, not kept as the previous value + + Constraints: + - Do not cancel, redirect, or defer the operation — an + amendment transforms content only + +"TodoEntryAmendment(identity: TopicIdentity, draft: TodoEntryDraft)": + location: amendments.py + annotations: | + The todo-entry-amendment view of one hook — the read-only surface + over the live shared draft, delivered at the pre-fixation moment + of a todo entry. + + `identity`: the identity of the topic whose todo is being entered + `draft`: the live shared holder the view reads through + + Apply the `convention` practice for the data-model rules and + intra-package imports. + Use the `registering-hooks` practice for the hook signature that + receives this view. + properties: + "identity -> TopicIdentity": | + The identity of the topic whose todo is being entered. + "text -> str": | + The live draft text. + methods: + "amend(text: str)": | + Buffer one amendment replacing the full text. + + `text`: the complete new text + + Requirements: + - The call buffers into the buffer of this hook alone and + changes nothing until the delivery commits it + + Constraints: + - Do not cancel, redirect, or defer the operation — an + amendment transforms content only + +"TopicHooks()": + location: events.py + annotations: | + The checkpoint surface of the topics lifecycle — the two amendment + deliveries and the five notification emissions over the platform + facade. + + Apply the `convention` practice for the code style and + intra-package imports. + Use the `declaring-actions` practice for the emission contract of + the notification checkpoints. + Use the `per-tool-delivery` practice for the staged delivery loop + of the amendment checkpoints. + Use the `registering-hooks` practice for the registration contract + behind every checkpoint. + + Requirements: + - Cheap construction — no enumeration and no imports happen at + construction + - One `HookRegistry` per run carries every checkpoint of a + command — the assembly runs once per run whatever the number of + checkpoints; the transport of the shared `HookRegistry` is an + implementation detail + - Every context and draft is built from the values the caller + passes — no repository reads happen at a checkpoint + methods: + "amend_creation(identity: TopicIdentity, checked_out: bool, published: bool, commit_message: str | None, todo: str | None) -> draft: CreationDraft": | + Deliver the creation-amendment checkpoint and return the holder + with the final content. + + `identity`: the identity of the topic being created + `checked_out`: True when the chosen path checks out the fresh + branch + `published`: True when the chosen path publishes the work + `commit_message`: the draft commit message — None on paths that + build no commit + `todo`: the draft todo text — None when none resolved + `draft`: the holder carrying the final amended values + + Use the `per-tool-delivery` practice for the delivery loop. + + Algorithm: + 1. Resolve the address domain="topics", action="amend_creation" + against `declared_actions` + 2. Create the shared `CreationDraft` with the draft values + 3. Walk the subscriptions of the address in enumeration order: + per subscription build the hook's `CreationAmendment` view + over the live holder, wrap it via `wrap_context`, project the + call arguments via `build_hook_arguments` with the tool's own + context, and call the hook + 4. A hook that returns without raising and buffered an + amendment: the buffer replaces the holder content — except + when a structurally present field of the buffer is empty or + whitespace-only, which rejects the whole buffer + 5. A hook that raised: its buffer is discarded + 6. Both rejection cases emit the warning + hook of tool failed on + topics.amend_creation: — the raised error for the + discard, the empty-amendment reason for the rejection — and + the walk continues with the next hook + 7. Return the holder + + Requirements: + - The delivery is per hook — two hooks of one tool never share + a buffer or a failure + - The final holder content is the last committed buffer, or the + original draft values when no buffer committed + - An address without subscriptions returns the original draft + values — not an error + + Constraints: + - Do not apply any amendment after the walk ends — the caller + fixes the final draft into the artifacts itself + - Do not skip a subscriber of the address + "amend_todo_entry(identity: TopicIdentity, text: str) -> draft: TodoEntryDraft": | + Deliver the todo-entry-amendment checkpoint and return the + holder with the final text. + + `identity`: the identity of the topic whose todo is being + entered + `text`: the saved draft text + `draft`: the holder carrying the final amended text + + Use the `per-tool-delivery` practice for the delivery loop. + + Algorithm: + 1. Resolve the address domain="topics", + action="amend_todo_entry" against `declared_actions` + 2. Create the shared `TodoEntryDraft` with the draft text + 3. Walk the subscriptions in enumeration order with a per-hook + `TodoEntryAmendment` view over the live holder — the same + call, commit, and rejection rules as the creation amendment + 4. Return the holder + + Requirements: + - The delivery is per hook — two hooks of one tool never share + a buffer or a failure + - The final holder text is the last committed buffer, or the + saved draft when no buffer committed + - An address without subscriptions returns the saved draft — + not an error + + Constraints: + - Do not apply any amendment after the walk ends — the caller + writes the final text itself + - Do not skip a subscriber of the address + "emit_created(identity: TopicIdentity, checked_out: bool, published: bool, todo: str | None, commit_message: str | None, commit_hash: str | None)": | + Emit the creation notification — the facts of one completed + creation. + + `identity`: the identity of the created topic + `checked_out`: True when the creation path checked out the fresh + branch + `published`: True when the creation path published the work + `todo`: the final todo text, or None + `commit_message`: the final commit message — None on paths + building none + `commit_hash`: the hash of the built commit — None on paths + building none + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `TopicCreated` context from the values + 2. Emit the address domain="topics", action="topic_created" via + `emit_hook_event` — the context view of every receiving tool + reads the same instance through the delivery proxy + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + - A failing hook is skipped with a warning under the soft error + class of the action + "emit_published(identity: TopicIdentity, commit_message: str, commit_hash: str, todo: str)": | + Emit the publication notification — the facts of one successful + publication push. + + `identity`: the identity of the published topic + `commit_message`: the final commit message landed in git + `commit_hash`: the hash of the publication commit + `todo`: the final todo text landed in the publication commit + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `TopicPublished` context from the values + 2. Emit the address domain="topics", action="topic_published" + via `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + "emit_switched(identity: TopicIdentity, outcome: str)": | + Emit the switch notification — the outcome of one completed + switch. + + `identity`: the identity of the switched work — the branch-only + form when the branch hosts no topic + `outcome`: the outcome kind — local-checkout, + created-from-remote, or already-on-branch + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `TopicSwitched` context from the values + 2. Emit the address domain="topics", action="topic_switched" + via `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + "emit_todo_entered(identity: TopicIdentity, text: str)": | + Emit the todo-entry notification — the final text of one saved + todo entry. + + `identity`: the identity of the topic whose todo was entered + `text`: the final written text + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `TopicTodoEntered` context from the values + 2. Emit the address domain="topics", + action="topic_todo_entered" via `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + "emit_deleted(identity: TopicIdentity, local_branch: str | None, origin_twin: str | None, directory_removed: bool)": | + Emit the deletion notification — the removal composition of one + fully removed target. + + `identity`: the identity of the removed topic — slug and home + path; no branch fact + `local_branch`: the removed local branch name, or None + `origin_twin`: the removed origin twin name, or None + `directory_removed`: True when the topic directory was removed + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `TopicDeleted` context from the values + 2. Emit the address domain="topics", action="topic_deleted" + via `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + +--- + +Author: Goga +CreatedAt: 16/09/26 +Description: | + Owner of the topics domain hooks zone — the event identity, the + notification contexts, the amendment drafts, and the checkpoint + surface over the hooks platform. +```` + +#### `.usages/` file + +**File:** `goga/topics/hooks/.usages/checkpoints.md` + +````markdown +# topics — emitting lifecycle checkpoints + +How the topics flows consume the checkpoint surface of the hooks zone: +delivering the two amendments before the content is fixed and emitting +the five notifications after their moments. For the domain flows over +the topics facade. + +## The checkpoint surface + +One `TopicHooks` object serves every checkpoint of a command — the +surface shares one registry per run, so a command that reaches several +checkpoints enumerates the tool packages once. + +```python +from goga.topics.hooks import TopicHooks, TopicIdentity + +hooks = TopicHooks() +identity = TopicIdentity(slug="add-topics-hooks", year="2026", branch="add-topics-hooks") +``` + +`TopicIdentity` carries the three identity facts of every event: the +slug, the home path (composed from slug and year — no repository +reads), and the branch as entered. The branch-only form — slug None — +serves a manual switch onto a branch hosting no topic. + +## Amend before the content is fixed + +Deliver the amendment checkpoint before the mutation that fixes the +content, then read the final values from the returned holder and fix +them. + +```python +draft = hooks.amend_creation( + identity, + checked_out=False, + published=False, + commit_message=draft_message, # None on paths that build no commit + todo=draft_todo, # None when none resolved +) +final_message = draft.commit_message +final_todo = draft.todo +``` + +- The identity-only form is valid — a path with no commit and no todo + still delivers; the tool decides whether to act. +- A hook's buffered amendment commits only when the hook returns + without raising; an empty or whitespace-only value rejects the whole + buffer; both cases warn and the walk continues — the operation never + breaks. +- The caller fixes the final values into the artifacts itself; nothing + is applied to the repository here. + +```python +draft = hooks.amend_todo_entry(identity, saved_text) +write_todo(draft.text) +hooks.emit_todo_entered(identity, draft.text) +``` + +## Emit after the moment + +Emit each notification after its moment fully succeeds, with the final +facts — the amended content is the reported content. + +```python +hooks.emit_created(identity, checked_out=False, published=False, + todo=final_todo, commit_message=final_message, + commit_hash=planted_hash) +hooks.emit_published(identity, commit_message=final_message, + commit_hash=planted_hash, todo=final_todo) +hooks.emit_switched(identity, outcome="created-from-remote") +hooks.emit_deleted(identity, local_branch=branch, origin_twin=twin, + directory_removed=True) +``` + +- Every `emit_*` is fire-and-forget: a failing hook warns under the + soft error class and the command continues. +- Build every fact from the operation's own data — no git reads at a + checkpoint. +```` + +### Cell 3: `goga/topics` — MODIFIED CODEMANIFEST (diff) + +**Header — add one Imports block** (after the `goga/topics/editor` block): + +```yaml + - Types: + - TopicIdentity + - TopicHooks + - CreationDraft + - TodoEntryDraft + Usages: + - checkpoints + From: goga/topics/hooks +``` + +**Global Annotations — two insertions** (after the `deleting` practice paragraph): + +``` +Use the `checkpoints` practice for the lifecycle checkpoint patterns +of the topics hooks zone — the identity construction, the amendment +delivery before the content is fixed, and the notification emission +after the moment. +``` + +(inside the zone-description paragraph, after the deletion sentence:) + +``` +The domain opens its lifecycle to tool packages through its hooks +zone: the creation, publication, switch, todo-entry, and deletion +checkpoints fire inside the domain routines — a failing hook of the +soft actions warns and never breaks the operation, and every event +fact comes from the operation's own data. +``` + +**Body — replace the annotations of the six routines below** (everything not shown stays verbatim; `checkpoints` practice lines added to each): + +### `enter_topic_todo` — final signature + annotation + +```yaml +"enter_topic_todo(topic: str, year: str | None = None, branch: str | None = None) -> written: bool": + location: creation.py + annotations: | + Enter the todo of a topic — the editor session with the topic's + todo.md and the write of the saved text, without a commit; the + saved text passes through the todo-entry amendment before the + write, and the completed entry emits its notification. + + `topic`: topic input — a branch name or an already-normalized slug + `year`: optional year as four digits; None means the current year + `branch`: the branch fact of the identity, passed by the calling + operation; None leaves the identity without a branch + fact + `written`: True when the saved text was written; False when the + entry was cancelled + + Apply the `editor-entry` practice for the editor session pattern. + Apply the `topic-paths` practice for the todo-file path pattern. + Apply the `checkpoints` practice for the amendment delivery and + the notification emission. + + Algorithm: + 1. Resolve the todo.md path of the topic via `resolve_topic_file`; + an existing file provides the initial text + 2. Open the editor session via `edit_text` with the initial text + 3. A cancelled entry -> False — the file stays untouched, nothing + is delivered or emitted + 4. A saved text -> deliver the todo-entry amendment over + `TopicHooks`: the identity via `TopicIdentity` — the normalized + slug, the resolved year, `branch` — and amend_todo_entry with + the saved text; the final text of the returned `TodoEntryDraft` + replaces the text being written + 5. Write todo.md with the final text as entered with exactly one + trailing newline — a text already ending in one keeps it — + encoded UTF-8, without a commit -> True + 6. Emit topic_todo_entered — the identity and the final written + text + + Requirements: + - The topic directory exists — directory creation belongs to the + caller + - The write is the last mutation — nothing mutates after it; the + notification emission follows the write and mutates nothing + - The amendment delivers after the save and before the write; the + write carries the final amended text + - An entry completing with a saved write emits + topic_todo_entered; a cancelled entry delivers and emits nothing + - The identity needs no repository reads — the slug, the year, and + the branch arrive as inputs + + Constraints: + - Do not create the topic directory + - Do not commit the write +``` + +### `create_topic` — final Algorithm + new Requirements (annotation head, practice lines, and Constraints otherwise verbatim; practice lines gain the `checkpoints` line) + +```yaml + Apply the `click` practice for the publication ask and the + non-interactive detection. + Apply the `editor-entry` practice for the editor session. + Apply the `topic-paths` practice for the slug, existence, directory + creation, and todo-file path patterns. + Apply the `refs-and-switching` practice for the checkout pattern. + Apply the `checkpoints` practice for the creation amendment and + the creation notification. + + Algorithm: + 1. Preflight, read-only and before any input: [verbatim] + 2. Resolve the todo: [verbatim] + 3. `publish` without a resolved todo -> clean error asking for the + todo, before any mutation + 4. Neither `publish` nor `switch` without a resolved todo -> clean + error [verbatim tail] + 5. The publication ask — [verbatim] + 6. Deliver the creation amendment over `TopicHooks`: build the + identity via `TopicIdentity` — the normalized slug, the + resolved year, `branch_name` as entered — and deliver + amend_creation with the path facts — checked_out as `switch` + dictates, published as the chosen path dictates — the draft + commit message of the path (the no-switch and the publication + paths build one; the switch path delivers None) and the draft + todo when resolved; the amended values of the returned + `CreationDraft` replace the todo and the commit message carried + into the mutation steps + 7. The normal path without `switch`: build one quarantined commit + carrying the todo file todo.md — the path resolved via + `resolve_topic_file` — with the final todo content and the + final commit message on the base commit via + `commit_file_on_base`, capture the returned commit hash, and + plant the branch named as entered at it; the working copy, the + index, and HEAD stay untouched — the caller stays on their + branch; then emit topic_created over `TopicHooks` — the + identity, checked_out False, published False, the final todo, + the final commit message, and the captured commit hash + 8. The normal path under `switch`: create the branch at the base + commit via `create_branch_at_commit` and switch to it via + `checkout_local_branch` — a failed checkout rolls the planted + branch back via `delete_local_branch` (the occupancy oracle + would otherwise block the retry) —, create the topic directory + of the year via `ensure_topic_dir`, and write the todo file + todo.md when a todo resolved; the write is the last mutation of + the path; then emit topic_created — the identity, checked_out + True, published False, the final todo when written, and no + commit facts + 9. The publication path: delegate to `publish_topic` with the + name, the amended todo, the base, the amended template, and the + year — the publication path fires its checkpoints inside the + delegated routine; nothing fires here + 10. Return the single result line + + Requirements: + - [existing bullets verbatim] + - The creation amendment delivers exactly once per creation, + immediately before the first mutation of the chosen path, with + the draft content of that path; the identity-only form is valid + - topic_created fires exactly once per successful creation — from + this routine on the no-switch and switch paths, from the + delegated publication routine after its push succeeds; a failed + creation fires nothing +``` + +### `publish_topic` — final Algorithm + new Requirement (head, practice lines + `checkpoints`, Requirements, Constraints otherwise verbatim) + +```yaml + Algorithm: + 1. Normalize `branch_name` into a slug via `normalize_topic_slug` + 2. [verbatim] + 3. [verbatim] + 4. [verbatim] + 5. [verbatim] + 6. Build the publication commit via `commit_file_on_base` — the + parent commit, the todo.md path resolved via + `resolve_topic_file` as a repository-root-relative posix + string, the final todo content, and the applied commit + message — and capture the returned commit hash + 7. Plant the branch named exactly as entered via + `create_branch_at_commit` + 8. Publish via `push_branch`; a failed publication deletes the + branch via `delete_local_branch` and surfaces one clean error + carrying the reason — nothing fires on the failure + 9. After the successful push, emit over `TopicHooks` with the + identity via `TopicIdentity` — the normalized slug, the + resolved year, `branch_name` as entered: topic_created — + checked_out False, published True, the final todo, the applied + commit message, the captured commit hash — then + topic_published — the same final commit message, commit hash, + and todo + 10. Return the single result line + + Requirements: + - [existing bullets verbatim] + - The creation amendment belongs to the creating orchestration — + this routine fires the publication checkpoints only; a direct + call publishes without amend_creation + - The two publication checkpoints fire only after the push + succeeds, in the order topic_created then topic_published; a + failed publication that rolls back fires nothing +``` + +### `switch_topic` — final Algorithm + new Requirements (head/practice lines + `checkpoints`, otherwise verbatim) + +```yaml + Algorithm: + 1. [verbatim] + 2. [verbatim] + 3. `todo` and the chosen candidate hosts no topic -> clean error — + switching creates nothing; nothing fires + 4. [verbatim idempotent] + 5. [verbatim mutation] + 6. Emit topic_switched over `TopicHooks` — the identity via + `TopicIdentity`: the hosted slug of the chosen candidate when + it hosts one, the resolved year, the branch as entered; the + branch-only identity when it hosts none — and the outcome kind: + already-on-branch, local-checkout, or created-from-remote + 7. With `todo` -> enter the todo of the topic via + `enter_topic_todo`, passing the switched branch as the branch + fact + 8. Return the single result line + + Requirements: + - [existing bullets verbatim] + - topic_switched fires on every completed switch, every outcome + included; the identity degrades to branch-only when the chosen + candidate hosts no topic + - `todo` onto a branch hosting no topic keeps the clean + pre-mutation error and fires nothing + - The identity facts are the operation's own data — the hosted + slug of the chosen candidate, the resolved year, and the branch + name +``` + +### `ensure_topic` — final Algorithm + new Requirements (head/practice lines + `checkpoints`, otherwise verbatim) + +```yaml + Algorithm: + 1. [verbatim] + 2. [verbatim] + 3. No candidate -> the fast creation: normalize `identifier` into + a slug via `normalize_topic_slug`; an empty slug or an + occupancy conflict [verbatim tail]; deliver the creation + amendment over `TopicHooks` with the identity via + `TopicIdentity` — the normalized slug, the resolved year, the + branch name as entered — checked_out True, published False, no + draft commit message (the path builds no commit), and no draft + todo (the todo resolves later through the entry); create the + branch named as entered from the current HEAD and switch to it + via `create_and_switch_branch`; create the topic directory of + the year via `ensure_topic_dir`; with `todo` enter the todo of + the fresh topic via `enter_topic_todo`, passing the branch + name as the branch fact — the entry starts only after the + switch; after the creation completes, emit topic_created over + `TopicHooks` — the identity, checked_out True, published False, + the final todo when the entry resolved one, and no commit + facts + 4. Otherwise -> the switch procedure via `switch_topic` without + the entry — the switch notification fires inside it; with + `todo`, take the hosted topic of the switched work [verbatim + resolution] — and: a hosted topic exists -> enter its todo via + `enter_topic_todo` with the branch fact; the hosting branch + hosts no topic -> an empty slug of its name is a clean error, + otherwise create the topic directory of the year via + `ensure_topic_dir`, then enter the todo of the fresh topic via + `enter_topic_todo` with the derived identity — the slug + normalized from the branch name, the resolved year — as the + topic input and the branch fact; no creation checkpoint fires + for the directory creation + 5. Return the single result line + + Requirements: + - [existing bullets verbatim, except `With `todo`, no step follows + the todo write`, reworded as below] + - With `todo`, no mutation follows the todo write — the creation + notification alone may follow it + - The fast creation delivers the creation amendment exactly once, + immediately before its first mutation, and emits topic_created + after the creation completes — the identity-only amendment form + is the norm on this path + - Directory creation under the todo flag of a topic-less branch + fires no creation checkpoint — the todo entry alone fires its + two + - The todo entries pass the operation's branch fact into the + entry +``` + +### `delete_topics` — final Algorithm + new Requirements (head/practice lines + `checkpoints`, otherwise verbatim) + +```yaml + Algorithm: + 1. Per target, in order: a local branch exists -> capture its + commit via `resolve_ref_commit` first, then delete the local + branch via `delete_local_branch` + 2. An origin twin exists -> delete it on origin via + `delete_remote_branch`; a failed deletion restores the local + branch at the captured commit via `create_branch_at_commit` + and surfaces one clean error — the targets removed before the + failure stay removed and fired theirs + 3. A target with only an origin twin -> delete it on origin via + `delete_remote_branch` + 4. A target with a directory -> remove the topic directory via + `remove_topic_dir` + 5. After the target's full removal, emit topic_deleted over + `TopicHooks` — the identity via `TopicIdentity`: the target's + slug and the resolved year, no branch fact — with the removal + composition: the removed local branch name or None, the + removed origin twin name or None, and whether the topic + directory was removed + 6. Return the single result line + + Requirements: + - [existing bullets verbatim] + - A target fires after its complete removal; targets fully + removed before a later failure fire theirs; a target whose + removal fails midway fires nothing + - No deleted-commit hash is carried — the captured rollback + commit stays internal +``` + +**Footer — append one clause** to the Description: + +``` +; the lifecycle events of the domain fire through its hooks zone +``` + +**`.usages/` files of `goga/topics`:** `todo-entry.md` and +`creating.md` gain one clause each — the written todo.md content (and +the built commit message) is the final amended text when a tool +package subscribes an amendment hook; the remaining files are +unchanged. + +## Dependency Map + +``` +goga/hooks/catalog ─┐ +goga/hooks/dispatch+registry+tools ─┤ (unchanged platform) + ▼ + goga/hooks ──(5 Types + 3 Usages)──▶ goga/topics/hooks ◀──(resolve_topic_dir + topic-paths)── goga/history + ▲ + (TopicIdentity, TopicHooks, │ + CreationDraft, TodoEntryDraft,│ + checkpoints) │ + ▼ + goga/commands/topics+pipeline+hooks ◀── goga/topics (MODIFIED) +``` + +No cycles. Order: catalog → topics/hooks → topics. + +## Verification Checklist + +After implementing each artifact: + +- [ ] `goga lint` passes over all touched CODEMANIFESTs (DSL syntax, references, casing) +- [ ] Cell 2 facade imports: `python -c "from goga.topics.hooks import TopicHooks, TopicIdentity, CreationDraft, TodoEntryDraft, TopicCreated, TopicPublished, TopicSwitched, TopicTodoEntered, TopicDeleted, CreationAmendment, TodoEntryAmendment"` +- [ ] `goga hooks` lists the seven topics actions (tool → domain → action) with no command change +- [ ] `goga schema goga/topics` shows the new subcell with its 11 types and the parent's new dependency +- [ ] Each notification fires exactly at its moment: created once per path (no-switch, switch, publication-after-push, ensure fast); rolled-back publication emits nothing; deleted per fully removed target; `switch --todo` onto a topic-less branch emits nothing +- [ ] Marginal corners: `ensure --todo` on a topic-less branch fires `amend_todo_entry` + `topic_todo_entered` with derived identity and no `topic_created`; manual switch onto a topic-less branch emits branch-only `topic_switched` +- [ ] Amendment semantics: raising hook → buffer discarded; empty/whitespace buffer → rejected at commit; hooks of one tool independent; warning form `hook of tool failed on topics.: `; enumeration continues +- [ ] Final amended drafts land in the artifacts and are reported as the final facts of the corresponding notifications +- [ ] No new git reads for events (identity facts from operation data only; the plant hash from the existing return value) +- [ ] `goga pipeline -t` receives the same events (emission in the domain routines; commands untouched) +- [ ] Docs synchronized: `docs/features/topics/hooks.md` reference replaces the stub; action lists in `docs/features/hooks/index.md`, `docs/features/hooks/hooks.md`, `docs/features/tools/hooks.md` include the topics domain +- [ ] Tests per conventions: `tests/topics/hooks/` covers identity, contexts, amendments, events; flow tests extended; `tests/hooks/catalog/test_catalog.py` asserts the seven records; CLI tested by direct handler calls; mocks only at git/editor boundaries +- [ ] `pytest tests/ -x` passes; `ruff check` over the touched sources passes +- [ ] Existing behavior unchanged: result lines, error surface, mutation order of every flow diff --git a/.goga/history/2026/add-topics-hooks/design.md b/.goga/history/2026/add-topics-hooks/design.md new file mode 100644 index 00000000..6bde8ce6 --- /dev/null +++ b/.goga/history/2026/add-topics-hooks/design.md @@ -0,0 +1,2113 @@ +# Design Document: `add-topics-hooks` + +Seven platform hook actions for the topics lifecycle: five post-fact +notifications over the plain emission, two pre-fixation amendments over a +per-hook staged delivery, home-path identity, no-read contexts. This +document specifies **what to implement and how** — the complete +architectural specification derived from the CODEMANIFEST changes +materialized by the architecture plan. No implementation code is written +at this stage. + +Design target path: `.goga/history/2026/add-topics-hooks/design.md` +(this file). + +--- + +## Contract Changes + +### Changed CODEMANIFEST Files + +- `goga/hooks/catalog/CODEMANIFEST`: the `Requirements` of + `declared_actions` grew from 5 to 12 bullets — seven topics records + appended (all `domain="topics"`, `error_class="soft"`): `topic_created`, + `topic_published`, `topic_switched`, `topic_todo_entered`, + `topic_deleted`, `amend_creation`, `amend_todo_entry`. Algorithm, + Constraints, `Action`, header, and footer unchanged. +- `goga/topics/CODEMANIFEST`: new `Imports` block from `goga/topics/hooks` + (`TopicIdentity`, `TopicHooks`, `CreationDraft`, `TodoEntryDraft` + the + `checkpoints` practice); global annotations extended (the `checkpoints` + practice paragraph and the hooks-zone sentences); annotations of six + routines reworked — `enter_topic_todo` (signature gains + `branch: str | None = None`), `create_topic`, `publish_topic`, + `switch_topic`, `ensure_topic`, `delete_topics`; footer Description gains + the lifecycle-events clause. +- `goga/topics/hooks/CODEMANIFEST`: CREATED — 11 types across + `identity.py`, `contexts.py`, `amendments.py`, `events.py`, with + `Imports` from `goga/hooks` (5 Types + 3 Usages) and `goga/history` + (`resolve_topic_dir` + `topic-paths`). + +### New Entities + +Cell `goga/topics/hooks` (all new code — the directory currently holds +only `CODEMANIFEST`): + +- `TopicIdentity(slug, year, branch)` — `identity.py` — the identity + vocabulary of every topics event; `home_path` composed purely from + `slug` + `year` through `resolve_topic_dir`. +- `TopicCreated(identity, checked_out, published, todo, commit_message, commit_hash)` — `contexts.py` — read-only creation facts. +- `TopicPublished(identity, commit_message, commit_hash, todo)` — `contexts.py` — read-only publication facts. +- `TopicSwitched(identity, outcome)` — `contexts.py` — read-only switch facts; `outcome` is exactly one of `local-checkout`, `created-from-remote`, `already-on-branch`. +- `TopicTodoEntered(identity, text)` — `contexts.py` — read-only todo-entry facts. +- `TopicDeleted(identity, local_branch, origin_twin, directory_removed)` — `contexts.py` — read-only deletion facts. +- `CreationDraft(commit_message, todo)` — `amendments.py` — the shared mutable holder of the creation amendment. +- `TodoEntryDraft(text)` — `amendments.py` — the shared mutable holder of the todo-entry amendment. +- `CreationAmendment(identity, checked_out, published, draft)` — `amendments.py` — the per-hook read view over the live holder, with `amend(commit_message, todo)` buffering a whole replacement. +- `TodoEntryAmendment(identity, draft)` — `amendments.py` — the per-hook read view over the live holder, with `amend(text)` buffering a whole replacement. +- `TopicHooks()` — `events.py` — the checkpoint surface: `amend_creation`, `amend_todo_entry`, `emit_created`, `emit_published`, `emit_switched`, `emit_todo_entered`, `emit_deleted`. + +### Changed Entities + +- `declared_actions` (`goga/hooks/catalog`, `catalog.py`) — the runtime + constant `_DECLARED_ACTIONS` gains the seven topics records; the + routine itself is unchanged. +- `enter_topic_todo` (`goga/topics`, `creation.py`) — signature gains + `branch: str | None = None`; the saved text passes through + `amend_todo_entry` before the write; a completed entry emits + `topic_todo_entered`. +- `create_topic` (`goga/topics`, `creation.py`) — step 6 delivers + `amend_creation` immediately before the first mutation of the chosen + path; the no-switch path captures the planted commit hash and emits + `topic_created`; the switch path emits `topic_created` after its last + mutation; the publication path delegates with the amended values. +- `publish_topic` (`goga/topics`, `publishing.py`) — captures the + publication commit hash; after the successful push emits + `topic_created` then `topic_published`; a rolled-back publication fires + nothing. +- `switch_topic` (`goga/topics`, `switching.py`) — emits `topic_switched` + on every completed switch with the outcome kind; the todo entry receives + the switched branch as the branch fact. +- `ensure_topic` (`goga/topics`, `ensuring.py`) — the fast creation + delivers the identity-only `amend_creation` immediately before its + first mutation and emits `topic_created` after the creation completes; + the todo entries pass the operation's branch fact. +- `delete_topics` (`goga/topics`, `deletion.py`) — emits `topic_deleted` + after each target's full removal with the removal composition. + +### Deleted Entities + +None. + +### Usages and Annotations Changes + +- `goga/topics/hooks/.usages/checkpoints.md` — CREATED (consumer + practice: the checkpoint surface, amend-before-fixed, + emit-after-moment). +- `goga/topics/.usages/todo-entry.md` — one clause added: the written + content is the final amended text when a tool subscribes an amendment + hook. +- `goga/topics/.usages/creating.md` — one clause added: the written + todo.md content and the built commit message are the final amended + values. +- Annotation-level: the `checkpoints` practice line added to the global + annotations and to each of the six reworked routines (see the changed + files above). + +## Applied Fixes + +### Fixed CODEMANIFEST Defects + +None. The contract validation phase found no DSL defects: + +- `goga lint`: 77 cells, 0 errors. +- Structure: header/body/footer with `---` separators, key casing, + `location` values (same-level `.py` files), and signature types are + valid per `goga-cell`. +- All `Imports` targets resolve: the five types exist on the + `goga/hooks` facade (`goga/hooks/__init__.py` re-exports + `HookRegistry`, `emit_hook_event`, `wrap_context`, `build_hook_arguments`, + `declared_actions`); `resolve_topic_dir` exists on the `goga/history` + facade; all imported usages exist (`goga/hooks/.usages/{declaring-actions, + per-tool-delivery,registering-hooks}.md`, `goga/history/.usages/topic-paths.md`, + `.goga/usages/conventions.md`). +- Entity/Routine selection and practice connection conform to + `goga-cookbook`: every connected practice is referenced in at least one + annotation; no unreferenced practice; no mutation (`::`) or embedding + (`->`) is used, correctly. +- The four consistency dimensions (interface↔type, type↔mutation, + interface↔interface, annotations↔entity) hold — verified against the + actual platform surface (`goga/hooks/dispatch/emit.py`, + `goga/hooks/registry/state.py`) and the onboarding per-tool-delivery + precedent (`goga/onboarding/participation/participation.py`). + +The items below are **design decisions inside the contract's explicit +freedom** (recorded here, not CODEMANIFEST defects). The most important: + +1. **One registry per run** (D1): `TopicHooks` shares a module-level + lazily-built `HookRegistry` in `events.py` — the CODEMANIFEST leaves + "the transport of the shared `HookRegistry`" an implementation detail, + and per-instance registries would multiply the package enumeration + across the nested public calls (`ensure_topic` → `switch_topic` → + `enter_topic_todo`; `create_topic` → `publish_topic`). +2. **Switch branch fact** (D2): the identity's `branch` for + `topic_switched` is the branch the working copy is on after the + switch — the candidate's display name for local candidates, its short + name for a remote-tracking candidate — the same fact step 7 passes + into `enter_topic_todo` ("the switched branch"). +3. **Effective commit message** (D3): a hook may null the draft commit + message (`amend(None, ...)`); on a commit-building path the built-in + domain default then applies (the existing `_plant_topic_branch` + fallback), and the emission reports the message that lands in git. +4. **Applied draft message** (D4): `amend_creation` receives the draft + message with the `{slug}` placeholder already replaced — the tool sees + and amends the actual text. +5. **Nulled todo on a todo-requiring path** (D5): the no-switch creation + path re-raises its clean "needs a todo" error when the final amended + todo is None (nothing has mutated yet — "a failed creation fires + nothing" holds); the publication path's own guard covers its case. +6. **Private richer entry** (D6): `_enter_topic_todo` (the existing + unwrapped mirror) returns the final written text (`str | None`) while + the public `enter_topic_todo` keeps `-> written: bool` — `ensure_topic` + needs the final todo for its `topic_created` emission. +7. **Error class honored from the catalog** (D7): the amendment walk + resolves the address against `declared_actions()` and treats a raised + hook failure per the record's error class exactly like + `emit_hook_event` (all seven topics actions are soft today); the + empty-amendment rejection always warns and continues — the CODEMANIFEST + fixes that behavior unconditionally. +8. **Advisory amendment on the ensure fast path** (D8): the fast creation + of `ensure_topic` delivers the identity-only creation amendment but + deliberately does not read the returned holder — an amended todo does + not land on this path. The todo resolves later through the entry's + own `amend_todo_entry`, which owns the written text, and + `commit_message` stays None (the path builds no commit). The + `checkpoints` practice states this so a tool author is not surprised. + +## Entity Interaction and Data Flow + +### Interaction Diagram + +``` + goga/commands (unchanged CLI + pipeline) + │ create_topic / switch_topic / ensure_topic / + │ publish_topic / delete_topics / enter_topic_todo + ▼ + ┌───────────────────────┐ + │ goga/topics │ domain flows (six routines fire + │ creation / switching │ checkpoints at their moments) + │ publishing / ensuring│──── TopicHooks.amend_* (pre-fixation) + │ deletion │──── TopicHooks.emit_* (post-moment) + └──────────┬────────────┘ + │ from .hooks (relative import) + ▼ + ┌──────────────────────────────┐ + │ goga/topics/hooks │ the hooks zone + │ identity.py TopicIdentity│◀── resolve_topic_dir (goga/history) + │ contexts.py 5 contexts │ + │ amendments.py drafts+views │ + │ events.py TopicHooks + │ + │ _RUN_REGISTRY│◀── HookRegistry, emit_hook_event, + └──────────┬───────────────────┘ wrap_context, build_hook_arguments, + │ declared_actions (goga/hooks facade) + ▼ + ┌──────────────────────────────┐ + │ goga/hooks (platform) │ catalog.py (+7 records), + │ catalog / registry / │ registry, dispatch — unchanged code + │ dispatch / tools │ + └──────────────────────────────┘ +``` + +### Data Flows + +**Amendment flow (pre-fixation)** — e.g. `create_topic` step 6: + +1. The domain routine composes `TopicIdentity(slug, year, branch)` from + its own data (no repository reads) and calls + `TopicHooks().amend_creation(identity, checked_out, published, draft_message, draft_todo)`. +2. `events.py` obtains the shared run registry (`_run_registry()` — + built once per process run), resolves + `Action("topics", "amend_creation", "soft")` against `declared_actions()`. +3. A `CreationDraft` holder is created with the draft values. +4. Per subscription of the address, in enumeration order: a fresh + `CreationAmendment` view over the live holder is wrapped by + `wrap_context`, projected by `build_hook_arguments` with the tool's + `self_context`, and called. +5. A returned hook's buffer replaces the holder content (whole + replacement); a raised hook or an empty/whitespace buffer field is a + warning (`hook of tool failed on topics.amend_creation: + `) and the walk continues. +6. The holder returns to the domain, which reads the final + `commit_message`/`todo` and fixes them into the artifacts (commit + build / delegation / file write). + +**Notification flow (post-moment)** — e.g. `publish_topic` step 9: + +1. The routine composes `TopicIdentity` and the final facts (applied + message, captured commit hash, final todo). +2. `TopicHooks().emit_created(...)` builds the frozen `TopicCreated` + context and calls + `emit_hook_event(_run_registry(), "topics", "topic_created", context_for)`. +3. `emit_hook_event` (platform, unchanged) builds the registry once, + resolves the address, and delivers the **same context instance** to + every subscribed hook through the per-tool delivery proxy; a failing + hook of the soft action is a warning and the command continues. + +**Catalog flow**: `declared_actions()` gains the seven records; the +`goga hooks` inspection command and the address resolution of every +checkpoint read the same list — no command change. + +### Entity Dependencies + +- `goga/hooks/catalog` — no imports (data only). Everything downstream + resolves addresses against it. +- `goga/topics/hooks` — imports `goga/hooks` (facade) and + `goga/history` (`resolve_topic_dir`). Imports neither its parent + domain package nor any domain module — no cycle. +- `goga/topics` — imports `goga/topics/hooks` (the four types) in + `creation.py`, `publishing.py`, `switching.py`, `ensuring.py`, + `deletion.py` via the relative `from .hooks import ...`. +- Design order (implementation): `catalog.py` records → + `goga/topics/hooks` (`identity.py` → `contexts.py` → `amendments.py` + → `events.py` → `__init__.py`) → domain routine wiring → tests → + docs. + +## Code Stack Trace + +### Trace: `declared_actions` (catalog extension) + +#### Chain + +1. **Input**: any checkpoint (`emit_hook_event`, the amendment walk) or + the `goga hooks` command calls `declared_actions()`. +2. **Step**: the routine sorts `_DECLARED_ACTIONS` by + `(domain, name)` → checkpoint: the seven appended records sort as + `amend_creation`, `amend_todo_entry`, `topic_created`, `topic_deleted`, + `topic_published`, `topic_switched`, `topic_todo_entered` after the + onboarding and statuses groups — deterministic and complete ✓. +3. **Step**: address resolution in `emit.py:72-77` + (`next(entry for entry in declared_actions() ...)`) finds + `Action(domain="topics", name=..., error_class="soft")` for every + address the zone emits → checkpoint: every emitted address resolves ✓ + (today `emit_hook_event` raises `ValueError` on an unknown address — + the records must exist before any checkpoint fires, hence the + implementation order). +4. **Output**: the 10-record list; `goga hooks` lists the topics domain + with no command change ✓. + +#### Checkpoint Summary + +- Determinism and completeness: passed — maintained data, `sorted()`. +- Runtime gap: `_DECLARED_ACTIONS` (catalog.py:40-44) currently carries + 3 records — the implementation appends the seven (see Algorithm + Design). + +### Trace: `TopicIdentity` construction and `home_path` + +#### Chain + +1. **Input**: a domain routine builds + `TopicIdentity(slug=..., year=..., branch=...)` — `slug` already + normalized by the caller, `year` already resolved (four digits), + `branch` the operation's branch fact. +2. **Step**: frozen kw-only dataclass stores the three fields → + checkpoint: kw-only + frozen matches the `Action`/`Stage` data-model + precedent and the conventions ✓. +3. **Step**: a hook (or the domain) reads `identity.home_path` → + `None` when `slug is None` (branch-only form); otherwise + `resolve_topic_dir(slug, year).as_posix()` → checkpoint: + `resolve_topic_dir` re-normalizes its input — idempotent for an + already-normalized slug ✓; the slug is non-None on this branch, so + the empty-slug `ValueError` is unreachable ✓; nothing is read or + created (pure composition) ✓; the result is the posix string + `.goga/history//` the contract fixes ✓. +4. **Output**: the three attribute reads plus the composed + `home_path`; attribute reads pass through the delivery proxy + (`wrap_context` mediates plain attribute access) ✓. + +#### Checkpoint Summary + +- Type flow: `str | None` / `str` / `str | None` fields match every + context constructor that carries `identity` — passed. +- No repository reads at composition — passed. + +### Trace: the five notification contexts (`contexts.py`) + +#### Chain + +1. **Input**: an `emit_*` method constructs the context from the values + the caller passed. +2. **Step**: frozen kw-only dataclass stores the facts → checkpoint: + read-only — a hook observes and cannot alter (frozen; assignment also + blocked on the delivery proxy) ✓. +3. **Step**: a hook reads `context.identity.home_path`, + `context.commit_hash`, ... → plain attribute/property reads pass + through the proxy ✓; `TopicSwitched.outcome` is exactly one of the + three fixed kinds (the emitting routine constructs it from its + outcome mapping — see `switch_topic`) ✓. +4. **Output**: observed facts only; no method surface, no write path. + +#### Checkpoint Summary + +- Interface↔type consistency with `emit_*` signatures — passed (field + lists identical to the method parameters beyond `identity`). +- `TopicCreated.commit_message`/`commit_hash` "present exactly when the + path builds a commit" — passed by construction on every emitting path + (D3 keeps the message truthful). + +### Trace: `CreationDraft` / `TodoEntryDraft` holders + +#### Chain + +1. **Input**: `amend_creation` creates `CreationDraft(commit_message=..., + todo=...)` with the path's draft values. +2. **Step**: kw-only dataclass (mutable — the `StatusRegistry` + precedent) stores the content; the properties `commit_message`/`todo` + read the live fields → checkpoint: content changes only through the + delivery commit (`_commit`, private-by-convention) — the delivered + views expose no write path, and `wrap_context` blocks assignment on + the proxy ✓. +3. **Step**: after each hook's buffer commits, later views read the + updated fields ("a later hook sees the committed amendments of the + earlier hooks") ✓. +4. **Output**: the domain reads `draft.commit_message` / `draft.todo` / + `draft.text` after the walk — the final amended values. + +#### Checkpoint Summary + +- Read-passthrough of the views — passed (views hold the same holder + instance). +- Single mutation point — passed (the `_commit` of the walk). + +### Trace: `CreationAmendment.amend` / `TodoEntryAmendment.amend` + +#### Chain + +1. **Input**: a hook calls `context.amend(commit_message=..., todo=...)` + (or `context.amend(text=...)`) on the delivered proxy. +2. **Step**: the method call passes through `wrap_context` and runs on + the view; the view buffers the whole replacement into its private + `_buffered` field (`tuple[str | None, str | None] | None` / + `str | None`) → checkpoint: the holder is untouched ("changes nothing + until the delivery commits it") ✓; a repeated `amend` overwrites the + buffer (whole replacement, last wins) ✓. +3. **Step**: the hook returns; the walk inspects `_buffered` → commit / + reject decision (next trace). +4. **Output**: no return value; the buffer rides on the view. + +#### Checkpoint Summary + +- Constraint "do not cancel, redirect, or defer the operation" — the + view carries no such method — passed. +- Buffer isolation per hook — passed (a fresh view per subscription). + +### Trace: `TopicHooks.amend_creation` — the per-hook walk + +#### Chain + +1. **Input**: the domain calls + `TopicHooks().amend_creation(identity, checked_out, published, commit_message, todo)`. +2. **Step**: `_run_registry()` returns the shared run registry, + building it on first use (`HookRegistry()` + `build_once()`) → + checkpoint: one build per run whatever the number of checkpoints + (D1); a broken package import surfaces here as the single fatal + `ImportError` ✓. +3. **Step**: resolve the address against `declared_actions()` — an + unknown address is a clean `ValueError` of the emitting side + (mirroring `emit.py:72-77`); the record's `error_class` is read ✓. +4. **Step**: create the shared `CreationDraft` with the draft values. +5. **Step**: for each `subscription` of + `registry.subscriptions_for("topics", "amend_creation")`, in + enumeration order — **no grouping by tool** (the per-hook refinement + of the `per-tool-delivery` practice): + 1. build a fresh `CreationAmendment(identity=..., checked_out=..., + published=..., draft=holder)` **outside** the failure intercept + (a crashing view builder is the emitting side's bug, never a hook + failure — the `context_for` placement of `emit.py:83-85`); + 2. inside one intercept (`Exception` only, mirroring + `emit.py:87-95`): `wrap_context(view)` → + `build_hook_arguments(subscription.hook, proxy, + registry.self_context(subscription.tool))` → + `subscription.hook(**arguments)` → checkpoint: the hook receives + values only for the declared offered names (`context`, `self`) ✓; + the tool's `self` context is the same instance across every + checkpoint of the run (shared registry) ✓. +6. **Step**: a hook that raised → its buffer is discarded; per the + record's error class: soft — `logger.warning("hook %s of tool %s + failed on topics.amend_creation: %s", name, tool, reason)` and the + walk continues; hard — `ValueError` in the same shape as + `emit.py:97-99` (dead branch today — all seven records are soft). +7. **Step**: a hook that returned → read `view._buffered`; `None` → + nothing to commit, next subscription. Otherwise reject the whole + buffer when a structurally present field is empty or whitespace-only + (`(cm is not None and not cm.strip()) or (todo is not None and not + todo.strip())`) → the same warning with the reason + `"the buffered amendment is empty or whitespace-only"`, walk + continues. Else `holder._commit((cm, todo))` — the whole replacement. +8. **Output**: the holder returns to the caller — the last committed + buffer, or the original draft values when nothing committed. An + address without subscriptions returns the original values — not an + error ✓. + +#### Checkpoint Summary + +- Per-hook independence ("two hooks of one tool never share a buffer or + a failure") — passed: a fresh view per subscription, commit decided + per subscription. +- Delivery never filtered — passed ("do not skip a subscriber"). +- No post-walk application — passed (the caller fixes the final draft + itself). + +### Trace: `TopicHooks.amend_todo_entry` + +Identical chain with `TodoEntryDraft`/`TodoEntryAmendment`, address +`topics.amend_todo_entry`, single `text` field; the buffer rejection +covers `text is None or not text.strip()` (the contract types `text` as +`str`; a None buffer value is treated as the rejection case). Output: +the holder with the final text. + +### Trace: `TopicHooks.emit_created` / `emit_published` / `emit_switched` / `emit_todo_entered` / `emit_deleted` + +#### Chain + +1. **Input**: a domain routine calls the emit method with the final + facts. +2. **Step**: build the frozen context from the values. +3. **Step**: `emit_hook_event(_run_registry(), "topics", "", + context_for=lambda _tool: context)` — the **same instance** for every + receiving tool, each through its own delivery proxy → checkpoint: + matches the CODEMANIFEST ("the context view of every receiving tool + reads the same instance through the delivery proxy") and the + `declaring-actions` practice ("return the same instance to share") ✓; + the platform performs the registry build, the address resolution, the + per-tool views, the projection, and the soft-failure warning ✓. +4. **Output**: `None` — fire-and-forget; nothing collected ✓. + +#### Checkpoint Summary + +- Signature alignment with `emit_hook_event(registry, domain, action, + context_for)` — passed. +- All five addresses exist in the catalog after the extension — passed. + +### Trace: `enter_topic_todo` (changed) + +#### Chain + +1. **Input**: `enter_topic_todo(topic, year=None, branch=None)` — from + the CLI-driven flows via `switch_topic`/`ensure_topic` or direct + consumer call. +2. **Step**: `resolved_year = year or current_year()`; resolve the + todo.md path via `resolve_topic_file`; read the initial text if the + file exists (UTF-8, replacement decode) → unchanged ✓. +3. **Step**: `saved = edit_text(initial)` → `None` (cancelled) → + return `False` — nothing delivered, nothing emitted ✓. +4. **Step**: identity = + `TopicIdentity(slug=normalize_topic_slug(topic), year=resolved_year, + branch=branch)` → checkpoint: pure inputs — "the identity needs no + repository reads" ✓ (`normalize_topic_slug` already imported in + `creation.py`). +5. **Step**: `draft = TopicHooks().amend_todo_entry(identity, saved)` — + delivery after the save, before the write ✓. +6. **Step**: `_write_todo(topic, resolved_year, draft.text)` — the + single-trailing-newline rule applies to the final amended text → + the write is the last mutation ✓. +7. **Step**: `TopicHooks().emit_todo_entered(identity, draft.text)` — + the final written text is the reported text ✓; returns `True`. +8. **Output**: `written: bool`; the file carries the amended text; the + notification fired. + +Internal (D6): the existing unwrapped mirror `_enter_topic_todo(topic, +year, branch)` returns the final written text (`str | None`); the public +wrapper returns `written is not None`. + +#### Checkpoint Summary + +- Cancelled entry: no delivery, no emission, file untouched — passed. +- Emission follows the write and mutates nothing — passed. +- Error boundary: the `OSError` wrapper unchanged (the checkpoint code + performs no I/O). + +### Trace: `create_topic` (changed) + +#### Chain + +1. **Input**: `create_topic(branch_name, base_ref, todo, publish, + commit_message, year, switch)` — unchanged signature. +2. **Steps 1–5** (preflight, todo resolution, publish/no-switch todo + guards, publication ask): unchanged → checkpoint: every decision + precedes the first mutation ✓; a failing preflight fires nothing ✓. +3. **Step 6** (new — the amendment): `identity = + TopicIdentity(slug, resolved_year, branch_name)`; the chosen path is + now known (`publishing = _publication_asked(...)`); the draft facts: + - `checked_out = switch and not publishing`, `published = publishing` + (D12 — the path facts); + - draft message: `publishing` → the applied template + `(commit_message or _DEFAULT_COMMIT_MESSAGE).replace("{slug}", slug)` + — the `or` predicate deliberately normalizes an empty template to + the built-in default here, so the delegated publication lands the + default; a direct `publish_topic` call keeps its own + `is not None` predicate — behavior preserved there; + no-switch → the applied built-in default; switch path → `None` (D4); + - draft todo: `resolved_todo`. + `draft = TopicHooks().amend_creation(identity, checked_out, + published, draft_message, resolved_todo)`; then + `final_todo = draft.todo`, `final_message = draft.commit_message` + → checkpoint: delivered exactly once, immediately before the first + mutation of the chosen path ✓; identity-only form valid (switch path + without a todo) ✓. +4. **Step 7** (no-switch): if `final_todo is None` → the same clean + "the local creation needs a todo" error (D5 — nothing has mutated); + else `commit = _plant_topic_branch(branch_name, final_todo, + base_commit, slug, resolved_year, final_message)` — the helper + already returns the commit hash; `final_message` is None only when a + hook nulled it → the helper's built-in default applies (D3); then + `emit_created(identity, checked_out=False, published=False, + todo=final_todo, commit_message=final_message or applied_default, + commit_hash=commit)` → checkpoint: the reported message is the + message that lands in git ✓. +5. **Step 8** (switch): `_enter_fresh_branch(branch_name, base_commit, + final_todo, year, resolved_year)` unchanged except it writes + `final_todo`; after it returns → `emit_created(identity, + checked_out=True, published=False, todo=final_todo, + commit_message=None, commit_hash=None)` — "the final todo when + written, and no commit facts" ✓. +6. **Step 9** (publication): `publish_topic(branch_name, final_todo, + base_ref, final_message, year)` — the amended todo and the amended + message travel as the template (the helper's `.replace("{slug}", ...)` + is a no-op on an applied/amended text without the placeholder); + nothing fires here — the delegated routine fires its checkpoints + after its push ✓. +7. **Output**: the unchanged single result line; emissions add no + output. + +#### Checkpoint Summary + +- "topic_created fires exactly once per successful creation" — no-switch + and switch paths emit here; the publication path's emission lives in + the delegate — passed. +- Amendment before the first mutation, once — passed. +- Existing behavior (result lines, error surface, mutation order) + unchanged — passed. + +### Trace: `publish_topic` (changed) + +#### Chain + +1. **Input**: `publish_topic(branch_name, todo, base_ref, + commit_message, year)` — unchanged signature (also the delegate of + the publication path). +2. **Steps 1–5** (guards, occupancy, origin, base resolution): + unchanged; nothing fires before the mutation chain ✓. +3. **Step 6**: compute `applied = (commit_message if commit_message + is not None else _DEFAULT_COMMIT_MESSAGE).replace("{slug}", slug)` + once; `commit = _plant_topic_branch(branch_name, todo, base_commit, + slug, resolved_year, applied)` — captures the returned hash → + checkpoint: "no new git reads for events (the plant hash from the + existing return value)" ✓. +4. **Step 7–8**: plant + push; a failed push rolls the branch back and + surfaces the clean error — **nothing fires** ✓. +5. **Step 9** (after the successful push): + `identity = TopicIdentity(slug, resolved_year, branch_name)`; + `emit_created(identity, checked_out=False, published=True, todo=todo, + commit_message=applied, commit_hash=commit)`; then + `emit_published(identity, commit_message=applied, + commit_hash=commit, todo=todo)` → checkpoint: the order + topic_created → topic_published ✓; "a direct call publishes without + amend_creation" — no amendment here ✓. +6. **Output**: the unchanged result line. + +#### Checkpoint Summary + +- Both contexts carry the same final message/hash/todo — passed. +- Rollback fires nothing — passed. + +### Trace: `switch_topic` (changed) + +#### Chain + +1. **Input**: `switch_topic(identifier, todo, year)` — unchanged + signature. +2. **Steps 1–5** (terminal guard, resolution, prompt, no-topic guard, + cleanliness probe, checkout/creation): unchanged; the no-topic guard + fires before any mutation — nothing fires ✓. +3. **Step 5 reworked internally**: `_apply_candidate(chosen)` now + returns `(line, outcome)` with `outcome` ∈ {`already-on-branch`, + `local-checkout`, `created-from-remote`} — the three existing return + branches map one-to-one onto the kinds; the lines are unchanged. +4. **Step 6** (new): `branch_fact = _short_name(chosen.branch) if + chosen.remote else chosen.branch` (the post-switch local branch — + D2); `identity = TopicIdentity(slug=chosen.topic, year=resolved_year, + branch=branch_fact)` — `chosen.topic` may be None → the branch-only + identity ✓; `TopicHooks().emit_switched(identity, outcome)` — every + outcome included, the idempotent already-on-branch too ✓. +5. **Step 7**: with `todo` → `enter_topic_todo(chosen.topic, year, + branch=branch_fact)` — "passing the switched branch as the branch + fact" ✓ (the current call gains the kwarg). +6. **Output**: the unchanged single result line. + +#### Checkpoint Summary + +- "topic_switched fires on every completed switch" — the emission sits + after `_apply_candidate` on every path — passed. +- Branch-only identity when the candidate hosts no topic — passed. +- Identity facts from the operation's own data (candidate's hosted slug, + resolved year, branch name) — no git reads — passed. + +### Trace: `ensure_topic` (changed) + +#### Chain + +1. **Input**: `ensure_topic(identifier, todo, year)` — unchanged + signature (also the pipeline's entry: + `commands/pipeline/pipeline.py:239`). +2. **Step 1–2** (terminal guard, candidate resolution): unchanged. +3. **Step 3 — the fast creation** (`_create_fresh_work`): slug guard and + the occupancy oracles first (clean errors, nothing fires); then + `identity = TopicIdentity(slug, resolved_year, identifier)` (the + branch name as entered — the identifier becomes the branch) and + `TopicHooks().amend_creation(identity, checked_out=True, + published=False, commit_message=None, todo=None)` — the identity-only + form, delivered immediately before `create_and_switch_branch` (the + first mutation) ✓; the returned holder is deliberately not read — + on this path the creation amendment observes only: an amended todo + does not land here (the todo resolves later through the entry's own + `amend_todo_entry`, which owns the written text), and + `commit_message` is None because the path builds no commit (D8); + `create_and_switch_branch(identifier)` → + `ensure_topic_dir(identifier, year)` → with `todo`: + `final_todo = _enter_topic_todo(identifier, year, branch=identifier)` + (the private mirror, D6 — "passing the branch name as the branch + fact"); after the creation completes → + `emit_created(identity, checked_out=True, published=False, + todo=final_todo, commit_message=None, commit_hash=None)` — "the final + todo when the entry resolved one, and no commit facts" ✓. +4. **Step 4 — the switch path**: `switch_topic(identifier, todo=False, + year)` — the switch notification fires inside it ✓; then with `todo` + `_enter_switched_todo(candidates, year)` reworked: `current = + resolve_current_branch_name()`; a hosted topic exists → + `enter_topic_todo(topic, year, branch=current)`; a hosting branch + without a topic → `ensure_topic_dir(current, year)` then + `enter_topic_todo(current, year, branch=current)` (the derived + identity — the topic input is the branch name, which normalizes to + the slug) — "no creation checkpoint fires for the directory + creation" ✓ (no `amend_creation`/`emit_created` on this branch). +5. **Output**: the unchanged single result line. + +#### Checkpoint Summary + +- "The fast creation delivers the creation amendment exactly once, + immediately before its first mutation" — passed. +- "`ensure --todo` on a topic-less branch fires `amend_todo_entry` + + `topic_todo_entered` with derived identity and no `topic_created`" — + passed (the entry fires its own pair; the directory creation fires + nothing). +- One registry across `ensure → switch → entry` (D1) — the enumeration + runs once. + +### Trace: `delete_topics` (changed) + +#### Chain + +1. **Input**: `delete_topics(targets, year)` — unchanged signature; + targets resolved and confirmed by the caller. +2. **Steps 1–4** (per target: capture commit, delete local, delete + origin twin with restore-on-failure, remove the directory): + unchanged — a failure mid-target restores and raises before the + emission → "a target whose removal fails midway fires nothing" ✓. +3. **Step 5** (new, inside the per-target loop after the directory + removal): `directory_removed = remove_topic_dir(...) if target.has_dir + else False` (the actual return — D-d7); `identity = + TopicIdentity(slug=target.topic, year=resolved_year, branch=None)`; + `TopicHooks().emit_deleted(identity, local_branch=target.branch, + origin_twin=target.remote, directory_removed=directory_removed)` → + checkpoint: fires after the target's **complete** removal ✓; targets + fully removed before a later failure already fired theirs (the + emission precedes the next target's processing) ✓; no commit hash + carried (the captured rollback commit stays local) ✓. +4. **Output**: the unchanged single result line. + +#### Checkpoint Summary + +- Per-target timing — passed. +- The restore path (failure) emits nothing for the failing target — + passed. + +## Algorithm Design + +### `goga/hooks/catalog/catalog.py` — `_DECLARED_ACTIONS` extension + +**Responsibility**: the maintained data every address resolution reads. + +**Algorithm:** +``` +1. Append seven records to _DECLARED_ACTIONS (topics domain, soft class): + amend_creation, amend_todo_entry, topic_created, topic_deleted, + topic_published, topic_switched, topic_todo_entered + → the list stays in (domain, name) sorted order; declared_actions() + behavior is otherwise untouched +``` + +**Errors:** none (data). + +**Edge Cases:** none — the routine's `sorted()` already fixes the output +order regardless of insertion order. + +### `goga/topics/hooks/identity.py` — `TopicIdentity` + +**Responsibility**: the identity vocabulary of every topics event. + +**Algorithm:** +``` +1. @dataclass(frozen=True, kw_only=True) with fields slug: str | None, + year: str, branch: str | None +2. home_path property: + IF slug is None -> None + ELSE -> resolve_topic_dir(slug, year).as_posix() +``` + +**Errors:** none — the empty-slug `ValueError` of `resolve_topic_dir` +is unreachable (`slug` non-None on the composing branch, and +re-normalization of a normalized slug is the identity). + +**Edge Cases:** +- Branch-only form (`slug=None`) → `home_path` None. +- Deletion form (`branch=None`) → the removal composition carries the + branch names instead. + +### `goga/topics/hooks/contexts.py` — the five contexts + +**Responsibility**: read-only facts of one completed operation each. + +**Algorithm:** +``` +1. Five @dataclass(frozen=True, kw_only=True) classes, fields exactly + as the signatures declare: + TopicCreated(identity, checked_out, published, todo, commit_message, commit_hash) + TopicPublished(identity, commit_message, commit_hash, todo) + TopicSwitched(identity, outcome) + TopicTodoEntered(identity, text) + TopicDeleted(identity, local_branch, origin_twin, directory_removed) +2. Plain data fields (attribute reads suffice; no computed members) +``` + +**Errors:** none. + +**Edge Cases:** `TopicSwitched.outcome` is one of the three fixed +kinds by construction of the emitting routine. + +### `goga/topics/hooks/amendments.py` — holders and views + +**Responsibility**: the shared draft holders and the per-hook views. + +**Algorithm:** +``` +CreationDraft / TodoEntryDraft: +1. @dataclass(kw_only=True) — mutable (the StatusRegistry precedent): + CreationDraft(commit_message: str | None, todo: str | None) + TodoEntryDraft(text: str) +2. _commit(values) — private; the single mutation point: replaces the + whole content from the walk + +CreationAmendment / TodoEntryAmendment: +1. @dataclass(kw_only=True) with the signature fields, the holder stored + under the private field name `_draft` (the walk in events.py is the + sole constructor caller: CreationAmendment(identity=..., + checked_out=..., published=..., _draft=holder) — the kw name is an + internal wiring detail; the CODEMANIFEST signature documents the + input semantically), plus a private _buffered field (init=False, + repr=False, default None) +2. commit_message / todo / text properties read through self._draft + (the live holder) — the holder is never a public attribute of the + view, so a delivered view exposes no write path to it: the proxy + blocks assignment on the view, and reaching `_draft` deliberately + is out-of-contract usage (the same cooperative trust the platform + gives the `self` context) +3. amend(...) -> assigns self._buffered = the whole replacement + (CreationAmendment: (commit_message, todo); TodoEntryAmendment: text); + no holder contact +``` + +**Errors:** none — buffering never raises. + +**Edge Cases:** +- A hook calling `amend` twice → the last buffer wins. +- `amend(None, None)` → a lawful whole replacement to the identity-only + form (commits; the path guards decide the consequences — D5). + +### `goga/topics/hooks/events.py` — `TopicHooks` and the run registry + +**Responsibility**: the checkpoint surface over the platform facade. + +**Algorithm:** +``` +_RUN_REGISTRY: HookRegistry | None = None # module state, one per run + +_run_registry(): +1. IF _RUN_REGISTRY is None: create HookRegistry(), build_once(), + store it +2. return _RUN_REGISTRY + → one enumeration per process run; every TopicHooks instance and + every checkpoint shares it (D1) + +TopicHooks(): +1. No state — cheap construction; no enumeration, no imports at init + +amend_creation(identity, checked_out, published, commit_message, todo): +1. registry = _run_registry() +2. record = resolve ("topics", "amend_creation") against declared_actions() + -> None is a clean ValueError of the emitting side +3. holder = CreationDraft(commit_message=commit_message, todo=todo) +4. FOR subscription IN registry.subscriptions_for("topics", "amend_creation"): + view = CreationAmendment(identity=..., checked_out=..., published=..., + _draft=holder) # outside the intercept + TRY: + proxy = wrap_context(view) + args = build_hook_arguments(subscription.hook, proxy, + registry.self_context(subscription.tool)) + subscription.hook(**args) + EXCEPT Exception AS reason: + IF record.error_class == "hard": RAISE ValueError( + "hook {name} of tool {tool} failed on topics.amend_creation: {reason}") + WARN "hook {name} of tool {tool} failed on topics.amend_creation: {reason}" + CONTINUE # buffer discarded + IF view._buffered is not None: + IF a structurally present field is empty/whitespace-only: + WARN ... ": the buffered amendment is empty or whitespace-only" + CONTINUE # whole buffer rejected + holder._commit(view._buffered) # whole replacement +5. return holder + +amend_todo_entry(identity, text): + the same walk over TodoEntryDraft/TodoEntryAmendment, address + topics.amend_todo_entry; the single-field rejection covers + text is None or not text.strip() + +emit_created / emit_published / emit_switched / emit_todo_entered / emit_deleted: +1. context = the frozen context from the values +2. emit_hook_event(_run_registry(), "topics", "", + context_for=lambda _tool: context) + → the same instance per tool; the platform owns resolution, delivery, + and the soft warning +``` + +**Errors:** +- `ValueError` (unknown address) → the emitting side's bug; never a hook + outcome. +- `ValueError` (hard-class raised hook) → dead branch today; identical + shape to `emit_hook_event`. +- `ImportError` from `build_once` (a broken tool package import) → the + single fatal case, surfacing from the platform unchanged. + +**Edge Cases:** +- An address without subscriptions → the walk returns the original + draft; the emission delivers nothing. +- Two hooks of one tool → independent views, buffers, and failures. + +### `goga/topics/hooks/__init__.py` — the facade + +**Algorithm:** +``` +1. Re-export the eleven types from their modules (relative imports): + TopicIdentity, TopicCreated, TopicPublished, TopicSwitched, + TopicTodoEntered, TopicDeleted, CreationDraft, TodoEntryDraft, + CreationAmendment, TodoEntryAmendment, TopicHooks +2. __all__ carries exactly the eleven names, alphabetically +3. Package docstring: the hooks-zone owner description (the CODEMANIFEST + Description voice); importing the package imports no tool package + and enumerates nothing +``` + +### `goga/topics/creation.py` — `enter_topic_todo`, `create_topic` + +**Algorithm** — per the Code Stack Trace sections above; the concrete +wiring: + +``` +enter_topic_todo(topic, year=None, branch=None): +1-3. unchanged (path resolve, prefill read, editor session); + cancelled -> False (nothing delivered or emitted) +4. identity = TopicIdentity(slug=normalize_topic_slug(topic), + year=resolved_year, branch=branch) +5. draft = TopicHooks().amend_todo_entry(identity, saved) +6. _write_todo(topic, resolved_year, draft.text) -> the final text +7. TopicHooks().emit_todo_entered(identity, draft.text); return True + +_create_topic — insert between the ask and the path branches: + publishing = _publication_asked(publish, resolved_todo) + identity = TopicIdentity(slug, resolved_year, branch_name) + draft = TopicHooks().amend_creation(identity, + checked_out=switch and not publishing, + published=publishing, + commit_message=, + todo=resolved_todo) + # the applied message uses the `or` predicate: an empty template + # normalizes to the built-in default before the delegation + final_todo = draft.todo; final_message = draft.commit_message +no-switch branch: + IF final_todo is None -> the "needs a todo" clean error (D5) + commit = _plant_topic_branch(branch_name, final_todo, base_commit, + slug, resolved_year, final_message) + TopicHooks().emit_created(identity, checked_out=False, published=False, + todo=final_todo, + commit_message=final_message or , + commit_hash=commit) +switch branch: _enter_fresh_branch(..., final_todo, ...) then + TopicHooks().emit_created(identity, checked_out=True, published=False, + todo=final_todo, commit_message=None, + commit_hash=None) +publication branch: publish_topic(branch_name, final_todo, base_ref, + final_message, year) +``` + +**Errors:** the existing `click.ClickException` boundary is unchanged; +the checkpoint code adds no I/O. A hook failure is a log warning inside +the walk, never an exception to the flow. + +**Edge Cases:** see D3/D4/D5 above; a failed preflight/ask fires +nothing. + +### `goga/topics/publishing.py` — `publish_topic` + +**Algorithm:** +``` +_publish_topic — replace the plant call: + applied = (commit_message if commit_message is not None + else _DEFAULT_COMMIT_MESSAGE).replace("{slug}", slug) + commit = _plant_topic_branch(branch_name, todo, base_commit, slug, + resolved_year, applied) + push; on failure the existing rollback — nothing fires +after the successful push: + identity = TopicIdentity(slug, resolved_year, branch_name) + TopicHooks().emit_created(identity, checked_out=False, published=True, + todo=todo, commit_message=applied, + commit_hash=commit) + TopicHooks().emit_published(identity, commit_message=applied, + commit_hash=commit, todo=todo) +``` + +**Errors:** unchanged; the rollback path fires nothing. + +**Edge Cases:** a direct CLI call publishes without `amend_creation` +(the creation amendment belongs to the creating orchestration). + +### `goga/topics/switching.py` — `switch_topic` + +**Algorithm:** +``` +_apply_candidate(chosen) -> (line, outcome): + already-on-branch -> ("Already on branch X", "already-on-branch") + local checkout -> ("Switched to branch X", "local-checkout") + remote creation -> ("Created branch from X", "created-from-remote") + (the lines unchanged; the outcome mapping added) + +_switch_topic — after the mutation, before the todo entry: + branch_fact = _short_name(chosen.branch) if chosen.remote else chosen.branch + identity = TopicIdentity(slug=chosen.topic, year=resolved_year, + branch=branch_fact) + TopicHooks().emit_switched(identity, outcome) + IF todo: enter_topic_todo(chosen.topic, year, branch=branch_fact) +``` + +**Errors:** unchanged. + +**Edge Cases:** the idempotent already-on-branch outcome still emits; +the `todo` no-topic guard fires before any mutation and emits nothing. + +### `goga/topics/ensuring.py` — `ensure_topic` + +**Algorithm:** +``` +_create_fresh_work — after the oracles, before create_and_switch_branch: + identity = TopicIdentity(slug, resolved_year, identifier) + TopicHooks().amend_creation(identity, checked_out=True, published=False, + commit_message=None, todo=None) + # the returned holder stays unread — the creation amendment observes + # only on this path (D8): the todo-entry amendment owns the written + # text, and the path builds no commit + create_and_switch_branch(identifier); ensure_topic_dir(identifier, year) + final_todo = _enter_topic_todo(identifier, year, branch=identifier) IF todo + TopicHooks().emit_created(identity, checked_out=True, published=False, + todo=final_todo, commit_message=None, + commit_hash=None) + +_enter_switched_todo — the entry calls gain the branch fact: + hosted topic -> enter_topic_todo(topic, year, branch=current) + fresh directory -> ensure_topic_dir(current, year); + enter_topic_todo(current, year, branch=current) +``` + +**Errors:** unchanged; note the OSError boundary comment covers only +the directory creation and the write — the checkpoints add no I/O. + +**Edge Cases:** the directory creation of a topic-less branch fires no +creation checkpoint; the todo entry alone fires its two. + +### `goga/topics/deletion.py` — `delete_topics` + +**Algorithm:** +``` +_delete_topics — inside the per-target loop, after the directory removal: + directory_removed = (remove_topic_dir(target.topic, resolved_year) + if target.has_dir else False) + identity = TopicIdentity(slug=target.topic, year=resolved_year, branch=None) + TopicHooks().emit_deleted(identity, local_branch=target.branch, + origin_twin=target.remote, + directory_removed=directory_removed) +``` + +**Errors:** unchanged; the restore-on-failure path raises before the +emission. + +**Edge Cases:** a remote-only target (`branch=None`) still fires with +its twin name; a directory-less target reports +`directory_removed=False`. + +## Cross-cutting Concerns + +- **Error handling**: two layers, strictly separated. (1) The domain + flows keep their `click.ClickException` clean-error boundary — every + preflight conflict, failed publication, and failed remote deletion is + one clean error, and a flow that errors before its moment fires + nothing. (2) The hook layer never breaks an operation: the seven + topics actions are soft — a raised hook discards its buffer, an + empty/whitespace buffer is rejected whole, both are warnings, and the + walk/delivery continues in enumeration order. The single fatal case is + a broken tool package import surfacing from `build_once` (platform + behavior, unchanged). +- **Logging**: `logging` per the conventions; one module logger in + `events.py`. Warnings use the platform's exact shape — + `hook of tool failed on topics.: ` — + naming the hook, the tool, the action, and the reason (the rejection + reason string: `the buffered amendment is empty or whitespace-only`). + No INFO/DEBUG additions in the zone (the platform's emit path already + owns the diagnostics surface). +- **Validation**: the catalog is the single address source — the walk + and `emit_hook_event` both resolve against `declared_actions()`; an + unknown address is the emitting side's `ValueError`. Buffer + validation at commit time only (empty/whitespace structural fields + reject the whole buffer); no validation of hook-authored content + beyond that (a tool may lawfully transform any field, including + nulling it — the path guards own the consequences). +- **Caching**: exactly one `HookRegistry` per process run (D1) — the + module-level lazily-built `_RUN_REGISTRY` in `events.py`, shared by + every checkpoint and every `TopicHooks` instance; `build_once` on the + same object is idempotent, so nested public calls + (`ensure → switch → entry`, `create → publish`) never multiply the + package enumeration. Nothing else is cached; no state survives a + process exit. Tests reset the registry through a fixture (see Test + Stack Trace). +- **Concurrency**: none introduced — the flows are single-threaded CLI + paths; the registry and contexts follow the platform's existing + (non-thread-safe, run-scoped) model. + +## Usages Analysis + +### `convention` (both changed cells) +- **What it provides**: the project's mandatory Python rules — relative + imports, kw-only dataclasses, logging, docstring style, test + structure, mock boundaries, validation commands. +- **Where used**: every module of the zone and every touched domain + module (global annotations + type annotations). +- **Why chosen**: the project-wide baseline. +- **How exactly**: relative intra-package imports (`from ...hooks + import ...` inside the zone, `from .hooks import ...` in the domain + modules); `@dataclass(kw_only=True)` for all eleven types; Google + docstrings mirroring the manifest annotations; tests under + `tests/topics/hooks/` with `__init__.py` and a local `conftest.py`. + +### `declaring-actions` (imported from `goga/hooks`) +- **What it provides**: the domain-maintainer side of opening an action: + catalog record, context contract, emission at the checkpoint. +- **Where used**: `TopicHooks` type annotation and the five `emit_*` + methods. +- **Why chosen**: the five notifications are plain emissions. +- **How exactly**: `emit_hook_event(registry, "topics", "", + context_for=lambda _tool: context)` — the same instance shared per + tool; the emission assembles the registry on first use. + +### `per-tool-delivery` (imported from `goga/hooks`) +- **What it provides**: the staged per-tool delivery loop skeleton over + the public primitives — registry build once, subscriptions in + enumeration order, wrap/project/call, commit only after success. +- **Where used**: `TopicHooks` type annotation; `amend_creation`; + `amend_todo_entry`. +- **Why chosen**: the amendments need per-hook outcomes (the buffer + commit), which the plain emission cannot collect. +- **How exactly**: with the declared refinement — the commit granularity + is the **single hook**, not the tool: a fresh view per subscription, + the commit decided per subscription, so two hooks of one tool never + share a buffer or a failure. The practice's tool-grouped loop is + otherwise followed (build once, warn naming tool/action/reason, + never filter delivery, `build_hook_arguments` as the single + projection). + +### `registering-hooks` (imported from `goga/hooks`) +- **What it provides**: the tool-author registration contract — the + hook signature (`context`/`self` offered names) and the failure + behavior behind every checkpoint. +- **Where used**: `TopicHooks` type annotation; `CreationAmendment`; + `TodoEntryAmendment`. +- **Why chosen**: the views are the delivered objects hooks receive; + their read/`amend` surface must match the delivered-context rules + (reads pass through the proxy, assignment blocked, `amend` is a plain + method call). + +### `topic-paths` (imported from `goga/history`) +- **What it provides**: the topic directory composition contract of + `resolve_topic_dir`. +- **Where used**: `TopicIdentity` (the `home_path` composition). +- **Why chosen**: the home path must be the canonical + `.goga/history//` posix form the history facade owns. +- **How exactly**: `resolve_topic_dir(slug, year).as_posix()` — pure, + nothing created. + +### `checkpoints` (imported from `goga/topics/hooks` into `goga/topics`) +- **What it provides**: the consumer practice of the zone — one + `TopicHooks` object per command, amend before fixation, emit after + the moment, facts from the operation's own data. +- **Where used**: the global annotations and the six reworked routines + of `goga/topics`. +- **Why chosen**: the binding practice for every checkpoint call site. +- **How exactly**: as the Code Stack Traces specify — identity + construction from operation data, amendment immediately before the + first mutation of the path, emission after the moment fully succeeds. + +### Imported usages — traceable dependency summary + +- `declaring-actions`, `per-tool-delivery`, `registering-hooks` from + `goga/hooks` — paths `goga/hooks/.usages/*.md` — the emission and + delivery contracts the zone composes (read and applied above). +- `topic-paths` from `goga/history` — path + `goga/history/.usages/topic-paths.md` — the path-composition contract + behind `home_path`. +- `checkpoints` from `goga/topics/hooks` — path + `goga/topics/hooks/.usages/checkpoints.md` — the consumer practice + for the domain flows (this cell's own `.usages/`, consumed by + `goga/topics`). + +## `.usages/` Update + +### Cell: `goga/topics/hooks` + +#### Existing Files — Consistency +- **`checkpoints`** → `goga/topics/hooks/.usages/checkpoints.md` + - Status: current — created with the manifest; every referenced name + (`TopicHooks`, `TopicIdentity`, `amend_creation`, `amend_todo_entry`, + `emit_*`, `CreationDraft` accessors) matches the CODEMANIFEST + signatures; the examples compile against the designed API (kw-only + constructor arguments as shown). + - Additions needed: none. + - Updates needed: none — one clarification may accompany the + implementation if desired: the run registry is shared per process + run (D1), which the file already states as "one registry per run". + +#### New Files +- None — the zone is one functional domain (the checkpoint surface); + a single practice file covers it. + +### Cell: `goga/topics` + +#### Existing Files — Consistency +- **`todo-entry`** → `goga/topics/.usages/todo-entry.md` + - Status: current — the added clause (the written content is the + final amended text) matches the designed `enter_topic_todo`. + - Additions needed: none. + - Updates needed: none. +- **`creating`** → `goga/topics/.usages/creating.md` + - Status: current — the added clause (todo.md content and commit + message are the final amended values) matches the designed + `create_topic`/`publish_topic`. + - Additions needed: none. + - Updates needed: none. +- Remaining files (`deleting`, `switching`, `ensuring`, `board` if + present): no content changes required — the hooks are log-level + facts, not consumer-visible behavior of those flows. Optional + one-line clauses mirroring the two above may be added at + implementation time for `switching`/`deleting`/`ensuring` if the + documentation stage judges them useful; not required by the contract. + +#### New Files +- None — no new functional domain of the consumer surface opens (the + hooks zone documents itself in its own cell). + +## Test Stack Trace + +### General Setup + +Two fixture families, following the established boundaries: + +1. **The platform environment** (the `tests/hooks/conftest.py` shape, + re-declared locally in `tests/topics/hooks/conftest.py` and + `tests/topics/conftest.py`): + - `pin_package_environment` — pins + `goga.hooks.tools.packages.packages_distributions` to a fixed + mapping (`{"goga_tool_one": ["pkg-one"], "goga_tool_two": ["pkg-two"]}`); + - `install_tool_package(module_name, register_hooks)` — mounts fake + `goga_tool_*` modules in `sys.modules` (monkeypatch-undone); + - `recording_hooks` — subscribes recording hooks (appending + `(tool, hook_name, context)` and captured facts to lists the test + asserts). +2. **The run-registry reset** (autouse in both conftest files): + `monkeypatch.setattr("goga.topics.hooks.events._RUN_REGISTRY", None)` + — every test starts with an unbuilt registry, so no subscription + leaks across tests and enumeration counts are per-test. +3. **The git/editor boundaries** (existing `tests/topics` fixtures): + mocked at the import point per module (`monkeypatch.setattr(creation, + "list_branch_refs", ...)`, the recording parent mock for the git + mutations, `edit_text` stubbed on the creation module). + +Mock policy: the platform (registry, dispatch, wrap, projection) and +the whole hooks zone run **for real**; only the package environment, +the git/editor boundaries, and the run-registry reset are pinned — the +`convention` and `statuses` precedents combined. + +### Source File Registry + +- `goga/hooks/catalog/catalog.py` — the seven records +- `goga/topics/hooks/__init__.py` — the facade (11 re-exports) +- `goga/topics/hooks/identity.py` — `TopicIdentity` +- `goga/topics/hooks/contexts.py` — the five contexts +- `goga/topics/hooks/amendments.py` — holders and views +- `goga/topics/hooks/events.py` — `TopicHooks`, `_run_registry` +- `goga/topics/creation.py`, `publishing.py`, `switching.py`, + `ensuring.py`, `deletion.py` — the checkpoint wiring +- Tests: `tests/hooks/catalog/test_catalog.py` (extended); + `tests/topics/hooks/{__init__,conftest,test_identity,test_contexts, + test_amendments,test_events}.py` (new); `tests/topics/test_{creation, + publishing,switching,ensuring,deletion}.py` (extended). + +--- + +### Positive Tests + +#### `test_declared_actions_carries_the_seven_topics_records` + +**Setup**: none (pure data). + +**Input**: `declared_actions()`. + +**Trace**: +``` +declared_actions() + -> sorted(_DECLARED_ACTIONS, key=(domain, name)) + returns: 10 records +``` + +**Assertions**: +``` +topics = [a for a in declared_actions() if a.domain == "topics"] +[(a.name, a.error_class) for a in topics] == [ + ("amend_creation", "soft"), + ("amend_todo_entry", "soft"), + ("topic_created", "soft"), + ("topic_deleted", "soft"), + ("topic_published", "soft"), + ("topic_switched", "soft"), + ("topic_todo_entered", "soft"), +] +len(declared_actions()) == 10 # 3 existing + 7 topics +``` + +**Sufficiency**: the addresses must exist before any checkpoint fires; +an address the zone emits but the catalog misses is a runtime +`ValueError` in every flow — this pins the catalog against drift. + +--- + +#### `test_topic_identity_home_path_composes_purely` + +**Setup**: none (pure composition; no filesystem). + +**Input**: `TopicIdentity(slug="add-topics-hooks", year="2026", +branch="add-topics-hooks")`. + +**Trace**: +``` +TopicIdentity(slug=..., year=..., branch=...) + -> identity.home_path + -> resolve_topic_dir("add-topics-hooks", "2026") + -> normalize (idempotent) -> .goga/history/2026/add-topics-hooks + -> .as_posix() + returns: ".goga/history/2026/add-topics-hooks" +``` + +**Assertions**: +``` +identity.home_path == ".goga/history/2026/add-topics-hooks" +identity.slug == "add-topics-hooks"; identity.branch == "add-topics-hooks" +``` + +**Sufficiency**: the home path is the canonical addressing fact every +notification carries; a regression here corrupts every tool's view of +the topic location. + +--- + +#### `test_amend_creation_walks_per_hook_and_commits_in_order` + +**Setup**: enumeration pinned to two tools; two packages installed — +`goga_tool_one` subscribing `first` and `second` to +`topics.amend_creation`, `goga_tool_two` subscribing `tail`; +run-registry reset applied. + +**Input**: +``` +hooks = TopicHooks() +draft = hooks.amend_creation( + identity, checked_out=False, published=False, + commit_message="goga: create topic add-topics-hooks", todo="first todo") +``` +Hooks: `first` calls `context.amend("m1", "t1")`; `second` records +`context.todo` (live read) and calls `context.amend("m2", "t2")`; `tail` +records `context.todo`. + +**Trace**: +``` +amend_creation(...) + -> _run_registry(): build_once() enumerates both packages once + -> holder = CreationDraft("goga: create topic add-topics-hooks", "first todo") + -> sub one/first: view over holder; hook buffers ("m1", "t1") + returns -> holder._commit(("m1","t1")) [non-empty] + -> sub one/second: view reads context.todo == "t1" (committed amendment of the earlier hook) + hook buffers ("m2","t2") -> holder._commit(("m2","t2")) + -> sub two/tail: view reads context.todo == "t2" + returns holder +``` + +**Assertions**: +``` +draft.commit_message == "m2"; draft.todo == "t2" # last committed buffer +second saw context.todo == "t1"; tail saw context.todo == "t2" +enumeration boundary called exactly once +``` + +**Sufficiency**: pins the per-hook commit granularity (two hooks of one +tool, independent buffers, ordered visibility) — the core refinement +the zone adds over the tool-grouped practice. + +--- + +#### `test_emit_created_shares_one_instance_and_returns_none` + +**Setup**: enumeration pinned; two packages each subscribing one hook +to `topics.topic_created`. + +**Input**: `result = TopicHooks().emit_created(identity, checked_out=False, +published=False, todo="t", commit_message="m", commit_hash="abc123")`. + +**Trace**: +``` +emit_created(...) + -> TopicCreated(...) built once + -> emit_hook_event(_run_registry(), "topics", "topic_created", + context_for=lambda _tool: context) + -> both hooks called with the delivered proxy +``` + +**Assertions**: +``` +result is None +the two deliveries observe the identical underlying context — + each through its own fresh proxy (type is not TopicCreated), the + shared instance pinned one attribute deep: + recorded[0].identity is recorded[1].identity +each hook read: checked_out False, published False, todo "t", + commit_message "m", commit_hash "abc123", identity.home_path as composed +``` + +**Sufficiency**: the context-instance sharing and the fire-and-forget +contract of every notification — prevents per-tool copies (stale facts) +and accidental return-channel collection. + +--- + +#### `test_enter_topic_todo_writes_amended_text_and_emits_final` + +**Setup**: `tmp_path` as cwd; topic directory +`.goga/history/2026/feature-foo/` created; `edit_text` stubbed to return +`"saved text"`; enumeration pinned; one package subscribing an +`amend_todo_entry` hook (`context.amend("amended text")`) and a +`topic_todo_entered` recorder; registry reset. + +**Input**: `enter_topic_todo("feature-foo", year="2026", +branch="feature-foo")`. + +**Trace**: +``` +enter_topic_todo(...) + -> resolve_topic_file -> path (file absent -> initial None) + -> edit_text(None) -> "saved text" + -> identity = TopicIdentity("feature-foo", "2026", "feature-foo") + -> amend_todo_entry(identity, "saved text") -> holder.text "amended text" + -> _write_todo: todo.md == "amended text\n" (UTF-8, single newline) + -> emit_todo_entered(identity, "amended text") + returns True +``` + +**Assertions**: +``` +result is True +(todo.md).read_text() == "amended text\n" +recorded topic_todo_entered context.text == "amended text" +recorded context.identity.branch == "feature-foo" +``` + +**Sufficiency**: the pre-fixation/post-moment pair of the entry — the +file carries the amended text and the notification reports the same +final value (the `.usages/todo-entry.md` clause made executable). + +--- + +#### `test_create_topic_no_switch_emits_created_with_commit_hash` + +**Setup**: git boundary mocked at the import point (empty inventory, +current branch `main`, occupancy free, base resolved, plant wired to a +recording mock returning `"deadbeef"`); `sys.stdin` pinned to an +interactive terminal (`isatty` → True, the tests/topics precedent) so +the editor-todo resolution runs; `edit_text` → `"the todo"`; +enumeration pinned with a `topic_created` recorder; registry reset. + +**Input**: `create_topic("Feature/Foo_Bar", "HEAD", todo=None, year="2026")` +(interactive terminal pinned; the editor stubbed). + +**Trace**: +``` +_create_topic -> preflight free; resolved_todo "the todo"; ask False (publish False) +-> amend_creation(identity(slug "feature-foo-bar", 2026, "Feature/Foo_Bar"), + checked_out False, published False, commit_message "goga: create topic feature-foo-bar", + todo "the todo") -> unamended (no subscriber) +-> _plant_topic_branch(... final values ...) returns "deadbeef" +-> emit_created(identity, False, False, "the todo", , "deadbeef") +returns "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" +``` + +**Assertions**: +``` +result line unchanged +recorded context.commit_hash == "deadbeef" +recorded context.commit_message == "goga: create topic feature-foo-bar" +recorded context.todo == "the todo"; checked_out False; published False +plant mock called once (mutation order unchanged) +``` + +**Sufficiency**: the no-switch path's checkpoint wiring — the hash comes +from the existing plant return (no new git read) and the identity facts +come from the operation's own data. + +--- + +#### `test_publish_topic_emits_created_then_published_after_push` + +**Setup**: git boundary mocked (occupancy free, origin configured, base +resolved, plant → `"cafe123"`, push succeeding); enumeration pinned with +recorders for both actions; registry reset. + +**Input**: `publish_topic("Feature/Foo_Bar", "the todo", "HEAD", +year="2026")`. + +**Trace**: +``` +_publish_topic -> guards pass +-> applied = "goga: create topic feature-foo-bar" +-> commit = _plant_topic_branch(..., applied) -> "cafe123" +-> push_branch OK +-> emit_created(identity, False, True, "the todo", applied, "cafe123") +-> emit_published(identity, applied, "cafe123", "the todo") +returns the unchanged line +``` + +**Assertions**: +``` +emission order == ["topic_created", "topic_published"] +both contexts carry commit_hash "cafe123", commit_message applied, todo "the todo" +created.checked_out False; created.published True +``` + +**Sufficiency**: the publication pair fires only after the push, in the +fixed order, with the identical final facts — the contract's central +ordering guarantee. + +--- + +#### `test_switch_topic_emits_switched_for_every_outcome` (parametrized) + +**Setup**: three inventory scenarios — (a) already on `feature-foo`; +(b) local branch `feature-foo` not current; (c) remote-tracking +`origin/feature-foo` only; tree-clean probe True; a `topic_switched` +recorder; registry reset. + +**Input**: `switch_topic("feature-foo", year="2026")` per scenario +(candidate hosts topic `feature-foo` in (a)/(b); a fourth parametrization +uses a topic-less branch). + +**Trace** (scenario c): +``` +_switch_topic -> chosen = origin/feature-foo (remote, topic feature-foo) +-> create_branch_from_remote_tracking(...) # line unchanged +-> branch_fact = "feature-foo" (short name) +-> emit_switched(TopicIdentity("feature-foo", "2026", "feature-foo"), + "created-from-remote") +returns "Created branch feature-foo from origin/feature-foo" +``` + +**Assertions**: +``` +(a) outcome "already-on-branch"; (b) "local-checkout"; (c) "created-from-remote" +topic-less branch: identity.slug is None; identity.home_path is None; + identity.branch == the branch name +all lines unchanged +``` + +**Sufficiency**: every completed switch fires exactly once, the +idempotent outcome included, and the branch-only degradation works — +the marginal corner of the switch contract. + +--- + +#### `test_ensure_fast_creation_amends_identity_only_and_emits_after_entry` + +**Setup**: empty inventory (zero candidates), tree-clean True; +`create_and_switch_branch` recorded; `sys.stdin` pinned to an +interactive terminal (`isatty` → True, the tests/topics precedent); +`edit_text` → `"fresh todo"`; +enumeration pinned with `amend_creation`/`topic_created`/ +`amend_todo_entry`/`topic_todo_entered` recorders; registry reset. + +**Input**: `ensure_topic("New_Work", todo=True, year="2026")`. + +**Trace**: +``` +_ensure_topic -> zero candidates -> _create_fresh_work +-> slug "new-work", oracles free +-> amend_creation(TopicIdentity("new-work","2026","New_Work"), + checked_out True, published False, commit_message None, todo None) +-> create_and_switch_branch("New_Work"); ensure_topic_dir +-> _enter_topic_todo("New_Work", "2026", branch="New_Work") -> "fresh todo" +-> emit_created(identity, True, False, "fresh todo", None, None) +``` + +**Assertions**: +``` +amend_creation recorded once, before create_and_switch_branch (call order) +its context.commit_message is None and context.todo is None (identity-only) +topic_created context.todo == "fresh todo"; commit_hash None; checked_out True +topic_todo_entered fired with identity.branch "New_Work" +result line unchanged +``` + +**Sufficiency**: the fast-creation corner — identity-only amendment +before the first mutation, notification after the entry with the final +todo, all from one registry build. + +--- + +#### `test_delete_topics_emits_per_target_after_full_removal` + +**Setup**: two targets +(`DeleteTarget("one", branch="one", remote="one", has_dir=True)`, +`DeleteTarget("two", branch=None, remote=None, has_dir=False)`); git +boundary recorded (`resolve_ref_commit` → hash, deletions succeeding, +`remove_topic_dir` real over `tmp_path` with `.goga/history/2026/one/` +created); a `topic_deleted` recorder; registry reset. + +**Input**: `delete_topics(targets, year="2026")`. + +**Trace**: +``` +_delete_topics +-> target one: capture commit, delete local, delete remote, remove dir -> True + -> emit_deleted(identity(slug "one", 2026, branch None), + local_branch "one", origin_twin "one", directory_removed True) +-> target two: no branch, no twin, no dir -> emit_deleted(identity, + local_branch None, origin_twin None, directory_removed False) +returns the unchanged line +``` + +**Assertions**: +``` +two emissions, in target order +first: local_branch "one", origin_twin "one", directory_removed True +second: all-absent composition, directory_removed False +both identities: branch is None; home_path ".goga/history/2026/" +``` + +**Sufficiency**: the per-target timing and the removal-composition +mapping — including the directory-less and remote-only target shapes. + +--- + +### Negative Tests + +#### `test_amend_creation_discards_buffer_of_raising_hook` + +**Setup**: enumeration pinned; `goga_tool_one` subscribes `boom` +(calls `context.amend("m", "t")` then `raise RuntimeError("kaputt")`); +`goga_tool_two` subscribes `tail` (`context.amend("late", "late-t")`); +registry reset; `caplog` at WARNING. + +**Input**: `TopicHooks().amend_creation(identity, False, False, "orig", +"orig todo")`. + +**Trace**: +``` +walk: boom raises after buffering -> buffer discarded, warning emitted +walk: tail returns -> buffer committed +returns holder +``` + +**Assertions**: +``` +draft.commit_message == "late"; draft.todo == "late-t" # boom's buffer gone +any("hook boom of tool one failed on topics.amend_creation: kaputt" + in r.message for r in caplog.records) +the walk reached tail (its buffer landed) +``` + +**Sufficiency**: a failing hook never breaks the operation and never +leaks its buffer — the soft-action core guarantee. + +--- + +#### `test_amend_creation_rejects_empty_amendment_whole` + +**Setup**: one tool subscribing `blank` → +`context.amend(" ", "fine text")`; registry reset; `caplog`. + +**Input**: `TopicHooks().amend_creation(identity, False, False, "orig", +"orig todo")`. + +**Trace**: +``` +walk: blank returns; buffer (" ", "fine text") +-> commit_message structurally present and whitespace-only +-> whole buffer rejected with the empty-amendment warning +returns holder with the original values +``` + +**Assertions**: +``` +draft.commit_message == "orig"; draft.todo == "orig todo" +"failed on topics.amend_creation: the buffered amendment is empty or whitespace-only" + in caplog.text +``` + +**Sufficiency**: the whole-replacement rejection — a whitespace field +must not partially land (the message survives while the todo changes). + +--- + +#### `test_amend_todo_entry_rejects_blank_text_buffer` + +**Setup**: enumeration pinned; one tool subscribing `blank` → +`context.amend(" ")` (whitespace buffer); registry reset; `caplog` +at WARNING. + +**Input**: `TopicHooks().amend_todo_entry(identity, "saved text")`. + +**Trace**: +``` +walk: blank returns; buffer " " +-> text buffer blank (whitespace-only) +-> whole buffer rejected with the empty-amendment warning +returns holder with the saved text +``` + +**Assertions**: +``` +draft.text == "saved text" +"failed on topics.amend_todo_entry: the buffered amendment is empty or whitespace-only" + in caplog.text +``` + +**Sufficiency**: pins the single predicate that distinguishes the +todo-entry walk from the creation walk (`text is None or +not text.strip()` — a None buffer is rejected here, lawful on the +creation side) — a regression that unifies the two walks without +changing the contract is caught, and the write path is guaranteed a +non-blank text by rejection rather than by luck. + +--- + +#### `test_publish_topic_rollback_fires_nothing` + +**Setup**: git boundary mocked with `push_branch` raising +`CalledProcessError` (rollback recorded); both action recorders +installed; registry reset. + +**Input**: `publish_topic("Feature/Foo_Bar", "the todo", "HEAD", +year="2026")`. + +**Trace**: +``` +plant OK -> push raises -> delete_local_branch (rollback) -> ClickException +``` + +**Assertions**: +``` +pytest.raises(click.ClickException) +recorded emissions == [] # nothing fired on the failure +delete_local_branch called once # the rollback still runs +``` + +**Sufficiency**: a rolled-back publication must leave no event trail — +tools would otherwise record a publication that does not exist. + +--- + +#### `test_create_topic_failed_preflight_fires_nothing` + +**Setup**: occupancy conflict wired (`check_branch_occupancy` → a +reason); recorders for all seven actions; registry reset. + +**Input**: `create_topic("Feature/Foo_Bar", "HEAD", todo="x", +year="2026")`. + +**Trace**: +``` +preflight conflict -> ClickException before any input-driven step +``` + +**Assertions**: +``` +pytest.raises(click.ClickException) +recorded emissions and amendments == [] +``` + +**Sufficiency**: "a failed creation fires nothing" — the amendment +delivers only immediately before the first mutation, never before the +decisions. + +--- + +#### `test_enter_topic_todo_cancelled_entry_delivers_and_emits_nothing` + +**Setup**: topic directory present; `edit_text` → `None` (cancelled); +recorders installed; registry reset. + +**Input**: `enter_topic_todo("feature-foo", year="2026")`. + +**Trace**: +``` +edit_text -> None -> return False before any delivery +``` + +**Assertions**: +``` +result is False +todo.md absent; recorded checkpoints == [] +``` + +**Sufficiency**: a cancelled entry is a non-event — the amendment +moment never arrives. + +--- + +#### `test_enter_topic_todo_failed_write_emits_nothing` + +**Setup**: `tmp_path` as cwd; topic directory created; `edit_text` +stubbed to return `"saved"`; `_write_todo` on the creation module +monkeypatched to raise `OSError`; a `topic_todo_entered` recorder +installed; registry reset. + +**Input**: `enter_topic_todo("feature-foo", year="2026")`. + +**Trace**: +``` +enter_topic_todo -> save "saved" +-> amend_todo_entry delivered (holder.text "saved" — no subscriber) +-> _write_todo raises OSError +-> the wrapper converts it to ClickException; emit_todo_entered + is never reached +``` + +**Assertions**: +``` +pytest.raises(click.ClickException) +recorded topic_todo_entered == [] +``` + +**Sufficiency**: pins the write-then-emit order on the failure path — +the only point where the order guarantees the event carries a written +fact; catches an emit-before-write or emit-in-finally regression. + +--- + +#### `test_switch_todo_onto_topicless_branch_fires_nothing` + +**Setup**: one candidate `bare-branch` (topic None) with interactive +terminal; recorders installed; registry reset. + +**Input**: `switch_topic("bare-branch", todo=True, year="2026")`. + +**Trace**: +``` +chosen.topic is None and todo -> clean error before any mutation +``` + +**Assertions**: +``` +pytest.raises(click.ClickException, match="hosts no topic") +recorded checkpoints == [] +``` + +**Sufficiency**: the pre-mutation guard must also suppress the switch +notification — the only switch path that fires nothing. + +--- + +### Edge Case Tests + +#### `test_run_registry_built_once_across_checkpoints` + +**Setup**: enumeration boundary mock (call-counting); one tool +subscribing to `amend_creation`, `topic_created`, `topic_todo_entered`, +`amend_todo_entry`; registry reset. + +**Input**: +``` +hooks = TopicHooks() +hooks.amend_creation(identity, False, False, None, None) # identity-only form +hooks.emit_created(identity, False, False, None, None, None) +hooks.amend_todo_entry(identity, "t") +hooks.emit_todo_entered(identity, "t") +TopicHooks().emit_switched(identity, "local-checkout") # a second instance +``` + +**Trace**: +``` +every checkpoint -> _run_registry() -> the single built object +``` + +**Assertions**: +``` +boundary.call_count == 1 +all checkpoints delivered to the subscriber +``` + +**Sufficiency**: D1 — "the checkpoints never multiply the package +enumeration", including across separate `TopicHooks` instances and +nested flows. + +--- + +#### `test_topic_hooks_construction_enumerates_nothing` + +**Setup**: enumeration boundary mock installed; registry reset. + +**Input**: `TopicHooks()` (no checkpoint calls). + +**Trace**: +``` +__init__ stores nothing, touches nothing +``` + +**Assertions**: +``` +boundary.call_count == 0; _RUN_REGISTRY stays None +``` + +**Sufficiency**: "cheap construction — no enumeration and no imports +happen at construction" — keeps import-time and construction-time +behavior identical for every consumer. + +--- + +#### `test_amend_creation_without_subscriptions_returns_original_values` + +**Setup**: enumeration pinned to a tool subscribing nothing; registry +reset. + +**Input**: `amend_creation(identity, False, False, "m", None)`. + +**Trace**: +``` +walk over zero subscriptions -> holder untouched +``` + +**Assertions**: +``` +draft.commit_message == "m"; draft.todo is None # no error +``` + +**Sufficiency**: the no-subscriber case is the everyday case — the +amendment is a transparent no-op the flows can always call. + +--- + +#### `test_amend_creation_identity_only_form_is_valid` + +**Setup**: one tool subscribing a recorder (no amend call); registry +reset. + +**Input**: `amend_creation(identity, True, False, None, None)`. + +**Trace**: +``` +holder created with (None, None); hook observes, buffers nothing +``` + +**Assertions**: +``` +draft.commit_message is None; draft.todo is None +recorded view read: checked_out True, published False, + commit_message None, todo None +``` + +**Sufficiency**: the identity-only form is the norm on the ensure fast +path — a hook must be able to observe it without acting. + +--- + +#### `test_create_topic_switch_path_amended_null_todo_degrades_gracefully` + +**Setup**: switch=True, todo resolved, inventory free; a tool whose +`amend_creation` hook calls `context.amend(None, None)` (nulls the +todo); recorders; registry reset. + +**Input**: `create_topic("Feature/Foo_Bar", "HEAD", todo="the todo", +switch=True, year="2026")`. + +**Trace**: +``` +amendment commits (None, None) -> final_todo None +switch path: branch planted+checked out, dir ensured, no todo write +-> emit_created(identity, True, False, None, None, None) +``` + +**Assertions**: +``` +topic_created fired once with todo None +todo.md absent (nothing written) +result line unchanged +``` + +**Sufficiency**: the switch path's todo is optional — a nulled amended +todo degrades gracefully and the notification reports the truth. + +--- + +#### `test_ensure_todo_on_topicless_branch_fires_only_the_entry_pair` + +**Setup**: one candidate `bare-branch` (topic None), tree-clean, +current branch becomes `bare-branch` after the switch; `edit_text` → +`"fresh"`; recorders for all seven actions; registry reset. + +**Input**: `ensure_topic("bare-branch", todo=True, year="2026")`. + +**Trace**: +``` +switch_topic -> emit_switched(branch-only identity) # the switch's own event +-> _enter_switched_todo: no hosted topic -> ensure_topic_dir(current) +-> _enter_topic_todo(current, year, branch=current) + -> amend_todo_entry(derived identity) -> emit_todo_entered +no amend_creation / topic_created anywhere +``` + +**Assertions**: +``` +fired actions == ["topic_switched", "topic_todo_entered"] +topic_todo_entered identity.slug == "bare-branch" (derived) and + identity.branch == "bare-branch" +topic_created not recorded +``` + +**Sufficiency**: the marginal corner of the ensure contract — the +directory creation of a topic-less branch fires no creation +checkpoint; the entry alone fires its two. + +--- + +#### `test_amend_views_block_no_write_path_to_the_holder` + +**Setup**: a holder and a view constructed directly; no platform. + +**Input**: `view.amend("m", "t")`; then read `holder.commit_message`. + +**Trace**: +``` +amend buffers on the view only; the holder fields stay at the draft values +``` + +**Assertions**: +``` +holder.commit_message == ; holder.todo == +view.commit_message reads the live holder (the draft values, not the buffer) +``` + +**Sufficiency**: "the content changes only through the delivery commit — +never through a delivered view" — the buffering isolation that makes +the discard-on-failure semantics possible. + +--- + +#### `test_amend_called_twice_last_buffer_wins` + +**Setup**: enumeration pinned; one tool subscribing `fickle` whose hook +calls `context.amend("first", "t-first")` then +`context.amend("second", "t-second")`; registry reset. + +**Input**: `TopicHooks().amend_creation(identity, False, False, "orig", +"orig todo")`. + +**Trace**: +``` +walk: fickle buffers ("first", "t-first") then overwrites its buffer + with ("second", "t-second"); the holder stays untouched during + both calls +returns -> view._buffered == ("second", "t-second") -> committed whole +``` + +**Assertions**: +``` +draft.commit_message == "second"; draft.todo == "t-second" +``` + +**Sufficiency**: pins the whole-replacement last-wins semantics of the +buffer — the sole guarantee that `amend` means "replace entirely", not +"extend"; prevents a drift to an accumulative or first-wins semantics +no other scenario distinguishes. + +--- + +## Additional Instructions for the Implementation Agent + +- Implement in the order: `catalog.py` records → `goga/topics/hooks` + (`identity.py` → `contexts.py` → `amendments.py` → `events.py` → + `__init__.py`) → domain wiring (`creation.py`, `publishing.py`, + `switching.py`, `ensuring.py`, `deletion.py`) → tests → docs. The + catalog records must exist before any checkpoint runs. +- All eleven zone types are `@dataclass(kw_only=True)`; the identity and + the five contexts are additionally `frozen=True`; the two holders and + the two views are mutable (`StatusRegistry` precedent; the views store + the holder under the private field `_draft` and carry the private + `_buffered` field with `init=False, repr=False` — the holder is never + a public attribute of a delivered view). +- Use relative imports everywhere intra-package: `from ...hooks + import ...` inside the zone; `from .hooks import TopicHooks, + TopicIdentity` in the domain modules. No new module-level import + cycles are created (the zone imports neither the parent domain nor + any domain module). +- The run registry is the module-level `_RUN_REGISTRY` of `events.py` + with the lazy `_run_registry()` builder (D1). Never hold a registry on + a `TopicHooks` instance and never call `HookRegistry()` inside a + domain routine — the checkpoints own the sharing. +- The warning text is fixed: `hook of tool failed on + topics.: ` with the rejection reason + `the buffered amendment is empty or whitespace-only`. Use + `logger.warning` with `%s` placeholders (the `emit.py` style), one + module logger in `events.py`. +- Do not change: any public signature, any result line, any error + message, the mutation order of any flow, the `goga/commands/**` code, + the platform cells (`goga/hooks/{dispatch,registry,tools}`). The + emissions are additive wiring inside the six routines. One documented + exception: an empty commit-message template on the delegated + publication path normalizes to the built-in default at the amendment + draft (the `or` predicate); a direct `publish_topic` call keeps its + `is not None` predicate unchanged. +- The existing mocks of `enter_topic_todo` in `tests/topics/test_switching.py` + and `test_ensuring.py` record calls — extend their assertions for the + new `branch=` keyword (and update the signature-contract test in + `test_creation.py` for the `branch` parameter). +- Docs synchronization (per the plan's checklist): replace the stub + `docs/features/topics/hooks.md` with the seven-action reference (the + checkpoint moments, the context surfaces, the amendment contract); + add the topics domain to the declared-actions lists in + `docs/features/hooks/index.md`, `docs/features/hooks/hooks.md`, and + `docs/features/tools/hooks.md`. No `mkdocs.yml` nav change (the page + exists). +- Validation: `pytest tests/ -x` green; `ruff check` over the touched + sources green; `goga lint` stays at 0 errors; the facade check + `python -c "from goga.topics.hooks import TopicHooks, TopicIdentity, + CreationDraft, TodoEntryDraft, TopicCreated, TopicPublished, + TopicSwitched, TopicTodoEntered, TopicDeleted, CreationAmendment, + TodoEntryAmendment"` passes; `goga hooks` lists the seven topics + actions with no command change; `goga schema goga/topics` shows the + new subcell (already materialized). diff --git a/.goga/history/2026/add-topics-hooks/plan.md b/.goga/history/2026/add-topics-hooks/plan.md new file mode 100644 index 00000000..66d92487 --- /dev/null +++ b/.goga/history/2026/add-topics-hooks/plan.md @@ -0,0 +1,1853 @@ +# Plan: `add-topics-hooks` + +Result of compiling the design document +`.goga/history/2026/add-topics-hooks/design.md` into ralphex execution +tasks. The CODEMANIFEST changes are already materialized in the working +tree — this plan implements the code against them. + +--- + +## Purpose + +Implement the seven platform hook actions of the topics lifecycle: five +post-fact notifications over the plain emission and two pre-fixation +amendments over a per-hook staged delivery, plus the home-path identity +and the no-read contexts. + +After implementation: + +- `goga/hooks/catalog` declares the seven topics records (all soft) — + every checkpoint address resolves. +- The new cell `goga/topics/hooks` provides the eleven contract types + (`TopicIdentity`, five contexts, two draft holders, two amendment + views, `TopicHooks`) behind its facade, sharing one lazily-built + `HookRegistry` per run. +- The six domain routines of `goga/topics` (`enter_topic_todo`, + `create_topic`, `publish_topic`, `switch_topic`, `ensure_topic`, + `delete_topics`) fire their checkpoints at the specified moments with + facts from the operation's own data; public signatures, result lines, + error surface, and mutation order are unchanged (one documented + exception: the empty-template normalization on the delegated + publication path). +- Docs describe the seven actions; all tests, lint, and the goga + validations pass. + +The most important gaps between contract and code: the zone directory +holds only `CODEMANIFEST` (no Python at all), the catalog constant +carries 3 of 10 records, `enter_topic_todo` lacks the `branch` +parameter, and no domain routine fires anything. + +Strategy: bottom-up by cell — catalog data first (addresses must exist +before any checkpoint fires), then the zone (identity → contexts → +amendments → events → facade), then the domain wiring in the design's +routine order, then docs and the cross-cell validation. Every coding +task follows the TDD workflow. + +## Context + +### Contract Surface + +All three `CODEMANIFEST` files are **read-only** for the implementation +agent. Where this plan and a manifest disagree, the manifest wins — fix +the code, never the contract. + +#### Cell: `goga/hooks/catalog` (changed — data extension) + +**Entity: `declared_actions() -> actions: list[Action]`** +- Type: function (Routine) +- Declared `location`: `catalog.py` (existing file + `goga/hooks/catalog/catalog.py`) +- Facade obligation: importable from `goga/hooks` (already re-exported — + no facade change in this plan) +- Behavioral change: the runtime constant `_DECLARED_ACTIONS` gains the + seven topics records — `topic_created`, `topic_published`, + `topic_switched`, `topic_todo_entered`, `topic_deleted`, + `amend_creation`, `amend_todo_entry` (all `domain="topics"`, + `error_class="soft"`). The routine itself (the `sorted()` by + `(domain, name)`) is unchanged. Total: 10 records (3 existing + 7 + topics). +- Semantic requirements: deterministic and complete on every call; + published records are never rewritten; the catalog is maintained data, + not discovery (do not derive records from installed packages). +- `Action` (the frozen kw-only record dataclass) is unchanged. + +#### Cell: `goga/topics/hooks` (created — all new code) + +The directory currently holds only `CODEMANIFEST` and +`.usages/checkpoints.md`. Every entity below is new Python; the facade +`__init__.py` must expose exactly the eleven names through `__all__` +(alphabetical). + +**Entity: `TopicIdentity(slug: str | None, year: str, branch: str | None)`** +- Type: class (Entity) — `@dataclass(frozen=True, kw_only=True)` +- Declared `location`: `identity.py` +- Facade obligation: importable from `goga/topics/hooks` +- Properties: `slug -> str | None` (normalized slug; None in the + branch-only form), `home_path -> str | None` + (`.goga/history//` posix string via `resolve_topic_dir`; + None when slug is None; pure composition — nothing read or created), + `branch -> str | None` (branch as entered; None only in the deletion + context) +- Imported dependencies: `resolve_topic_dir` from `goga/history` +- Key requirement: pure composition — no repository reads, nothing + created + +**Entity: `TopicCreated(identity: TopicIdentity, checked_out: bool, published: bool, todo: str | None, commit_message: str | None, commit_hash: str | None)`** +- Type: class (Entity) — `@dataclass(frozen=True, kw_only=True)` +- Declared `location`: `contexts.py` +- Facade obligation: importable from `goga/topics/hooks` +- Properties: the six signature fields, plain data reads +- Key requirements: read-only facts of a completed creation; + `commit_message`/`commit_hash` present exactly when the path builds a + commit + +**Entity: `TopicPublished(identity: TopicIdentity, commit_message: str, commit_hash: str, todo: str)`** +- Type: class (Entity) — `@dataclass(frozen=True, kw_only=True)` +- Declared `location`: `contexts.py` +- Properties: the four signature fields. Read-only facts of one + successful publication push. + +**Entity: `TopicSwitched(identity: TopicIdentity, outcome: str)`** +- Type: class (Entity) — `@dataclass(frozen=True, kw_only=True)` +- Declared `location`: `contexts.py` +- Properties: `identity`, `outcome`. The outcome is exactly one of + `local-checkout`, `created-from-remote`, `already-on-branch` — fixed + by construction of the emitting routine. + +**Entity: `TopicTodoEntered(identity: TopicIdentity, text: str)`** +- Type: class (Entity) — `@dataclass(frozen=True, kw_only=True)` +- Declared `location`: `contexts.py` +- Properties: `identity`, `text` (the final written text — after every + amendment). No prior text is carried. + +**Entity: `TopicDeleted(identity: TopicIdentity, local_branch: str | None, origin_twin: str | None, directory_removed: bool)`** +- Type: class (Entity) — `@dataclass(frozen=True, kw_only=True)` +- Declared `location`: `contexts.py` +- Properties: the four signature fields. No deleted-commit hash is + carried; the identity carries no branch fact. + +**Entity: `CreationDraft(commit_message: str | None, todo: str | None)`** +- Type: class (Entity) — `@dataclass(kw_only=True)`, **mutable** (the + `StatusRegistry` precedent) +- Declared `location`: `amendments.py` +- Properties: `commit_message -> str | None`, `todo -> str | None` (live + fields) +- Key requirement: the content changes only through the delivery commit + of the amendment checkpoint — never through a delivered view + +**Entity: `TodoEntryDraft(text: str)`** +- Type: class (Entity) — `@dataclass(kw_only=True)`, **mutable** +- Declared `location`: `amendments.py` +- Property: `text -> str` (live field). Same single-mutation-point rule. + +**Entity: `CreationAmendment(identity: TopicIdentity, checked_out: bool, published: bool, draft: CreationDraft)`** +- Type: class (Entity) — `@dataclass(kw_only=True)`, mutable view +- Declared `location`: `amendments.py` +- Properties: `identity`, `checked_out`, `published`, and the read-through + `commit_message -> str | None`, `todo -> str | None` (they read the + live holder — a later hook sees the committed amendments of the earlier + hooks) +- Method: `amend(commit_message: str | None, todo: str | None)` — buffer + one amendment replacing the full draft content; buffers into this + hook's buffer alone; whole replacement (a field left out returns as + None); never cancels/redirects/defers the operation +- Datamodel decision (from the design review): the holder is stored + under the private field `_draft` (the walk in `events.py` is the sole + constructor caller — the kw name is an internal wiring detail; the + CODEMANIFEST signature documents the input semantically), plus a + private `_buffered` field (`init=False, repr=False`, default `None`). + The holder is never a public attribute of a delivered view. + +**Entity: `TodoEntryAmendment(identity: TopicIdentity, draft: TodoEntryDraft)`** +- Type: class (Entity) — `@dataclass(kw_only=True)`, mutable view +- Declared `location`: `amendments.py` +- Properties: `identity`, and the read-through `text -> str` +- Method: `amend(text: str)` — buffer one amendment replacing the full + text; same isolation rules + +**Entity: `TopicHooks()`** +- Type: class (Entity) — cheap construction, no state +- Declared `location`: `events.py` +- Facade obligation: importable from `goga/topics/hooks` +- Methods: + - `amend_creation(identity, checked_out, published, commit_message, todo) -> draft: CreationDraft` — the per-hook staged walk (full algorithm in Task 6) + - `amend_todo_entry(identity, text) -> draft: TodoEntryDraft` — the same walk over the single text field + - `emit_created(identity, checked_out, published, todo, commit_message, commit_hash)` — fire-and-forget + - `emit_published(identity, commit_message, commit_hash, todo)` — fire-and-forget + - `emit_switched(identity, outcome)` — fire-and-forget + - `emit_todo_entered(identity, text)` — fire-and-forget + - `emit_deleted(identity, local_branch, origin_twin, directory_removed)` — fire-and-forget +- Key requirements: no enumeration and no imports at construction; one + `HookRegistry` per run carries every checkpoint (the transport — the + module-level `_RUN_REGISTRY` with a lazy `_run_registry()` builder — + is an implementation detail the design fixes as decision D1); every + context and draft is built from caller values — no repository reads +- Imported dependencies: `HookRegistry`, `emit_hook_event`, + `wrap_context`, `build_hook_arguments`, `declared_actions` from the + `goga/hooks` facade; `resolve_topic_dir` from `goga/history` + (identity.py only) + +#### Cell: `goga/topics` (changed — checkpoint wiring in six routines) + +All six keep their public signatures (one addition: `enter_topic_todo` +gains `branch: str | None = None`), result lines, error surface, and +mutation order. The emissions are additive wiring. The cell imports the +zone via the relative `from .hooks import ...`. + +**Entity: `enter_topic_todo(topic, year=None, branch=None) -> written: bool`** — `creation.py`. The saved text passes through `amend_todo_entry` before the write; a completed entry emits `topic_todo_entered`; the write is the last mutation. + +**Entity: `create_topic(branch_name, base_ref, todo=None, publish=False, commit_message=None, year=None, switch=False) -> result: str`** — `creation.py`. Step 6 delivers `amend_creation` immediately before the first mutation of the chosen path; the no-switch path captures the planted commit hash and emits `topic_created`; the switch path emits after its last mutation; the publication path delegates with the amended values. + +**Entity: `publish_topic(branch_name, todo, base_ref, commit_message=None, year=None) -> result: str`** — `publishing.py`. Captures the publication commit hash; after the successful push emits `topic_created` then `topic_published`; a rolled-back publication fires nothing; a direct call publishes without `amend_creation`. + +**Entity: `switch_topic(identifier, todo=False, year=None) -> result: str`** — `switching.py`. Emits `topic_switched` on every completed switch with the outcome kind; the todo entry receives the switched branch as the branch fact. + +**Entity: `ensure_topic(identifier, todo=False, year=None) -> result: str`** — `ensuring.py`. The fast creation delivers the identity-only `amend_creation` immediately before its first mutation and emits `topic_created` after the creation completes; the todo entries pass the operation's branch fact. + +**Entity: `delete_topics(targets, year=None) -> result: str`** — `deletion.py`. Emits `topic_deleted` after each target's full removal with the removal composition. + +Unchanged entities of `goga/topics` (`BoardRecord`, `collect_topic_board`, `SwitchCandidate`, `resolve_switch_candidates`, `check_branch_occupancy`, `check_slug_occupancy`, `DeleteTarget`, `resolve_delete_targets`) are not touched. + +### Re-exports + +No DSL re-export blocks (`->Name: {}`) exist in any of the three +manifests. Facade obligations are language-level: the zone +`__init__.py` must list the eleven names in `__all__`; the `goga/hooks` +facade already re-exports `declared_actions`; the `goga/topics` facade +already re-exports the six routines (signature of `enter_topic_todo` +grows the parameter — no facade change). + +### Usages Context + +- **`convention`** (`.goga/usages/conventions.md` — connected by both + changed cells and the zone): the project's mandatory Python rules — + relative imports, kw-only dataclasses, Google docstrings mirroring the + manifest annotations, logging, test structure, mock boundaries, + validation commands. Relevant to every task. +- **`click`** (`.goga/usages/cooks/click.md` — connected by + `goga/topics`): the interactive moments of the domain — the numbered + candidate selection, the publication ask, the non-interactive + detection with its clean error. Relevant to the domain wiring tasks. +- **`editor-entry`** (imported from `goga/topics/editor`): the editor + session patterns (`edit_text` — blank/unchanged save returns None). + Relevant to the `enter_topic_todo` / `create_topic` tasks. +- **`topic-statuses`** (imported from `goga/history`): the status scale + patterns — used only by the board, which this plan does not touch. + Listed for completeness; no task applies it. + +### Imported Usages + +- **`declaring-actions`** from `goga/hooks` — + `goga/hooks/.usages/declaring-actions.md`: the domain-maintainer side + of opening an action — catalog record, context contract, emission at + the checkpoint; the same context instance is returned to share per + tool. Applied by the five `emit_*` methods (Task 6) and reflected in + the catalog task (Task 1). +- **`per-tool-delivery`** from `goga/hooks` — + `goga/hooks/.usages/per-tool-delivery.md`: the staged per-tool + delivery loop skeleton — registry build once, subscriptions in + enumeration order, wrap/project/call, commit only after success. The + zone manifest declares one refinement: **the commit granularity is the + single hook, not the tool** — a fresh view per subscription, the + commit decided per subscription, so two hooks of one tool never share + a buffer or a failure. Applied by `amend_creation` / + `amend_todo_entry` (Task 6). +- **`registering-hooks`** from `goga/hooks` — + `goga/hooks/.usages/registering-hooks.md`: the tool-author + registration contract — the hook signature (`context`/`self` offered + names) and the failure behavior behind every checkpoint. Applied by + the amendment views (Task 5) and the walks (Task 6). +- **`topic-paths`** from `goga/history` — + `goga/history/.usages/topic-paths.md`: the topic directory composition + contract of `resolve_topic_dir`. Applied by `TopicIdentity.home_path` + (Task 3); also the consumer patterns of the history facade used by the + domain tasks. +- **`checkpoints`** from `goga/topics/hooks` — + `goga/topics/hooks/.usages/checkpoints.md`: the consumer practice of + the zone — one `TopicHooks` object per command, amend before + fixation, emit after the moment, facts from the operation's own data. + The binding practice for every checkpoint call site (Tasks 7–12). + +### Local Usages + +- `goga/topics/hooks/.usages/checkpoints.md` — functional category: the + checkpoint surface for domain-flow consumers. Status: **already + created** (with the manifest; the D8 advisory-amendment clause added + during design review). Related entities: `TopicHooks`, + `TopicIdentity`. No creation task needed — the file is current; Tasks + 7–12 apply it. +- `goga/topics/.usages/todo-entry.md` — one clause added (the written + content is the final amended text). Status: **already updated** in the + working tree. No task needed. +- `goga/topics/.usages/creating.md` — one clause added (todo.md content + and commit message are the final amended values). Status: **already + updated** in the working tree. No task needed. +- The design plans no other `.usages/` changes (optional one-line + clauses for `switching`/`deleting`/`ensuring` are explicitly not + required by the contract — do not add them). + +### External Dependencies + +- The hooks platform (`goga/hooks` facade: `HookRegistry`, + `emit_hook_event`, `wrap_context`, `build_hook_arguments`, + `declared_actions`) — consumed as-is, never modified. +- `goga/history` facade (`resolve_topic_dir`, + `normalize_topic_dir`-family, `remove_topic_dir`, ...) — consumed + as-is. +- The nested `goga/topics/git` and `goga/topics/editor` cells — consumed + as-is. +- Tools: `pytest` (with `pytest-cov`), `ruff`, and the `goga` CLI + (`goga lint`, `goga schema`, `goga hooks`). Python 3.10+; + `T | None` union syntax is the project norm. + +### Entity Interaction and Data Flow + +Verbatim from the design document: + +``` + goga/commands (unchanged CLI + pipeline) + │ create_topic / switch_topic / ensure_topic / + │ publish_topic / delete_topics / enter_topic_todo + ▼ + ┌───────────────────────┐ + │ goga/topics │ domain flows (six routines fire + │ creation / switching │ checkpoints at their moments) + │ publishing / ensuring│──── TopicHooks.amend_* (pre-fixation) + │ deletion │──── TopicHooks.emit_* (post-moment) + └──────────┬────────────┘ + │ from .hooks (relative import) + ▼ + ┌──────────────────────────────┐ + │ goga/topics/hooks │ the hooks zone + │ identity.py TopicIdentity│◀── resolve_topic_dir (goga/history) + │ contexts.py 5 contexts │ + │ amendments.py drafts+views │ + │ events.py TopicHooks + │ + │ _RUN_REGISTRY│◀── HookRegistry, emit_hook_event, + └──────────┬───────────────────┘ wrap_context, build_hook_arguments, + │ declared_actions (goga/hooks facade) + ▼ + ┌──────────────────────────────┐ + │ goga/hooks (platform) │ catalog.py (+7 records), + │ catalog / registry / │ registry, dispatch — unchanged code + │ dispatch / tools │ + └──────────────────────────────┘ +``` + +**Amendment flow (pre-fixation)** — e.g. `create_topic` step 6: + +1. The domain routine composes `TopicIdentity(slug, year, branch)` from + its own data (no repository reads) and calls + `TopicHooks().amend_creation(identity, checked_out, published, draft_message, draft_todo)`. +2. `events.py` obtains the shared run registry (`_run_registry()` — + built once per process run), resolves + `Action("topics", "amend_creation", "soft")` against `declared_actions()`. +3. A `CreationDraft` holder is created with the draft values. +4. Per subscription of the address, in enumeration order: a fresh + `CreationAmendment` view over the live holder is wrapped by + `wrap_context`, projected by `build_hook_arguments` with the tool's + `self_context`, and called. +5. A returned hook's buffer replaces the holder content (whole + replacement); a raised hook or an empty/whitespace buffer field is a + warning (`hook of tool failed on topics.amend_creation: + `) and the walk continues. +6. The holder returns to the domain, which reads the final + `commit_message`/`todo` and fixes them into the artifacts (commit + build / delegation / file write). + +**Notification flow (post-moment)** — e.g. `publish_topic` step 9: + +1. The routine composes `TopicIdentity` and the final facts (applied + message, captured commit hash, final todo). +2. `TopicHooks().emit_created(...)` builds the frozen `TopicCreated` + context and calls + `emit_hook_event(_run_registry(), "topics", "topic_created", context_for)`. +3. `emit_hook_event` (platform, unchanged) builds the registry once, + resolves the address, and delivers the **same context instance** to + every subscribed hook through the per-tool delivery proxy; a failing + hook of the soft action is a warning and the command continues. + +**Catalog flow**: `declared_actions()` gains the seven records; the +`goga hooks` inspection command and the address resolution of every +checkpoint read the same list — no command change. + +**Entity dependencies** (no cycles): `goga/hooks/catalog` imports +nothing; `goga/topics/hooks` imports `goga/hooks` (facade) and +`goga/history` — never its parent domain package; `goga/topics` imports +`goga/topics/hooks` via `from .hooks import ...` in `creation.py`, +`publishing.py`, `switching.py`, `ensuring.py`, `deletion.py`. + +## Facts + +- The three CODEMANIFEST files are already materialized in the working + tree: `goga/hooks/catalog/CODEMANIFEST` (12 `declared_actions` + requirement bullets — 7 topics records), `goga/topics/CODEMANIFEST` + (imports the zone, six reworked routines), `goga/topics/hooks/CODEMANIFEST` + (11 types). `goga lint`: 77 cells, 0 errors at design time. +- `goga/topics/hooks/.usages/checkpoints.md`, + `goga/topics/.usages/todo-entry.md`, and + `goga/topics/.usages/creating.md` already carry their planned content. +- `goga/hooks/catalog/catalog.py` currently carries 3 records in + `_DECLARED_ACTIONS` (onboarding ×2, statuses ×1). +- `_plant_topic_branch` (publishing.py) already returns the built commit + hash — the emissions reuse the existing return, no new git reads. +- `_apply_candidate` (switching.py) currently returns only the result + line; `_short_name` lives in `goga/topics/board.py`. +- `enter_topic_todo` (public wrapper) + `_enter_topic_todo` (unwrapped + mirror returning `bool`) exist in creation.py; the mirror's return + becomes the final written text (`str | None`) — decision D6. +- `ensure_topic`'s `_create_fresh_work` currently calls the public + `enter_topic_todo(identifier, year)`. +- The `goga/hooks` facade re-exports `HookRegistry`, `emit_hook_event`, + `wrap_context`, `build_hook_arguments`, `declared_actions`; + `resolve_topic_dir` is on the `goga/history` facade — all imports of + the zone manifest resolve. +- `tests/hooks/conftest.py` provides the platform-environment fixture + family (`pin_package_environment`, `install_tool_package`) — the zone + and domain test conftests re-declare them locally per the design. +- `tests/topics/conftest.py` exists with the `builtin_scale` fixture; + `tests/topics/test_{creation,publishing,switching,ensuring,deletion}.py` + exist and mock `enter_topic_todo` (switching/ensuring) and the + git/editor boundaries at the import point. +- `tests/hooks/catalog/test_catalog.py` exists (`TestCatalogContract`, + `TestDeclaredActions`). +- `docs/features/topics/hooks.md` is a stub ("no hook actions today"); + the declared-actions lists live in `docs/features/hooks/index.md`, + `docs/features/hooks/hooks.md`, `docs/features/tools/hooks.md`; the + page exists in `mkdocs.yml` nav — no nav change. +- Design decisions D1–D8 (see Tasks) are contract-freedom decisions, + fixed by the design review: D1 one registry per run; D2 switch branch + fact; D3 effective commit message; D4 applied draft message; D5 nulled + todo on a todo-requiring path; D6 private richer entry; D7 error class + honored from the catalog; D8 advisory amendment on the ensure fast + path. + +## Gap Analysis + +- **Missing contract entities**: all eleven `goga/topics/hooks` types — + the directory has no Python files at all (`__init__.py`, `identity.py`, + `contexts.py`, `amendments.py`, `events.py` all missing). +- **Missing facade exposure**: the eleven names are absent from any + facade (`goga/topics/hooks` has no `__init__.py`). +- **Incorrect `location` placement**: none — all planned files match the + manifest locations exactly (same directory level as `CODEMANIFEST`, + `.py` extension). +- **API mismatches**: `enter_topic_todo` lacks `branch: str | None = None`. +- **Behavioral mismatches**: `_DECLARED_ACTIONS` carries 3 of 10 + records; no domain routine delivers an amendment or emits a + notification; `_apply_candidate` returns no outcome kind; the todo + entries pass no branch fact; `_publish_topic` does not capture/emit. +- **Existing code that can be reused**: `_plant_topic_branch` (hash + return), `_enter_topic_todo` mirror (return-type change only), + `_apply_candidate`'s three branches (outcome mapping added), the + platform emission/delivery primitives (used as-is), the + `tests/hooks/conftest.py` fixture family (pattern for the new + conftests), the existing domain test suites (extended in place). +- **Test coverage gaps**: no `tests/topics/hooks/` directory; the domain + tests assert no checkpoints; the catalog test does not pin the topics + records; `tests/topics/conftest.py` lacks the platform fixtures and + the registry reset. +- **Missing visibility in workspace or git**: `goga/topics/hooks/` and + the `.goga/history/2026/add-topics-hooks/` directory are untracked; + `goga/hooks/catalog/CODEMANIFEST`, `goga/topics/CODEMANIFEST`, and the + two `.usages` files are modified — the materialized contract state is + present and authoritative for this plan. + +--- + +## Tasks + +> **Package ordering rule**: coding tasks for each package are completed before starting the next. Within each coding task, contract tests are written first (TDD workflow). +> +> Package order: `goga/hooks/catalog` (Task 1) → `goga/topics/hooks` +> (Tasks 2–6) → `goga/topics` (Tasks 7–12) → cross-cell docs and +> integration (Tasks 13–14). The catalog records must exist before any +> checkpoint fires (`emit_hook_event` raises `ValueError` on an unknown +> address). + +### Task 1: Extend the action catalog with the seven topics records (TDD coding) + +**Cell**: `goga/hooks/catalog`. **Entities**: `declared_actions` (data +extension only — the routine and `Action` are unchanged). **Locations**: +`goga/hooks/catalog/catalog.py`, `tests/hooks/catalog/test_catalog.py` +(extend the existing `TestDeclaredActions` class). + +The catalog is the single address source — every checkpoint the zone +will emit resolves its address against `declared_actions()`; today +`_DECLARED_ACTIONS` (catalog.py:40-44) carries 3 records and the +implementation appends the seven. The list stays maintained data: append +the records; the routine's `sorted()` fixes the output order regardless +of insertion order. + +**Usages relevant to this task:** +- `convention`: docstring style mirroring the manifest annotations, + kw-only dataclass rules (untouched here), test structure — the test + goes into the existing `TestDeclaredActions` class of + `tests/hooks/catalog/test_catalog.py`. +- `declaring-actions` (`goga/hooks/.usages/declaring-actions.md`): the + domain-maintainer side of opening an action — the catalog record is + step one; read it for why the records must exist before any emission. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: extend `tests/hooks/catalog/test_catalog.py` — a test asserting the seven topics records exist with `error_class="soft"` and the total is 10 (scenario below; expected to fail at this stage) +- [ ] **Code**: append seven `Action` records to `_DECLARED_ACTIONS` in `goga/hooks/catalog/catalog.py` — all `domain="topics"`, `error_class="soft"`, names: `amend_creation`, `amend_todo_entry`, `topic_created`, `topic_deleted`, `topic_published`, `topic_switched`, `topic_todo_entered` (the list stays in `(domain, name)` sorted order; `declared_actions()` behavior is otherwise untouched) +- [ ] **Interface verification**: `pytest tests/hooks/catalog/test_catalog.py -v` — all pass +- [ ] **Logic tests**: the assertions below already cover the behavior (determinism, completeness, record shape); add none beyond them +- [ ] **Debugging**: `pytest tests/hooks/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [ ] **Contract re-verification**: the `Action` dataclass and the `declared_actions` signature/return are unchanged; `goga hooks` lists the topics domain with no command change +- [ ] **Lint**: `ruff check goga/hooks/catalog` — fix formatting if necessary + +Test scenario (from the design — `test_declared_actions_carries_the_seven_topics_records`): + +``` +Setup: none (pure data). +Input: declared_actions() +Trace: +declared_actions() + -> sorted(_DECLARED_ACTIONS, key=(domain, name)) + returns: 10 records +Assertions: +topics = [a for a in declared_actions() if a.domain == "topics"] +[(a.name, a.error_class) for a in topics] == [ + ("amend_creation", "soft"), + ("amend_todo_entry", "soft"), + ("topic_created", "soft"), + ("topic_deleted", "soft"), + ("topic_published", "soft"), + ("topic_switched", "soft"), + ("topic_todo_entered", "soft"), +] +len(declared_actions()) == 10 # 3 existing + 7 topics +Sufficiency: the addresses must exist before any checkpoint fires; an +address the zone emits but the catalog misses is a runtime ValueError +in every flow — this pins the catalog against drift. +``` + +### Task 2: Zone package skeleton and test infrastructure (infrastructure) + +**Cell**: `goga/topics/hooks`. **Entities**: none yet — this task creates +the package skeleton and the zone test infrastructure the entity tasks +build on. **Locations**: `goga/topics/hooks/__init__.py` (new), +`tests/topics/hooks/__init__.py` (new), `tests/topics/hooks/conftest.py` +(new). + +Python needs `__init__.py` for the package to be importable; the entity +tasks (3–6) append their re-exports to it so the facade stays current at +every task boundary. The conftest re-declares the platform-environment +fixture family locally (the `tests/hooks/conftest.py` shape) plus the +`recording_hooks` helper — the run-registry reset joins in Task 6 (it +patches `goga.topics.hooks.events._RUN_REGISTRY`, which does not exist +until Task 6; declaring it earlier would break the suite at the Task 3–5 +boundaries). + +**Usages relevant to this task:** +- `convention`: relative imports, package structure, test-infrastructure + rules (fixtures live in the local conftest; tests under + `tests/topics/hooks/` mirror the package path). + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] Create `goga/topics/hooks/__init__.py` — the package docstring in the CODEMANIFEST Description voice (the hooks-zone owner description: importing the package imports no tool package and enumerates nothing) and an empty `__all__: list[str] = []` placeholder that Tasks 3–6 grow to the eleven names +- [ ] Create `tests/topics/hooks/__init__.py` (empty, the tests package marker) +- [ ] Create `tests/topics/hooks/conftest.py` with the two platform-environment fixtures re-declared locally (the `tests/hooks/conftest.py` shape): `pin_package_environment` — pins `goga.hooks.tools.packages.packages_distributions` to a fixed mapping (`{"goga_tool_one": ["pkg-one"], "goga_tool_two": ["pkg-two"]}`); `install_tool_package(module_name, register_hooks)` — mounts fake `goga_tool_*` modules in `sys.modules` (monkeypatch-undone) +- [ ] Add to the same conftest the `recording_hooks` fixture — subscribes recording hooks (appending `(tool, hook_name, context)` tuples and captured facts to lists the tests assert) built on the two fixtures above +- [ ] Verify the package imports: `python -c "import goga.topics.hooks"` and the suite still collects: `pytest tests/topics/hooks/ --collect-only -q` (no test files yet — collection must be clean) +- [ ] Lint: `ruff check goga/topics/hooks tests/topics/hooks` — fix formatting if necessary + +### Task 3: `TopicIdentity` — the identity vocabulary (TDD coding) + +**Cell**: `goga/topics/hooks`. **Entities**: `TopicIdentity` +(`identity.py`). **Locations**: `goga/topics/hooks/identity.py` (new), +`goga/topics/hooks/__init__.py` (add the re-export), +`tests/topics/hooks/test_identity.py` (new). + +**Usages relevant to this task:** +- `convention`: `@dataclass(frozen=True, kw_only=True)` data-model rules + (the `Action`/`Stage` precedent), relative imports + (`from ...history import resolve_topic_dir` inside the zone — three + leading dots: `goga.topics.hooks` → package root → `goga.history`). +- `topic-paths` (`goga/history/.usages/topic-paths.md`): the topic + directory composition contract behind `home_path` — the composition + runs through `resolve_topic_dir`; `resolve_topic_dir` re-normalizes + its input (idempotent for an already-normalized slug) and returns the + `.goga/history//` path. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: create `tests/topics/hooks/test_identity.py` — facade accessibility (`from goga.topics.hooks import TopicIdentity`), kw-only construction (`TopicIdentity(slug=..., year=..., branch=...)`; positional construction raises `TypeError`), frozen behavior (attribute assignment raises `FrozenInstanceError`), the three property types (expected to fail at this stage) +- [ ] **Code**: create `goga/topics/hooks/identity.py` per the algorithm below — `@dataclass(frozen=True, kw_only=True)` with `slug: str | None`, `year: str`, `branch: str | None`, and the `home_path` property +- [ ] **Code**: add `TopicIdentity` to `goga/topics/hooks/__init__.py` (relative import from `.identity`, append to `__all__` keeping alphabetical order) +- [ ] **Interface verification**: `pytest tests/topics/hooks/test_identity.py -v` — all pass +- [ ] **Logic tests**: the pure-composition scenario below (positive), the branch-only form `slug=None` → `home_path is None` (edge), the deletion form `branch=None` keeps `home_path` composed (edge) +- [ ] **Debugging**: `pytest tests/topics/hooks/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [ ] **Contract re-verification**: facade import works; property set is exactly `slug`/`home_path`/`branch` with the declared types; no repository reads, nothing created (pure composition) +- [ ] **Lint**: `ruff check goga/topics/hooks tests/topics/hooks` — fix formatting if necessary + +Algorithm (from the design): + +``` +1. @dataclass(frozen=True, kw_only=True) with fields slug: str | None, + year: str, branch: str | None +2. home_path property: + IF slug is None -> None + ELSE -> resolve_topic_dir(slug, year).as_posix() + +Errors: none — the empty-slug ValueError of resolve_topic_dir is +unreachable (slug non-None on the composing branch, and re-normalization +of a normalized slug is the identity). + +Edge cases: +- Branch-only form (slug=None) -> home_path None. +- Deletion form (branch=None) -> the removal composition carries the + branch names instead. +``` + +Test scenario (from the design — `test_topic_identity_home_path_composes_purely`): + +``` +Setup: none (pure composition; no filesystem). +Input: TopicIdentity(slug="add-topics-hooks", year="2026", +branch="add-topics-hooks"). +Trace: +TopicIdentity(slug=..., year=..., branch=...) + -> identity.home_path + -> resolve_topic_dir("add-topics-hooks", "2026") + -> normalize (idempotent) -> .goga/history/2026/add-topics-hooks + -> .as_posix() + returns: ".goga/history/2026/add-topics-hooks" +Assertions: +identity.home_path == ".goga/history/2026/add-topics-hooks" +identity.slug == "add-topics-hooks"; identity.branch == "add-topics-hooks" +Sufficiency: the home path is the canonical addressing fact every +notification carries; a regression here corrupts every tool's view of +the topic location. +``` + +### Task 4: The five notification contexts (TDD coding) + +**Cell**: `goga/topics/hooks`. **Entities**: `TopicCreated`, +`TopicPublished`, `TopicSwitched`, `TopicTodoEntered`, `TopicDeleted` +(all `contexts.py`). **Locations**: `goga/topics/hooks/contexts.py` +(new), `goga/topics/hooks/__init__.py` (add five re-exports), +`tests/topics/hooks/test_contexts.py` (new). + +The contexts are read-only fact bags: an `emit_*` method constructs one +from the values the caller passed; a hook observes and cannot alter +(frozen; assignment is also blocked on the delivery proxy). Plain +attribute reads suffice — no computed members, no method surface, no +write path. + +**Usages relevant to this task:** +- `convention`: `@dataclass(frozen=True, kw_only=True)` rules, Google + docstrings mirroring the manifest annotations, relative import of + `TopicIdentity` from `.identity`. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: create `tests/topics/hooks/test_contexts.py` — per context: facade accessibility, kw-only construction, frozen behavior (assignment raises), the exact field set with declared types, `identity: TopicIdentity` carried through (expected to fail at this stage) +- [ ] **Code**: create `goga/topics/hooks/contexts.py` per the algorithm below — five `@dataclass(frozen=True, kw_only=True)` classes with fields exactly as the signatures declare; plain data fields only +- [ ] **Code**: add the five names to `goga/topics/hooks/__init__.py` (relative imports from `.contexts`, `__all__` stays alphabetical) +- [ ] **Interface verification**: `pytest tests/topics/hooks/test_contexts.py -v` — all pass +- [ ] **Logic tests**: field-passthrough reads per context (each constructor value reads back identically); `TopicSwitched.outcome` accepts and returns each of the three fixed kinds (`local-checkout`, `created-from-remote`, `already-on-branch`) — the kind is fixed by construction of the emitting routine, so the context itself just carries the string +- [ ] **Debugging**: `pytest tests/topics/hooks/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [ ] **Contract re-verification**: facade imports work; the five field lists are identical to the method parameters beyond `identity` of the matching `emit_*` signatures (interface↔type consistency); no method surface, no write path +- [ ] **Lint**: `ruff check goga/topics/hooks tests/topics/hooks` — fix formatting if necessary + +Algorithm (from the design): + +``` +1. Five @dataclass(frozen=True, kw_only=True) classes, fields exactly + as the signatures declare: + TopicCreated(identity, checked_out, published, todo, commit_message, commit_hash) + TopicPublished(identity, commit_message, commit_hash, todo) + TopicSwitched(identity, outcome) + TopicTodoEntered(identity, text) + TopicDeleted(identity, local_branch, origin_twin, directory_removed) +2. Plain data fields (attribute reads suffice; no computed members) + +Errors: none. +Edge cases: TopicSwitched.outcome is one of the three fixed kinds by +construction of the emitting routine. +``` + +### Task 5: Draft holders and amendment views (TDD coding) + +**Cell**: `goga/topics/hooks`. **Entities**: `CreationDraft`, +`TodoEntryDraft`, `CreationAmendment`, `TodoEntryAmendment` (all +`amendments.py`). **Locations**: `goga/topics/hooks/amendments.py` +(new), `goga/topics/hooks/__init__.py` (add four re-exports), +`tests/topics/hooks/test_amendments.py` (new). + +**Usages relevant to this task:** +- `convention`: kw-only dataclass rules; the holders are **mutable** + dataclasses (the `StatusRegistry` precedent) while the views are + mutable dataclasses with private fields. +- `registering-hooks` (`goga/hooks/.usages/registering-hooks.md`): the + views are the delivered objects hooks receive — reads pass through the + proxy, assignment is blocked on the proxy, `amend` is a plain method + call. The hook signature (`context`/`self` offered names) and failure + behavior behind every checkpoint are defined there. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: create `tests/topics/hooks/test_amendments.py` — per type: facade accessibility, kw-only construction, the read-through properties (`view.commit_message`/`view.todo`/`view.text` read the live holder fields); `amend` returns `None` and raises nothing (expected to fail at this stage) +- [ ] **Code**: create `goga/topics/hooks/amendments.py` per the algorithm below — the two mutable holders with the private `_commit`, and the two views storing the holder under the private field `_draft` with the private `_buffered` buffer (`init=False, repr=False`, default `None`) +- [ ] **Code**: add the four names to `goga/topics/hooks/__init__.py` (relative imports from `.amendments`, `__all__` stays alphabetical) +- [ ] **Interface verification**: `pytest tests/topics/hooks/test_amendments.py -v` — all pass +- [ ] **Logic tests**: the two design scenarios below (`test_amend_views_block_no_write_path_to_the_holder`, `test_amend_called_twice_last_buffer_wins`) plus: a repeated `amend` overwrites the buffer (whole replacement, last wins — covered by the second scenario); `amend(None, None)` is a lawful whole replacement that buffers without holder contact +- [ ] **Debugging**: `pytest tests/topics/hooks/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [ ] **Contract re-verification**: facade imports work; the views expose no write path to the holder (`_draft` is private; `commit_message`/`todo`/`text` are read-through properties, not fields); no cancel/redirect/defer method exists; buffering never raises +- [ ] **Lint**: `ruff check goga/topics/hooks tests/topics/hooks` — fix formatting if necessary + +Algorithm (from the design — includes the review-fixed `_draft` rule): + +``` +CreationDraft / TodoEntryDraft: +1. @dataclass(kw_only=True) — mutable (the StatusRegistry precedent): + CreationDraft(commit_message: str | None, todo: str | None) + TodoEntryDraft(text: str) +2. _commit(values) — private; the single mutation point: replaces the + whole content from the walk + +CreationAmendment / TodoEntryAmendment: +1. @dataclass(kw_only=True) with the signature fields, the holder stored + under the private field name `_draft` (the walk in events.py is the + sole constructor caller: CreationAmendment(identity=..., + checked_out=..., published=..., _draft=holder) — the kw name is an + internal wiring detail; the CODEMANIFEST signature documents the + input semantically), plus a private _buffered field (init=False, + repr=False, default None) +2. commit_message / todo / text properties read through self._draft + (the live holder) — the holder is never a public attribute of the + view, so a delivered view exposes no write path to it: the proxy + blocks assignment on the view, and reaching `_draft` deliberately + is out-of-contract usage (the same cooperative trust the platform + gives the `self` context) +3. amend(...) -> assigns self._buffered = the whole replacement + (CreationAmendment: (commit_message, todo); TodoEntryAmendment: text); + no holder contact + +Errors: none — buffering never raises. +Edge cases: +- A hook calling amend twice -> the last buffer wins. +- amend(None, None) -> a lawful whole replacement to the identity-only + form (commits; the path guards decide the consequences — D5). +``` + +Test scenarios (from the design): + +`test_amend_views_block_no_write_path_to_the_holder`: +``` +Setup: a holder and a view constructed directly; no platform. +Input: view.amend("m", "t"); then read holder.commit_message. +Trace: amend buffers on the view only; the holder fields stay at the +draft values. +Assertions: +holder.commit_message == ; holder.todo == +view.commit_message reads the live holder (the draft values, not the buffer) +Sufficiency: "the content changes only through the delivery commit — +never through a delivered view" — the buffering isolation that makes +the discard-on-failure semantics possible. +``` + +`test_amend_called_twice_last_buffer_wins`: +``` +Setup: enumeration pinned; one tool subscribing `fickle` whose hook +calls context.amend("first", "t-first") then +context.amend("second", "t-second"); registry reset. +Input: TopicHooks().amend_creation(identity, False, False, "orig", +"orig todo"). +Trace: +walk: fickle buffers ("first", "t-first") then overwrites its buffer + with ("second", "t-second"); the holder stays untouched during + both calls +returns -> view._buffered == ("second", "t-second") -> committed whole +Assertions: +draft.commit_message == "second"; draft.todo == "t-second" +Sufficiency: pins the whole-replacement last-wins semantics of the +buffer — the sole guarantee that amend means "replace entirely", not +"extend"; prevents a drift to an accumulative or first-wins semantics +no other scenario distinguishes. +``` + +Note: the walk-dependent trace of the second scenario executes fully +only after Task 6; write the test now against the view/holder semantics +(hook double calls `view.amend` twice, then assert the buffer content +committed through a direct `_commit`), or defer the full-walk variant to +Task 6's `test_events.py` — in either case the last-wins assertion is +mandatory in this task. + +### Task 6: `TopicHooks` and the run registry — the checkpoint surface (TDD coding) + +**Cell**: `goga/topics/hooks`. **Entities**: `TopicHooks` (`events.py`), +the module-level `_RUN_REGISTRY` with the lazy `_run_registry()` +builder. **Locations**: `goga/topics/hooks/events.py` (new), +`goga/topics/hooks/__init__.py` (add the re-export — `__all__` reaches +its final eleven names), `tests/topics/hooks/conftest.py` (add the +autouse run-registry reset), `tests/topics/hooks/test_events.py` (new). + +This is the core of the zone: the two amendment walks (per-hook staged +delivery over the public primitives) and the five plain emissions over +`emit_hook_event`. Decision D1 — one registry per run: the shared +module-level lazily-built `HookRegistry`, so nested public calls +(`ensure_topic` → `switch_topic` → `enter_topic_todo`; +`create_topic` → `publish_topic`) never multiply the package +enumeration. + +**Usages relevant to this task:** +- `convention`: one module logger in `events.py`; `logger.warning` with + `%s` placeholders (the `emit.py` style); relative imports + (`from ...hooks import HookRegistry, emit_hook_event, wrap_context, + build_hook_arguments, declared_actions`). +- `declaring-actions` (`goga/hooks/.usages/declaring-actions.md`): the + emission contract — `emit_hook_event(registry, "topics", "", + context_for=lambda _tool: context)`; the same instance shared per + tool; the emission assembles the registry on first use. +- `per-tool-delivery` (`goga/hooks/.usages/per-tool-delivery.md`): the + staged delivery loop skeleton — build once, subscriptions in + enumeration order, wrap/project/call, commit only after success, warn + naming tool/action/reason, never filter delivery, + `build_hook_arguments` as the single projection. **With the declared + refinement: the commit granularity is the single hook, not the tool** + — a fresh view per subscription, the commit decided per subscription + (no grouping by tool). +- `registering-hooks` (`goga/hooks/.usages/registering-hooks.md`): the + hook receives values only for the declared offered names (`context`, + `self`); the tool's `self` context is the same instance across every + checkpoint of the run (shared registry). + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: create `tests/topics/hooks/test_events.py` — facade accessibility of `TopicHooks`; cheap construction (scenario `test_topic_hooks_construction_enumerates_nothing` below); the seven method signatures callable as declared (expected to fail at this stage) +- [ ] **Code**: create `goga/topics/hooks/events.py` per the algorithm below — `_RUN_REGISTRY` module state, `_run_registry()`, `TopicHooks()` with `amend_creation`, `amend_todo_entry`, and the five `emit_*` methods +- [ ] **Code**: add `TopicHooks` to `goga/topics/hooks/__init__.py` — the final facade: `__all__` carries exactly the eleven names, alphabetically (`CreationAmendment`, `CreationDraft`, `TodoEntryAmendment`, `TodoEntryDraft`, `TopicCreated`, `TopicDeleted`, `TopicHooks`, `TopicIdentity`, `TopicPublished`, `TopicSwitched`, `TopicTodoEntered`) +- [ ] **Code**: add the autouse run-registry reset to `tests/topics/hooks/conftest.py` — `monkeypatch.setattr("goga.topics.hooks.events._RUN_REGISTRY", None)` — every test starts with an unbuilt registry, so no subscription leaks across tests and enumeration counts are per-test +- [ ] **Interface verification**: `pytest tests/topics/hooks/test_events.py -v` — all pass +- [ ] **Logic tests**: the eight design scenarios below — `test_amend_creation_walks_per_hook_and_commits_in_order`, `test_emit_created_shares_one_instance_and_returns_none`, `test_amend_creation_discards_buffer_of_raising_hook`, `test_amend_creation_rejects_empty_amendment_whole`, `test_amend_todo_entry_rejects_blank_text_buffer`, `test_run_registry_built_once_across_checkpoints`, `test_amend_creation_without_subscriptions_returns_original_values`, `test_amend_creation_identity_only_form_is_valid` — plus the construction scenario `test_topic_hooks_construction_enumerates_nothing` (below) +- [ ] **Debugging**: `pytest tests/topics/hooks/ -x` then `pytest tests/hooks/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [ ] **Contract re-verification**: the facade check passes — `python -c "from goga.topics.hooks import TopicHooks, TopicIdentity, CreationDraft, TodoEntryDraft, TopicCreated, TopicPublished, TopicSwitched, TopicTodoEntered, TopicDeleted, CreationAmendment, TodoEntryAmendment"`; no post-walk application of any amendment (the caller fixes the final draft itself); no subscriber of the address is skipped +- [ ] **Lint**: `ruff check goga/topics/hooks tests/topics/hooks` — fix formatting, apply decomposition if necessary + +Algorithm (from the design): + +``` +_RUN_REGISTRY: HookRegistry | None = None # module state, one per run + +_run_registry(): +1. IF _RUN_REGISTRY is None: create HookRegistry(), build_once(), + store it +2. return _RUN_REGISTRY + -> one enumeration per process run; every TopicHooks instance and + every checkpoint shares it (D1) + +TopicHooks(): +1. No state — cheap construction; no enumeration, no imports at init + +amend_creation(identity, checked_out, published, commit_message, todo): +1. registry = _run_registry() +2. record = resolve ("topics", "amend_creation") against declared_actions() + -> None is a clean ValueError of the emitting side +3. holder = CreationDraft(commit_message=commit_message, todo=todo) +4. FOR subscription IN registry.subscriptions_for("topics", "amend_creation"): + view = CreationAmendment(identity=..., checked_out=..., + published=..., _draft=holder) # outside the intercept + TRY: + proxy = wrap_context(view) + args = build_hook_arguments(subscription.hook, proxy, + registry.self_context(subscription.tool)) + subscription.hook(**args) + EXCEPT Exception AS reason: + IF record.error_class == "hard": RAISE ValueError( + "hook {name} of tool {tool} failed on topics.amend_creation: {reason}") + WARN "hook {name} of tool {tool} failed on topics.amend_creation: {reason}" + CONTINUE # buffer discarded + IF view._buffered is not None: + IF a structurally present field is empty/whitespace-only: + WARN ... ": the buffered amendment is empty or whitespace-only" + CONTINUE # whole buffer rejected + holder._commit(view._buffered) # whole replacement +5. return holder + +amend_todo_entry(identity, text): + the same walk over TodoEntryDraft/TodoEntryAmendment, address + topics.amend_todo_entry; the single-field rejection covers + text is None or not text.strip() + +emit_created / emit_published / emit_switched / emit_todo_entered / emit_deleted: +1. context = the frozen context from the values +2. emit_hook_event(_run_registry(), "topics", "", + context_for=lambda _tool: context) + -> the same instance per tool; the platform owns resolution, delivery, + and the soft warning +``` + +Walk details fixed by the design trace (verified against the platform): + +- The fresh `CreationAmendment` view is built **outside** the failure + intercept — a crashing view builder is the emitting side's bug, never + a hook failure (the `context_for` placement of `emit.py:83-85`). +- The intercept catches `Exception` only, mirroring `emit.py:87-95`. +- The hard-class branch raises `ValueError` in the same shape as + `emit.py:97-99` — dead today (all seven records are soft). +- The buffer rejection predicate (creation): + `(cm is not None and not cm.strip()) or (todo is not None and not todo.strip())` + — reject the **whole** buffer when either structurally present field is + empty or whitespace-only, with the reason string + `the buffered amendment is empty or whitespace-only`. +- The todo-entry rejection covers `text is None or not text.strip()` + (the contract types `text` as `str`; a None buffer value is treated as + the rejection case — this single predicate distinguishes the two + walks). +- The warning text is fixed: `hook of tool failed on + topics.: ` — `logger.warning` with `%s` placeholders, + one module logger in `events.py`. No INFO/DEBUG additions (the + platform's emit path owns the diagnostics surface). +- An address without subscriptions returns the original draft values — + not an error. +- Errors: `ValueError` (unknown address) is the emitting side's bug; + `ImportError` from `build_once` (a broken tool package import) is the + single fatal case, surfacing from the platform unchanged. + +Test scenarios (from the design): + +`test_amend_creation_walks_per_hook_and_commits_in_order` (positive): +``` +Setup: enumeration pinned to two tools; two packages installed — +goga_tool_one subscribing `first` and `second` to +topics.amend_creation, goga_tool_two subscribing `tail`; +run-registry reset applied. +Input: +hooks = TopicHooks() +draft = hooks.amend_creation( + identity, checked_out=False, published=False, + commit_message="goga: create topic add-topics-hooks", todo="first todo") +Hooks: `first` calls context.amend("m1", "t1"); `second` records +context.todo (live read) and calls context.amend("m2", "t2"); `tail` +records context.todo. +Trace: +amend_creation(...) + -> _run_registry(): build_once() enumerates both packages once + -> holder = CreationDraft("goga: create topic add-topics-hooks", "first todo") + -> sub one/first: view over holder; hook buffers ("m1", "t1") + returns -> holder._commit(("m1","t1")) [non-empty] + -> sub one/second: view reads context.todo == "t1" (committed amendment of the earlier hook) + hook buffers ("m2","t2") -> holder._commit(("m2","t2")) + -> sub two/tail: view reads context.todo == "t2" +returns holder +Assertions: +draft.commit_message == "m2"; draft.todo == "t2" # last committed buffer +second saw context.todo == "t1"; tail saw context.todo == "t2" +enumeration boundary called exactly once +Sufficiency: pins the per-hook commit granularity (two hooks of one +tool, independent buffers, ordered visibility) — the core refinement +the zone adds over the tool-grouped practice. +``` + +`test_emit_created_shares_one_instance_and_returns_none` (positive): +``` +Setup: enumeration pinned; two packages each subscribing one hook +to topics.topic_created. +Input: result = TopicHooks().emit_created(identity, checked_out=False, +published=False, todo="t", commit_message="m", commit_hash="abc123"). +Trace: +emit_created(...) + -> TopicCreated(...) built once + -> emit_hook_event(_run_registry(), "topics", "topic_created", + context_for=lambda _tool: context) + -> both hooks called with the delivered proxy +Assertions: +result is None +the two deliveries observe the identical underlying context — + each through its own fresh proxy (type is not TopicCreated), the + shared instance pinned one attribute deep: + recorded[0].identity is recorded[1].identity +each hook read: checked_out False, published False, todo "t", + commit_message "m", commit_hash "abc123", identity.home_path as composed +Sufficiency: the context-instance sharing and the fire-and-forget +contract of every notification — prevents per-tool copies (stale facts) +and accidental return-channel collection. +``` + +`test_amend_creation_discards_buffer_of_raising_hook` (negative): +``` +Setup: enumeration pinned; goga_tool_one subscribes `boom` +(calls context.amend("m", "t") then raise RuntimeError("kaputt")); +goga_tool_two subscribes `tail` (context.amend("late", "late-t")); +registry reset; caplog at WARNING. +Input: TopicHooks().amend_creation(identity, False, False, "orig", +"orig todo"). +Trace: +walk: boom raises after buffering -> buffer discarded, warning emitted +walk: tail returns -> buffer committed +returns holder +Assertions: +draft.commit_message == "late"; draft.todo == "late-t" # boom's buffer gone +any("hook boom of tool one failed on topics.amend_creation: kaputt" + in r.message for r in caplog.records) +the walk reached tail (its buffer landed) +Sufficiency: a failing hook never breaks the operation and never +leaks its buffer — the soft-action core guarantee. +``` + +`test_amend_creation_rejects_empty_amendment_whole` (negative): +``` +Setup: one tool subscribing `blank` -> +context.amend(" ", "fine text"); registry reset; caplog. +Input: TopicHooks().amend_creation(identity, False, False, "orig", +"orig todo"). +Trace: +walk: blank returns; buffer (" ", "fine text") +-> commit_message structurally present and whitespace-only +-> whole buffer rejected with the empty-amendment warning +returns holder with the original values +Assertions: +draft.commit_message == "orig"; draft.todo == "orig todo" +"failed on topics.amend_creation: the buffered amendment is empty or whitespace-only" + in caplog.text +Sufficiency: the whole-replacement rejection — a whitespace field +must not partially land (the message survives while the todo changes). +``` + +`test_amend_todo_entry_rejects_blank_text_buffer` (negative): +``` +Setup: enumeration pinned; one tool subscribing `blank` -> +context.amend(" ") (whitespace buffer); registry reset; caplog +at WARNING. +Input: TopicHooks().amend_todo_entry(identity, "saved text"). +Trace: +walk: blank returns; buffer " " +-> text buffer blank (whitespace-only) +-> whole buffer rejected with the empty-amendment warning +returns holder with the saved text +Assertions: +draft.text == "saved text" +"failed on topics.amend_todo_entry: the buffered amendment is empty or whitespace-only" + in caplog.text +Sufficiency: pins the single predicate that distinguishes the +todo-entry walk from the creation walk (text is None or +not text.strip() — a None buffer is rejected here, lawful on the +creation side) — a regression that unifies the two walks without +changing the contract is caught, and the write path is guaranteed a +non-blank text by rejection rather than by luck. +``` + +`test_run_registry_built_once_across_checkpoints` (edge): +``` +Setup: enumeration boundary mock (call-counting); one tool +subscribing to amend_creation, topic_created, topic_todo_entered, +amend_todo_entry; registry reset. +Input: +hooks = TopicHooks() +hooks.amend_creation(identity, False, False, None, None) # identity-only form +hooks.emit_created(identity, False, False, None, None, None) +hooks.amend_todo_entry(identity, "t") +hooks.emit_todo_entered(identity, "t") +TopicHooks().emit_switched(identity, "local-checkout") # a second instance +Trace: +every checkpoint -> _run_registry() -> the single built object +Assertions: +boundary.call_count == 1 +all checkpoints delivered to the subscriber +Sufficiency: D1 — "the checkpoints never multiply the package +enumeration", including across separate TopicHooks instances and +nested flows. +``` + +`test_amend_creation_without_subscriptions_returns_original_values` (edge): +``` +Setup: enumeration pinned to a tool subscribing nothing; registry +reset. +Input: amend_creation(identity, False, False, "m", None). +Trace: walk over zero subscriptions -> holder untouched. +Assertions: +draft.commit_message == "m"; draft.todo is None # no error +Sufficiency: the no-subscriber case is the everyday case — the +amendment is a transparent no-op the flows can always call. +``` + +`test_amend_creation_identity_only_form_is_valid` (edge): +``` +Setup: one tool subscribing a recorder (no amend call); registry +reset. +Input: amend_creation(identity, True, False, None, None). +Trace: holder created with (None, None); hook observes, buffers nothing. +Assertions: +draft.commit_message is None; draft.todo is None +recorded view read: checked_out True, published False, + commit_message None, todo None +Sufficiency: the identity-only form is the norm on the ensure fast +path — a hook must be able to observe it without acting. +``` + +`test_topic_hooks_construction_enumerates_nothing` (edge): +``` +Setup: enumeration boundary mock installed; registry reset. +Input: TopicHooks() (no checkpoint calls). +Trace: __init__ stores nothing, touches nothing. +Assertions: +boundary.call_count == 0; _RUN_REGISTRY stays None +Sufficiency: "cheap construction — no enumeration and no imports +happen at construction" — keeps import-time and construction-time +behavior identical for every consumer. +``` + +### Task 7: `enter_topic_todo` — the todo-entry checkpoint pair (TDD coding) + +**Cell**: `goga/topics`. **Entities**: `enter_topic_todo` (signature +gains `branch: str | None = None`) and the private mirror +`_enter_topic_todo` (its return becomes the final written text — D6). +**Locations**: `goga/topics/creation.py` (modify), +`tests/topics/conftest.py` (extend — the platform fixtures and the +registry reset for the domain tests), `tests/topics/test_creation.py` +(extend). + +This is the first domain task: extend `tests/topics/conftest.py` with +the platform-environment fixture family (`pin_package_environment`, +`install_tool_package`, `recording_hooks` — the same local shape as +`tests/topics/hooks/conftest.py`) and the autouse registry reset +(`monkeypatch.setattr("goga.topics.hooks.events._RUN_REGISTRY", None)`); +the zone is complete by now, so the reset is safe for every test under +`tests/topics/`. The child conftest of `tests/topics/hooks/` shadows the +parent for its own directory — no double application. + +**Usages relevant to this task:** +- `convention`: relative imports (`from .hooks import TopicHooks, + TopicIdentity` in creation.py), test structure, mock boundaries — + `edit_text` stubbed on the creation module, the git/editor boundaries + mocked at the import point per module. +- `editor-entry` (`goga/topics/editor/.usages/editor-entry.md`): the + editor session pattern — `edit_text(initial)` returns the saved text + or `None` on cancellation (blank/unchanged save → None). +- `topic-paths` (`goga/history/.usages/topic-paths.md`): the todo-file + path pattern (`resolve_topic_file`). +- `checkpoints` (`goga/topics/hooks/.usages/checkpoints.md`): the + binding practice — identity construction from operation data + (`TopicIdentity(slug=normalize_topic_slug(topic), year=resolved_year, + branch=branch)` — no repository reads), amendment delivery after the + save and before the write, notification emission after the write. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: update the signature-contract test of `tests/topics/test_creation.py` for the `branch` parameter (`inspect.signature` shape: `enter_topic_todo(topic: str, year: str | None = None, branch: str | None = None) -> bool`); add facade re-export check (`from goga.topics import enter_topic_todo` — unchanged, still importable) (expected to fail at this stage) +- [ ] **Code**: rework `enter_topic_todo` / `_enter_topic_todo` in `goga/topics/creation.py` per the algorithm below — the `branch` parameter, the amendment delivery, the emission, and the D6 return-type change of the mirror (`str | None`; the public wrapper returns `written is not None`) +- [ ] **Interface verification**: `pytest tests/topics/test_creation.py -v` — all pass (existing tests included) +- [ ] **Logic tests**: the three design scenarios below — `test_enter_topic_todo_writes_amended_text_and_emits_final` (positive), `test_enter_topic_todo_cancelled_entry_delivers_and_emits_nothing` (negative), `test_enter_topic_todo_failed_write_emits_nothing` (negative) +- [ ] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass; the existing switching/ensuring tests that mock `enter_topic_todo` keep passing unchanged (their assertions gain the `branch=` keyword only in Tasks 10–11) +- [ ] **Contract re-verification**: cancelled entry → no delivery, no emission, file untouched; emission follows the write and mutates nothing; the `OSError` wrapper boundary unchanged (the checkpoint code performs no I/O); the write is the last mutation +- [ ] **Lint**: `ruff check goga/topics` — fix formatting if necessary + +Algorithm (from the design): + +``` +enter_topic_todo(topic, year=None, branch=None): +1-3. unchanged (path resolve, prefill read, editor session); + cancelled -> False (nothing delivered or emitted) +4. identity = TopicIdentity(slug=normalize_topic_slug(topic), + year=resolved_year, branch=branch) +5. draft = TopicHooks().amend_todo_entry(identity, saved) + # the returned TodoEntryDraft holder — draft.text is the final text +6. _write_todo(topic, resolved_year, draft.text) -> the final text + (the single-trailing-newline rule applies to the final amended text; + the write is the last mutation) +7. TopicHooks().emit_todo_entered(identity, draft.text); return True + +Internal (D6): the existing unwrapped mirror _enter_topic_todo(topic, +year, branch) returns the final written text (str | None); the public +wrapper returns written is not None. +``` + +Test scenarios (from the design): + +`test_enter_topic_todo_writes_amended_text_and_emits_final`: +``` +Setup: tmp_path as cwd; topic directory +.goga/history/2026/feature-foo/ created; edit_text stubbed to return +"saved text"; enumeration pinned; one package subscribing an +amend_todo_entry hook (context.amend("amended text")) and a +topic_todo_entered recorder; registry reset. +Input: enter_topic_todo("feature-foo", year="2026", +branch="feature-foo"). +Trace: +enter_topic_todo(...) + -> resolve_topic_file -> path (file absent -> initial None) + -> edit_text(None) -> "saved text" + -> identity = TopicIdentity("feature-foo", "2026", "feature-foo") + -> amend_todo_entry(identity, "saved text") -> holder.text "amended text" + -> _write_todo: todo.md == "amended text\n" (UTF-8, single newline) + -> emit_todo_entered(identity, "amended text") +returns True +Assertions: +result is True +(todo.md).read_text() == "amended text\n" +recorded topic_todo_entered context.text == "amended text" +recorded context.identity.branch == "feature-foo" +Sufficiency: the pre-fixation/post-moment pair of the entry — the +file carries the amended text and the notification reports the same +final value (the .usages/todo-entry.md clause made executable). +``` + +`test_enter_topic_todo_cancelled_entry_delivers_and_emits_nothing`: +``` +Setup: topic directory present; edit_text -> None (cancelled); +recorders installed; registry reset. +Input: enter_topic_todo("feature-foo", year="2026"). +Trace: edit_text -> None -> return False before any delivery. +Assertions: +result is False +todo.md absent; recorded checkpoints == [] +Sufficiency: a cancelled entry is a non-event — the amendment +moment never arrives. +``` + +`test_enter_topic_todo_failed_write_emits_nothing`: +``` +Setup: tmp_path as cwd; topic directory created; edit_text +stubbed to return "saved"; _write_todo on the creation module +monkeypatched to raise OSError; a topic_todo_entered recorder +installed; registry reset. +Input: enter_topic_todo("feature-foo", year="2026"). +Trace: +enter_topic_todo -> save "saved" +-> amend_todo_entry delivered (holder.text "saved" — no subscriber) +-> _write_todo raises OSError +-> the wrapper converts it to ClickException; emit_todo_entered + is never reached +Assertions: +pytest.raises(click.ClickException) +recorded topic_todo_entered == [] +Sufficiency: pins the write-then-emit order on the failure path — +the only point where the order guarantees the event carries a written +fact; catches an emit-before-write or emit-in-finally regression. +``` + +### Task 8: `create_topic` — the creation amendment and notification (TDD coding) + +**Cell**: `goga/topics`. **Entities**: `create_topic` (checkpoint wiring +inside `_create_topic`). **Locations**: `goga/topics/creation.py` +(modify), `tests/topics/test_creation.py` (extend). + +Steps 1–5 (preflight, todo resolution, guards, publication ask) are +unchanged — every decision precedes the first mutation; a failing +preflight fires nothing. The amendment inserts between the ask and the +path branches; the emissions close the no-switch and switch paths; the +publication path delegates with the amended values and fires nothing +here. + +**Usages relevant to this task:** +- `convention`: relative imports, mock boundaries (`sys.stdin` pinned to + an interactive terminal via the tests/topics precedent — `isatty` → + True — wherever the editor-todo resolution must run). +- `click` (`.goga/usages/cooks/click.md`): the publication ask and the + non-interactive detection — unchanged behavior, pinned by the test + setups. +- `editor-entry`: the editor session of the todo resolution (`edit_text` + stub). +- `topic-paths`: the slug, existence, directory-creation, and todo-file + path patterns. +- `refs-and-switching` (`goga/topics/git/.usages/refs-and-switching.md`): + the checkout pattern of the switch path — `_enter_fresh_branch` and + its create-then-checkout sequence are unchanged; the wiring only adds + the emission after the path completes. +- `publishing` (`goga/topics/git/.usages/publishing.md`): the + quarantined plant of the no-switch path — `_plant_topic_branch` + already returns the commit hash. +- `checkpoints`: the creation amendment immediately before the first + mutation of the chosen path; `topic_created` after the last mutation + of the path. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: the public signature, result lines, and error surface are unchanged — assert via the existing contract tests of `tests/topics/test_creation.py` (they must keep passing unmodified; expected failure at this stage comes only from the new wiring assertions below) +- [ ] **Code**: rework `_create_topic` in `goga/topics/creation.py` per the algorithm below — the amendment step between the ask and the path branches, the no-switch hash capture and emission, the switch-path emission, the publication delegation with the final values +- [ ] **Interface verification**: `pytest tests/topics/test_creation.py -v` — all pass +- [ ] **Logic tests**: the three design scenarios below — `test_create_topic_no_switch_emits_created_with_commit_hash` (positive), `test_create_topic_failed_preflight_fires_nothing` (negative), `test_create_topic_switch_path_amended_null_todo_degrades_gracefully` (edge) +- [ ] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [ ] **Contract re-verification**: `topic_created` fires exactly once per successful creation (no-switch and switch paths emit here; the publication path's emission lives in the delegate); the amendment delivers exactly once, immediately before the first mutation; existing behavior (result lines, error surface, mutation order) unchanged +- [ ] **Lint**: `ruff check goga/topics` — fix formatting if necessary + +Algorithm (from the design — includes decisions D3, D4, D5, D12): + +``` +_create_topic — insert between the ask and the path branches: + publishing = _publication_asked(publish, resolved_todo) + identity = TopicIdentity(slug, resolved_year, branch_name) + draft = TopicHooks().amend_creation(identity, + checked_out=switch and not publishing, + published=publishing, + commit_message=, + todo=resolved_todo) + # the returned CreationDraft holder — its amended values replace the + # todo and the commit message carried into the mutation steps + # the applied message uses the `or` predicate: an empty template + # normalizes to the built-in default before the delegation + final_todo = draft.todo; final_message = draft.commit_message +no-switch branch: + IF final_todo is None -> the "needs a todo" clean error (D5 — + nothing has mutated yet; "a failed creation fires nothing" holds) + commit = _plant_topic_branch(branch_name, final_todo, base_commit, + slug, resolved_year, final_message) + TopicHooks().emit_created(identity, checked_out=False, published=False, + todo=final_todo, + commit_message=final_message or , + commit_hash=commit) +switch branch: _enter_fresh_branch(..., final_todo, ...) then + TopicHooks().emit_created(identity, checked_out=True, published=False, + todo=final_todo, commit_message=None, + commit_hash=None) +publication branch: publish_topic(branch_name, final_todo, base_ref, + final_message, year) +``` + +Draft-message composition (from the design trace, step 6): +`publishing` → the applied template +`(commit_message or _DEFAULT_COMMIT_MESSAGE).replace("{slug}", slug)` +— the `or` predicate deliberately normalizes an empty template to the +built-in default here, so the delegated publication lands the default +(a direct `publish_topic` call keeps its own `is not None` predicate — +behavior preserved there; this is the one documented exception); +no-switch → the applied built-in default; switch path → `None` (D4 — +the tool sees and amends the actual text, with the `{slug}` placeholder +already replaced). + +D3 (effective commit message): a hook may null the draft commit message +(`amend(None, ...)`); on a commit-building path the built-in domain +default then applies (the existing `_plant_topic_branch` fallback), and +the emission reports the message that lands in git +(`final_message or `). + +Test scenarios (from the design): + +`test_create_topic_no_switch_emits_created_with_commit_hash`: +``` +Setup: git boundary mocked at the import point (empty inventory, +current branch main, occupancy free, base resolved, plant wired to a +recording mock returning "deadbeef"); sys.stdin pinned to an +interactive terminal (isatty -> True, the tests/topics precedent) so +the editor-todo resolution runs; edit_text -> "the todo"; +enumeration pinned with a topic_created recorder; registry reset. +Input: create_topic("Feature/Foo_Bar", "HEAD", todo=None, year="2026") +(interactive terminal pinned; the editor stubbed). +Trace: +_create_topic -> preflight free; resolved_todo "the todo"; ask False (publish False) +-> amend_creation(identity(slug "feature-foo-bar", 2026, "Feature/Foo_Bar"), + checked_out False, published False, commit_message "goga: create topic feature-foo-bar", + todo "the todo") -> unamended (no subscriber) +-> _plant_topic_branch(... final values ...) returns "deadbeef" +-> emit_created(identity, False, False, "the todo", , "deadbeef") +returns "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" +Assertions: +result line unchanged +recorded context.commit_hash == "deadbeef" +recorded context.commit_message == "goga: create topic feature-foo-bar" +recorded context.todo == "the todo"; checked_out False; published False +plant mock called once (mutation order unchanged) +Sufficiency: the no-switch path's checkpoint wiring — the hash comes +from the existing plant return (no new git read) and the identity facts +come from the operation's own data. +``` + +`test_create_topic_failed_preflight_fires_nothing` (negative): +``` +Setup: occupancy conflict wired (check_branch_occupancy -> a +reason); recorders for all seven actions; registry reset. +Input: create_topic("Feature/Foo_Bar", "HEAD", todo="x", +year="2026"). +Trace: preflight conflict -> ClickException before any input-driven step. +Assertions: +pytest.raises(click.ClickException) +recorded emissions and amendments == [] +Sufficiency: "a failed creation fires nothing" — the amendment +delivers only immediately before the first mutation, never before the +decisions. +``` + +`test_create_topic_switch_path_amended_null_todo_degrades_gracefully` (edge): +``` +Setup: switch=True, todo resolved, inventory free; a tool whose +amend_creation hook calls context.amend(None, None) (nulls the +todo); recorders; registry reset. +Input: create_topic("Feature/Foo_Bar", "HEAD", todo="the todo", +switch=True, year="2026"). +Trace: +amendment commits (None, None) -> final_todo None +switch path: branch planted+checked out, dir ensured, no todo write +-> emit_created(identity, True, False, None, None, None) +Assertions: +topic_created fired once with todo None +todo.md absent (nothing written) +result line unchanged +Sufficiency: the switch path's todo is optional — a nulled amended +todo degrades gracefully and the notification reports the truth. +``` + +### Task 9: `publish_topic` — the publication pair (TDD coding) + +**Cell**: `goga/topics`. **Entities**: `publish_topic` (checkpoint +wiring inside `_publish_topic`). **Locations**: +`goga/topics/publishing.py` (modify), `tests/topics/test_publishing.py` +(extend). + +The routine is also the delegate of the creation's publication path. +Guards unchanged; nothing fires before the mutation chain. The plant +call is replaced by the applied-message computation plus hash capture; +a failed push rolls the branch back and surfaces the clean error — +nothing fires. After the successful push: `topic_created` then +`topic_published`. + +**Usages relevant to this task:** +- `convention`: relative imports (`from .hooks import TopicHooks, + TopicIdentity`), mock boundaries (git boundary mocked at the import + point). +- `topic-paths`: the slug, current-branch, and todo-file path patterns. +- `publishing` (`goga/topics/git/.usages/publishing.md`): the + quarantined commit building, branch planting, publication, and + rollback patterns. +- `checkpoints`: the publication notifications fire only after the push + succeeds, in the fixed order, with the identical final facts. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: the public signature and result line are unchanged — the existing contract tests of `tests/topics/test_publishing.py` keep passing unmodified; add the facade re-export check if absent (expected to fail at this stage only for the new wiring) +- [ ] **Code**: rework `_publish_topic` in `goga/topics/publishing.py` per the algorithm below — the applied message computed once, the plant hash captured, the two emissions after the successful push +- [ ] **Interface verification**: `pytest tests/topics/test_publishing.py -v` — all pass +- [ ] **Logic tests**: the two design scenarios below — `test_publish_topic_emits_created_then_published_after_push` (positive), `test_publish_topic_rollback_fires_nothing` (negative) +- [ ] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [ ] **Contract re-verification**: both contexts carry the same final message/hash/todo; rollback fires nothing; a direct call publishes without `amend_creation` (the creation amendment belongs to the creating orchestration) +- [ ] **Lint**: `ruff check goga/topics` — fix formatting if necessary + +Algorithm (from the design): + +``` +_publish_topic — replace the plant call: + applied = (commit_message if commit_message is not None + else _DEFAULT_COMMIT_MESSAGE).replace("{slug}", slug) + commit = _plant_topic_branch(branch_name, todo, base_commit, slug, + resolved_year, applied) + push; on failure the existing rollback — nothing fires +after the successful push: + identity = TopicIdentity(slug, resolved_year, branch_name) + TopicHooks().emit_created(identity, checked_out=False, published=True, + todo=todo, commit_message=applied, + commit_hash=commit) + TopicHooks().emit_published(identity, commit_message=applied, + commit_hash=commit, todo=todo) + +Edge case: a direct CLI call publishes without amend_creation (the +creation amendment belongs to the creating orchestration). +``` + +Note: `_plant_topic_branch` keeps its internal +`message.replace("{slug}", slug)` — on an already-applied text without +the placeholder it is a no-op. + +Test scenarios (from the design): + +`test_publish_topic_emits_created_then_published_after_push`: +``` +Setup: git boundary mocked (occupancy free, origin configured, base +resolved, plant -> "cafe123", push succeeding); enumeration pinned with +recorders for both actions; registry reset. +Input: publish_topic("Feature/Foo_Bar", "the todo", "HEAD", +year="2026"). +Trace: +_publish_topic -> guards pass +-> applied = "goga: create topic feature-foo-bar" +-> commit = _plant_topic_branch(..., applied) -> "cafe123" +-> push_branch OK +-> emit_created(identity, False, True, "the todo", applied, "cafe123") +-> emit_published(identity, applied, "cafe123", "the todo") +returns the unchanged line +Assertions: +emission order == ["topic_created", "topic_published"] +both contexts carry commit_hash "cafe123", commit_message applied, todo "the todo" +created.checked_out False; created.published True +Sufficiency: the publication pair fires only after the push, in the +fixed order, with the identical final facts — the contract's central +ordering guarantee. +``` + +`test_publish_topic_rollback_fires_nothing` (negative): +``` +Setup: git boundary mocked with push_branch raising +CalledProcessError (rollback recorded); both action recorders +installed; registry reset. +Input: publish_topic("Feature/Foo_Bar", "the todo", "HEAD", +year="2026"). +Trace: plant OK -> push raises -> delete_local_branch (rollback) -> ClickException +Assertions: +pytest.raises(click.ClickException) +recorded emissions == [] # nothing fired on the failure +delete_local_branch called once # the rollback still runs +Sufficiency: a rolled-back publication must leave no event trail — +tools would otherwise record a publication that does not exist. +``` + +### Task 10: `switch_topic` — the switch notification (TDD coding) + +**Cell**: `goga/topics`. **Entities**: `switch_topic` (checkpoint wiring +inside `_switch_topic` and the outcome mapping of `_apply_candidate`). +**Locations**: `goga/topics/switching.py` (modify), +`tests/topics/test_switching.py` (extend — including the existing +`enter_topic_todo` mock assertions for the new `branch=` keyword). + +`_apply_candidate` returns `(line, outcome)` — the three existing +return branches map one-to-one onto the kinds; the lines are unchanged. +The emission sits after `_apply_candidate` on every path — the +idempotent already-on-branch included. The branch fact (D2): the +identity's `branch` for `topic_switched` is the branch the working copy +is on after the switch — the candidate's display name for local +candidates, its short name for a remote-tracking candidate — the same +fact step 7 passes into `enter_topic_todo`. + +**Usages relevant to this task:** +- `convention`: relative imports (`from .hooks import TopicHooks, + TopicIdentity`), mock boundaries. +- `click`: the numbered selection prompt and the non-interactive + detection — unchanged; pinned by the test setups (interactive terminal + pinned for the todo flows). +- `refs-and-switching` (`goga/topics/git/.usages/refs-and-switching.md`): + the checkout and remote-tracking branch patterns. +- `checkpoints`: the switch notification after the completed switch, + every outcome included; the identity degrades to branch-only when the + chosen candidate hosts no topic. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: the public signature and result lines are unchanged — the existing contract tests of `tests/topics/test_switching.py` keep passing; extend the existing `enter_topic_todo` mock assertions for the new `branch=` keyword (expected to fail at this stage) +- [ ] **Code**: rework `_apply_candidate` to return `(line, outcome)` with the three-kind mapping, and `_switch_topic` per the algorithm below — the branch fact, the identity, the emission, the branch kwarg of the entry +- [ ] **Interface verification**: `pytest tests/topics/test_switching.py -v` — all pass +- [ ] **Logic tests**: the two design scenarios below — `test_switch_topic_emits_switched_for_every_outcome` (positive, parametrized over the three inventory scenarios plus the topic-less branch), `test_switch_todo_onto_topicless_branch_fires_nothing` (negative) +- [ ] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [ ] **Contract re-verification**: `topic_switched` fires on every completed switch; branch-only identity when the candidate hosts no topic; identity facts from the operation's own data (candidate's hosted slug, resolved year, branch name — no git reads); the `todo` no-topic guard fires before any mutation and emits nothing +- [ ] **Lint**: `ruff check goga/topics` — fix formatting if necessary + +Algorithm (from the design): + +``` +_apply_candidate(chosen) -> (line, outcome): + already-on-branch -> ("Already on branch X", "already-on-branch") + local checkout -> ("Switched to branch X", "local-checkout") + remote creation -> ("Created branch from X", "created-from-remote") + (the lines unchanged; the outcome mapping added) + +_switch_topic — after the mutation, before the todo entry: + branch_fact = _short_name(chosen.branch) if chosen.remote else chosen.branch + identity = TopicIdentity(slug=chosen.topic, year=resolved_year, + branch=branch_fact) + TopicHooks().emit_switched(identity, outcome) + IF todo: enter_topic_todo(chosen.topic, year, branch=branch_fact) + +Edge cases: the idempotent already-on-branch outcome still emits; the +todo no-topic guard fires before any mutation and emits nothing. +``` + +Test scenarios (from the design): + +`test_switch_topic_emits_switched_for_every_outcome` (parametrized): +``` +Setup: three inventory scenarios — (a) already on feature-foo; +(b) local branch feature-foo not current; (c) remote-tracking +origin/feature-foo only; tree-clean probe True; a topic_switched +recorder; registry reset. +Input: switch_topic("feature-foo", year="2026") per scenario +(candidate hosts topic feature-foo in (a)/(b); a fourth parametrization +uses a topic-less branch). +Trace (scenario c): +_switch_topic -> chosen = origin/feature-foo (remote, topic feature-foo) +-> create_branch_from_remote_tracking(...) # line unchanged +-> branch_fact = "feature-foo" (short name) +-> emit_switched(TopicIdentity("feature-foo", "2026", "feature-foo"), + "created-from-remote") +returns "Created branch feature-foo from origin/feature-foo" +Assertions: +(a) outcome "already-on-branch"; (b) "local-checkout"; (c) "created-from-remote" +topic-less branch: identity.slug is None; identity.home_path is None; + identity.branch == the branch name +all lines unchanged +Sufficiency: every completed switch fires exactly once, the idempotent +outcome included, and the branch-only degradation works — the marginal +corner of the switch contract. +``` + +`test_switch_todo_onto_topicless_branch_fires_nothing` (negative): +``` +Setup: one candidate bare-branch (topic None) with interactive +terminal; recorders installed; registry reset. +Input: switch_topic("bare-branch", todo=True, year="2026"). +Trace: chosen.topic is None and todo -> clean error before any mutation. +Assertions: +pytest.raises(click.ClickException, match="hosts no topic") +recorded checkpoints == [] +Sufficiency: the pre-mutation guard must also suppress the switch +notification — the only switch path that fires nothing. +``` + +### Task 11: `ensure_topic` — the fast-creation checkpoints and the branch facts (TDD coding) + +**Cell**: `goga/topics`. **Entities**: `ensure_topic` (checkpoint wiring +inside `_create_fresh_work` and `_enter_switched_todo`). +**Locations**: `goga/topics/ensuring.py` (modify), +`tests/topics/test_ensuring.py` (extend — including the existing +`enter_topic_todo` mock assertions for the new `branch=` keyword). + +The fast creation delivers the identity-only creation amendment +immediately before `create_and_switch_branch` (the first mutation) and +deliberately does not read the returned holder — D8: an amended todo +does not land on this path (the todo resolves later through the entry's +own `amend_todo_entry`, which owns the written text), and +`commit_message` stays None (the path builds no commit). The todo entry +uses the private mirror `_enter_topic_todo` (D6) so the final todo +feeds the `topic_created` emission. + +**Usages relevant to this task:** +- `convention`: relative imports (`from .creation import + _enter_topic_todo` — the private mirror, internal to the cell; + `from .hooks import TopicHooks, TopicIdentity`), mock boundaries + (`sys.stdin` isatty pin for the editor-todo path). +- `click`: the interactive moments inherited from the switch + orchestration. +- `topic-paths`: the slug and topic-directory patterns of the creation. +- `refs-and-switching`: the checkout and create-and-switch patterns. +- `checkpoints`: the creation amendment and the creation notification of + the fast creation — the file's advisory-amendment clause documents D8 + for tool authors. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: the public signature and result line are unchanged — the existing contract tests of `tests/topics/test_ensuring.py` keep passing; extend the existing `enter_topic_todo` mock assertions for the new `branch=` keyword (expected to fail at this stage) +- [ ] **Code**: rework `_create_fresh_work` and `_enter_switched_todo` in `goga/topics/ensuring.py` per the algorithm below +- [ ] **Interface verification**: `pytest tests/topics/test_ensuring.py -v` — all pass +- [ ] **Logic tests**: the two design scenarios below — `test_ensure_fast_creation_amends_identity_only_and_emits_after_entry` (positive), `test_ensure_todo_on_topicless_branch_fires_only_the_entry_pair` (edge) +- [ ] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [ ] **Contract re-verification**: the fast creation delivers the creation amendment exactly once, immediately before its first mutation, and emits `topic_created` after the creation completes (the identity-only amendment form is the norm on this path); directory creation under the todo flag of a topic-less branch fires no creation checkpoint — the todo entry alone fires its two; the todo entries pass the operation's branch fact; one registry across `ensure → switch → entry` (D1) +- [ ] **Lint**: `ruff check goga/topics` — fix formatting if necessary + +Algorithm (from the design): + +``` +_create_fresh_work — after the oracles, before create_and_switch_branch: + identity = TopicIdentity(slug, resolved_year, identifier) + TopicHooks().amend_creation(identity, checked_out=True, published=False, + commit_message=None, todo=None) + # the returned holder stays unread — the creation amendment observes + # only on this path (D8): the todo-entry amendment owns the written + # text, and the path builds no commit + create_and_switch_branch(identifier); ensure_topic_dir(identifier, year) + final_todo = _enter_topic_todo(identifier, year, branch=identifier) IF todo + TopicHooks().emit_created(identity, checked_out=True, published=False, + todo=final_todo, commit_message=None, + commit_hash=None) + +_enter_switched_todo — the entry calls gain the branch fact: + hosted topic -> enter_topic_todo(topic, year, branch=current) + fresh directory -> ensure_topic_dir(current, year); + enter_topic_todo(current, year, branch=current) + +Edge cases: the directory creation of a topic-less branch fires no +creation checkpoint; the todo entry alone fires its two. +``` + +Test scenarios (from the design): + +`test_ensure_fast_creation_amends_identity_only_and_emits_after_entry`: +``` +Setup: empty inventory (zero candidates), tree-clean True; +create_and_switch_branch recorded; sys.stdin pinned to an +interactive terminal (isatty -> True, the tests/topics precedent); +edit_text -> "fresh todo"; +enumeration pinned with amend_creation/topic_created/ +amend_todo_entry/topic_todo_entered recorders; registry reset. +Input: ensure_topic("New_Work", todo=True, year="2026"). +Trace: +_ensure_topic -> zero candidates -> _create_fresh_work +-> slug "new-work", oracles free +-> amend_creation(TopicIdentity("new-work","2026","New_Work"), + checked_out True, published False, commit_message None, todo None) +-> create_and_switch_branch("New_Work"); ensure_topic_dir +-> _enter_topic_todo("New_Work", "2026", branch="New_Work") -> "fresh todo" +-> emit_created(identity, True, False, "fresh todo", None, None) +Assertions: +amend_creation recorded once, before create_and_switch_branch (call order) +its context.commit_message is None and context.todo is None (identity-only) +topic_created context.todo == "fresh todo"; commit_hash None; checked_out True +topic_todo_entered fired with identity.branch "New_Work" +result line unchanged +Sufficiency: the fast-creation corner — identity-only amendment +before the first mutation, notification after the entry with the final +todo, all from one registry build. +``` + +`test_ensure_todo_on_topicless_branch_fires_only_the_entry_pair` (edge): +``` +Setup: one candidate bare-branch (topic None), tree-clean, +current branch becomes bare-branch after the switch; edit_text -> +"fresh"; recorders for all seven actions; registry reset. +Input: ensure_topic("bare-branch", todo=True, year="2026"). +Trace: +switch_topic -> emit_switched(branch-only identity) # the switch's own event +-> _enter_switched_todo: no hosted topic -> ensure_topic_dir(current) +-> _enter_topic_todo(current, year, branch=current) + -> amend_todo_entry(derived identity) -> emit_todo_entered +no amend_creation / topic_created anywhere +Assertions: +fired actions == ["topic_switched", "topic_todo_entered"] +topic_todo_entered identity.slug == "bare-branch" (derived) and + identity.branch == "bare-branch" +topic_created not recorded +Sufficiency: the marginal corner of the ensure contract — the +directory creation of a topic-less branch fires no creation +checkpoint; the entry alone fires its two. +``` + +### Task 12: `delete_topics` — the per-target deletion notification (TDD coding) + +**Cell**: `goga/topics`. **Entities**: `delete_topics` (checkpoint +wiring inside `_delete_topics`). **Locations**: +`goga/topics/deletion.py` (modify), `tests/topics/test_deletion.py` +(extend). + +The removal steps are unchanged — a failure mid-target restores and +raises before the emission. The new step fires inside the per-target +loop after the directory removal. + +**Usages relevant to this task:** +- `convention`: relative imports (`from .hooks import TopicHooks, + TopicIdentity`), mock boundaries (`remove_topic_dir` real over + `tmp_path`; deletions recorded). +- `deleting` (`goga/topics/git/.usages/deleting.md`): the symmetric + local-and-origin removal and the restore-on-failure patterns. +- `checkpoints`: the deletion notification after the target's full + removal, with the removal composition; no branch fact on the identity; + no deleted-commit hash carried. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: the public signature and result line are unchanged — the existing contract tests of `tests/topics/test_deletion.py` keep passing (expected to fail at this stage only for the new wiring) +- [ ] **Code**: extend the per-target loop of `_delete_topics` in `goga/topics/deletion.py` per the algorithm below +- [ ] **Interface verification**: `pytest tests/topics/test_deletion.py -v` — all pass +- [ ] **Logic tests**: the design scenario below — `test_delete_topics_emits_per_target_after_full_removal` (positive; covers the directory-less and remote-only target shapes as edge cases) +- [ ] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [ ] **Contract re-verification**: a target fires after its complete removal; targets fully removed before a later failure already fired theirs; a target whose removal fails midway fires nothing (the restore path raises before the emission); no commit hash carried +- [ ] **Lint**: `ruff check goga/topics` — fix formatting if necessary + +Algorithm (from the design): + +``` +_delete_topics — inside the per-target loop, after the directory removal: + directory_removed = (remove_topic_dir(target.topic, resolved_year) + if target.has_dir else False) + identity = TopicIdentity(slug=target.topic, year=resolved_year, branch=None) + TopicHooks().emit_deleted(identity, local_branch=target.branch, + origin_twin=target.remote, + directory_removed=directory_removed) + +Edge cases: a remote-only target (branch=None) still fires with its +twin name; a directory-less target reports directory_removed=False. +``` + +Test scenario (from the design): + +`test_delete_topics_emits_per_target_after_full_removal`: +``` +Setup: two targets +(DeleteTarget("one", branch="one", remote="one", has_dir=True), +DeleteTarget("two", branch=None, remote=None, has_dir=False)); git +boundary recorded (resolve_ref_commit -> hash, deletions succeeding, +remove_topic_dir real over tmp_path with .goga/history/2026/one/ +created); a topic_deleted recorder; registry reset. +Input: delete_topics(targets, year="2026"). +Trace: +_delete_topics +-> target one: capture commit, delete local, delete remote, remove dir -> True + -> emit_deleted(identity(slug "one", 2026, branch None), + local_branch "one", origin_twin "one", directory_removed True) +-> target two: no branch, no twin, no dir -> emit_deleted(identity, + local_branch None, origin_twin None, directory_removed False) +returns the unchanged line +Assertions: +two emissions, in target order +first: local_branch "one", origin_twin "one", directory_removed True +second: all-absent composition, directory_removed False +both identities: branch is None; home_path ".goga/history/2026/" +Sufficiency: the per-target timing and the removal-composition +mapping — including the directory-less and remote-only target shapes. +``` + +### Task 13: Documentation synchronization (infrastructure) + +**Scope**: the seven-action reference for tool authors and the +declared-actions lists. **Locations**: `docs/features/topics/hooks.md` +(replace the stub), `docs/features/hooks/index.md`, +`docs/features/hooks/hooks.md`, `docs/features/tools/hooks.md` (extend +the declared-actions lists). No `mkdocs.yml` nav change (the page +exists). + +The current `docs/features/topics/hooks.md` states the topics domain +exposes no hook actions — after this plan it exposes seven. The three +declared-actions lists enumerate the catalog per domain; the topics +domain joins them. + +**Usages relevant to this task:** +- `checkpoints` (`goga/topics/hooks/.usages/checkpoints.md`): the + authoritative consumer description of the surface — the reference page + restates it for tool authors (the checkpoint moments, the context + surfaces, the amendment contract). + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] Replace the stub `docs/features/topics/hooks.md` with the seven-action reference: the checkpoint moments (amend before fixation, emit after the moment), the context surfaces (the five notification contexts and the two amendment views with their fields), and the amendment contract (whole replacement, empty/whitespace rejection, soft failure, the advisory-amendment note of the ensure fast path) +- [ ] Add the topics domain (the seven actions with their error class) to the declared-actions lists in `docs/features/hooks/index.md`, `docs/features/hooks/hooks.md`, and `docs/features/tools/hooks.md` +- [ ] Verify: `mkdocs build` stays green (or the project's docs validation command), and every documented action name matches `declared_actions()` exactly +- [ ] No `mkdocs.yml` change — the page exists in the nav + +### Task 14: Cross-cell integration validation (integration tests) + +**Scope**: the whole feature across the three cells — zone, domain +wiring, catalog, docs. All named scenarios already landed in Tasks 1–12; +this task verifies the assembled whole against the contract-level +guarantees and the goga tooling. + +**Usages relevant to this task:** +- `convention`: the validation commands table — all commands run in a + virtualenv; Python 3.10+ compatibility. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] Run the full suite: `pytest tests/ -x` — green (all 26 named scenarios plus the existing tests; the platform, the zone, and the domain tests run together — proving no registry/subscription leak and no import cycle) +- [ ] Facade check: `python -c "from goga.topics.hooks import TopicHooks, TopicIdentity, CreationDraft, TodoEntryDraft, TopicCreated, TopicPublished, TopicSwitched, TopicTodoEntered, TopicDeleted, CreationAmendment, TodoEntryAmendment"` — passes +- [ ] Catalog surface: `goga hooks` lists the seven topics actions (all soft) with no command change +- [ ] Manifest validation: `goga lint` — 0 errors (stays at the design-time baseline); `goga schema goga/topics` resolves the `goga/topics/hooks` subcell and shows `goga/topics` importing it +- [ ] Behavior preservation sweep: the result lines, error messages, and mutation order of the six routines are unchanged — re-run the pre-existing domain tests untouched by the checkpoint additions and confirm no assertion was weakened to accommodate the wiring + +--- + +## Validation Commands + +- `pytest tests/ -x`: Run all tests +- `pytest tests/hooks/catalog/test_catalog.py -v`: Catalog contract and records +- `pytest tests/topics/hooks/ -v`: Zone tests (identity, contexts, amendments, events) +- `pytest tests/topics/ -v`: Domain tests (creation, publishing, switching, ensuring, deletion) +- `ruff check goga/hooks/catalog goga/topics`: Lint the touched sources +- `python -c "from goga.topics.hooks import TopicHooks, TopicIdentity, CreationDraft, TodoEntryDraft, TopicCreated, TopicPublished, TopicSwitched, TopicTodoEntered, TopicDeleted, CreationAmendment, TodoEntryAmendment"`: Facade accessibility of the eleven zone names +- `goga hooks`: The seven topics actions are listed (no command change) +- `goga lint`: Manifest validation stays at 0 errors +- `goga schema goga/topics`: The `goga/topics/hooks` subcell resolves and `goga/topics` imports it + +--- + +## Completion Criteria + +- [ ] Every contract entity is implemented in the correct `location` (`identity.py`, `contexts.py`, `amendments.py`, `events.py`, `__init__.py` of the zone; the six domain routines in their existing files) +- [ ] Every contract entity is accessible from the facade (the eleven zone names in `__all__`; the six routines from `goga.topics`) +- [ ] Properties and methods match the declared API (kw-only constructors; frozen identity/contexts; mutable holders/views with the private `_draft`/`_buffered` fields) +- [ ] Descriptions are reflected in behavior (the walks' commit/rejection rules, the emissions' same-instance delivery, the domain checkpoint moments per the traces) +- [ ] Contract dependencies are met (the five platform names and `resolve_topic_dir` import through the declared facades; no new import cycle) +- [ ] Re-exports are accessible from the facade (no DSL re-export blocks exist; the language-level facade obligations hold) +- [ ] Every coding task followed the TDD workflow (contract tests → code → verification → logic tests → debugging → re-verification → lint) +- [ ] Contract tests and logic tests cover facade, API, and behavior within each coding task (all 26 named scenarios plus per-task contract tests) +- [ ] Integration tests exist where cross-entity scenarios require them (Task 14; the cross-flow scenarios landed in Tasks 6, 10, 11) +- [ ] No package boundary was expanded (no new cells, no changes to `goga/commands/**` or the platform cells `goga/hooks/{dispatch,registry,tools}`) +- [ ] `CODEMANIFEST` files were not modified (contract is read-only) +- [ ] All validation commands pass +- [ ] Every Usages entry is mentioned in at least one task (`convention` — all tasks; `declaring-actions`, `per-tool-delivery`, `registering-hooks` — Tasks 1, 5, 6; `topic-paths` — Tasks 3, 7, 8, 9, 11; `checkpoints` — Tasks 7–13; `click` — Tasks 8, 10, 11; `editor-entry` — Tasks 7, 8; `refs-and-switching` — Tasks 8, 10, 11; `publishing` — Tasks 8, 9; `deleting` — Task 12; `topic-statuses` — untouched by this plan, noted in Usages Context) diff --git a/.goga/history/2026/add-topics-hooks/prd.md b/.goga/history/2026/add-topics-hooks/prd.md new file mode 100644 index 00000000..2fc4710a --- /dev/null +++ b/.goga/history/2026/add-topics-hooks/prd.md @@ -0,0 +1,139 @@ +# Topics Domain Extension Surface: Lifecycle Events and Artifact Amendment + +## Problem + +Goga users organize work as topics, but everything that happens to a topic — creation, publication, switching, todo entry, deletion — stays invisible outside goga. Teams that also track the same work in external systems (task trackers first of all) duplicate the bookkeeping manually: a ticket is created by hand when work starts, updated by hand when the todo changes, and closed by hand when the topic is deleted. The external record is slow to update, drifts, or is abandoned. + +The root cause is structural. The hooks platform exists precisely as the extension surface of goga domains for installed tool packages, and several domains already declare actions — but the topics domain declares none. A tool author cannot observe the topic lifecycle at all, so no integration (a tracker syncer, a chat notifier, a dashboard feeder) can be built against it. The manual duplication is a symptom of this missing surface. + +## Users + +- **Primary: tool package author.** Builds `goga_tool_*` packages on the existing hooks platform (has already authored `register_hooks` callbacks for the statuses and onboarding actions). Wants to react to the topic lifecycle and to co-author topic artifacts — a task-tracker syncer is the first target. Needs stable action addresses, a predictable event context, clear failure semantics, and the guarantee that published catalog records are never rewritten by a goga update. +- **Secondary: goga end user.** Runs `goga topics create/switch/delete` daily and installs tool packages built by others. Needs topics commands to keep working exactly as today when hooks are installed: a broken integration degrades with a warning, never breaks topic work, and adds no new prompts or steps. +- **Affected: external-system readers** (team members). Read the tracker or dashboard without using goga; expect the external record to reflect the topic lifecycle accurately and timely, and expect artifact content (commit messages, todos) to reference the external record where an integration provides one. + +## Goals + +1. **Observable topic lifecycle.** A tool-package author can subscribe to every explicit operation of the topics domain — creation (across all its paths), publication, switching, todo entry, deletion — through the existing hooks platform, with stable action addresses and no goga code changes on the tool side. +2. **Integration-ready event facts.** Each notification event delivers the facts of the moment — topic identity (slug, year, hosting branch) plus the final content of the operation (todo text, commit message, deletion composition) — sufficient for a tool to update an external system from one event, without re-deriving repository state. +3. **Co-authored topic artifacts.** At the birth of a topic and at the revision of its todo, a tool can transform the content the domain is about to fix — the commit message and the todo text — so the repository and the external system reference each other (ticket ID in the commit message, signature in the todo). +4. **Dependable topics flow.** Topics commands behave exactly as today when nothing subscribes, and a failing hook of any topics action degrades predictably (soft) without breaking or blocking topic work. + +Explicitly not optimized: ready-made integrations shipped with goga, two-way synchronization, and status-progression events (see Scope). + +## User Experience + +### The tool author's experience + +**Entry point.** The author writes a `register_hooks(hooks)` callback in their `goga_tool_*` package and subscribes to any subset of the seven topics actions through the platform's `hooks.subscribe(domain, action, name, hook)` mechanism. Nothing is cached: package edits apply from the next run. + +**Verification.** `goga hooks` shows the topics actions with their subscribed hooks grouped by tool, on par with the existing domains — wiring can be verified without running a topic operation. + +**Notification events.** A hook receives `context` — a read-only view of the event facts built by the domain from the operation's own data (never re-read from the repository) — and may also declare `self`, the tool's isolated run-scoped state that links its hook invocations of one run. The five notification actions fire after the corresponding operation fully succeeds: + +| Address | Fires | +|---|---| +| `topics.topic_created` | once per successful creation — every path: quarantined no-switch, switch path, publication path, and the fast creation of `ensure` | +| `topics.topic_published` | after a successful publication push to origin | +| `topics.topic_switched` | on every completed switch — local checkout, local branch created from a remote-tracking ref, and the idempotent already-on-branch outcome | +| `topics.topic_todo_entered` | when an editor entry on an existing topic completes with a saved write (not on cancellation) | +| `topics.topic_deleted` | once per fully deleted target, after its complete removal | + +**Event composition.** A published creation emits `topic_created` then `topic_published`. A switch with the todo entry emits `topic_switched`, then (after the editor session) `topic_todo_entered`. `ensure` emits the underlying moment — `topic_created` or `topic_switched`, plus `topic_todo_entered` under its todo flag — and introduces no separate event of its own. The same events fire when pipelines invoke the same domain routines (`goga pipeline -t `); in that flow the topic always exists, because the ensure semantics creates it. + +**Amendment actions.** Two pre-fixation moments let a tool transform the content the domain is about to fix: + +- `topics.amend_creation` — after all user input is resolved (the todo value from the command line or from the creation editor session) and after the publication ask, immediately before the first mutation of a creation, on every creation path. The context carries the topic identity facts, the draft commit message when the chosen path builds a commit (quarantine and publication paths; absent for the switch and ensure paths, which write the todo without a commit), and the draft primary todo when one is resolved (absent on the switch path without a todo — the event still fires with the identity facts, and the tool decides whether to act without a description). +- `topics.amend_todo_entry` — when the user saves the todo of an existing topic in an editor session (`switch --todo`, `ensure --todo`), after the save and before the file write. The context carries the saved draft text and the identity facts. + +**The amendment chain.** Each draft is exposed read-only together with one amendment operation that replaces its full content. Tools apply in the platform's deterministic enumeration order, each tool seeing the result of the previous one. The domain fixes only the final draft: the amended commit message lands in git history, the amended todo lands in `todo.md`, and the corresponding notification events report the final content. The author's typical flow — create the tracker ticket from the draft todo at `amend_creation`, stamp its ID into the commit message and a signature into the todo — completes within one subscription. + +**Amendment guards.** A hook that raises is skipped: its amendment is not applied, the draft stands as the previous tool left it (or as the default), a warning names the tool, the action, and the reason, and the chain continues. An amendment to empty or whitespace-only content is rejected the same way. An amendment can never cancel, redirect, or defer the operation. + +### The end user's experience + +Nothing new to learn. Topics commands keep their current outcomes, outputs, exit codes, prompts, and mutation boundaries. With no tool subscribed, commands behave and perform exactly as before the change. With integrations installed, the only additions are: platform-standard warning lines on stderr when a hook fails (naming the tool, the action, and the reason), and tool-authored content inside the artifacts the user already works with (a ticket reference in a creation commit message, a signature line in `todo.md`). A broken tool package import remains the only fatal case, as on the platform today. + +### Boundaries of the experience + +- Notification events are observations after the fact: hooks cannot veto, alter, or roll back an operation, and nothing is returned from them. +- Amendment influence is limited to the two content kinds (creation commit message, todo text) at the two pre-fixation moments. +- Delivery is fire-and-forget with no replay, queueing, or event log: a tool that missed an event rebuilds its state from the board and the history tree. External consistency after a soft skip is the tool's responsibility. +- A manual `goga topics switch` onto a branch hosting no topic is the one marginal case: `topic_switched` fires with branch-only identity (no topic facts present). + +## Requirements + +### Action catalog + +- **R1.** The product must declare seven actions under the `topics` domain, addressable through the existing subscription mechanism: five notification actions — `topic_created`, `topic_published`, `topic_switched`, `topic_todo_entered`, `topic_deleted` — and two amendment actions — `amend_creation`, `amend_todo_entry`. All seven carry the soft error class. Catalog records are additive and never rewritten; the existing actions of other domains keep their addresses and behavior. + +### Notification events + +- **R2. `topic_created`** must fire exactly once per successful creation, on every creation path (quarantined no-switch, switch path, publication path, and `ensure`'s fast creation), only after the operation fully succeeds. The context must carry the final facts: the topic slug, the year, the branch name as entered, the final todo text when one was resolved, and the path facts stating whether the branch was checked out and whether the topic was published. +- **R3. `topic_published`** must fire after a successful publication push. A published creation emits `topic_created` and then `topic_published`; both carry the final content, including the final commit message. A publication that fails and rolls back emits no events at all. +- **R4. `topic_switched`** must fire on every completed switch: a local checkout, a local branch created from a remote-tracking ref, and the idempotent already-on-branch outcome. The context must carry the branch name always; the topic slug and year when the branch hosts a topic of the command's scope; branch-only identity when it does not (the marginal manual-switch case). +- **R5. `topic_todo_entered`** must fire when an editor entry on an existing topic completes with a saved write, and must not fire on a cancelled entry. The context must carry the final written text, the slug, the year, and the branch. +- **R6. `topic_deleted`** must fire once per target whose deletion completed — the local branch, the origin twin when present, and the topic directory when targeted, all removed. A target whose deletion fails midway (and is restored) must emit nothing; targets fully removed before the failure must emit theirs. The context must carry the slug, the year, and which elements were removed. +- **R7.** Events of one command must fire in operation order, after the success of their moment; delivery to hooks follows the platform's tool enumeration order. + +### Amendment actions + +- **R8. `amend_creation`** must fire on every creation path after all user input is resolved and after the publication ask, immediately before the first mutation. The context must carry the identity facts (slug, year, branch as entered, path facts), the draft commit message when the path builds a commit, and the draft primary todo when one is resolved; absent drafts are simply not present. +- **R9. `amend_todo_entry`** must fire after the user saves the todo of an existing topic in an editor session and before the file write. The context must carry the saved draft text and the identity facts. +- **R10.** Amendment must follow the transformation-chain semantics: each draft is readable, each has one amendment operation replacing its full content; subscribed tools apply in enumeration order, each receiving the result of the previous one; the domain fixes only the final draft into the artifact (commit message or todo file). +- **R11.** Amendment guards must hold for both actions: a raising hook is skipped with its amendment unapplied and a warning naming the tool, the action, and the reason — the draft stands and the chain continues; an amendment to empty or whitespace-only content is rejected with the same warning form; no amendment can cancel, redirect, or defer the operation. +- **R12.** Amended content must be observable where the artifact lives: the final commit message in git history, the final todo text in `todo.md`, and both reported as final facts by the corresponding notification events. + +### Common contract + +- **R13.** Event contexts must be built by the domain from the operation's own data, without re-reading the repository; facts are read-only views; per-tool isolation and the optional per-tool `self` follow the platform rules. +- **R14.** With no subscriber for an action, the command must behave and perform exactly as today — identical output, exit codes, and prompts. No topics action may introduce a new prompt, question, or required input; the existing preflight, todo resolution, and publication-ask ordering is preserved. +- **R15.** `goga hooks` must list all seven topics actions with their subscribed hooks grouped by tool, consistent with the presentation of the existing domains. +- **R16.** The topics hooks documentation must describe every action — its firing moment, error class, context facts (and, for the amendment actions, the drafts and the chain semantics) — and must replace the current statement that the topics domain exposes no hook actions; the action lists in the platform documentation must be synchronized. + +## Constraints + +- **C1.** The extension must use the existing hooks platform only — `register_hooks`/`subscribe` registration, the run registry, fire-and-forget delivery, per-tool context views, soft/hard error classes, `goga hooks` inspection. No parallel registration or delivery mechanism may be introduced; the amendment surface uses the platform's established capability of method-bearing contexts (the statuses action is the precedent). +- **C2.** The action catalog is additive and published records are never rewritten: the seven records and their soft error class are permanent. +- **C3.** Existing actions and their subscribers must not break: no address renames, no silent disappearances of previously registered hooks. +- **C4.** Topics command semantics remain unchanged: outcomes, outputs, exit codes, prompts, and mutation boundaries (all mutations local except the two origin pushes — publication and deletion; no fetch ever happens). The amendment actions add no user decision points and no new interactive moments. +- **C5.** Goga itself performs no external-system interaction: no tracker APIs, credentials, or integration configuration in goga; the network surface of the affected commands remains exactly the two pushes. All integration behavior lives in tool packages. +- **C6.** Topic identity, addressing, and statuses remain owned by the history domain: events carry facts consistent with that model; no status-progression events are introduced by this change. +- **C7.** Emission must be unobtrusive: without subscribers there is no user-visible change in behavior or speed of the commands; the only permitted new output is the platform-standard hook diagnostic (the warning naming tool, action, and reason). + +## Scope + +### In Scope + +- The seven catalog actions of the `topics` domain and their emission at every checkpoint of the topics-domain operations, including the same domain routines invoked through pipeline topic resolution (`goga pipeline -t `). +- The event context contract per action: identity and moment facts for notifications; drafts plus amendment operations and the transformation-chain semantics for the amendment actions. +- The amendment guards (soft skip, empty-amendment rejection) and the fixation of amended content into the artifacts. +- Inspection of topics subscriptions through `goga hooks`. +- Documentation: the topics hooks reference covering all seven actions, replacement of the "no hook actions" statement, and synchronization of the action lists in the hooks platform documentation. + +### Out of Scope + +- Ready-made integrations and reference tool packages (trackers, chats, dashboards) — goga ships the surface only. +- Two-way synchronization: any reading of, or reacting to, external systems by goga itself. +- Status-progression events of topics (the derived status scale belongs to the history domain; a possible future topic). +- Events for read operations (the board, status listings). +- Delivery guarantees beyond fire-and-forget: replay, queueing, an event log, at-least-once semantics. +- End-user controls over hooks (configuration toggles, filters, enable/disable) — hook management stays with the tool packages and the platform. +- Changes to the hooks platform core (registry, dispatch, error-class model) beyond the additive catalog records. +- Changes to topics command semantics, prompts, or outputs. +- Emission from history-domain mutations (e.g., orphan pruning). + +## Success Criteria + +1. **Subscribable lifecycle.** A subscribed tool package receives each notification event on the corresponding successful operation: every creation path fires `topic_created`; `--publish` additionally fires `topic_published`; switches — local checkout, remote-created, and idempotent — fire `topic_switched`; a saved editor todo fires `topic_todo_entered`; each fully deleted target fires `topic_deleted`. +2. **Facts sufficiency.** From the notification context alone, a hook identifies the topic (slug, year, branch) and the final facts of the moment (todo text, commit message, publication fact, deletion composition) without reading the repository. +3. **Composition.** `ensure` emits `topic_created` or `topic_switched` (plus `topic_todo_entered` under its todo flag) and no separate event of its own; `switch --todo` emits `topic_switched` then `topic_todo_entered`; a published creation emits `topic_created` then `topic_published`. +4. **No success — no event.** A rolled-back publication, a switch aborted on a dirty tree, and a deletion that fails and restores emit no events for the failed part; fully completed parts before a failure emit theirs. +5. **Amendment chain.** With two subscribed tools, amendments apply in enumeration order, each tool seeing the previous tool's result; the fixed artifact (the commit message in git history, the `todo.md` content) carries the final amended content, and the notification events report it as the final facts. +6. **Amendment guards.** A raising hook leaves the draft unchanged, the command completes successfully, one warning names the tool, action, and reason, and the remaining hooks still run; an empty amendment is rejected the same way. +7. **Marginal switch.** A manual switch onto a branch hosting no topic emits `topic_switched` with branch-only identity; a pipeline-driven (ensure) switch always carries full topic identity. +8. **Transparency.** With no subscribed tools, every topics command produces byte-identical output, exit codes, and prompts as before the change. +9. **Soft degradation.** A failing notification hook does not change the command's outcome; exactly one warning is shown; other subscribed hooks still receive the event. +10. **Inspection.** `goga hooks` shows the topics actions with subscribed tools grouped under the topics domain, on par with the existing domains. +11. **Stability.** Existing `statuses` and `onboarding` subscriptions keep working without re-registration after the update. +12. **Documentation.** All seven actions are documented with their firing moment, error class, and context facts or drafts; no stale "no hook actions" claim remains in the shipped documentation. diff --git a/.goga/history/2026/add-topics-hooks/task.md b/.goga/history/2026/add-topics-hooks/task.md new file mode 100644 index 00000000..57cdf930 --- /dev/null +++ b/.goga/history/2026/add-topics-hooks/task.md @@ -0,0 +1,96 @@ +# Topics hooks — seven platform actions for the topics lifecycle + +## Current State + +- The hooks platform is complete and closed for this task: the `goga/hooks` facade (`HookRegistry`, `emit_hook_event`, `wrap_context`, `build_hook_arguments`, `declared_actions`, `enumerate_tool_packages`) over four subcells — catalog, dispatch, registry, tools. The catalog `goga/hooks/catalog` holds the actions of other domains (onboarding, history/statuses); the `goga hooks` command inspects them. +- The topics domain (`goga/topics`) implements the full lifecycle — creation (quarantined no-switch, switch, publication paths), the ensure orchestration, switching, todo entry, deletion — but emits no hook events. `docs/features/topics/hooks.md` is a stub stating that the topics domain exposes no hook actions. +- Two public emission mechanisms exist: the plain `emit_hook_event` (fire-and-forget; the statuses precedent) and the staged per-tool delivery over the public primitives (documented in `goga/hooks/.usages/per-tool-delivery.md`; the onboarding precedent). +- The domain discards values the hooks facts need: the creation plant returns a commit hash the callers ignore today; the final commit message and the resolved year are not threaded through the switching paths. + +## Description + +Open the topics lifecycle to installed tool packages through seven soft hook actions, per the accepted ADR at `.goga/history/2026/add-topics-hooks/adr.md` (the authoritative decision record for this task): + +- Five notification actions — `topics/topic_created`, `topics/topic_published`, `topics/topic_switched`, `topics/topic_todo_entered`, `topics/topic_deleted` — emitted after the success of their moment via the plain platform emission. +- Two amendment actions — `topics/amend_creation`, `topics/amend_todo_entry` — delivered through the staged per-tool delivery pattern with per-hook buffers: a hook's buffered amendment commits only when its call returns without raising; a raising or empty/whitespace-only amendment is discarded with a platform-form warning; the enumeration continues in the platform order. The domain fixes only the final drafts into the artifacts and reports them as the final facts of the corresponding notifications. +- Topic identity in every context: slug + home path + branch as entered (branch-only in the marginal manual-switch corner); the home path `.goga/history//` is derived from slug and year without repository reads. +- Emission lives in the topics domain routines; pipeline topic resolution inherits the events through `ensure_topic`. `topic_created` is deferred until push success on the publication path; deletion emits per target inside the removal loop. Contexts are built from the operation's own data — the values discarded today (plant commit hash, final commit message, resolved year) are threaded through; no new git reads are introduced for events. +- The task completes with its documentation surface — the topics hooks reference replacing the stub and the synchronized action lists — and with unit tests per the project conventions covering every emission point and amendment semantic. + +## Scope + +**In scope:** + +- Seven additive `error_class=soft` records in `goga/hooks/catalog`, never rewritten +- Notification emissions at the ADR moments: the four creation paths (exactly once per successful creation), publication (deferred until push success), switch (with the switch outcome kind: local-checkout / created-from-remote / already-on-branch), todo entry, per-target deletion inside the removal loop +- The amendment chain for both amendment actions: one shared draft holder per action, a read-only draft view with exactly one amendment operation replacing the full content, per-hook buffers, empty-amendment rejection at commit, platform-form warnings, hook-granular independence within a tool +- Context models per the ADR semantics: identity vocabulary; path facts; commit message + commit hash iff the path builds a commit; final todo; switch outcome kind; removal composition; identity-only degenerate case of `amend_creation` is valid; no prior todo text; no deleted-commit hash +- Threading of currently discarded values (commit hash from the plant, final commit message, resolved year) through the creation, publication, and switching paths +- Marginal corners per the ADR: `ensure --todo` on a branch hosting no topic fires `amend_todo_entry` + `topic_todo_entered` with derived identity and no `topic_created`; `switch --todo` onto a topic-less branch keeps its pre-mutation error and emits nothing +- Documentation: replace the stub `docs/features/topics/hooks.md` with the topics hooks reference (firing moment, error class, facts/drafts per action); synchronize the action lists in `docs/features/hooks/index.md`, `docs/features/hooks/hooks.md`, `docs/features/tools/hooks.md` +- Tests per the project conventions: unit coverage of every emission point and amendment semantic; CLI tested by direct handler calls; mocks only at the git/editor boundaries + +**Out of scope:** + +- Any change to the hooks platform core: registration, run registry, error classes, dispatch, the `goga hooks` command — all reused as-is +- New CLI commands or flags; changes to `goga/commands/topics` and `goga/commands/pipeline` (emission lives in the domain; the pipeline flow inherits the events through `ensure_topic` without caller changes) +- Behavior changes of the existing topics operations beyond threading the values — result lines, error surface, and mutation order stay as today +- Design-stage open questions from the ADR: sharing one `HookRegistry` within a command, exact member names and signatures of context views / drafts / amendment operations, exact warning reason wording, the concrete shape of the home-path fact +- ADR-rejected options: prior todo text in the todo contexts; `topic_created` for the `ensure --todo` directory creation; the deleted-commit hash in the deletion context; amendment delivery via the plain emission + +## Acceptance Criteria + +- `goga/hooks/catalog` declares exactly seven new topics actions, all `error_class=soft`, added additively; the existing actions are untouched +- Each notification fires exactly at its ADR moment: `topic_created` once per successful creation on each of the four paths; deferred until push success on the publication path (a rolled-back publication emits nothing); `topic_deleted` per fully removed target even before a later failure; `switch --todo` onto a topic-less branch emits nothing +- The marginal corners hold: `ensure --todo` on a branch hosting no topic fires `amend_todo_entry` + `topic_todo_entered` with derived identity (slug normalized from the branch name, current year → home path) and emits no `topic_created`; a manual switch onto a branch hosting no topic emits `topic_switched` with branch-only identity +- Amendment semantics hold: a raising hook's amendment is not applied; an empty or whitespace-only amendment is rejected at commit; hooks of one tool are independent; warnings follow the platform form `hook of tool failed on topics.: `; the enumeration continues in the platform order +- The final amended drafts reach the artifacts (amended commit message into git, amended text into todo.md) and are reported as the final facts of the corresponding notifications +- No new git reads for events: contexts are built from operation data — the plant commit hash, the final commit message, and the resolved year are threaded through +- `goga pipeline -t` receives the same events as the topics CLI paths — emission lives in the domain routines, not in the CLI cell +- `goga hooks` shows the seven topics actions in its tool → domain → action presentation without any change to the command +- The four documentation files are synchronized: the topics hooks reference replaces the stub; the action lists include the topics domain +- `pytest tests/ -x` passes; `ruff check` over the touched sources passes + +## Stack + +- **Frameworks:** none beyond the existing — the goga hooks platform facade (`goga/hooks`) consumed as-is +- **Libraries:** Python 3.10+ stdlib (dataclasses `kw_only=True`, logging); click (existing, unchanged CLI surface) +- **Infrastructure:** none — no databases, brokers, or services + +## External Dependencies + +| Component | Usage file | Status | +|-----------|------------|--------| +| (none new) | — | no new external components; `pyproject.toml` unchanged | + +Practices the task relies on: `.goga/usages/conventions.md` (mandatory base), `.goga/usages/cooks/click.md` (existing, unchanged), `goga/hooks/.usages/declaring-actions.md` and `goga/hooks/.usages/per-tool-delivery.md` (cell-level practices of `goga/hooks`, to be connected via Imports at the design stage). + +## Risks and Constraints + +- Per-hook atomicity is reachable only through the staged delivery pattern — the plain emission cannot provide it; falling back to `emit_hook_event` for amendments breaks the published atomicity contract (a landed amendment before a raise would survive) +- The amendment chain adds hook-driven moments inside the creation/ensure flows — a registered tool package that raises must not break the command (soft class), and the enumeration order must remain the platform's +- Threading the values changes internal call shapes of the domain routines — the external behavior, result lines, and error surface must stay identical +- Contexts must not introduce repository reads: any fact requiring a git lookup is a defect +- The marginal corners (branch-only identity, `ensure --todo` on a topic-less branch) are easy to miss — they are explicit acceptance criteria +- Documentation drift risk: four files must present identical action sets + +## Scope Estimate + +Single task. One cohesive domain feature: one domain (topics), one additive platform touch (catalog records), one documentation surface. All parts share the identity vocabulary and the amendment mechanics feeding the notifications' final facts — a split would produce fragments without independent value and would duplicate context. Moderately large, but homogeneous in domain and stack. + +## Existing Architecture + +- **`goga/hooks/catalog`** — additive edit: seven topics action records +- **`goga/topics`** — the emitting domain: emission points in `create_topic`, `enter_topic_todo` (creation), `publish_topic` (publishing), `switch_topic` (switching), `ensure_topic` (ensuring), `delete_topics` (deletion); the amendment chain machinery and the context views live in this cell +- **`goga/hooks`** — consumed as-is via its public surface: `emit_hook_event` for notifications; the staged delivery primitives per `goga/hooks/.usages/per-tool-delivery.md` for amendments +- **`goga/topics/git`** — consumed as-is; the plant's commit hash return value starts being used by the domain +- **`goga/commands/topics`, `goga/commands/pipeline`** — no changes; the pipeline flow inherits the events through `ensure_topic` +- **`goga/commands/hooks`** — no changes; the new actions appear in the inspection automatically +- Cross-import rule holds: `goga/topics` importing from `goga/hooks` creates no cycle (`goga/hooks` does not import `goga/topics`) +- Documentation surface: `docs/features/topics/hooks.md`, `docs/features/hooks/index.md`, `docs/features/hooks/hooks.md`, `docs/features/tools/hooks.md` + +## Notes + +- The ADR at `.goga/history/2026/add-topics-hooks/adr.md` is the authoritative decision record; this task inherits its nine decisions and its three deliberate PRD deviations (year → home path, commit hash added, switch outcome kind added) +- ADR open questions (registry sharing within a command, member names and signatures, home-path fact shape) remain design-stage questions — the task deliberately leaves them open +- The task contains no code examples and prescribes no architecture, per stage constraints; contract shapes are the design stage's output diff --git a/goga/hooks/catalog/CODEMANIFEST b/goga/hooks/catalog/CODEMANIFEST index b319df44..a4574da2 100644 --- a/goga/hooks/catalog/CODEMANIFEST +++ b/goga/hooks/catalog/CODEMANIFEST @@ -75,6 +75,33 @@ Annotations: | record domain="onboarding", name="amend_config", error_class="soft": a failing hook of the action is skipped with a warning and the sequence continues + - The catalog carries the topics creation-notification action — the + record domain="topics", name="topic_created", error_class="soft": a + failing hook of the action is skipped with a warning and the command + continues + - The catalog carries the topics publication-notification action — the + record domain="topics", name="topic_published", error_class="soft": a + failing hook of the action is skipped with a warning and the command + continues + - The catalog carries the topics switch-notification action — the + record domain="topics", name="topic_switched", error_class="soft": a + failing hook of the action is skipped with a warning and the command + continues + - The catalog carries the topics todo-entry-notification action — the + record domain="topics", name="topic_todo_entered", error_class="soft": + a failing hook of the action is skipped with a warning and the command + continues + - The catalog carries the topics deletion-notification action — the + record domain="topics", name="topic_deleted", error_class="soft": a + failing hook of the action is skipped with a warning and the command + continues + - The catalog carries the topics creation-amendment action — the record + domain="topics", name="amend_creation", error_class="soft": a failing + hook of the action is skipped with a warning and the command continues + - The catalog carries the topics todo-entry-amendment action — the + record domain="topics", name="amend_todo_entry", error_class="soft": a + failing hook of the action is skipped with a warning and the command + continues Constraints: - Do not derive records from installed packages or imports — the diff --git a/goga/topics/.usages/creating.md b/goga/topics/.usages/creating.md index 66e286c8..748d1e45 100644 --- a/goga/topics/.usages/creating.md +++ b/goga/topics/.usages/creating.md @@ -42,6 +42,10 @@ print(result) # one line describing what was created - On an interactive terminal without an explicit publish decision, the publication ask runs when a todo was obtained — the answer chooses between the local path and the publication path. +- The written todo.md content and the built commit message are the + final amended values when a tool package subscribes an amendment + hook — the creation amendment runs before the first mutation of the + chosen path. ## Occupancy diff --git a/goga/topics/.usages/todo-entry.md b/goga/topics/.usages/todo-entry.md index 2f18f85a..d23affcb 100644 --- a/goga/topics/.usages/todo-entry.md +++ b/goga/topics/.usages/todo-entry.md @@ -14,7 +14,10 @@ and writes the saved text without a commit. written = enter_topic_todo("Feature/Foo_Bar", year="2025") - Saved text -> todo.md overwritten as entered plus a trailing - newline, UTF-8 — no commit. + newline, UTF-8 — no commit. The written content is the final + amended text when a tool package subscribes an amendment hook — + the saved text passes through the todo-entry amendment before the + write. - Cancelled entry (empty or unchanged file) -> False, the file stays untouched. - The topic directory must exist — creation belongs to the caller. diff --git a/goga/topics/CODEMANIFEST b/goga/topics/CODEMANIFEST index 2edc9d82..75655737 100644 --- a/goga/topics/CODEMANIFEST +++ b/goga/topics/CODEMANIFEST @@ -43,6 +43,14 @@ Imports: Usages: - editor-entry From: goga/topics/editor + - Types: + - TopicIdentity + - TopicHooks + - CreationDraft + - TodoEntryDraft + Usages: + - checkpoints + From: goga/topics/hooks Usages: convention: .goga/usages/conventions.md @@ -73,6 +81,10 @@ Annotations: | cell. Use the `deleting` practice for the symmetric local-and-origin removal and the restore-on-failure patterns of the topics git cell. + Use the `checkpoints` practice for the lifecycle checkpoint patterns + of the topics hooks zone — the identity construction, the amendment + delivery before the content is fixed, and the notification emission + after the moment. This cell owns the topics domain — the work-tracker view of the history tree: the cross-branch topic inventory of one year with @@ -91,6 +103,11 @@ Annotations: | existing topic; and the identified-topic deletion — the local branch, the origin twin, and the topic directory removed symmetrically with restore on failure. + The domain opens its lifecycle to tool packages through its hooks + zone: the creation, publication, switch, todo-entry, and deletion + checkpoints fire inside the domain routines — a failing hook of the + soft actions warns and never breaks the operation, and every event + fact comes from the operation's own data. Topic identity, addressing, and statuses belong to the history facade; git access to the topics git cell; the editor session to the editor cell. Git infrastructure failures and the fatal @@ -282,6 +299,7 @@ Annotations: | the non-interactive detection. Apply the `refs-and-switching` practice for the checkout and remote-tracking branch patterns. + Apply the `checkpoints` practice for the switch notification. Algorithm: 1. `todo` without an interactive terminal -> clean error before any @@ -291,7 +309,7 @@ Annotations: | list with statuses and the number prompt, or the failure with the list without interactive input 3. `todo` and the chosen candidate hosts no topic -> clean error — - switching creates nothing + switching creates nothing; nothing fires 4. Already on the hosting branch -> idempotent success without mutation; with `todo` the entry still runs 5. A mutation is needed -> probe the working tree cleanliness @@ -300,14 +318,28 @@ Annotations: | `checkout_local_branch`; remote-only host -> create the local branch from the remote-tracking ref via `create_branch_from_remote_tracking` - 6. With `todo` -> enter the todo of the topic via - `enter_topic_todo` - 7. Return the single result line + 6. Emit topic_switched over `TopicHooks` — the identity via + `TopicIdentity`: the hosted slug of the chosen candidate when + it hosts one, the resolved year, the branch as entered; the + branch-only identity when it hosts none — and the outcome kind: + already-on-branch, local-checkout, or created-from-remote + 7. With `todo` -> enter the todo of the topic via + `enter_topic_todo`, passing the switched branch as the branch + fact + 8. Return the single result line Requirements: - Every mutation is local — no network, no fetch, no push - Nothing is mutated before the candidate choice is complete - The result is exactly one line + - topic_switched fires on every completed switch, every outcome + included; the identity degrades to branch-only when the chosen + candidate hosts no topic + - `todo` onto a branch hosting no topic keeps the clean + pre-mutation error and fires nothing + - The identity facts are the operation's own data — the hosted + slug of the chosen candidate, the resolved year, and the branch + name Constraints: - Do not create a topic for a branch without one @@ -335,6 +367,8 @@ Annotations: | patterns of the creation. Apply the `refs-and-switching` practice for the checkout and create-and-switch patterns. + Apply the `checkpoints` practice for the creation amendment and + the creation notification of the fast creation. Algorithm: 1. `todo` without an interactive terminal -> clean error before any @@ -343,22 +377,35 @@ Annotations: | 3. No candidate -> the fast creation: normalize `identifier` into a slug via `normalize_topic_slug`; an empty slug or an occupancy conflict — the oracles `check_branch_occupancy` and - `check_slug_occupancy` — is a clean error; create the branch - named as entered from the current HEAD and switch to it via - `create_and_switch_branch`; create the topic directory of the - year via `ensure_topic_dir`; with `todo` enter the todo of the - fresh topic via `enter_topic_todo` — the entry starts only after - the switch - 4. Otherwise -> the switch procedure via `switch_topic` without the - entry; with `todo`, take the hosted topic of the switched work — - the resolution candidate of step 2 whose branch is the current + `check_slug_occupancy` — is a clean error; deliver the creation + amendment over `TopicHooks` with the identity via + `TopicIdentity` — the normalized slug, the resolved year, the + branch name as entered — checked_out True, published False, no + draft commit message (the path builds no commit), and no draft + todo (the todo resolves later through the entry); create the + branch named as entered from the current HEAD and switch to it + via `create_and_switch_branch`; create the topic directory of + the year via `ensure_topic_dir`; with `todo` enter the todo of + the fresh topic via `enter_topic_todo`, passing the branch + name as the branch fact — the entry starts only after the + switch; after the creation completes, emit topic_created over + `TopicHooks` — the identity, checked_out True, published False, + the final todo when the entry resolved one, and no commit + facts + 4. Otherwise -> the switch procedure via `switch_topic` without + the entry — the switch notification fires inside it; with + `todo`, take the hosted topic of the switched work — the + resolution candidate of step 2 whose branch is the current branch read via `resolve_current_branch_name` (a remote-tracking candidate matches by its short name) — and: a hosted topic - exists -> enter its todo via `enter_topic_todo`; the hosting - branch hosts no topic -> an empty slug of its name is a clean - error, otherwise create the topic directory of the year via - `ensure_topic_dir`, then enter the todo of the fresh topic via - `enter_topic_todo` + exists -> enter its todo via `enter_topic_todo` with the branch + fact; the hosting branch hosts no topic -> an empty slug of its + name is a clean error, otherwise create the topic directory of + the year via `ensure_topic_dir`, then enter the todo of the + fresh topic via `enter_topic_todo` with the derived identity — + the slug normalized from the branch name, the resolved year — + as the topic input and the branch fact; no creation checkpoint + fires for the directory creation 5. Return the single result line Requirements: @@ -366,8 +413,18 @@ Annotations: | identifier never creates anything - The creation always starts from the current HEAD — the configuration base is never read here - - With `todo`, no step follows the todo write + - With `todo`, no mutation follows the todo write — the creation + notification alone may follow it - Every mutation is local — no network, no fetch, no push + - The fast creation delivers the creation amendment exactly once, + immediately before its first mutation, and emits topic_created + after the creation completes — the identity-only amendment form + is the norm on this path + - Directory creation under the todo flag of a topic-less branch + fires no creation checkpoint — the todo entry alone fires its + two + - The todo entries pass the operation's branch fact into the + entry Constraints: - Do not ask about publication — the fast process publishes @@ -402,6 +459,8 @@ Annotations: | Apply the `topic-paths` practice for the slug, existence, directory creation, and todo-file path patterns. Apply the `refs-and-switching` practice for the checkout pattern. + Apply the `checkpoints` practice for the creation amendment and + the creation notification. Algorithm: 1. Preflight, read-only and before any input: normalize @@ -424,22 +483,41 @@ Annotations: | 5. The publication ask — interactive terminal, `publish` not set, and a todo resolved: the answer chooses the path; no ask otherwise - 6. The normal path without `switch`: build one quarantined commit + 6. Deliver the creation amendment over `TopicHooks`: build the + identity via `TopicIdentity` — the normalized slug, the + resolved year, `branch_name` as entered — and deliver + amend_creation with the path facts — checked_out as `switch` + dictates, published as the chosen path dictates — the draft + commit message of the path (the no-switch and the publication + paths build one; the switch path delivers None) and the draft + todo when resolved; the amended values of the returned + `CreationDraft` replace the todo and the commit message carried + into the mutation steps + 7. The normal path without `switch`: build one quarantined commit carrying the todo file todo.md — the path resolved via - `resolve_topic_file` — on the base commit, and plant the branch - named as entered at it; the working copy, the index, and HEAD - stay untouched — the caller stays on their branch - 7. The normal path under `switch`: create the branch at the base + `resolve_topic_file` — with the final todo content and the + final commit message on the base commit via + `commit_file_on_base`, capture the returned commit hash, and + plant the branch named as entered at it; the working copy, the + index, and HEAD stay untouched — the caller stays on their + branch; then emit topic_created over `TopicHooks` — the + identity, checked_out False, published False, the final todo, + the final commit message, and the captured commit hash + 8. The normal path under `switch`: create the branch at the base commit via `create_branch_at_commit` and switch to it via `checkout_local_branch` — a failed checkout rolls the planted branch back via `delete_local_branch` (the occupancy oracle would otherwise block the retry) —, create the topic directory of the year via `ensure_topic_dir`, and write the todo file - todo.md when a todo resolved; the write is the last action of - the path - 8. The publication path: delegate to `publish_topic` with the - name, the todo, the base, the template, and the year - 9. Return the single result line + todo.md when a todo resolved; the write is the last mutation of + the path; then emit topic_created — the identity, checked_out + True, published False, the final todo when written, and no + commit facts + 9. The publication path: delegate to `publish_topic` with the + name, the amended todo, the base, the amended template, and the + year — the publication path fires its checkpoints inside the + delegated routine; nothing fires here + 10. Return the single result line Requirements: - Every decision — preflight, todo, ask — precedes the first @@ -459,6 +537,13 @@ Annotations: | - The branch keeps the name as entered; the topic directory takes the slug - The caller stays on their branch unless `switch` is set + - The creation amendment delivers exactly once per creation, + immediately before the first mutation of the chosen path, with + the draft content of that path; the identity-only form is valid + - topic_created fires exactly once per successful creation — from + this routine on the no-switch and switch paths, from the + delegated publication routine after its push succeeds; a failed + creation fires nothing Constraints: - Do not validate branch-name characters — git owns name validity @@ -466,33 +551,55 @@ Annotations: | - Do not write artifact files other than the topic todo file inside the topic directory -"enter_topic_todo(topic: str, year: str | None = None) -> written: bool": +"enter_topic_todo(topic: str, year: str | None = None, branch: str | None = None) -> written: bool": location: creation.py annotations: | Enter the todo of a topic — the editor session with the topic's - todo.md and the write of the saved text, without a commit. + todo.md and the write of the saved text, without a commit; the + saved text passes through the todo-entry amendment before the + write, and the completed entry emits its notification. `topic`: topic input — a branch name or an already-normalized slug `year`: optional year as four digits; None means the current year + `branch`: the branch fact of the identity, passed by the calling + operation; None leaves the identity without a branch + fact `written`: True when the saved text was written; False when the entry was cancelled Apply the `editor-entry` practice for the editor session pattern. Apply the `topic-paths` practice for the todo-file path pattern. + Apply the `checkpoints` practice for the amendment delivery and + the notification emission. Algorithm: 1. Resolve the todo.md path of the topic via `resolve_topic_file`; an existing file provides the initial text 2. Open the editor session via `edit_text` with the initial text - 3. A cancelled entry -> False — the file stays untouched - 4. The saved text -> write todo.md as entered with exactly one + 3. A cancelled entry -> False — the file stays untouched, nothing + is delivered or emitted + 4. A saved text -> deliver the todo-entry amendment over + `TopicHooks`: the identity via `TopicIdentity` — the normalized + slug, the resolved year, `branch` — and amend_todo_entry with + the saved text; the final text of the returned `TodoEntryDraft` + replaces the text being written + 5. Write todo.md with the final text as entered with exactly one trailing newline — a text already ending in one keeps it — encoded UTF-8, without a commit -> True + 6. Emit topic_todo_entered — the identity and the final written + text Requirements: - - The write is the last action — nothing follows it - The topic directory exists — directory creation belongs to the caller + - The write is the last mutation — nothing mutates after it; the + notification emission follows the write and mutates nothing + - The amendment delivers after the save and before the write; the + write carries the final amended text + - An entry completing with a saved write emits + topic_todo_entered; a cancelled entry delivers and emits nothing + - The identity needs no repository reads — the slug, the year, and + the branch arrive as inputs Constraints: - Do not create the topic directory @@ -557,6 +664,8 @@ Annotations: | and tree-reading patterns. Apply the `publishing` practice for the quarantined commit building, branch planting, publication, and rollback patterns. + Apply the `checkpoints` practice for the publication + notifications. Algorithm: 1. Normalize `branch_name` into a slug via `normalize_topic_slug` @@ -572,14 +681,22 @@ Annotations: | mutation 6. Build the publication commit via `commit_file_on_base` — the parent commit, the todo.md path resolved via `resolve_topic_file` - as a repository-root-relative posix string, the todo content, - and the applied `commit_message` + as a repository-root-relative posix string, the final todo + content, and the applied commit message — and capture the + returned commit hash 7. Plant the branch named exactly as entered via `create_branch_at_commit` 8. Publish via `push_branch`; a failed publication deletes the branch via `delete_local_branch` and surfaces one clean error - carrying the reason - 9. Return the single result line + carrying the reason — nothing fires on the failure + 9. After the successful push, emit over `TopicHooks` with the + identity via `TopicIdentity` — the normalized slug, the + resolved year, `branch_name` as entered: topic_created — + checked_out False, published True, the final todo, the applied + commit message, the captured commit hash — then + topic_published — the same final commit message, commit hash, + and todo + 10. Return the single result line Requirements: - The working copy, the index, and HEAD stay untouched — the caller @@ -597,6 +714,12 @@ Annotations: | bare todo gains it — encoded UTF-8; the sole artifact of the topic directory - The result is exactly one line + - The creation amendment belongs to the creating orchestration — + this routine fires the publication checkpoints only; a direct + call publishes without amend_creation + - The two publication checkpoints fire only after the push + succeeds, in the order topic_created then topic_published; a + failed publication that rolls back fires nothing Constraints: - Do not validate branch-name characters — git owns name validity @@ -744,6 +867,7 @@ Annotations: | Apply the `deleting` practice for the symmetric removal and the restore-on-failure patterns. + Apply the `checkpoints` practice for the deletion notification. Algorithm: 1. Per target, in order: a local branch exists -> capture its @@ -753,17 +877,28 @@ Annotations: | `delete_remote_branch`; a failed deletion restores the local branch at the captured commit via `create_branch_at_commit` and surfaces one clean error — the targets removed before the - failure stay removed + failure stay removed and fired theirs 3. A target with only an origin twin -> delete it on origin via `delete_remote_branch` 4. A target with a directory -> remove the topic directory via `remove_topic_dir` - 5. Return the single result line + 5. After the target's full removal, emit topic_deleted over + `TopicHooks` — the identity via `TopicIdentity`: the target's + slug and the resolved year, no branch fact — with the removal + composition: the removed local branch name or None, the + removed origin twin name or None, and whether the topic + directory was removed + 6. Return the single result line Requirements: - The deletion is unconditional — no merge checks; the confirmation belongs to the caller - The deletion push is a network operation; no fetch ever happens + - A target fires after its complete removal; targets fully + removed before a later failure fire theirs; a target whose + removal fails midway fires nothing + - No deleted-commit hash is carried — the captured rollback + commit stays internal Constraints: - Do not re-resolve the identifiers — the caller passes resolved @@ -780,4 +915,5 @@ Description: | explicit base with preflight and publication ask — quarantined without a switch by default, checked out under the switch flag — fast creation with publication, the ensure orchestration of the fast - process, the todo entry of a topic, and identified-topic deletion. + process, the todo entry of a topic, and identified-topic deletion; + the lifecycle events of the domain fire through its hooks zone. diff --git a/goga/topics/hooks/.usages/checkpoints.md b/goga/topics/hooks/.usages/checkpoints.md new file mode 100644 index 00000000..79bdbcdb --- /dev/null +++ b/goga/topics/hooks/.usages/checkpoints.md @@ -0,0 +1,80 @@ +# topics — emitting lifecycle checkpoints + +How the topics flows consume the checkpoint surface of the hooks zone: +delivering the two amendments before the content is fixed and emitting +the five notifications after their moments. For the domain flows over +the topics facade. + +## The checkpoint surface + +One `TopicHooks` object serves every checkpoint of a command — the +surface shares one registry per run, so a command that reaches several +checkpoints enumerates the tool packages once. + +```python +from goga.topics.hooks import TopicHooks, TopicIdentity + +hooks = TopicHooks() +identity = TopicIdentity(slug="add-topics-hooks", year="2026", branch="add-topics-hooks") +``` + +`TopicIdentity` carries the three identity facts of every event: the +slug, the home path (composed from slug and year — no repository +reads), and the branch as entered. The branch-only form — slug None — +serves a manual switch onto a branch hosting no topic. + +## Amend before the content is fixed + +Deliver the amendment checkpoint before the mutation that fixes the +content, then read the final values from the returned holder and fix +them. + +```python +draft = hooks.amend_creation( + identity, + checked_out=False, + published=False, + commit_message=draft_message, # None on paths that build no commit + todo=draft_todo, # None when none resolved +) +final_message = draft.commit_message +final_todo = draft.todo +``` + +- The identity-only form is valid — a path with no commit and no todo + still delivers; the tool decides whether to act. On the ensure fast + creation the creation amendment observes only — an amended todo does + not land there; the todo-entry amendment owns the written text. +- A hook's buffered amendment commits only when the hook returns + without raising; an empty or whitespace-only value rejects the whole + buffer; both cases warn and the walk continues — the operation never + breaks. +- The caller fixes the final values into the artifacts itself; nothing + is applied to the repository here. + +```python +draft = hooks.amend_todo_entry(identity, saved_text) +write_todo(draft.text) +hooks.emit_todo_entered(identity, draft.text) +``` + +## Emit after the moment + +Emit each notification after its moment fully succeeds, with the final +facts — the amended content is the reported content. + +```python +hooks.emit_created(identity, checked_out=False, published=False, + todo=final_todo, commit_message=final_message, + commit_hash=planted_hash) +hooks.emit_published(identity, commit_message=final_message, + commit_hash=planted_hash, todo=final_todo) +hooks.emit_switched(identity, outcome="created-from-remote") +hooks.emit_deleted(identity, local_branch=branch, origin_twin=twin, + directory_removed=True) +``` + +- Every `emit_*` is fire-and-forget: a failing hook warns under the + soft error class and the command continues. +- Build every fact from the operation's own data — no git reads at a + checkpoint. diff --git a/goga/topics/hooks/CODEMANIFEST b/goga/topics/hooks/CODEMANIFEST new file mode 100644 index 00000000..70aacf21 --- /dev/null +++ b/goga/topics/hooks/CODEMANIFEST @@ -0,0 +1,581 @@ +Imports: + - Types: + - HookRegistry + - emit_hook_event + - wrap_context + - build_hook_arguments + - declared_actions + Usages: + - declaring-actions + - per-tool-delivery + - registering-hooks + From: goga/hooks + - Types: + - resolve_topic_dir + Usages: + - topic-paths + From: goga/history + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and + testing in the project + + Use the `declaring-actions` practice for the emission contract of + the notification checkpoints. + Use the `per-tool-delivery` practice for the staged delivery loop of + the amendment checkpoints — its loop skeleton, primitives, and + failure handling apply with one refinement: the commit granularity + is the single hook, not the tool — the per-hook requirements of the + delivery methods take precedence over the practice's tool-grouped + commit. + Use the `registering-hooks` practice for the hook signature and the + failure handling behind every checkpoint. + Use the `topic-paths` practice for the topic directory composition + behind the home path of the identity. + + This cell owns the hooks zone of the topics domain: the identity + vocabulary of the lifecycle events, the read-only notification + contexts of the five moments, the amendment drafts of the two + pre-fixation moments with their per-hook staged delivery, and the + checkpoint surface that delivers the amendments and emits the + notifications over the platform facade. One registry per run carries + every checkpoint of a command — the checkpoints never multiply the + package enumeration. Every context is built from the operation data + the caller passes — no repository reads happen here. A failing hook + never breaks the operation: the seven topics actions are soft, a + failure is a warning naming the hook, the tool, the action, and the + reason, and the delivery continues in the platform order. No package + enumeration and no subscription state live here — the platform + carries the tool packages. Use relative imports. + +--- + +"TopicIdentity(slug: str | None, year: str, branch: str | None)": + location: identity.py + annotations: | + The identity vocabulary of every topics event — the topic slug, + its home path, and the branch as entered by the operation. + + `slug`: the normalized topic slug; None in the branch-only form — + a switch onto a branch hosting no topic + `year`: the resolved year as four digits — the composition input + of the home path; it always arrives resolved — the + constructing operation passes its year input when given, + otherwise the current year + `branch`: the branch name as entered by the operation; None only + in the deletion context, whose removal composition + carries the branch names + + Apply the `convention` practice for the data-model rules and + intra-package imports. + Use the `topic-paths` practice for the topic directory composition + behind the home path — the composition runs through + `resolve_topic_dir`. + + Requirements: + - Pure composition — the home path derives from `slug` and `year` + without repository reads and without creating anything + properties: + "slug -> str | None": | + The normalized topic slug, or None in the branch-only form. + "home_path -> str | None": | + The topic home path .goga/history// as a posix + string, composed from the slug and the year inputs; None when + the slug is None. Pure composition — nothing is read or + created. + "branch -> str | None": | + The branch name as entered by the operation, or None in the + deletion context. + +"TopicCreated(identity: TopicIdentity, checked_out: bool, published: bool, todo: str | None, commit_message: str | None, commit_hash: str | None)": + location: contexts.py + annotations: | + The read-only context of the creation notification — the final + facts of one completed creation. + + `identity`: the identity of the created topic + `checked_out`: True when the creation path checked out the fresh + branch + `published`: True when the creation path published the work + `todo`: the final todo text, or None when none resolved + `commit_message`: the final commit message — present exactly when + the creation path builds a commit, None otherwise + `commit_hash`: the hash of the built commit — present exactly when + the creation path builds a commit, None otherwise + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Read-only facts of a completed operation — a hook observes the + outcome and cannot alter it + properties: + "identity -> TopicIdentity": | + The identity of the created topic. + "checked_out -> bool": | + True when the creation path checked out the fresh branch. + "published -> bool": | + True when the creation path published the work. + "todo -> str | None": | + The final todo text, or None when none resolved. + "commit_message -> str | None": | + The final commit message — present exactly when the creation + path builds a commit, None otherwise. + "commit_hash -> str | None": | + The hash of the built commit — present exactly when the creation + path builds a commit, None otherwise. + +"TopicPublished(identity: TopicIdentity, commit_message: str, commit_hash: str, todo: str)": + location: contexts.py + annotations: | + The read-only context of the publication notification — the final + facts of one successful publication push. + + `identity`: the identity of the published topic + `commit_message`: the final commit message landed in git + `commit_hash`: the hash of the publication commit + `todo`: the final todo text landed in the publication commit + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Read-only facts of a completed operation + properties: + "identity -> TopicIdentity": | + The identity of the published topic. + "commit_message -> str": | + The final commit message landed in git. + "commit_hash -> str": | + The hash of the publication commit. + "todo -> str": | + The final todo text landed in the publication commit. + +"TopicSwitched(identity: TopicIdentity, outcome: str)": + location: contexts.py + annotations: | + The read-only context of the switch notification — the outcome of + one completed switch. + + `identity`: the identity of the switched work — the branch-only + form when the branch hosts no topic + `outcome`: the outcome kind — exactly one of local-checkout, + created-from-remote, already-on-branch + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The outcome value is exactly one of the three fixed kinds + - Read-only facts of a completed operation + properties: + "identity -> TopicIdentity": | + The identity of the switched work — the branch-only form when + the branch hosts no topic. + "outcome -> str": | + The outcome kind — local-checkout, created-from-remote, or + already-on-branch. + +"TopicTodoEntered(identity: TopicIdentity, text: str)": + location: contexts.py + annotations: | + The read-only context of the todo-entry notification — the final + text of one saved todo entry. + + `identity`: the identity of the topic whose todo was entered + `text`: the final written text — after every amendment + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - No prior text is carried — a tool keeps its own state in its own + context + - Read-only facts of a completed operation + properties: + "identity -> TopicIdentity": | + The identity of the topic whose todo was entered. + "text -> str": | + The final written text — after every amendment. + +"TopicDeleted(identity: TopicIdentity, local_branch: str | None, origin_twin: str | None, directory_removed: bool)": + location: contexts.py + annotations: | + The read-only context of the deletion notification — the removal + composition of one fully removed target. + + `identity`: the identity of the removed topic — slug and home + path; no branch fact + `local_branch`: the removed local branch name, or None when the + target had none + `origin_twin`: the removed origin twin name, or None when the + target had none + `directory_removed`: True when the topic directory was removed + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - No deleted-commit hash is carried + - Read-only facts of a completed operation + properties: + "identity -> TopicIdentity": | + The identity of the removed topic — slug and home path; no + branch fact. + "local_branch -> str | None": | + The removed local branch name, or None. + "origin_twin -> str | None": | + The removed origin twin name, or None. + "directory_removed -> bool": | + True when the topic directory was removed. + +"CreationDraft(commit_message: str | None, todo: str | None)": + location: amendments.py + annotations: | + The shared draft holder of the creation amendment — the content + the creation path is about to fix, and after the delivery the + final amended content. + + `commit_message`: the draft commit message — None on paths that + build no commit + `todo`: the draft todo text — None when none resolved + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The content changes only through the delivery commit of the + amendment checkpoint — never through a delivered view + properties: + "commit_message -> str | None": | + The draft or final commit message — None on paths that build no + commit. + "todo -> str | None": | + The draft or final todo text — None when none resolved. + +"TodoEntryDraft(text: str)": + location: amendments.py + annotations: | + The shared draft holder of the todo-entry amendment — the saved + text the entry path is about to write, and after the delivery the + final amended text. + + `text`: the saved draft text + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The content changes only through the delivery commit of the + amendment checkpoint — never through a delivered view + properties: + "text -> str": | + The saved draft or final amended text. + +"CreationAmendment(identity: TopicIdentity, checked_out: bool, published: bool, draft: CreationDraft)": + location: amendments.py + annotations: | + The creation-amendment view of one hook — the read-only surface + over the live shared draft, delivered at the pre-fixation moment + of a creation. + + `identity`: the identity of the topic being created + `checked_out`: True when the chosen path checks out the fresh + branch + `published`: True when the chosen path publishes the work + `draft`: the live shared holder the view reads through + + Apply the `convention` practice for the data-model rules and + intra-package imports. + Use the `registering-hooks` practice for the hook signature that + receives this view. + + Requirements: + - The reads pass through to the live holder — a later hook sees + the committed amendments of the earlier hooks + - The identity-only form — no commit message and no todo on the + chosen path — is valid; the tool decides whether to act + properties: + "identity -> TopicIdentity": | + The identity of the topic being created. + "checked_out -> bool": | + True when the chosen path checks out the fresh branch. + "published -> bool": | + True when the chosen path publishes the work. + "commit_message -> str | None": | + The live draft commit message — None on paths that build no + commit. + "todo -> str | None": | + The live draft todo text — None when none resolved. + methods: + "amend(commit_message: str | None, todo: str | None)": | + Buffer one amendment replacing the full draft content. + + `commit_message`: the complete new commit message — None keeps + the field structurally absent + `todo`: the complete new todo text — None keeps the field + structurally absent + + Requirements: + - The call buffers into the buffer of this hook alone and + changes nothing until the delivery commits it + - The replacement is whole — a field left out is returned as + None, not kept as the previous value + + Constraints: + - Do not cancel, redirect, or defer the operation — an + amendment transforms content only + +"TodoEntryAmendment(identity: TopicIdentity, draft: TodoEntryDraft)": + location: amendments.py + annotations: | + The todo-entry-amendment view of one hook — the read-only surface + over the live shared draft, delivered at the pre-fixation moment + of a todo entry. + + `identity`: the identity of the topic whose todo is being entered + `draft`: the live shared holder the view reads through + + Apply the `convention` practice for the data-model rules and + intra-package imports. + Use the `registering-hooks` practice for the hook signature that + receives this view. + properties: + "identity -> TopicIdentity": | + The identity of the topic whose todo is being entered. + "text -> str": | + The live draft text. + methods: + "amend(text: str)": | + Buffer one amendment replacing the full text. + + `text`: the complete new text + + Requirements: + - The call buffers into the buffer of this hook alone and + changes nothing until the delivery commits it + + Constraints: + - Do not cancel, redirect, or defer the operation — an + amendment transforms content only + +"TopicHooks()": + location: events.py + annotations: | + The checkpoint surface of the topics lifecycle — the two amendment + deliveries and the five notification emissions over the platform + facade. + + Apply the `convention` practice for the code style and + intra-package imports. + Use the `declaring-actions` practice for the emission contract of + the notification checkpoints. + Use the `per-tool-delivery` practice for the staged delivery loop + of the amendment checkpoints. + Use the `registering-hooks` practice for the registration contract + behind every checkpoint. + + Requirements: + - Cheap construction — no enumeration and no imports happen at + construction + - One `HookRegistry` per run carries every checkpoint of a + command — the assembly runs once per run whatever the number of + checkpoints; the transport of the shared `HookRegistry` is an + implementation detail + - Every context and draft is built from the values the caller + passes — no repository reads happen at a checkpoint + methods: + "amend_creation(identity: TopicIdentity, checked_out: bool, published: bool, commit_message: str | None, todo: str | None) -> draft: CreationDraft": | + Deliver the creation-amendment checkpoint and return the holder + with the final content. + + `identity`: the identity of the topic being created + `checked_out`: True when the chosen path checks out the fresh + branch + `published`: True when the chosen path publishes the work + `commit_message`: the draft commit message — None on paths that + build no commit + `todo`: the draft todo text — None when none resolved + `draft`: the holder carrying the final amended values + + Use the `per-tool-delivery` practice for the delivery loop. + + Algorithm: + 1. Resolve the address domain="topics", action="amend_creation" + against `declared_actions` + 2. Create the shared `CreationDraft` with the draft values + 3. Walk the subscriptions of the address in enumeration order: + per subscription build the hook's `CreationAmendment` view + over the live holder, wrap it via `wrap_context`, project the + call arguments via `build_hook_arguments` with the tool's own + context, and call the hook + 4. A hook that returns without raising and buffered an + amendment: the buffer replaces the holder content — except + when a structurally present field of the buffer is empty or + whitespace-only, which rejects the whole buffer + 5. A hook that raised: its buffer is discarded + 6. Both rejection cases emit the warning + hook of tool failed on + topics.amend_creation: — the raised error for the + discard, the empty-amendment reason for the rejection — and + the walk continues with the next hook + 7. Return the holder + + Requirements: + - The delivery is per hook — two hooks of one tool never share + a buffer or a failure + - The final holder content is the last committed buffer, or the + original draft values when no buffer committed + - An address without subscriptions returns the original draft + values — not an error + + Constraints: + - Do not apply any amendment after the walk ends — the caller + fixes the final draft into the artifacts itself + - Do not skip a subscriber of the address + "amend_todo_entry(identity: TopicIdentity, text: str) -> draft: TodoEntryDraft": | + Deliver the todo-entry-amendment checkpoint and return the + holder with the final text. + + `identity`: the identity of the topic whose todo is being + entered + `text`: the saved draft text + `draft`: the holder carrying the final amended text + + Use the `per-tool-delivery` practice for the delivery loop. + + Algorithm: + 1. Resolve the address domain="topics", + action="amend_todo_entry" against `declared_actions` + 2. Create the shared `TodoEntryDraft` with the draft text + 3. Walk the subscriptions in enumeration order with a per-hook + `TodoEntryAmendment` view over the live holder — the same + call, commit, and rejection rules as the creation amendment + 4. Return the holder + + Requirements: + - The delivery is per hook — two hooks of one tool never share + a buffer or a failure + - The final holder text is the last committed buffer, or the + saved draft when no buffer committed + - An address without subscriptions returns the saved draft — + not an error + + Constraints: + - Do not apply any amendment after the walk ends — the caller + writes the final text itself + - Do not skip a subscriber of the address + "emit_created(identity: TopicIdentity, checked_out: bool, published: bool, todo: str | None, commit_message: str | None, commit_hash: str | None)": | + Emit the creation notification — the facts of one completed + creation. + + `identity`: the identity of the created topic + `checked_out`: True when the creation path checked out the fresh + branch + `published`: True when the creation path published the work + `todo`: the final todo text, or None + `commit_message`: the final commit message — None on paths + building none + `commit_hash`: the hash of the built commit — None on paths + building none + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `TopicCreated` context from the values + 2. Emit the address domain="topics", action="topic_created" via + `emit_hook_event` — the context view of every receiving tool + reads the same instance through the delivery proxy + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + - A failing hook is skipped with a warning under the soft error + class of the action + "emit_published(identity: TopicIdentity, commit_message: str, commit_hash: str, todo: str)": | + Emit the publication notification — the facts of one successful + publication push. + + `identity`: the identity of the published topic + `commit_message`: the final commit message landed in git + `commit_hash`: the hash of the publication commit + `todo`: the final todo text landed in the publication commit + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `TopicPublished` context from the values + 2. Emit the address domain="topics", action="topic_published" + via `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + "emit_switched(identity: TopicIdentity, outcome: str)": | + Emit the switch notification — the outcome of one completed + switch. + + `identity`: the identity of the switched work — the branch-only + form when the branch hosts no topic + `outcome`: the outcome kind — local-checkout, + created-from-remote, or already-on-branch + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `TopicSwitched` context from the values + 2. Emit the address domain="topics", action="topic_switched" + via `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + "emit_todo_entered(identity: TopicIdentity, text: str)": | + Emit the todo-entry notification — the final text of one saved + todo entry. + + `identity`: the identity of the topic whose todo was entered + `text`: the final written text + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `TopicTodoEntered` context from the values + 2. Emit the address domain="topics", + action="topic_todo_entered" via `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + "emit_deleted(identity: TopicIdentity, local_branch: str | None, origin_twin: str | None, directory_removed: bool)": | + Emit the deletion notification — the removal composition of one + fully removed target. + + `identity`: the identity of the removed topic — slug and home + path; no branch fact + `local_branch`: the removed local branch name, or None + `origin_twin`: the removed origin twin name, or None + `directory_removed`: True when the topic directory was removed + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `TopicDeleted` context from the values + 2. Emit the address domain="topics", action="topic_deleted" + via `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + +--- + +Author: Goga +CreatedAt: 16/09/26 +Description: | + Owner of the topics domain hooks zone — the event identity, the + notification contexts, the amendment drafts, and the checkpoint + surface over the hooks platform. From cb91c38c47a62113168394f1a515eb7c6d5d4473 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Wed, 16 Sep 2026 23:34:45 +0000 Subject: [PATCH 041/205] feat: extend the action catalog with the seven topics records --- .goga/history/2026/add-topics-hooks/plan.md | 14 ++++++------- goga/hooks/catalog/catalog.py | 7 +++++++ tests/hooks/catalog/test_catalog.py | 22 +++++++++++++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/.goga/history/2026/add-topics-hooks/plan.md b/.goga/history/2026/add-topics-hooks/plan.md index 66d92487..9c9a2dcf 100644 --- a/.goga/history/2026/add-topics-hooks/plan.md +++ b/.goga/history/2026/add-topics-hooks/plan.md @@ -490,13 +490,13 @@ of insertion order. **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: extend `tests/hooks/catalog/test_catalog.py` — a test asserting the seven topics records exist with `error_class="soft"` and the total is 10 (scenario below; expected to fail at this stage) -- [ ] **Code**: append seven `Action` records to `_DECLARED_ACTIONS` in `goga/hooks/catalog/catalog.py` — all `domain="topics"`, `error_class="soft"`, names: `amend_creation`, `amend_todo_entry`, `topic_created`, `topic_deleted`, `topic_published`, `topic_switched`, `topic_todo_entered` (the list stays in `(domain, name)` sorted order; `declared_actions()` behavior is otherwise untouched) -- [ ] **Interface verification**: `pytest tests/hooks/catalog/test_catalog.py -v` — all pass -- [ ] **Logic tests**: the assertions below already cover the behavior (determinism, completeness, record shape); add none beyond them -- [ ] **Debugging**: `pytest tests/hooks/ -x` — fix implementation code until all tests pass (do NOT fix test code) -- [ ] **Contract re-verification**: the `Action` dataclass and the `declared_actions` signature/return are unchanged; `goga hooks` lists the topics domain with no command change -- [ ] **Lint**: `ruff check goga/hooks/catalog` — fix formatting if necessary +- [x] **Contract tests**: extend `tests/hooks/catalog/test_catalog.py` — a test asserting the seven topics records exist with `error_class="soft"` and the total is 10 (scenario below; expected to fail at this stage) +- [x] **Code**: append seven `Action` records to `_DECLARED_ACTIONS` in `goga/hooks/catalog/catalog.py` — all `domain="topics"`, `error_class="soft"`, names: `amend_creation`, `amend_todo_entry`, `topic_created`, `topic_deleted`, `topic_published`, `topic_switched`, `topic_todo_entered` (the list stays in `(domain, name)` sorted order; `declared_actions()` behavior is otherwise untouched) +- [x] **Interface verification**: `pytest tests/hooks/catalog/test_catalog.py -v` — all pass +- [x] **Logic tests**: the assertions below already cover the behavior (determinism, completeness, record shape); add none beyond them +- [x] **Debugging**: `pytest tests/hooks/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [x] **Contract re-verification**: the `Action` dataclass and the `declared_actions` signature/return are unchanged; `goga hooks` lists the topics domain with no command change +- [x] **Lint**: `ruff check goga/hooks/catalog` — fix formatting if necessary Test scenario (from the design — `test_declared_actions_carries_the_seven_topics_records`): diff --git a/goga/hooks/catalog/catalog.py b/goga/hooks/catalog/catalog.py index 5c11ade4..acc84f71 100644 --- a/goga/hooks/catalog/catalog.py +++ b/goga/hooks/catalog/catalog.py @@ -41,6 +41,13 @@ class Action: Action(domain="onboarding", name="amend_config", error_class="soft"), Action(domain="onboarding", name="declare_session", error_class="soft"), Action(domain="statuses", name="register_statuses", error_class="soft"), + Action(domain="topics", name="amend_creation", error_class="soft"), + Action(domain="topics", name="amend_todo_entry", error_class="soft"), + Action(domain="topics", name="topic_created", error_class="soft"), + Action(domain="topics", name="topic_deleted", error_class="soft"), + Action(domain="topics", name="topic_published", error_class="soft"), + Action(domain="topics", name="topic_switched", error_class="soft"), + Action(domain="topics", name="topic_todo_entered", error_class="soft"), ] diff --git a/tests/hooks/catalog/test_catalog.py b/tests/hooks/catalog/test_catalog.py index e8b7f37e..0a2af3ab 100644 --- a/tests/hooks/catalog/test_catalog.py +++ b/tests/hooks/catalog/test_catalog.py @@ -95,6 +95,28 @@ def test_catalog_carries_onboarding_actions(self) -> None: assert [(r.domain, r.name) for r in records] == sorted((r.domain, r.name) for r in records) assert ("statuses", "register_statuses") in {(r.domain, r.name) for r in records} + def test_declared_actions_carries_the_seven_topics_records(self) -> None: + """The seven topics lifecycle actions are declared addresses, soft failures. + + Five post-fact notifications and two pre-fixation amendments — every + checkpoint the topics zone emits resolves its address here. An + address the zone emits but the catalog misses is a runtime + ValueError in every flow, so the record set is pinned against + drift, together with the complete total: 3 existing + 7 topics. + """ + topics = [action for action in declared_actions() if action.domain == "topics"] + + assert [(action.name, action.error_class) for action in topics] == [ + ("amend_creation", "soft"), + ("amend_todo_entry", "soft"), + ("topic_created", "soft"), + ("topic_deleted", "soft"), + ("topic_published", "soft"), + ("topic_switched", "soft"), + ("topic_todo_entered", "soft"), + ] + assert len(declared_actions()) == 10 + def test_declared_actions_is_deterministic_and_complete(self) -> None: """Same records in ``(domain, name)`` order on every call, unfiltered. From 47218b0f093d4f9665f9978aa9e4d2551e716647 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Wed, 16 Sep 2026 23:39:22 +0000 Subject: [PATCH 042/205] feat: add topics hooks zone package skeleton and test fixtures --- .goga/history/2026/add-topics-hooks/plan.md | 12 +- goga/topics/hooks/__init__.py | 8 + tests/topics/hooks/__init__.py | 1 + tests/topics/hooks/conftest.py | 170 ++++++++++++++++++++ 4 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 goga/topics/hooks/__init__.py create mode 100644 tests/topics/hooks/__init__.py create mode 100644 tests/topics/hooks/conftest.py diff --git a/.goga/history/2026/add-topics-hooks/plan.md b/.goga/history/2026/add-topics-hooks/plan.md index 9c9a2dcf..7e7e2412 100644 --- a/.goga/history/2026/add-topics-hooks/plan.md +++ b/.goga/history/2026/add-topics-hooks/plan.md @@ -548,12 +548,12 @@ boundaries). **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] Create `goga/topics/hooks/__init__.py` — the package docstring in the CODEMANIFEST Description voice (the hooks-zone owner description: importing the package imports no tool package and enumerates nothing) and an empty `__all__: list[str] = []` placeholder that Tasks 3–6 grow to the eleven names -- [ ] Create `tests/topics/hooks/__init__.py` (empty, the tests package marker) -- [ ] Create `tests/topics/hooks/conftest.py` with the two platform-environment fixtures re-declared locally (the `tests/hooks/conftest.py` shape): `pin_package_environment` — pins `goga.hooks.tools.packages.packages_distributions` to a fixed mapping (`{"goga_tool_one": ["pkg-one"], "goga_tool_two": ["pkg-two"]}`); `install_tool_package(module_name, register_hooks)` — mounts fake `goga_tool_*` modules in `sys.modules` (monkeypatch-undone) -- [ ] Add to the same conftest the `recording_hooks` fixture — subscribes recording hooks (appending `(tool, hook_name, context)` tuples and captured facts to lists the tests assert) built on the two fixtures above -- [ ] Verify the package imports: `python -c "import goga.topics.hooks"` and the suite still collects: `pytest tests/topics/hooks/ --collect-only -q` (no test files yet — collection must be clean) -- [ ] Lint: `ruff check goga/topics/hooks tests/topics/hooks` — fix formatting if necessary +- [x] Create `goga/topics/hooks/__init__.py` — the package docstring in the CODEMANIFEST Description voice (the hooks-zone owner description: importing the package imports no tool package and enumerates nothing) and an empty `__all__: list[str] = []` placeholder that Tasks 3–6 grow to the eleven names +- [x] Create `tests/topics/hooks/__init__.py` (empty, the tests package marker) +- [x] Create `tests/topics/hooks/conftest.py` with the two platform-environment fixtures re-declared locally (the `tests/hooks/conftest.py` shape): `pin_package_environment` — pins `goga.hooks.tools.packages.packages_distributions` to a fixed mapping (`{"goga_tool_one": ["pkg-one"], "goga_tool_two": ["pkg-two"]}`); `install_tool_package(module_name, register_hooks)` — mounts fake `goga_tool_*` modules in `sys.modules` (monkeypatch-undone) +- [x] Add to the same conftest the `recording_hooks` fixture — subscribes recording hooks (appending `(tool, hook_name, context)` tuples and captured facts to lists the tests assert) built on the two fixtures above +- [x] Verify the package imports: `python -c "import goga.topics.hooks"` and the suite still collects: `pytest tests/topics/hooks/ --collect-only -q` (no test files yet — collection must be clean) +- [x] Lint: `ruff check goga/topics/hooks tests/topics/hooks` — fix formatting if necessary ### Task 3: `TopicIdentity` — the identity vocabulary (TDD coding) diff --git a/goga/topics/hooks/__init__.py b/goga/topics/hooks/__init__.py new file mode 100644 index 00000000..e890fd2a --- /dev/null +++ b/goga/topics/hooks/__init__.py @@ -0,0 +1,8 @@ +"""The topics domain hooks zone — the lifecycle checkpoint surface. + +Owner of the event identity, the notification contexts, the amendment +drafts, and the checkpoint surface over the hooks platform. Importing the +package imports no tool package and enumerates nothing. +""" + +__all__: list[str] = [] diff --git a/tests/topics/hooks/__init__.py b/tests/topics/hooks/__init__.py new file mode 100644 index 00000000..b979a798 --- /dev/null +++ b/tests/topics/hooks/__init__.py @@ -0,0 +1 @@ +"""Tests of the topics hooks zone cell — ``goga/topics/hooks``.""" diff --git a/tests/topics/hooks/conftest.py b/tests/topics/hooks/conftest.py new file mode 100644 index 00000000..676889fc --- /dev/null +++ b/tests/topics/hooks/conftest.py @@ -0,0 +1,170 @@ +"""Local fixtures of the topics hooks zone tests — the platform environment. + +The zone consumes the hooks platform through its facade, never around it: +the only outside points of a zone test are the two the platform tests +already pin — the installed-distributions mapping read by +``packages_distributions`` and the ``sys.modules`` entry of a +``goga_tool_*`` package. The fixtures below re-declare that boundary +locally, so the registry, the delivery, and the zone code under test run +for real, and add the recording hooks the checkpoint tests assert their +deliveries through. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable, Sequence +from types import ModuleType +from typing import Any +from unittest import mock + +import pytest + +ENUMERATION_TARGET = "goga.hooks.tools.packages.packages_distributions" +"""The attribute the enumeration reads — the single enumeration mock point.""" + +TWO_TOOL_ENVIRONMENT: dict[str, list[str]] = { + "goga_tool_one": ["pkg-one"], + "goga_tool_two": ["pkg-two"], +} +"""The fixed environment of the zone tests — two installed tool packages.""" + +TOPICS_ACTIONS: tuple[str, ...] = ( + "amend_creation", + "amend_todo_entry", + "topic_created", + "topic_deleted", + "topic_published", + "topic_switched", + "topic_todo_entered", +) +"""The seven topics addresses a recording pass subscribes by default.""" + + +def _tool_identity(module_name: str) -> str: + """The tool identity of a ``goga_tool_*`` module — the platform derivation. + + Args: + module_name: The top-level module name of the fake package. + + Returns: + The canonical hyphen form without the ``goga_tool_`` prefix. + """ + return module_name.removeprefix("goga_tool_").replace("_", "-") + + +@pytest.fixture +def pin_package_environment( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[dict[str, list[str]]], mock.MagicMock]: + """Factory: pin the installed-packages mapping the enumeration reads. + + ``mapping`` carries the shape of ``packages_distributions()`` — a + top-level module name mapped to the distributions providing it. Names + without the ``goga_tool_`` prefix stay in the mapping on purpose: they + prove the filter. Returns the boundary mock, so a test can also assert + how often the environment was read. + + Args: + monkeypatch: the pytest patcher restoring the boundary on teardown. + + Returns: + The pinning factory: mapping in, boundary mock out. + """ + + def _pin(mapping: dict[str, list[str]]) -> mock.MagicMock: + boundary = mock.MagicMock(return_value=mapping) + + monkeypatch.setattr(ENUMERATION_TARGET, boundary) + + return boundary + + return _pin + + +@pytest.fixture +def install_tool_package( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[str, Callable[[Any], None] | None], ModuleType]: + """Factory: install one fake ``goga_tool_*`` package into ``sys.modules``. + + ``register_hooks`` becomes the facade callback of the package; omitting it + leaves the facade without a callback — the quiet-skip condition. Each call + installs one package and each installation is undone on teardown — one + restored ``sys.modules`` entry per fake package. + + Args: + monkeypatch: the pytest patcher restoring ``sys.modules`` on teardown. + + Returns: + The installing factory: module name in, the installed module out. + """ + + def _install( + module_name: str, + register_hooks: Callable[[Any], None] | None = None, + ) -> ModuleType: + module = ModuleType(module_name) + + if register_hooks is not None: + module.register_hooks = register_hooks + + monkeypatch.setitem(sys.modules, module_name, module) + + return module + + return _install + + +@pytest.fixture +def recording_hooks( + pin_package_environment: Callable[[dict[str, list[str]]], mock.MagicMock], + install_tool_package: Callable[[str, Callable[[Any], None] | None], ModuleType], +) -> Callable[..., list[tuple[str, str, object]]]: + """Factory: subscribe recording hooks over the topics actions. + + The environment is pinned to the fixed two-tool mapping for the + fixture's own packages — a test pinning it explicitly overrides the + default with its own call. Each call installs one fake tool package + whose callback subscribes one recording hook per requested action, + named by the action; every delivery appends ``(tool, hook_name, + context)`` to the one shared records list — the tests assert the + fired actions, their order, and the facts of the delivered contexts + off that list. + + Args: + pin_package_environment: the enumeration-boundary pinning factory. + install_tool_package: the fake-package installing factory. + + Returns: + The subscribing factory: one action name or a sequence of them, + plus optionally the tool module name, in — the shared records + list out. + """ + pin_package_environment(TWO_TOOL_ENVIRONMENT) + + records: list[tuple[str, str, object]] = [] + + def _recorder(tool: str, action: str) -> Callable[[object], None]: + def hook(context: object) -> None: + records.append((tool, action, context)) + + return hook + + def _record( + actions: str | Sequence[str] = TOPICS_ACTIONS, + *, + module_name: str = "goga_tool_one", + ) -> list[tuple[str, str, object]]: + selected = [actions] if isinstance(actions, str) else list(actions) + tool = _tool_identity(module_name) + + def register_hooks(hooks: Any) -> None: + for action in selected: + hooks.subscribe("topics", action, action, _recorder(tool, action)) + + install_tool_package(module_name, register_hooks=register_hooks) + + return records + + return _record From 846442727d0721f260ca7207139d49dd59c02ce5 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Wed, 16 Sep 2026 23:42:04 +0000 Subject: [PATCH 043/205] feat: add TopicIdentity to the topics hooks zone --- .goga/history/2026/add-topics-hooks/plan.md | 16 ++-- goga/topics/hooks/__init__.py | 4 +- goga/topics/hooks/identity.py | 60 ++++++++++++ tests/topics/hooks/test_identity.py | 101 ++++++++++++++++++++ 4 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 goga/topics/hooks/identity.py create mode 100644 tests/topics/hooks/test_identity.py diff --git a/.goga/history/2026/add-topics-hooks/plan.md b/.goga/history/2026/add-topics-hooks/plan.md index 7e7e2412..a8fd4fd8 100644 --- a/.goga/history/2026/add-topics-hooks/plan.md +++ b/.goga/history/2026/add-topics-hooks/plan.md @@ -575,14 +575,14 @@ boundaries). **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: create `tests/topics/hooks/test_identity.py` — facade accessibility (`from goga.topics.hooks import TopicIdentity`), kw-only construction (`TopicIdentity(slug=..., year=..., branch=...)`; positional construction raises `TypeError`), frozen behavior (attribute assignment raises `FrozenInstanceError`), the three property types (expected to fail at this stage) -- [ ] **Code**: create `goga/topics/hooks/identity.py` per the algorithm below — `@dataclass(frozen=True, kw_only=True)` with `slug: str | None`, `year: str`, `branch: str | None`, and the `home_path` property -- [ ] **Code**: add `TopicIdentity` to `goga/topics/hooks/__init__.py` (relative import from `.identity`, append to `__all__` keeping alphabetical order) -- [ ] **Interface verification**: `pytest tests/topics/hooks/test_identity.py -v` — all pass -- [ ] **Logic tests**: the pure-composition scenario below (positive), the branch-only form `slug=None` → `home_path is None` (edge), the deletion form `branch=None` keeps `home_path` composed (edge) -- [ ] **Debugging**: `pytest tests/topics/hooks/ -x` — fix implementation code until all tests pass (do NOT fix test code) -- [ ] **Contract re-verification**: facade import works; property set is exactly `slug`/`home_path`/`branch` with the declared types; no repository reads, nothing created (pure composition) -- [ ] **Lint**: `ruff check goga/topics/hooks tests/topics/hooks` — fix formatting if necessary +- [x] **Contract tests**: create `tests/topics/hooks/test_identity.py` — facade accessibility (`from goga.topics.hooks import TopicIdentity`), kw-only construction (`TopicIdentity(slug=..., year=..., branch=...)`; positional construction raises `TypeError`), frozen behavior (attribute assignment raises `FrozenInstanceError`), the three property types (expected to fail at this stage) +- [x] **Code**: create `goga/topics/hooks/identity.py` per the algorithm below — `@dataclass(frozen=True, kw_only=True)` with `slug: str | None`, `year: str`, `branch: str | None`, and the `home_path` property +- [x] **Code**: add `TopicIdentity` to `goga/topics/hooks/__init__.py` (relative import from `.identity`, append to `__all__` keeping alphabetical order) +- [x] **Interface verification**: `pytest tests/topics/hooks/test_identity.py -v` — all pass +- [x] **Logic tests**: the pure-composition scenario below (positive), the branch-only form `slug=None` → `home_path is None` (edge), the deletion form `branch=None` keeps `home_path` composed (edge) +- [x] **Debugging**: `pytest tests/topics/hooks/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [x] **Contract re-verification**: facade import works; property set is exactly `slug`/`home_path`/`branch` with the declared types; no repository reads, nothing created (pure composition) +- [x] **Lint**: `ruff check goga/topics/hooks tests/topics/hooks` — fix formatting if necessary Algorithm (from the design): diff --git a/goga/topics/hooks/__init__.py b/goga/topics/hooks/__init__.py index e890fd2a..44a15df3 100644 --- a/goga/topics/hooks/__init__.py +++ b/goga/topics/hooks/__init__.py @@ -5,4 +5,6 @@ package imports no tool package and enumerates nothing. """ -__all__: list[str] = [] +from .identity import TopicIdentity + +__all__: list[str] = ["TopicIdentity"] diff --git a/goga/topics/hooks/identity.py b/goga/topics/hooks/identity.py new file mode 100644 index 00000000..549f3824 --- /dev/null +++ b/goga/topics/hooks/identity.py @@ -0,0 +1,60 @@ +"""The identity vocabulary of the topics lifecycle events. + +The entity declared in the cell CODEMANIFEST with ``location: identity.py``: +``TopicIdentity`` — the topic slug with its home path and the branch as +entered by the operation. Pure composition: the home path derives from the +slug and the year inputs through the history composer — nothing is read and +nothing is created here. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ...history import resolve_topic_dir + + +@dataclass(frozen=True, kw_only=True) +class TopicIdentity: + """The identity vocabulary of every topics event — slug, home path, branch. + + The identity every notification context and every amendment view + carries. The home path is composed from the slug and the year inputs + on read — no repository access, no filesystem effect. + + Attributes: + slug: The normalized topic slug, or None in the branch-only form — + a switch onto a branch hosting no topic. + year: The resolved year as four digits — the composition input of + the home path; it always arrives resolved — the constructing + operation passes its year input when given, otherwise the + current year. + branch: The branch name as entered by the operation, or None only + in the deletion context, whose removal composition carries the + branch names. + + Requirements: + Pure composition — the home path derives from ``slug`` and ``year`` + without repository reads and without creating anything. + """ + + slug: str | None + year: str + branch: str | None + + @property + def home_path(self) -> str | None: + """Return the topic home path as a posix string — None without a slug. + + The path ``.goga/history//`` is composed through + ``resolve_topic_dir`` — the slug is re-normalized, the identity for + an already-normalized slug — and nothing is read or created. + + Returns: + The topic home path ``.goga/history//`` as a posix + string, or None when the slug is None. + """ + if self.slug is None: + return None + + return resolve_topic_dir(self.slug, self.year).as_posix() diff --git a/tests/topics/hooks/test_identity.py b/tests/topics/hooks/test_identity.py new file mode 100644 index 00000000..33ca0b98 --- /dev/null +++ b/tests/topics/hooks/test_identity.py @@ -0,0 +1,101 @@ +"""Contract and logic tests for the entity declared in +``goga/topics/hooks/CODEMANIFEST`` with ``location: identity.py``: +``TopicIdentity(slug, year, branch)`` — the identity vocabulary of every +topics event. + +Pure composition — no fixtures, no mocks: the home path derives from the +slug and the year inputs through ``resolve_topic_dir`` and nothing is read +or created. +""" + +from __future__ import annotations + +import dataclasses +import typing + +import pytest +from goga.topics.hooks import TopicIdentity + +from tests.conftest import is_kw_only_dataclass + +# --- Contract tests --- + + +class TestTopicIdentityContract: + def test_entity_is_importable_from_the_zone_facade(self) -> None: + """The identity lives on the zone package and its ``__all__`` is exact.""" + import goga.topics.hooks as zone + + assert zone.TopicIdentity is TopicIdentity + assert zone.__all__ == ["TopicIdentity"] + + def test_identity_is_a_kw_only_frozen_dataclass(self) -> None: + """``TopicIdentity(slug=..., year=..., branch=...)`` — keyword-only, frozen.""" + identity = TopicIdentity(slug="feature-foo", year="2026", branch="feature-foo") + + assert identity.slug == "feature-foo" + assert identity.year == "2026" + assert identity.branch == "feature-foo" + + assert dataclasses.is_dataclass(TopicIdentity) + assert TopicIdentity.__dataclass_params__.frozen + assert is_kw_only_dataclass(TopicIdentity) + + with pytest.raises(TypeError): + TopicIdentity("feature-foo", "2026", "feature-foo") # type: ignore[misc] + + def test_identity_assignment_raises_frozen_instance_error(self) -> None: + """The identity is read-only fact — no holder rewrites it.""" + identity = TopicIdentity(slug="feature-foo", year="2026", branch="feature-foo") + + with pytest.raises(dataclasses.FrozenInstanceError): + identity.slug = "other" # type: ignore[misc] + + def test_identity_carries_the_declared_fields_types_and_properties(self) -> None: + """``slug``/``year``/``branch`` fields; ``home_path`` the sole computed member.""" + field_names = [field.name for field in dataclasses.fields(TopicIdentity)] + field_types = typing.get_type_hints(TopicIdentity) + + assert field_names == ["slug", "year", "branch"] + assert field_types["slug"] == str | None + assert field_types["year"] is str + assert field_types["branch"] == str | None + assert isinstance(TopicIdentity.home_path, property) + assert typing.get_type_hints(TopicIdentity.home_path.fget)["return"] == str | None + + +# --- Logic tests --- + + +class TestTopicIdentityComposition: + def test_topic_identity_home_path_composes_purely(self) -> None: + """The canonical addressing fact every notification carries. + + The composition runs through ``resolve_topic_dir`` — re-normalizing + an already-normalized slug is the identity — and returns the posix + string of the topic directory. + """ + identity = TopicIdentity( + slug="add-topics-hooks", + year="2026", + branch="add-topics-hooks", + ) + + assert identity.home_path == ".goga/history/2026/add-topics-hooks" + assert identity.slug == "add-topics-hooks" + assert identity.branch == "add-topics-hooks" + + def test_branch_only_form_has_no_home_path(self) -> None: + """A switch onto a branch hosting no topic — slug None, home path None.""" + identity = TopicIdentity(slug=None, year="2026", branch="bare-branch") + + assert identity.slug is None + assert identity.home_path is None + assert identity.branch == "bare-branch" + + def test_deletion_form_keeps_the_home_path_composed(self) -> None: + """The deletion context carries no branch fact — slug and home path stay.""" + identity = TopicIdentity(slug="feature-foo", year="2026", branch=None) + + assert identity.branch is None + assert identity.home_path == ".goga/history/2026/feature-foo" From adcc817690745802eb14e2ba32f938e95b024021 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Wed, 16 Sep 2026 23:45:14 +0000 Subject: [PATCH 044/205] feat: add the five notification contexts to the topics hooks zone --- .goga/history/2026/add-topics-hooks/plan.md | 16 +- goga/topics/hooks/__init__.py | 16 +- goga/topics/hooks/contexts.py | 135 +++++++++++++ tests/topics/hooks/test_contexts.py | 213 ++++++++++++++++++++ tests/topics/hooks/test_identity.py | 2 +- 5 files changed, 372 insertions(+), 10 deletions(-) create mode 100644 goga/topics/hooks/contexts.py create mode 100644 tests/topics/hooks/test_contexts.py diff --git a/.goga/history/2026/add-topics-hooks/plan.md b/.goga/history/2026/add-topics-hooks/plan.md index a8fd4fd8..b1d2d950 100644 --- a/.goga/history/2026/add-topics-hooks/plan.md +++ b/.goga/history/2026/add-topics-hooks/plan.md @@ -645,14 +645,14 @@ write path. **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: create `tests/topics/hooks/test_contexts.py` — per context: facade accessibility, kw-only construction, frozen behavior (assignment raises), the exact field set with declared types, `identity: TopicIdentity` carried through (expected to fail at this stage) -- [ ] **Code**: create `goga/topics/hooks/contexts.py` per the algorithm below — five `@dataclass(frozen=True, kw_only=True)` classes with fields exactly as the signatures declare; plain data fields only -- [ ] **Code**: add the five names to `goga/topics/hooks/__init__.py` (relative imports from `.contexts`, `__all__` stays alphabetical) -- [ ] **Interface verification**: `pytest tests/topics/hooks/test_contexts.py -v` — all pass -- [ ] **Logic tests**: field-passthrough reads per context (each constructor value reads back identically); `TopicSwitched.outcome` accepts and returns each of the three fixed kinds (`local-checkout`, `created-from-remote`, `already-on-branch`) — the kind is fixed by construction of the emitting routine, so the context itself just carries the string -- [ ] **Debugging**: `pytest tests/topics/hooks/ -x` — fix implementation code until all tests pass (do NOT fix test code) -- [ ] **Contract re-verification**: facade imports work; the five field lists are identical to the method parameters beyond `identity` of the matching `emit_*` signatures (interface↔type consistency); no method surface, no write path -- [ ] **Lint**: `ruff check goga/topics/hooks tests/topics/hooks` — fix formatting if necessary +- [x] **Contract tests**: create `tests/topics/hooks/test_contexts.py` — per context: facade accessibility, kw-only construction, frozen behavior (assignment raises), the exact field set with declared types, `identity: TopicIdentity` carried through (expected to fail at this stage) +- [x] **Code**: create `goga/topics/hooks/contexts.py` per the algorithm below — five `@dataclass(frozen=True, kw_only=True)` classes with fields exactly as the signatures declare; plain data fields only +- [x] **Code**: add the five names to `goga/topics/hooks/__init__.py` (relative imports from `.contexts`, `__all__` stays alphabetical) +- [x] **Interface verification**: `pytest tests/topics/hooks/test_contexts.py -v` — all pass +- [x] **Logic tests**: field-passthrough reads per context (each constructor value reads back identically); `TopicSwitched.outcome` accepts and returns each of the three fixed kinds (`local-checkout`, `created-from-remote`, `already-on-branch`) — the kind is fixed by construction of the emitting routine, so the context itself just carries the string +- [x] **Debugging**: `pytest tests/topics/hooks/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [x] **Contract re-verification**: facade imports work; the five field lists are identical to the method parameters beyond `identity` of the matching `emit_*` signatures (interface↔type consistency); no method surface, no write path +- [x] **Lint**: `ruff check goga/topics/hooks tests/topics/hooks` — fix formatting if necessary Algorithm (from the design): diff --git a/goga/topics/hooks/__init__.py b/goga/topics/hooks/__init__.py index 44a15df3..46d37314 100644 --- a/goga/topics/hooks/__init__.py +++ b/goga/topics/hooks/__init__.py @@ -5,6 +5,20 @@ package imports no tool package and enumerates nothing. """ +from .contexts import ( + TopicCreated, + TopicDeleted, + TopicPublished, + TopicSwitched, + TopicTodoEntered, +) from .identity import TopicIdentity -__all__: list[str] = ["TopicIdentity"] +__all__: list[str] = [ + "TopicCreated", + "TopicDeleted", + "TopicIdentity", + "TopicPublished", + "TopicSwitched", + "TopicTodoEntered", +] diff --git a/goga/topics/hooks/contexts.py b/goga/topics/hooks/contexts.py new file mode 100644 index 00000000..1e884849 --- /dev/null +++ b/goga/topics/hooks/contexts.py @@ -0,0 +1,135 @@ +"""The notification contexts of the topics lifecycle events. + +The entities declared in the cell CODEMANIFEST with ``location: +contexts.py``: the five read-only fact bags of the post-moment +notifications — ``TopicCreated``, ``TopicPublished``, ``TopicSwitched``, +``TopicTodoEntered``, ``TopicDeleted``. An ``emit_*`` method constructs +one from the values the caller passed; a hook observes the outcome and +cannot alter it. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .identity import TopicIdentity + + +@dataclass(frozen=True, kw_only=True) +class TopicCreated: + """The read-only context of the creation notification. + + The final facts of one completed creation. + + Attributes: + identity: The identity of the created topic. + checked_out: True when the creation path checked out the fresh + branch. + published: True when the creation path published the work. + todo: The final todo text, or None when none resolved. + commit_message: The final commit message — present exactly when + the creation path builds a commit, None otherwise. + commit_hash: The hash of the built commit — present exactly when + the creation path builds a commit, None otherwise. + + Requirements: + Read-only facts of a completed operation — a hook observes the + outcome and cannot alter it. + """ + + identity: TopicIdentity + checked_out: bool + published: bool + todo: str | None + commit_message: str | None + commit_hash: str | None + + +@dataclass(frozen=True, kw_only=True) +class TopicPublished: + """The read-only context of the publication notification. + + The final facts of one successful publication push. + + Attributes: + identity: The identity of the published topic. + commit_message: The final commit message landed in git. + commit_hash: The hash of the publication commit. + todo: The final todo text landed in the publication commit. + + Requirements: + Read-only facts of a completed operation. + """ + + identity: TopicIdentity + commit_message: str + commit_hash: str + todo: str + + +@dataclass(frozen=True, kw_only=True) +class TopicSwitched: + """The read-only context of the switch notification. + + The outcome of one completed switch. + + Attributes: + identity: The identity of the switched work — the branch-only + form when the branch hosts no topic. + outcome: The outcome kind — exactly one of local-checkout, + created-from-remote, already-on-branch. + + Requirements: + The outcome value is exactly one of the three fixed kinds — + fixed by construction of the emitting routine. + Read-only facts of a completed operation. + """ + + identity: TopicIdentity + outcome: str + + +@dataclass(frozen=True, kw_only=True) +class TopicTodoEntered: + """The read-only context of the todo-entry notification. + + The final text of one saved todo entry. + + Attributes: + identity: The identity of the topic whose todo was entered. + text: The final written text — after every amendment. + + Requirements: + No prior text is carried — a tool keeps its own state in its + own context. + Read-only facts of a completed operation. + """ + + identity: TopicIdentity + text: str + + +@dataclass(frozen=True, kw_only=True) +class TopicDeleted: + """The read-only context of the deletion notification. + + The removal composition of one fully removed target. + + Attributes: + identity: The identity of the removed topic — slug and home + path; no branch fact. + local_branch: The removed local branch name, or None when the + target had none. + origin_twin: The removed origin twin name, or None when the + target had none. + directory_removed: True when the topic directory was removed. + + Requirements: + No deleted-commit hash is carried. + Read-only facts of a completed operation. + """ + + identity: TopicIdentity + local_branch: str | None + origin_twin: str | None + directory_removed: bool diff --git a/tests/topics/hooks/test_contexts.py b/tests/topics/hooks/test_contexts.py new file mode 100644 index 00000000..84fd046e --- /dev/null +++ b/tests/topics/hooks/test_contexts.py @@ -0,0 +1,213 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/hooks/CODEMANIFEST`` with ``location: contexts.py``: the +five notification contexts ``TopicCreated``, ``TopicPublished``, +``TopicSwitched``, ``TopicTodoEntered``, ``TopicDeleted``. + +Read-only fact bags — no fixtures, no mocks: an ``emit_*`` method +constructs one from the values the caller passed, every field read +returns the constructor value, and no method surface or write path +exists. +""" + +from __future__ import annotations + +import dataclasses +import typing + +import pytest +from goga.topics.hooks import ( + TopicCreated, + TopicDeleted, + TopicIdentity, + TopicPublished, + TopicSwitched, + TopicTodoEntered, +) + +from tests.conftest import is_kw_only_dataclass + +IDENTITY = TopicIdentity(slug="feature-foo", year="2026", branch="feature-foo") +DELETION_IDENTITY = TopicIdentity(slug="one", year="2026", branch=None) + +CONTEXT_FIELDS: dict[type, dict[str, object]] = { + TopicCreated: { + "identity": TopicIdentity, + "checked_out": bool, + "published": bool, + "todo": str | None, + "commit_message": str | None, + "commit_hash": str | None, + }, + TopicPublished: { + "identity": TopicIdentity, + "commit_message": str, + "commit_hash": str, + "todo": str, + }, + TopicSwitched: { + "identity": TopicIdentity, + "outcome": str, + }, + TopicTodoEntered: { + "identity": TopicIdentity, + "text": str, + }, + TopicDeleted: { + "identity": TopicIdentity, + "local_branch": str | None, + "origin_twin": str | None, + "directory_removed": bool, + }, +} +"""The declared field sets with their types — one entry per context.""" + +CONTEXT_CASES: list[tuple[type, dict[str, object]]] = [ + ( + TopicCreated, + { + "identity": IDENTITY, + "checked_out": False, + "published": False, + "todo": "the todo", + "commit_message": "goga: create topic feature-foo", + "commit_hash": "deadbeef", + }, + ), + ( + TopicPublished, + { + "identity": IDENTITY, + "commit_message": "goga: create topic feature-foo", + "commit_hash": "cafe123", + "todo": "the todo", + }, + ), + ( + TopicSwitched, + { + "identity": IDENTITY, + "outcome": "local-checkout", + }, + ), + ( + TopicTodoEntered, + { + "identity": IDENTITY, + "text": "the final text", + }, + ), + ( + TopicDeleted, + { + "identity": DELETION_IDENTITY, + "local_branch": "one", + "origin_twin": "one", + "directory_removed": True, + }, + ), +] +"""One construction case per context — keyword order matches the signature.""" + +CASE_IDS = [cls.__name__ for cls, _ in CONTEXT_CASES] + +# --- Contract tests --- + + +class TestContextsContract: + def test_entities_are_importable_from_the_zone_facade(self) -> None: + """The five contexts live on the zone package and ``__all__`` is exact.""" + import goga.topics.hooks as zone + + assert zone.TopicCreated is TopicCreated + assert zone.TopicPublished is TopicPublished + assert zone.TopicSwitched is TopicSwitched + assert zone.TopicTodoEntered is TopicTodoEntered + assert zone.TopicDeleted is TopicDeleted + assert zone.__all__ == [ + "TopicCreated", + "TopicDeleted", + "TopicIdentity", + "TopicPublished", + "TopicSwitched", + "TopicTodoEntered", + ] + + @pytest.mark.parametrize(("cls", "values"), CONTEXT_CASES, ids=CASE_IDS) + def test_contexts_are_kw_only_frozen_dataclasses( + self, + cls: type, + values: dict[str, object], + ) -> None: + """``Cls(field=..., ...)`` — keyword-only construction, frozen holder.""" + cls(**values) # type: ignore[arg-type] — construction itself must pass + + assert dataclasses.is_dataclass(cls) + assert cls.__dataclass_params__.frozen + assert is_kw_only_dataclass(cls) + + with pytest.raises(TypeError): + cls(*values.values()) # type: ignore[misc] + + @pytest.mark.parametrize(("cls", "values"), CONTEXT_CASES, ids=CASE_IDS) + def test_context_assignment_raises_frozen_instance_error( + self, + cls: type, + values: dict[str, object], + ) -> None: + """The contexts are read-only facts — no hook rewrites them.""" + context = cls(**values) # type: ignore[arg-type] + + with pytest.raises(dataclasses.FrozenInstanceError): + context.identity = IDENTITY # type: ignore[misc] + + with pytest.raises(dataclasses.FrozenInstanceError): + setattr(context, next(iter(values)), "rewritten") # type: ignore[misc] + + @pytest.mark.parametrize(("cls", "values"), CONTEXT_CASES, ids=CASE_IDS) + def test_contexts_carry_the_declared_fields_and_types( + self, + cls: type, + values: dict[str, object], + ) -> None: + """The field list and the type of every field match the declaration.""" + declared = CONTEXT_FIELDS[cls] + field_names = [field.name for field in dataclasses.fields(cls)] + field_types = typing.get_type_hints(cls) + + assert field_names == list(declared) + for name, expected_type in declared.items(): + assert field_types[name] == expected_type + + public_members = [name for name in dir(cls) if not name.startswith("_")] + assert public_members == [] # no method surface, no computed members + + +# --- Logic tests --- + + +class TestContextFacts: + @pytest.mark.parametrize(("cls", "values"), CONTEXT_CASES, ids=CASE_IDS) + def test_every_constructor_value_reads_back_identically( + self, + cls: type, + values: dict[str, object], + ) -> None: + """Plain data fields — attribute reads suffice, nothing transforms.""" + context = cls(**values) # type: ignore[arg-type] + + for name, value in values.items(): + if isinstance(value, TopicIdentity): + assert getattr(context, name) is value + else: + assert getattr(context, name) == value + + assert isinstance(context.identity, TopicIdentity) # type: ignore[attr-defined] + assert context.identity is values["identity"] # type: ignore[attr-defined] + + def test_switched_outcome_carries_each_fixed_kind(self) -> None: + """The kind is fixed by the emitting routine — the context carries the string.""" + for outcome in ("local-checkout", "created-from-remote", "already-on-branch"): + switched = TopicSwitched(identity=IDENTITY, outcome=outcome) + + assert switched.outcome == outcome + assert switched.identity is IDENTITY diff --git a/tests/topics/hooks/test_identity.py b/tests/topics/hooks/test_identity.py index 33ca0b98..8abbbbe8 100644 --- a/tests/topics/hooks/test_identity.py +++ b/tests/topics/hooks/test_identity.py @@ -27,7 +27,7 @@ def test_entity_is_importable_from_the_zone_facade(self) -> None: import goga.topics.hooks as zone assert zone.TopicIdentity is TopicIdentity - assert zone.__all__ == ["TopicIdentity"] + assert "TopicIdentity" in zone.__all__ def test_identity_is_a_kw_only_frozen_dataclass(self) -> None: """``TopicIdentity(slug=..., year=..., branch=...)`` — keyword-only, frozen.""" From d80de85de5847c2796543f7224033bb87a17df89 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Wed, 16 Sep 2026 23:49:58 +0000 Subject: [PATCH 045/205] feat: add draft holders and amendment views to the topics hooks zone --- .goga/history/2026/add-topics-hooks/plan.md | 16 +- goga/topics/hooks/__init__.py | 10 + goga/topics/hooks/amendments.py | 175 +++++++++++ tests/topics/hooks/test_amendments.py | 311 ++++++++++++++++++++ tests/topics/hooks/test_contexts.py | 4 + 5 files changed, 508 insertions(+), 8 deletions(-) create mode 100644 goga/topics/hooks/amendments.py create mode 100644 tests/topics/hooks/test_amendments.py diff --git a/.goga/history/2026/add-topics-hooks/plan.md b/.goga/history/2026/add-topics-hooks/plan.md index b1d2d950..0b80efcf 100644 --- a/.goga/history/2026/add-topics-hooks/plan.md +++ b/.goga/history/2026/add-topics-hooks/plan.md @@ -691,14 +691,14 @@ construction of the emitting routine. **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: create `tests/topics/hooks/test_amendments.py` — per type: facade accessibility, kw-only construction, the read-through properties (`view.commit_message`/`view.todo`/`view.text` read the live holder fields); `amend` returns `None` and raises nothing (expected to fail at this stage) -- [ ] **Code**: create `goga/topics/hooks/amendments.py` per the algorithm below — the two mutable holders with the private `_commit`, and the two views storing the holder under the private field `_draft` with the private `_buffered` buffer (`init=False, repr=False`, default `None`) -- [ ] **Code**: add the four names to `goga/topics/hooks/__init__.py` (relative imports from `.amendments`, `__all__` stays alphabetical) -- [ ] **Interface verification**: `pytest tests/topics/hooks/test_amendments.py -v` — all pass -- [ ] **Logic tests**: the two design scenarios below (`test_amend_views_block_no_write_path_to_the_holder`, `test_amend_called_twice_last_buffer_wins`) plus: a repeated `amend` overwrites the buffer (whole replacement, last wins — covered by the second scenario); `amend(None, None)` is a lawful whole replacement that buffers without holder contact -- [ ] **Debugging**: `pytest tests/topics/hooks/ -x` — fix implementation code until all tests pass (do NOT fix test code) -- [ ] **Contract re-verification**: facade imports work; the views expose no write path to the holder (`_draft` is private; `commit_message`/`todo`/`text` are read-through properties, not fields); no cancel/redirect/defer method exists; buffering never raises -- [ ] **Lint**: `ruff check goga/topics/hooks tests/topics/hooks` — fix formatting if necessary +- [x] **Contract tests**: create `tests/topics/hooks/test_amendments.py` — per type: facade accessibility, kw-only construction, the read-through properties (`view.commit_message`/`view.todo`/`view.text` read the live holder fields); `amend` returns `None` and raises nothing (expected to fail at this stage) +- [x] **Code**: create `goga/topics/hooks/amendments.py` per the algorithm below — the two mutable holders with the private `_commit`, and the two views storing the holder under the private field `_draft` with the private `_buffered` buffer (`init=False, repr=False`, default `None`) +- [x] **Code**: add the four names to `goga/topics/hooks/__init__.py` (relative imports from `.amendments`, `__all__` stays alphabetical) +- [x] **Interface verification**: `pytest tests/topics/hooks/test_amendments.py -v` — all pass +- [x] **Logic tests**: the two design scenarios below (`test_amend_views_block_no_write_path_to_the_holder`, `test_amend_called_twice_last_buffer_wins`) plus: a repeated `amend` overwrites the buffer (whole replacement, last wins — covered by the second scenario); `amend(None, None)` is a lawful whole replacement that buffers without holder contact +- [x] **Debugging**: `pytest tests/topics/hooks/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [x] **Contract re-verification**: facade imports work; the views expose no write path to the holder (`_draft` is private; `commit_message`/`todo`/`text` are read-through properties, not fields); no cancel/redirect/defer method exists; buffering never raises +- [x] **Lint**: `ruff check goga/topics/hooks tests/topics/hooks` — fix formatting if necessary Algorithm (from the design — includes the review-fixed `_draft` rule): diff --git a/goga/topics/hooks/__init__.py b/goga/topics/hooks/__init__.py index 46d37314..5832b8e8 100644 --- a/goga/topics/hooks/__init__.py +++ b/goga/topics/hooks/__init__.py @@ -5,6 +5,12 @@ package imports no tool package and enumerates nothing. """ +from .amendments import ( + CreationAmendment, + CreationDraft, + TodoEntryAmendment, + TodoEntryDraft, +) from .contexts import ( TopicCreated, TopicDeleted, @@ -15,6 +21,10 @@ from .identity import TopicIdentity __all__: list[str] = [ + "CreationAmendment", + "CreationDraft", + "TodoEntryAmendment", + "TodoEntryDraft", "TopicCreated", "TopicDeleted", "TopicIdentity", diff --git a/goga/topics/hooks/amendments.py b/goga/topics/hooks/amendments.py new file mode 100644 index 00000000..6304b478 --- /dev/null +++ b/goga/topics/hooks/amendments.py @@ -0,0 +1,175 @@ +"""The amendment drafts and views of the topics lifecycle events. + +The entities declared in the cell CODEMANIFEST with ``location: +amendments.py``: the shared draft holders ``CreationDraft`` and +``TodoEntryDraft`` and the per-hook amendment views ``CreationAmendment`` +and ``TodoEntryAmendment`` over them. A view buffers one amendment of +its hook alone; the holder content changes only through the delivery +commit of the amendment checkpoint — never through a delivered view. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .identity import TopicIdentity + + +@dataclass(kw_only=True) +class CreationDraft: + """The shared draft holder of the creation amendment. + + The content the creation path is about to fix, and after the + delivery the final amended content. + + Attributes: + commit_message: The draft commit message — None on paths that + build no commit. + todo: The draft todo text — None when none resolved. + + Requirements: + The content changes only through the delivery commit of the + amendment checkpoint — never through a delivered view. + """ + + commit_message: str | None + todo: str | None + + def _commit(self, values: tuple[str | None, str | None]) -> None: + """Replace the whole content from one committed buffer. + + The single mutation point of the holder — the delivery walk of + the amendment checkpoint is the sole caller. + + Args: + values: The committed buffer — the complete new pair. + """ + self.commit_message, self.todo = values + + +@dataclass(kw_only=True) +class TodoEntryDraft: + """The shared draft holder of the todo-entry amendment. + + The saved text the entry path is about to write, and after the + delivery the final amended text. + + Attributes: + text: The saved draft text. + + Requirements: + The content changes only through the delivery commit of the + amendment checkpoint — never through a delivered view. + """ + + text: str + + def _commit(self, text: str) -> None: + """Replace the whole text from one committed buffer. + + The single mutation point of the holder — the delivery walk of + the amendment checkpoint is the sole caller. + + Args: + text: The committed buffer — the complete new text. + """ + self.text = text + + +@dataclass(kw_only=True) +class CreationAmendment: + """The creation-amendment view of one hook. + + The read-only surface over the live shared draft, delivered at the + pre-fixation moment of a creation. The reads pass through to the + live holder — a later hook sees the committed amendments of the + earlier hooks. + + Attributes: + identity: The identity of the topic being created. + checked_out: True when the chosen path checks out the fresh + branch. + published: True when the chosen path publishes the work. + + Requirements: + The holder is never a public attribute of a delivered view — a + view exposes no write path to it. + The identity-only form — no commit message and no todo on the + chosen path — is valid; the tool decides whether to act. + """ + + identity: TopicIdentity + checked_out: bool + published: bool + _draft: CreationDraft + _buffered: tuple[str | None, str | None] | None = field(default=None, init=False, repr=False) + + @property + def commit_message(self) -> str | None: + """The live draft commit message — None on paths that build no commit.""" + return self._draft.commit_message + + @property + def todo(self) -> str | None: + """The live draft todo text — None when none resolved.""" + return self._draft.todo + + def amend(self, commit_message: str | None, todo: str | None) -> None: + """Buffer one amendment replacing the full draft content. + + The call buffers into the buffer of this hook alone and changes + nothing until the delivery commits it. The replacement is whole + — a field left out is returned as None, not kept as the previous + value. + + Args: + commit_message: The complete new commit message — None keeps + the field structurally absent. + todo: The complete new todo text — None keeps the field + structurally absent. + + Constraints: + Do not cancel, redirect, or defer the operation — an + amendment transforms content only. + """ + self._buffered = (commit_message, todo) + + +@dataclass(kw_only=True) +class TodoEntryAmendment: + """The todo-entry-amendment view of one hook. + + The read-only surface over the live shared draft, delivered at the + pre-fixation moment of a todo entry. + + Attributes: + identity: The identity of the topic whose todo is being entered. + + Requirements: + The holder is never a public attribute of a delivered view — a + view exposes no write path to it. + """ + + identity: TopicIdentity + _draft: TodoEntryDraft + _buffered: str | None = field(default=None, init=False, repr=False) + + @property + def text(self) -> str: + """The live draft text.""" + return self._draft.text + + def amend(self, text: str) -> None: + """Buffer one amendment replacing the full text. + + The call buffers into the buffer of this hook alone and changes + nothing until the delivery commits it. + + Args: + text: The complete new text. + + Constraints: + Do not cancel, redirect, or defer the operation — an + amendment transforms content only. + """ + self._buffered = text diff --git a/tests/topics/hooks/test_amendments.py b/tests/topics/hooks/test_amendments.py new file mode 100644 index 00000000..84ccd64e --- /dev/null +++ b/tests/topics/hooks/test_amendments.py @@ -0,0 +1,311 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/hooks/CODEMANIFEST`` with ``location: amendments.py``: the +draft holders ``CreationDraft`` and ``TodoEntryDraft`` and the amendment +views ``CreationAmendment`` and ``TodoEntryAmendment``. + +Buffering isolation — no fixtures, no mocks: an ``amend`` call buffers +on the view of its hook alone, the holder content changes only through +the delivery commit, and the read-through properties expose the live +holder fields. +""" + +from __future__ import annotations + +import dataclasses +import typing + +import pytest +from goga.topics.hooks import ( + CreationAmendment, + CreationDraft, + TodoEntryAmendment, + TodoEntryDraft, + TopicIdentity, +) + +from tests.conftest import is_kw_only_dataclass + +IDENTITY = TopicIdentity(slug="feature-foo", year="2026", branch="feature-foo") + +ZONE_ALL: list[str] = [ + "CreationAmendment", + "CreationDraft", + "TodoEntryAmendment", + "TodoEntryDraft", + "TopicCreated", + "TopicDeleted", + "TopicIdentity", + "TopicPublished", + "TopicSwitched", + "TopicTodoEntered", +] +"""The zone facade after this task — ten names, alphabetical.""" + + +def _creation_view(holder: CreationDraft) -> CreationAmendment: + """Build a creation-amendment view over ``holder`` — the wiring of the walk. + + Args: + holder: The live shared holder the view reads through. + + Returns: + The view the delivery of one hook receives. + """ + return CreationAmendment( + identity=IDENTITY, + checked_out=False, + published=False, + _draft=holder, + ) + + +# --- Contract tests --- + + +class TestAmendmentsContract: + def test_entities_are_importable_from_the_zone_facade(self) -> None: + """The four amendment types live on the zone package; ``__all__`` is exact.""" + import goga.topics.hooks as zone + + assert zone.CreationDraft is CreationDraft + assert zone.TodoEntryDraft is TodoEntryDraft + assert zone.CreationAmendment is CreationAmendment + assert zone.TodoEntryAmendment is TodoEntryAmendment + assert zone.__all__ == ZONE_ALL + + def test_holders_are_kw_only_mutable_dataclasses(self) -> None: + """``Holder(field=...)`` — keyword-only construction, live fields.""" + creation = CreationDraft( + commit_message="goga: create topic feature-foo", + todo="the todo", + ) + entry = TodoEntryDraft(text="saved text") + + assert creation.commit_message == "goga: create topic feature-foo" + assert creation.todo == "the todo" + assert entry.text == "saved text" + + for holder in (CreationDraft, TodoEntryDraft): + assert dataclasses.is_dataclass(holder) + assert not holder.__dataclass_params__.frozen + assert is_kw_only_dataclass(holder) + + with pytest.raises(TypeError): + CreationDraft("goga: create topic feature-foo", "the todo") # type: ignore[misc] + + with pytest.raises(TypeError): + TodoEntryDraft("saved text") # type: ignore[misc] + + def test_views_are_kw_only_mutable_dataclasses_over_the_private_holder(self) -> None: + """The views store the holder under ``_draft`` — the internal wiring keyword.""" + holder = CreationDraft(commit_message="m", todo="t") + view = CreationAmendment( + identity=IDENTITY, + checked_out=False, + published=False, + _draft=holder, + ) + entry_view = TodoEntryAmendment( + identity=IDENTITY, + _draft=TodoEntryDraft(text="saved"), + ) + + assert view.identity is IDENTITY + assert view.checked_out is False + assert view.published is False + assert entry_view.identity is IDENTITY + + for view_cls in (CreationAmendment, TodoEntryAmendment): + assert dataclasses.is_dataclass(view_cls) + assert not view_cls.__dataclass_params__.frozen + assert is_kw_only_dataclass(view_cls) + + with pytest.raises(TypeError): + CreationAmendment(IDENTITY, False, False, holder) # type: ignore[misc] + + with pytest.raises(TypeError): + TodoEntryAmendment(IDENTITY, TodoEntryDraft(text="saved")) # type: ignore[misc] + + def test_views_carry_the_wiring_fields_with_read_through_properties(self) -> None: + """The holder stays private; the content reads are properties over it.""" + assert [field.name for field in dataclasses.fields(CreationAmendment)] == [ + "identity", + "checked_out", + "published", + "_draft", + "_buffered", + ] + assert [field.name for field in dataclasses.fields(TodoEntryAmendment)] == [ + "identity", + "_draft", + "_buffered", + ] + + creation_hints = typing.get_type_hints(CreationAmendment) + assert creation_hints["identity"] == TopicIdentity + assert creation_hints["checked_out"] is bool + assert creation_hints["published"] is bool + assert creation_hints["_draft"] is CreationDraft + assert creation_hints["_buffered"] == tuple[str | None, str | None] | None + + entry_hints = typing.get_type_hints(TodoEntryAmendment) + assert entry_hints["identity"] == TopicIdentity + assert entry_hints["_draft"] is TodoEntryDraft + assert entry_hints["_buffered"] == str | None + + for cls, names in ( + (CreationAmendment, ("commit_message", "todo")), + (TodoEntryAmendment, ("text",)), + ): + field_names = {field.name for field in dataclasses.fields(cls)} + assert not field_names.intersection(names) # the reads are not fields + + for name in names: + assert isinstance(getattr(cls, name), property) + + assert typing.get_type_hints(CreationAmendment.commit_message.fget)["return"] == str | None + assert typing.get_type_hints(CreationAmendment.todo.fget)["return"] == str | None + assert typing.get_type_hints(TodoEntryAmendment.text.fget)["return"] is str + + def test_the_buffered_field_is_uninitialized_hidden_and_empty(self) -> None: + """``_buffered`` — init=False, repr=False, default None on a fresh view.""" + for view_cls in (CreationAmendment, TodoEntryAmendment): + buffered = {field.name: field for field in dataclasses.fields(view_cls)}["_buffered"] + + assert buffered.init is False + assert buffered.repr is False + assert buffered.default is None + + view = _creation_view(CreationDraft(commit_message="m", todo="t")) + + assert view._buffered is None + assert "_buffered" not in repr(view) + + def test_amend_returns_none_and_raises_nothing(self) -> None: + """Buffering never raises — every amendment value is lawful at the view.""" + view = _creation_view(CreationDraft(commit_message="m", todo="t")) + entry_view = TodoEntryAmendment( + identity=IDENTITY, + _draft=TodoEntryDraft(text="saved"), + ) + + assert view.amend("m2", "t2") is None + assert view.amend(None, None) is None + assert entry_view.amend("amended") is None + + def test_views_expose_amend_as_the_sole_public_method(self) -> None: + """No cancel, redirect, or defer — an amendment transforms content only.""" + for cls in (CreationAmendment, TodoEntryAmendment): + methods = { + name + for name in dir(cls) + if not name.startswith("_") + and callable(getattr(cls, name)) + and not isinstance(getattr(cls, name), property) + } + + assert methods == {"amend"} + + +# --- Logic tests --- + + +class TestCreationBuffering: + def test_amend_views_block_no_write_path_to_the_holder(self) -> None: + """The buffering isolation behind the discard-on-failure semantics. + + ``amend`` buffers on the view alone; the holder fields stay at + the draft values, and the view reads the live holder — the draft + values, not the buffer. + """ + holder = CreationDraft(commit_message="orig", todo="orig todo") + view = _creation_view(holder) + + assert view.amend("m", "t") is None + + assert holder.commit_message == "orig" + assert holder.todo == "orig todo" + assert view.commit_message == "orig" + assert view.todo == "orig todo" + + def test_amend_called_twice_last_buffer_wins(self) -> None: + """``amend`` means replace entirely — the last buffer wins, never accumulates. + + The walk of ``events.py`` lands in Task 6; the last-wins guarantee + is pinned here against the view/holder semantics — a hook double + buffers twice, the buffer content commits through the direct + ``_commit``. + """ + holder = CreationDraft(commit_message="orig", todo="orig todo") + view = _creation_view(holder) + + view.amend("first", "t-first") + view.amend("second", "t-second") + + assert view._buffered == ("second", "t-second") + assert holder.commit_message == "orig" # untouched during both calls + + holder._commit(view._buffered) + + assert holder.commit_message == "second" + assert holder.todo == "t-second" + + def test_amend_none_none_is_a_lawful_whole_replacement(self) -> None: + """``amend(None, None)`` buffers the identity-only form without holder contact.""" + holder = CreationDraft(commit_message="orig", todo="orig todo") + view = _creation_view(holder) + + view.amend(None, None) + + assert view._buffered == (None, None) + assert holder.commit_message == "orig" + assert holder.todo == "orig todo" + + holder._commit(view._buffered) + + assert holder.commit_message is None + assert holder.todo is None + + def test_view_reads_reflect_the_committed_amendments_of_earlier_hooks(self) -> None: + """The read-through reads the live holder — a later hook sees the earlier commits.""" + holder = CreationDraft(commit_message="orig", todo="orig todo") + view = _creation_view(holder) + + holder._commit(("m1", "t1")) + + assert view.commit_message == "m1" + assert view.todo == "t1" + + +class TestTodoEntryBuffering: + def test_entry_view_reads_through_the_live_holder(self) -> None: + """``text`` reads the live holder — ``amend`` buffers alone, ``_commit`` replaces.""" + holder = TodoEntryDraft(text="saved text") + view = TodoEntryAmendment(identity=IDENTITY, _draft=holder) + + assert view.text == "saved text" + + view.amend("amended") + + assert view._buffered == "amended" + assert holder.text == "saved text" + + holder._commit("amended") + + assert view.text == "amended" + assert holder.text == "amended" + + def test_entry_amend_called_twice_last_buffer_wins(self) -> None: + """The single-field whole replacement carries the same last-wins rule.""" + holder = TodoEntryDraft(text="saved text") + view = TodoEntryAmendment(identity=IDENTITY, _draft=holder) + + view.amend("first") + view.amend("second") + + assert view._buffered == "second" + assert holder.text == "saved text" + + holder._commit(view._buffered) + + assert holder.text == "second" diff --git a/tests/topics/hooks/test_contexts.py b/tests/topics/hooks/test_contexts.py index 84fd046e..41a37113 100644 --- a/tests/topics/hooks/test_contexts.py +++ b/tests/topics/hooks/test_contexts.py @@ -124,6 +124,10 @@ def test_entities_are_importable_from_the_zone_facade(self) -> None: assert zone.TopicTodoEntered is TopicTodoEntered assert zone.TopicDeleted is TopicDeleted assert zone.__all__ == [ + "CreationAmendment", + "CreationDraft", + "TodoEntryAmendment", + "TodoEntryDraft", "TopicCreated", "TopicDeleted", "TopicIdentity", From 18fcee681dc5dff2f123c2d3e484d658eefde62e Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 17 Sep 2026 00:00:44 +0000 Subject: [PATCH 046/205] feat: add TopicHooks and the run registry to the topics hooks zone --- .goga/history/2026/add-topics-hooks/plan.md | 18 +- goga/topics/hooks/__init__.py | 2 + goga/topics/hooks/events.py | 478 ++++++++++++++++++ tests/topics/hooks/conftest.py | 20 + tests/topics/hooks/test_amendments.py | 3 +- tests/topics/hooks/test_contexts.py | 1 + tests/topics/hooks/test_events.py | 510 ++++++++++++++++++++ 7 files changed, 1022 insertions(+), 10 deletions(-) create mode 100644 goga/topics/hooks/events.py create mode 100644 tests/topics/hooks/test_events.py diff --git a/.goga/history/2026/add-topics-hooks/plan.md b/.goga/history/2026/add-topics-hooks/plan.md index 0b80efcf..2635723f 100644 --- a/.goga/history/2026/add-topics-hooks/plan.md +++ b/.goga/history/2026/add-topics-hooks/plan.md @@ -819,15 +819,15 @@ enumeration. **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: create `tests/topics/hooks/test_events.py` — facade accessibility of `TopicHooks`; cheap construction (scenario `test_topic_hooks_construction_enumerates_nothing` below); the seven method signatures callable as declared (expected to fail at this stage) -- [ ] **Code**: create `goga/topics/hooks/events.py` per the algorithm below — `_RUN_REGISTRY` module state, `_run_registry()`, `TopicHooks()` with `amend_creation`, `amend_todo_entry`, and the five `emit_*` methods -- [ ] **Code**: add `TopicHooks` to `goga/topics/hooks/__init__.py` — the final facade: `__all__` carries exactly the eleven names, alphabetically (`CreationAmendment`, `CreationDraft`, `TodoEntryAmendment`, `TodoEntryDraft`, `TopicCreated`, `TopicDeleted`, `TopicHooks`, `TopicIdentity`, `TopicPublished`, `TopicSwitched`, `TopicTodoEntered`) -- [ ] **Code**: add the autouse run-registry reset to `tests/topics/hooks/conftest.py` — `monkeypatch.setattr("goga.topics.hooks.events._RUN_REGISTRY", None)` — every test starts with an unbuilt registry, so no subscription leaks across tests and enumeration counts are per-test -- [ ] **Interface verification**: `pytest tests/topics/hooks/test_events.py -v` — all pass -- [ ] **Logic tests**: the eight design scenarios below — `test_amend_creation_walks_per_hook_and_commits_in_order`, `test_emit_created_shares_one_instance_and_returns_none`, `test_amend_creation_discards_buffer_of_raising_hook`, `test_amend_creation_rejects_empty_amendment_whole`, `test_amend_todo_entry_rejects_blank_text_buffer`, `test_run_registry_built_once_across_checkpoints`, `test_amend_creation_without_subscriptions_returns_original_values`, `test_amend_creation_identity_only_form_is_valid` — plus the construction scenario `test_topic_hooks_construction_enumerates_nothing` (below) -- [ ] **Debugging**: `pytest tests/topics/hooks/ -x` then `pytest tests/hooks/ -x` — fix implementation code until all tests pass (do NOT fix test code) -- [ ] **Contract re-verification**: the facade check passes — `python -c "from goga.topics.hooks import TopicHooks, TopicIdentity, CreationDraft, TodoEntryDraft, TopicCreated, TopicPublished, TopicSwitched, TopicTodoEntered, TopicDeleted, CreationAmendment, TodoEntryAmendment"`; no post-walk application of any amendment (the caller fixes the final draft itself); no subscriber of the address is skipped -- [ ] **Lint**: `ruff check goga/topics/hooks tests/topics/hooks` — fix formatting, apply decomposition if necessary +- [x] **Contract tests**: create `tests/topics/hooks/test_events.py` — facade accessibility of `TopicHooks`; cheap construction (scenario `test_topic_hooks_construction_enumerates_nothing` below); the seven method signatures callable as declared (expected to fail at this stage) +- [x] **Code**: create `goga/topics/hooks/events.py` per the algorithm below — `_RUN_REGISTRY` module state, `_run_registry()`, `TopicHooks()` with `amend_creation`, `amend_todo_entry`, and the five `emit_*` methods +- [x] **Code**: add `TopicHooks` to `goga/topics/hooks/__init__.py` — the final facade: `__all__` carries exactly the eleven names, alphabetically (`CreationAmendment`, `CreationDraft`, `TodoEntryAmendment`, `TodoEntryDraft`, `TopicCreated`, `TopicDeleted`, `TopicHooks`, `TopicIdentity`, `TopicPublished`, `TopicSwitched`, `TopicTodoEntered`) +- [x] **Code**: add the autouse run-registry reset to `tests/topics/hooks/conftest.py` — `monkeypatch.setattr("goga.topics.hooks.events._RUN_REGISTRY", None)` — every test starts with an unbuilt registry, so no subscription leaks across tests and enumeration counts are per-test +- [x] **Interface verification**: `pytest tests/topics/hooks/test_events.py -v` — all pass +- [x] **Logic tests**: the eight design scenarios below — `test_amend_creation_walks_per_hook_and_commits_in_order`, `test_emit_created_shares_one_instance_and_returns_none`, `test_amend_creation_discards_buffer_of_raising_hook`, `test_amend_creation_rejects_empty_amendment_whole`, `test_amend_todo_entry_rejects_blank_text_buffer`, `test_run_registry_built_once_across_checkpoints`, `test_amend_creation_without_subscriptions_returns_original_values`, `test_amend_creation_identity_only_form_is_valid` — plus the construction scenario `test_topic_hooks_construction_enumerates_nothing` (below) +- [x] **Debugging**: `pytest tests/topics/hooks/ -x` then `pytest tests/hooks/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [x] **Contract re-verification**: the facade check passes — `python -c "from goga.topics.hooks import TopicHooks, TopicIdentity, CreationDraft, TodoEntryDraft, TopicCreated, TopicPublished, TopicSwitched, TopicTodoEntered, TopicDeleted, CreationAmendment, TodoEntryAmendment"`; no post-walk application of any amendment (the caller fixes the final draft itself); no subscriber of the address is skipped +- [x] **Lint**: `ruff check goga/topics/hooks tests/topics/hooks` — fix formatting, apply decomposition if necessary Algorithm (from the design): diff --git a/goga/topics/hooks/__init__.py b/goga/topics/hooks/__init__.py index 5832b8e8..5336cadb 100644 --- a/goga/topics/hooks/__init__.py +++ b/goga/topics/hooks/__init__.py @@ -18,6 +18,7 @@ TopicSwitched, TopicTodoEntered, ) +from .events import TopicHooks from .identity import TopicIdentity __all__: list[str] = [ @@ -27,6 +28,7 @@ "TodoEntryDraft", "TopicCreated", "TopicDeleted", + "TopicHooks", "TopicIdentity", "TopicPublished", "TopicSwitched", diff --git a/goga/topics/hooks/events.py b/goga/topics/hooks/events.py new file mode 100644 index 00000000..398e722b --- /dev/null +++ b/goga/topics/hooks/events.py @@ -0,0 +1,478 @@ +"""The checkpoint surface of the topics lifecycle events. + +The entity declared in the cell CODEMANIFEST with ``location: events.py``: +``TopicHooks`` — the two amendment deliveries and the five notification +emissions over the platform facade. One registry per run carries every +checkpoint of a command: the shared module-level ``HookRegistry`` is +assembled on the first checkpoint and never rebuilt, so nested flows never +multiply the package enumeration. Every context and draft is built from +the values the caller passes — no repository reads happen at a checkpoint. +""" + +from __future__ import annotations + +import logging + +from ...hooks import ( + HookRegistry, + build_hook_arguments, + declared_actions, + emit_hook_event, + wrap_context, +) +from .amendments import CreationAmendment, CreationDraft, TodoEntryAmendment, TodoEntryDraft +from .contexts import TopicCreated, TopicDeleted, TopicPublished, TopicSwitched, TopicTodoEntered +from .identity import TopicIdentity + +logger = logging.getLogger(__name__) + +_DOMAIN = "topics" +"""The owning domain of every checkpoint fired here.""" + +_CREATION_ACTION = "amend_creation" +"""The address of the creation-amendment checkpoint.""" + +_ENTRY_ACTION = "amend_todo_entry" +"""The address of the todo-entry-amendment checkpoint.""" + +_EMPTY_AMENDMENT = "the buffered amendment is empty or whitespace-only" +"""The rejection reason of a buffer whose structurally present content is blank.""" + +_RUN_REGISTRY: HookRegistry | None = None +"""The shared registry of the run — assembled on the first checkpoint, never rebuilt.""" + + +def _run_registry() -> HookRegistry: + """Return the shared run registry, assembling it on first use. + + One registry per run: the first checkpoint builds it once via + ``build_once`` and every later checkpoint — of this or any other + ``TopicHooks`` instance — shares the assembled object. + + Returns: + The assembled registry of the run. + """ + global _RUN_REGISTRY # noqa: PLW0603 — the design fixes the transport as this one module attribute + + if _RUN_REGISTRY is None: + registry = HookRegistry() + registry.build_once() + _RUN_REGISTRY = registry + + return _RUN_REGISTRY + + +def _error_class(action: str) -> str: + """Resolve the cataloged error class of one topics action address. + + Args: + action: The action name within the topics domain. + + Returns: + The error class recorded for the address in the action catalog. + + Raises: + ValueError: The address is not declared — a clean error of the + emitting side. + """ + record = next( + (entry for entry in declared_actions() if entry.domain == _DOMAIN and entry.name == action), + None, + ) + if record is None: + raise ValueError(f"unknown hook action: {_DOMAIN}.{action}") + + return record.error_class + + +def _blank(value: str | None) -> bool: + """Report whether a structurally present amendment field is blank. + + Args: + value: The buffered field value — None means structurally absent. + + Returns: + True when the field is present but empty or whitespace-only. + """ + return value is not None and not value.strip() + + +def _rejected_text(text: str | None) -> bool: + """Report whether a todo-entry buffer is rejected as blank. + + The single predicate that distinguishes the entry walk from the + creation walk: the contract types ``text`` as ``str``, so a None + buffer value is the rejection case here — lawful as the structurally + absent form on the creation side. + + Args: + text: The buffered text. + + Returns: + True when the text is None or whitespace-only. + """ + return text is None or not text.strip() + + +class TopicHooks: + """The checkpoint surface of the topics lifecycle. + + The two amendment deliveries and the five notification emissions over + the platform facade. Construction is cheap — no state, no enumeration, + no imports; the shared run registry assembles on the first checkpoint. + + Requirements: + Cheap construction — no enumeration and no imports happen at + construction. + One ``HookRegistry`` per run carries every checkpoint of a + command — the assembly runs once per run whatever the number of + checkpoints. + Every context and draft is built from the values the caller + passes — no repository reads happen at a checkpoint. + """ + + def amend_creation( + self, + identity: TopicIdentity, + checked_out: bool, + published: bool, + commit_message: str | None, + todo: str | None, + ) -> CreationDraft: + """Deliver the creation-amendment checkpoint and return the holder. + + Args: + identity: The identity of the topic being created. + checked_out: True when the chosen path checks out the fresh + branch. + published: True when the chosen path publishes the work. + commit_message: The draft commit message — None on paths that + build no commit. + todo: The draft todo text — None when none resolved. + + Returns: + The holder carrying the final amended values — the last + committed buffer, or the original draft values when no buffer + committed. + + Algorithm: + 1. Assemble the shared run registry and resolve the address + ``topics.amend_creation`` against ``declared_actions`` + 2. Create the shared ``CreationDraft`` with the draft values + 3. Walk the subscriptions of the address in enumeration order: + per subscription build the hook's ``CreationAmendment`` view + over the live holder — outside the failure intercept — wrap + it via ``wrap_context``, project the call arguments via + ``build_hook_arguments`` with the tool's own context, and + call the hook + 4. A hook that returned and buffered an amendment: the buffer + replaces the holder content whole — except when a + structurally present field is empty or whitespace-only, + which rejects the whole buffer + 5. A hook that raised: its buffer is discarded + 6. Both rejection cases emit the warning naming the hook, the + tool, the action, and the reason, and the walk continues + 7. Return the holder + + Requirements: + The delivery is per hook — two hooks of one tool never share a + buffer or a failure. + An address without subscriptions returns the original draft + values — not an error. + + Raises: + ValueError: The address is not declared, or a hook of a + hard-class record of the address failed — the message + names the hook, the tool, and the reason. + + Constraints: + Do not apply any amendment after the walk ends — the caller + fixes the final draft into the artifacts itself. + Do not skip a subscriber of the address. + """ + registry = _run_registry() + error_class = _error_class(_CREATION_ACTION) + holder = CreationDraft(commit_message=commit_message, todo=todo) + + for subscription in registry.subscriptions_for(_DOMAIN, _CREATION_ACTION): + # Outside the intercept: a crashing view builder is the emitting + # side's bug, never a hook failure. + view = CreationAmendment( + identity=identity, + checked_out=checked_out, + published=published, + _draft=holder, + ) + + try: + proxy = wrap_context(view) + arguments = build_hook_arguments( + subscription.hook, + proxy, + registry.self_context(subscription.tool), + ) + subscription.hook(**arguments) + except Exception as reason: + if error_class == "hard": + raise ValueError( + f"hook {subscription.name} of tool {subscription.tool} " + f"failed on {_DOMAIN}.{_CREATION_ACTION}: {reason}" + ) from reason + + logger.warning( + "hook %s of tool %s failed on %s.%s: %s", + subscription.name, + subscription.tool, + _DOMAIN, + _CREATION_ACTION, + reason, + ) + continue # the buffer of the failed hook is discarded + + buffered = view._buffered + if buffered is None: + continue + + if _blank(buffered[0]) or _blank(buffered[1]): + logger.warning( + "hook %s of tool %s failed on %s.%s: %s", + subscription.name, + subscription.tool, + _DOMAIN, + _CREATION_ACTION, + _EMPTY_AMENDMENT, + ) + continue # the whole buffer is rejected + + holder._commit(buffered) + + return holder + + def amend_todo_entry(self, identity: TopicIdentity, text: str) -> TodoEntryDraft: + """Deliver the todo-entry-amendment checkpoint and return the holder. + + The same per-hook staged walk as the creation amendment, over the + single text field. + + Args: + identity: The identity of the topic whose todo is being + entered. + text: The saved draft text. + + Returns: + The holder carrying the final amended text — the last + committed buffer, or the saved draft when no buffer committed. + + Algorithm: + 1. Assemble the shared run registry and resolve the address + ``topics.amend_todo_entry`` against ``declared_actions`` + 2. Create the shared ``TodoEntryDraft`` with the draft text + 3. Walk the subscriptions in enumeration order with a per-hook + ``TodoEntryAmendment`` view over the live holder — the same + call, commit, and rejection rules as the creation amendment + 4. Return the holder + + Requirements: + The delivery is per hook — two hooks of one tool never share a + buffer or a failure. + An address without subscriptions returns the saved draft — + not an error. + + Raises: + ValueError: The address is not declared, or a hook of a + hard-class record of the address failed — the message + names the hook, the tool, and the reason. + + Constraints: + Do not apply any amendment after the walk ends — the caller + writes the final text itself. + Do not skip a subscriber of the address. + """ + registry = _run_registry() + error_class = _error_class(_ENTRY_ACTION) + holder = TodoEntryDraft(text=text) + + for subscription in registry.subscriptions_for(_DOMAIN, _ENTRY_ACTION): + # Outside the intercept: a crashing view builder is the emitting + # side's bug, never a hook failure. + view = TodoEntryAmendment(identity=identity, _draft=holder) + + try: + proxy = wrap_context(view) + arguments = build_hook_arguments( + subscription.hook, + proxy, + registry.self_context(subscription.tool), + ) + subscription.hook(**arguments) + except Exception as reason: + if error_class == "hard": + raise ValueError( + f"hook {subscription.name} of tool {subscription.tool} " + f"failed on {_DOMAIN}.{_ENTRY_ACTION}: {reason}" + ) from reason + + logger.warning( + "hook %s of tool %s failed on %s.%s: %s", + subscription.name, + subscription.tool, + _DOMAIN, + _ENTRY_ACTION, + reason, + ) + continue # the buffer of the failed hook is discarded + + buffered = view._buffered + if buffered is not None and _rejected_text(buffered): + logger.warning( + "hook %s of tool %s failed on %s.%s: %s", + subscription.name, + subscription.tool, + _DOMAIN, + _ENTRY_ACTION, + _EMPTY_AMENDMENT, + ) + continue # the whole buffer is rejected + + if buffered is not None: + holder._commit(buffered) + + return holder + + def emit_created( # noqa: PLR0913, PLR0917 — the six facts are the declared checkpoint signature + self, + identity: TopicIdentity, + checked_out: bool, + published: bool, + todo: str | None, + commit_message: str | None, + commit_hash: str | None, + ) -> None: + """Emit the creation notification — the facts of one completed creation. + + Args: + identity: The identity of the created topic. + checked_out: True when the creation path checked out the fresh + branch. + published: True when the creation path published the work. + todo: The final todo text, or None. + commit_message: The final commit message — None on paths + building none. + commit_hash: The hash of the built commit — None on paths + building none. + + Algorithm: + 1. Build the ``TopicCreated`` context from the values + 2. Emit the address ``topics.topic_created`` via + ``emit_hook_event`` — the context view of every receiving + tool reads the same instance through the delivery proxy + + Requirements: + Fire-and-forget — nothing is collected and no value returns. + A failing hook is skipped with a warning under the soft error + class of the action. + """ + context = TopicCreated( + identity=identity, + checked_out=checked_out, + published=published, + todo=todo, + commit_message=commit_message, + commit_hash=commit_hash, + ) + emit_hook_event(_run_registry(), _DOMAIN, "topic_created", context_for=lambda _tool: context) + + def emit_published(self, identity: TopicIdentity, commit_message: str, commit_hash: str, todo: str) -> None: + """Emit the publication notification — one successful publication push. + + Args: + identity: The identity of the published topic. + commit_message: The final commit message landed in git. + commit_hash: The hash of the publication commit. + todo: The final todo text landed in the publication commit. + + Algorithm: + 1. Build the ``TopicPublished`` context from the values + 2. Emit the address ``topics.topic_published`` via + ``emit_hook_event`` + + Requirements: + Fire-and-forget — nothing is collected and no value returns. + """ + context = TopicPublished( + identity=identity, + commit_message=commit_message, + commit_hash=commit_hash, + todo=todo, + ) + emit_hook_event(_run_registry(), _DOMAIN, "topic_published", context_for=lambda _tool: context) + + def emit_switched(self, identity: TopicIdentity, outcome: str) -> None: + """Emit the switch notification — the outcome of one completed switch. + + Args: + identity: The identity of the switched work — the branch-only + form when the branch hosts no topic. + outcome: The outcome kind — local-checkout, + created-from-remote, or already-on-branch. + + Algorithm: + 1. Build the ``TopicSwitched`` context from the values + 2. Emit the address ``topics.topic_switched`` via + ``emit_hook_event`` + + Requirements: + Fire-and-forget — nothing is collected and no value returns. + """ + context = TopicSwitched(identity=identity, outcome=outcome) + emit_hook_event(_run_registry(), _DOMAIN, "topic_switched", context_for=lambda _tool: context) + + def emit_todo_entered(self, identity: TopicIdentity, text: str) -> None: + """Emit the todo-entry notification — one saved todo entry. + + Args: + identity: The identity of the topic whose todo was entered. + text: The final written text. + + Algorithm: + 1. Build the ``TopicTodoEntered`` context from the values + 2. Emit the address ``topics.topic_todo_entered`` via + ``emit_hook_event`` + + Requirements: + Fire-and-forget — nothing is collected and no value returns. + """ + context = TopicTodoEntered(identity=identity, text=text) + emit_hook_event(_run_registry(), _DOMAIN, "topic_todo_entered", context_for=lambda _tool: context) + + def emit_deleted( + self, + identity: TopicIdentity, + local_branch: str | None, + origin_twin: str | None, + directory_removed: bool, + ) -> None: + """Emit the deletion notification — one fully removed target. + + Args: + identity: The identity of the removed topic — slug and home + path; no branch fact. + local_branch: The removed local branch name, or None. + origin_twin: The removed origin twin name, or None. + directory_removed: True when the topic directory was removed. + + Algorithm: + 1. Build the ``TopicDeleted`` context from the values + 2. Emit the address ``topics.topic_deleted`` via + ``emit_hook_event`` + + Requirements: + Fire-and-forget — nothing is collected and no value returns. + """ + context = TopicDeleted( + identity=identity, + local_branch=local_branch, + origin_twin=origin_twin, + directory_removed=directory_removed, + ) + emit_hook_event(_run_registry(), _DOMAIN, "topic_deleted", context_for=lambda _tool: context) diff --git a/tests/topics/hooks/conftest.py b/tests/topics/hooks/conftest.py index 676889fc..35d7d568 100644 --- a/tests/topics/hooks/conftest.py +++ b/tests/topics/hooks/conftest.py @@ -23,6 +23,9 @@ ENUMERATION_TARGET = "goga.hooks.tools.packages.packages_distributions" """The attribute the enumeration reads — the single enumeration mock point.""" +RUN_REGISTRY_TARGET = "goga.topics.hooks.events._RUN_REGISTRY" +"""The module attribute holding the shared run registry of the zone.""" + TWO_TOOL_ENVIRONMENT: dict[str, list[str]] = { "goga_tool_one": ["pkg-one"], "goga_tool_two": ["pkg-two"], @@ -41,6 +44,23 @@ """The seven topics addresses a recording pass subscribes by default.""" +@pytest.fixture(autouse=True) +def reset_run_registry(monkeypatch: pytest.MonkeyPatch) -> None: + """Start every zone test with an unbuilt run registry. + + The shared registry of ``events.py`` is module state — without the + reset, a subscription installed by one test would leak into every + later test of the session, and the enumeration counts the checkpoint + tests assert would count earlier builds too. The reset pins the + attribute to None, so the first checkpoint of each test performs its + own single build. + + Args: + monkeypatch: the pytest patcher restoring the attribute on teardown. + """ + monkeypatch.setattr(RUN_REGISTRY_TARGET, None) + + def _tool_identity(module_name: str) -> str: """The tool identity of a ``goga_tool_*`` module — the platform derivation. diff --git a/tests/topics/hooks/test_amendments.py b/tests/topics/hooks/test_amendments.py index 84ccd64e..0313f190 100644 --- a/tests/topics/hooks/test_amendments.py +++ b/tests/topics/hooks/test_amendments.py @@ -34,12 +34,13 @@ "TodoEntryDraft", "TopicCreated", "TopicDeleted", + "TopicHooks", "TopicIdentity", "TopicPublished", "TopicSwitched", "TopicTodoEntered", ] -"""The zone facade after this task — ten names, alphabetical.""" +"""The final zone facade — the eleven names, alphabetical.""" def _creation_view(holder: CreationDraft) -> CreationAmendment: diff --git a/tests/topics/hooks/test_contexts.py b/tests/topics/hooks/test_contexts.py index 41a37113..c6444669 100644 --- a/tests/topics/hooks/test_contexts.py +++ b/tests/topics/hooks/test_contexts.py @@ -130,6 +130,7 @@ def test_entities_are_importable_from_the_zone_facade(self) -> None: "TodoEntryDraft", "TopicCreated", "TopicDeleted", + "TopicHooks", "TopicIdentity", "TopicPublished", "TopicSwitched", diff --git a/tests/topics/hooks/test_events.py b/tests/topics/hooks/test_events.py new file mode 100644 index 00000000..3be9137a --- /dev/null +++ b/tests/topics/hooks/test_events.py @@ -0,0 +1,510 @@ +"""Contract and logic tests for the entity declared in +``goga/topics/hooks/CODEMANIFEST`` with ``location: events.py``: +``TopicHooks`` — the checkpoint surface with the two amendment walks and +the five notification emissions, over the lazily-built shared run +registry. + +The environment boundary is pinned by the local fixtures of +``tests/topics/hooks/conftest.py`` — the enumeration mapping and the fake +``goga_tool_*`` modules — so the registry, the walks, and the platform +delivery run for real behind every checkpoint. The autouse reset of the +conftest starts every test with an unbuilt run registry: no subscription +leaks across tests and enumeration counts are per-test. +""" + +from __future__ import annotations + +import inspect +import logging +import typing +from collections.abc import Callable +from typing import Any + +import pytest +from goga.topics.hooks import ( + CreationDraft, + TodoEntryDraft, + TopicCreated, + TopicHooks, + TopicIdentity, +) + +from tests.topics.hooks.conftest import TWO_TOOL_ENVIRONMENT + +IDENTITY = TopicIdentity(slug="add-topics-hooks", year="2026", branch="add-topics-hooks") + +ZONE_ALL: list[str] = [ + "CreationAmendment", + "CreationDraft", + "TodoEntryAmendment", + "TodoEntryDraft", + "TopicCreated", + "TopicDeleted", + "TopicHooks", + "TopicIdentity", + "TopicPublished", + "TopicSwitched", + "TopicTodoEntered", +] +"""The final zone facade — the eleven names, alphabetical.""" + +METHOD_CONTRACTS: dict[str, tuple[tuple[str, object], ...]] = { + "amend_creation": ( + ("identity", TopicIdentity), + ("checked_out", bool), + ("published", bool), + ("commit_message", str | None), + ("todo", str | None), + ), + "amend_todo_entry": (("identity", TopicIdentity), ("text", str)), + "emit_created": ( + ("identity", TopicIdentity), + ("checked_out", bool), + ("published", bool), + ("todo", str | None), + ("commit_message", str | None), + ("commit_hash", str | None), + ), + "emit_published": ( + ("identity", TopicIdentity), + ("commit_message", str), + ("commit_hash", str), + ("todo", str), + ), + "emit_switched": (("identity", TopicIdentity), ("outcome", str)), + "emit_todo_entered": (("identity", TopicIdentity), ("text", str)), + "emit_deleted": ( + ("identity", TopicIdentity), + ("local_branch", str | None), + ("origin_twin", str | None), + ("directory_removed", bool), + ), +} +"""The seven checkpoint methods with their declared parameters and types.""" + +PinEnvironment = Callable[[dict[str, list[str]]], Any] +"""The enumeration-boundary pinning factory of the local conftest.""" + +InstallToolPackage = Callable[[str, Callable[[Any], None] | None], Any] +"""The fake-package installing factory of the local conftest.""" + + +def _register(*subscriptions: tuple[str, Callable[..., None]]) -> Callable[[Any], None]: + """Build a facade callback subscribing each hook on its topics action. + + Each pair is one subscription — the topics action name and the hook; + the hook's ``__name__`` is its hook name, so the failure warnings of + the walks name the functions the test declares. + + Args: + subscriptions: The (action, hook) pairs to subscribe. + + Returns: + The ``register_hooks`` callback of one fake tool package. + """ + + def register_hooks(hooks: Any) -> None: + for action, hook in subscriptions: + hooks.subscribe("topics", action, hook.__name__, hook) + + return register_hooks + + +# --- Contract tests --- + + +class TestEventsContract: + def test_entity_is_importable_from_the_zone_facade(self) -> None: + """The checkpoint surface lives on the zone package; ``__all__`` is final.""" + import goga.topics.hooks as zone + + assert zone.TopicHooks is TopicHooks + assert zone.__all__ == ZONE_ALL + + def test_the_module_state_is_one_lazily_built_run_registry( + self, + pin_package_environment: PinEnvironment, + ) -> None: + """``_RUN_REGISTRY`` starts unbuilt; the builder assembles it once.""" + from goga.topics.hooks import events + + boundary = pin_package_environment(TWO_TOOL_ENVIRONMENT) + + assert events._RUN_REGISTRY is None # the autouse reset of the conftest + + registry = events._run_registry() + + assert events._RUN_REGISTRY is registry + assert events._run_registry() is registry + assert boundary.call_count == 1 + + @pytest.mark.parametrize(("method", "parameters"), METHOD_CONTRACTS.items()) + def test_method_signatures_match_the_declared_api( + self, + method: str, + parameters: tuple[tuple[str, object], ...], + ) -> None: + """Every checkpoint method carries exactly its declared parameters.""" + function = getattr(TopicHooks, method) + hints = typing.get_type_hints(function) + + declared = list(inspect.signature(function).parameters)[1:] # past self + + assert declared == [name for name, _ in parameters] + + for name, annotation in parameters: + assert hints[name] == annotation + + def test_amendments_return_their_holders_and_emissions_return_none(self) -> None: + """The two walks return their holders; the five emissions return nothing.""" + assert typing.get_type_hints(TopicHooks.amend_creation)["return"] is CreationDraft + assert typing.get_type_hints(TopicHooks.amend_todo_entry)["return"] is TodoEntryDraft + + for method in ("emit_created", "emit_published", "emit_switched", "emit_todo_entered", "emit_deleted"): + assert typing.get_type_hints(getattr(TopicHooks, method))["return"] is type(None) + + def test_topic_hooks_construction_enumerates_nothing( + self, + pin_package_environment: PinEnvironment, + ) -> None: + """Cheap construction — no enumeration and no imports happen at construction. + + ``__init__`` stores nothing and touches nothing: the enumeration + boundary stays unread and the run registry stays unbuilt, keeping + import-time and construction-time behavior identical for every + consumer. + """ + from goga.topics.hooks import events + + boundary = pin_package_environment(TWO_TOOL_ENVIRONMENT) + + TopicHooks() + + assert boundary.call_count == 0 + assert events._RUN_REGISTRY is None + + +# --- Logic tests: the creation-amendment walk --- + + +class TestCreationWalk: + def test_amend_creation_walks_per_hook_and_commits_in_order( + self, + pin_package_environment: PinEnvironment, + install_tool_package: InstallToolPackage, + ) -> None: + """The per-hook commit granularity — the core zone refinement. + + Two hooks of one tool carry independent buffers and read the + committed amendments of the earlier hooks; the tail tool observes + the final state; the holder carries the last committed buffer. + """ + boundary = pin_package_environment(TWO_TOOL_ENVIRONMENT) + seen: dict[str, str | None] = {} + + def first(context: object) -> None: + context.amend("m1", "t1") # type: ignore[attr-defined] + + def second(context: object) -> None: + seen["second"] = context.todo # type: ignore[attr-defined] + context.amend("m2", "t2") # type: ignore[attr-defined] + + def tail(context: object) -> None: + seen["tail"] = context.todo # type: ignore[attr-defined] + + install_tool_package( + "goga_tool_one", + register_hooks=_register(("amend_creation", first), ("amend_creation", second)), + ) + install_tool_package("goga_tool_two", register_hooks=_register(("amend_creation", tail))) + + draft = TopicHooks().amend_creation( + IDENTITY, + checked_out=False, + published=False, + commit_message="goga: create topic add-topics-hooks", + todo="first todo", + ) + + assert draft.commit_message == "m2" + assert draft.todo == "t2" # the last committed buffer + assert seen == {"second": "t1", "tail": "t2"} # ordered visibility + assert boundary.call_count == 1 # one enumeration for the whole walk + + def test_amend_creation_discards_buffer_of_raising_hook( + self, + pin_package_environment: PinEnvironment, + install_tool_package: InstallToolPackage, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A failing hook never breaks the operation and never leaks its buffer.""" + pin_package_environment(TWO_TOOL_ENVIRONMENT) + + def boom(context: object) -> None: + context.amend("m", "t") # type: ignore[attr-defined] + raise RuntimeError("kaputt") + + def tail(context: object) -> None: + context.amend("late", "late-t") # type: ignore[attr-defined] + + install_tool_package("goga_tool_one", register_hooks=_register(("amend_creation", boom))) + install_tool_package("goga_tool_two", register_hooks=_register(("amend_creation", tail))) + + with caplog.at_level(logging.WARNING): + draft = TopicHooks().amend_creation(IDENTITY, False, False, "orig", "orig todo") + + assert draft.commit_message == "late" # the buffer of boom is gone + assert draft.todo == "late-t" + assert any( + "hook boom of tool one failed on topics.amend_creation: kaputt" in record.message + for record in caplog.records + ) + + def test_amend_creation_rejects_empty_amendment_whole( + self, + pin_package_environment: PinEnvironment, + install_tool_package: InstallToolPackage, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A whitespace field rejects the whole buffer — nothing partially lands.""" + + def blank(context: object) -> None: + context.amend(" ", "fine text") # type: ignore[attr-defined] + + pin_package_environment(TWO_TOOL_ENVIRONMENT) + install_tool_package("goga_tool_one", register_hooks=_register(("amend_creation", blank))) + + with caplog.at_level(logging.WARNING): + draft = TopicHooks().amend_creation(IDENTITY, False, False, "orig", "orig todo") + + assert draft.commit_message == "orig" # the original values survive + assert draft.todo == "orig todo" + expected_warning = ( + "hook blank of tool one failed on topics.amend_creation: the buffered amendment is empty or whitespace-only" + ) + + assert expected_warning in caplog.text + + def test_amend_creation_without_subscriptions_returns_original_values( + self, + pin_package_environment: PinEnvironment, + install_tool_package: InstallToolPackage, + ) -> None: + """The no-subscriber case — a transparent no-op, never an error.""" + + def recorder(context: object) -> None: + return None + + pin_package_environment(TWO_TOOL_ENVIRONMENT) + install_tool_package("goga_tool_one", register_hooks=_register(("topic_created", recorder))) + + draft = TopicHooks().amend_creation(IDENTITY, False, False, "m", None) + + assert draft.commit_message == "m" + assert draft.todo is None + + def test_amend_creation_identity_only_form_is_valid( + self, + recording_hooks: Callable[..., list[tuple[str, str, object]]], + ) -> None: + """The identity-only form is the norm on the ensure fast path — observable.""" + records = recording_hooks("amend_creation") + + draft = TopicHooks().amend_creation(IDENTITY, True, False, None, None) + + assert draft.commit_message is None + assert draft.todo is None + assert len(records) == 1 + + view = records[0][2] + + assert view.checked_out is True + assert view.published is False + assert view.commit_message is None + assert view.todo is None + + def test_amend_creation_hard_class_stops_the_walk_with_clean_error( + self, + pin_package_environment: PinEnvironment, + install_tool_package: InstallToolPackage, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A hard-class catalog record turns a hook failure into a clean error (D7).""" + from goga.hooks.catalog import Action + from goga.topics.hooks import events + + monkeypatch.setattr( + events, + "declared_actions", + lambda: [Action(domain="topics", name="amend_creation", error_class="hard")], + ) + + def boom(context: object) -> None: + raise RuntimeError("stop") + + pin_package_environment(TWO_TOOL_ENVIRONMENT) + install_tool_package("goga_tool_one", register_hooks=_register(("amend_creation", boom))) + + with pytest.raises(ValueError, match=r"hook boom of tool one failed on topics\.amend_creation: stop"): + TopicHooks().amend_creation(IDENTITY, False, False, "orig", "orig todo") + + +# --- Logic tests: the todo-entry-amendment walk --- + + +class TestTodoEntryWalk: + @pytest.mark.parametrize("blank", [" ", ""]) + def test_amend_todo_entry_rejects_blank_text_buffer( + self, + pin_package_environment: PinEnvironment, + install_tool_package: InstallToolPackage, + caplog: pytest.LogCaptureFixture, + blank: str, + ) -> None: + """The single-field rejection — a blank buffer never lands. + + A whitespace-only text is rejected whole, with the empty-amendment + warning — the single predicate that distinguishes this walk from + the creation walk, where a structurally absent field is lawful. + """ + + def buffer_blank(context: object) -> None: + context.amend(blank) # type: ignore[attr-defined] + + pin_package_environment(TWO_TOOL_ENVIRONMENT) + install_tool_package("goga_tool_one", register_hooks=_register(("amend_todo_entry", buffer_blank))) + + with caplog.at_level(logging.WARNING): + draft = TopicHooks().amend_todo_entry(IDENTITY, "saved text") + + assert draft.text == "saved text" + expected_warning = ( + "hook buffer_blank of tool one failed on topics.amend_todo_entry: " + "the buffered amendment is empty or whitespace-only" + ) + + assert expected_warning in caplog.text + + def test_amend_todo_entry_none_buffer_never_commits( + self, + pin_package_environment: PinEnvironment, + install_tool_package: InstallToolPackage, + ) -> None: + """A None buffer value is the rejection case, never a committed text. + + The contract types ``text`` as ``str``; the out-of-contract None + buffer is treated as the rejection case of this walk — the saved + text survives. On the creation side ``amend(None, None)`` is the + lawful identity-only form; this is the single predicate that tells + the two walks apart. + """ + + def buffer_none(context: object) -> None: + context.amend(None) # type: ignore[attr-defined] + + pin_package_environment(TWO_TOOL_ENVIRONMENT) + install_tool_package("goga_tool_one", register_hooks=_register(("amend_todo_entry", buffer_none))) + + draft = TopicHooks().amend_todo_entry(IDENTITY, "saved text") + + assert draft.text == "saved text" + + def test_amend_todo_entry_walks_per_hook_and_commits_the_last_buffer( + self, + pin_package_environment: PinEnvironment, + install_tool_package: InstallToolPackage, + ) -> None: + """The same per-hook staged walk over the single text field.""" + pin_package_environment(TWO_TOOL_ENVIRONMENT) + + def one(context: object) -> None: + context.amend("first") # type: ignore[attr-defined] + + def two(context: object) -> None: + context.amend("second") # type: ignore[attr-defined] + + install_tool_package( + "goga_tool_one", + register_hooks=_register(("amend_todo_entry", one), ("amend_todo_entry", two)), + ) + + draft = TopicHooks().amend_todo_entry(IDENTITY, "saved text") + + assert draft.text == "second" + + +# --- Logic tests: the notification emissions and the shared registry --- + + +class TestEmissions: + def test_emit_created_shares_one_instance_and_returns_none( + self, + recording_hooks: Callable[..., list[tuple[str, str, object]]], + ) -> None: + """The context-instance sharing and the fire-and-forget contract. + + Both tools observe the identical underlying context, each through + its own fresh delivery proxy — no per-tool copies, no return + channel. + """ + records = recording_hooks("topic_created") + recording_hooks("topic_created", module_name="goga_tool_two") + + result = TopicHooks().emit_created( + IDENTITY, + checked_out=False, + published=False, + todo="t", + commit_message="m", + commit_hash="abc123", + ) + + assert result is None + assert len(records) == 2 + first, second = records[0][2], records[1][2] + + assert type(first) is not TopicCreated # the delivery view, not the context + assert type(first) is not type(second) # a fresh proxy per delivery + assert first.identity is second.identity # the shared instance, one attribute deep + + for delivered in (first, second): + assert delivered.checked_out is False + assert delivered.published is False + assert delivered.todo == "t" + assert delivered.commit_message == "m" + assert delivered.commit_hash == "abc123" + assert delivered.identity.home_path == ".goga/history/2026/add-topics-hooks" + + def test_run_registry_built_once_across_checkpoints( + self, + pin_package_environment: PinEnvironment, + install_tool_package: InstallToolPackage, + ) -> None: + """D1 — the checkpoints never multiply the package enumeration. + + Every checkpoint — across separate ``TopicHooks`` instances and + both checkpoint kinds — shares the one assembled registry. + """ + actions: tuple[str, ...] = ("amend_creation", "topic_created", "topic_todo_entered", "amend_todo_entry") + delivered: list[str] = [] + boundary = pin_package_environment(TWO_TOOL_ENVIRONMENT) + + def register_hooks(registrar: Any) -> None: + for action in actions: + + def hook(context: object, _action: str = action) -> None: + delivered.append(_action) + + registrar.subscribe("topics", action, action, hook) + + install_tool_package("goga_tool_one", register_hooks=register_hooks) + + hooks = TopicHooks() + hooks.amend_creation(IDENTITY, False, False, None, None) # the identity-only form + hooks.emit_created(IDENTITY, False, False, None, None, None) + hooks.amend_todo_entry(IDENTITY, "t") + hooks.emit_todo_entered(IDENTITY, "t") + TopicHooks().emit_switched(IDENTITY, "local-checkout") # a second instance + + assert boundary.call_count == 1 + assert delivered == ["amend_creation", "topic_created", "amend_todo_entry", "topic_todo_entered"] From ead81941ac037f356eed6a30431e291cdc165f83 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 17 Sep 2026 00:05:10 +0000 Subject: [PATCH 047/205] feat: wire the enter_topic_todo checkpoint pair --- .goga/history/2026/add-topics-hooks/plan.md | 14 +- goga/topics/creation.py | 49 ++++-- tests/topics/conftest.py | 186 +++++++++++++++++++- tests/topics/test_creation.py | 132 +++++++++++++- 4 files changed, 353 insertions(+), 28 deletions(-) diff --git a/.goga/history/2026/add-topics-hooks/plan.md b/.goga/history/2026/add-topics-hooks/plan.md index 2635723f..82139db2 100644 --- a/.goga/history/2026/add-topics-hooks/plan.md +++ b/.goga/history/2026/add-topics-hooks/plan.md @@ -1129,13 +1129,13 @@ parent for its own directory — no double application. **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: update the signature-contract test of `tests/topics/test_creation.py` for the `branch` parameter (`inspect.signature` shape: `enter_topic_todo(topic: str, year: str | None = None, branch: str | None = None) -> bool`); add facade re-export check (`from goga.topics import enter_topic_todo` — unchanged, still importable) (expected to fail at this stage) -- [ ] **Code**: rework `enter_topic_todo` / `_enter_topic_todo` in `goga/topics/creation.py` per the algorithm below — the `branch` parameter, the amendment delivery, the emission, and the D6 return-type change of the mirror (`str | None`; the public wrapper returns `written is not None`) -- [ ] **Interface verification**: `pytest tests/topics/test_creation.py -v` — all pass (existing tests included) -- [ ] **Logic tests**: the three design scenarios below — `test_enter_topic_todo_writes_amended_text_and_emits_final` (positive), `test_enter_topic_todo_cancelled_entry_delivers_and_emits_nothing` (negative), `test_enter_topic_todo_failed_write_emits_nothing` (negative) -- [ ] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass; the existing switching/ensuring tests that mock `enter_topic_todo` keep passing unchanged (their assertions gain the `branch=` keyword only in Tasks 10–11) -- [ ] **Contract re-verification**: cancelled entry → no delivery, no emission, file untouched; emission follows the write and mutates nothing; the `OSError` wrapper boundary unchanged (the checkpoint code performs no I/O); the write is the last mutation -- [ ] **Lint**: `ruff check goga/topics` — fix formatting if necessary +- [x] **Contract tests**: update the signature-contract test of `tests/topics/test_creation.py` for the `branch` parameter (`inspect.signature` shape: `enter_topic_todo(topic: str, year: str | None = None, branch: str | None = None) -> bool`); add facade re-export check (`from goga.topics import enter_topic_todo` — unchanged, still importable) (expected to fail at this stage) +- [x] **Code**: rework `enter_topic_todo` / `_enter_topic_todo` in `goga/topics/creation.py` per the algorithm below — the `branch` parameter, the amendment delivery, the emission, and the D6 return-type change of the mirror (`str | None`; the public wrapper returns `written is not None`) +- [x] **Interface verification**: `pytest tests/topics/test_creation.py -v` — all pass (existing tests included) +- [x] **Logic tests**: the three design scenarios below — `test_enter_topic_todo_writes_amended_text_and_emits_final` (positive), `test_enter_topic_todo_cancelled_entry_delivers_and_emits_nothing` (negative), `test_enter_topic_todo_failed_write_emits_nothing` (negative) +- [x] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass; the existing switching/ensuring tests that mock `enter_topic_todo` keep passing unchanged (their assertions gain the `branch=` keyword only in Tasks 10–11) +- [x] **Contract re-verification**: cancelled entry → no delivery, no emission, file untouched; emission follows the write and mutates nothing; the `OSError` wrapper boundary unchanged (the checkpoint code performs no I/O); the write is the last mutation +- [x] **Lint**: `ruff check goga/topics` — fix formatting if necessary Algorithm (from the design): diff --git a/goga/topics/creation.py b/goga/topics/creation.py index a60b6baa..fbc4a593 100644 --- a/goga/topics/creation.py +++ b/goga/topics/creation.py @@ -16,9 +16,12 @@ an optional publication ask that delegates to the fast cycle of the publishing module — and the todo entry of a topic — the editor session over the topic's todo.md and the write of the saved text, without a -commit. Topic identity and addressing belong to the history facade; the +commit: the saved text passes through the todo-entry amendment before +the write and the completed entry emits its notification after it. +Topic identity and addressing belong to the history facade; the bounded git mutation belongs to the nested git cell; the editor session -belongs to the nested editor cell. Git infrastructure failures surface +belongs to the nested editor cell; the lifecycle checkpoints belong to +the nested hooks zone. Git infrastructure failures surface as ``click.ClickException`` — the clean-error boundary of the domain; the interactive moments follow the ``click`` practice. The status scale is never assembled here — creation is not a status consumer. @@ -50,6 +53,7 @@ read_ref_tree_paths, resolve_ref_commit, ) +from .hooks import TopicHooks, TopicIdentity # The board hint of an occupancy conflict — where the occupied names are # visible to the user. @@ -266,15 +270,19 @@ def create_topic( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared signat raise click.ClickException(f"cannot create the topic directory or write the todo file: {exc}") from exc -def enter_topic_todo(topic: str, year: str | None = None) -> bool: +def enter_topic_todo(topic: str, year: str | None = None, branch: str | None = None) -> bool: """Enter the todo of a topic. The editor session with the topic's todo.md and the write of the saved - text, without a commit. + text, without a commit; the saved text passes through the todo-entry + amendment before the write, and the completed entry emits its + notification after it. Args: topic: Topic input — a branch name or an already-normalized slug. year: Optional year as four digits; ``None`` means the current year. + branch: The branch fact of the identity, passed by the calling + operation; ``None`` leaves the identity without a branch fact. Returns: True when the saved text was written; False when the entry was @@ -284,12 +292,20 @@ def enter_topic_todo(topic: str, year: str | None = None) -> bool: 1. Resolve the todo.md path of the topic via ``resolve_topic_file``; an existing file provides the initial text 2. Open the editor session via ``edit_text`` with the initial text - 3. A cancelled entry -> False — the file stays untouched - 4. The saved text -> write todo.md as entered plus a single + 3. A cancelled entry -> False — the file stays untouched, nothing + is delivered or emitted + 4. The saved text -> deliver the todo-entry amendment over + ``TopicHooks`` — the identity from the normalized slug, the + resolved year, and ``branch``; the final text of the returned + draft replaces the text being written + 5. Write todo.md with the final text as entered plus a single trailing newline, encoded UTF-8, without a commit -> True + 6. Emit ``topic_todo_entered`` — the identity and the final + written text Requirements: - The write is the last action — nothing follows it. + The write is the last mutation — nothing mutates after it; the + notification emission follows the write and mutates nothing. The topic directory exists — directory creation belongs to the caller. @@ -303,10 +319,11 @@ def enter_topic_todo(topic: str, year: str | None = None) -> bool: write. """ try: - return _enter_topic_todo(topic, year) + written = _enter_topic_todo(topic, year, branch) except OSError as exc: # The boundary covers the prefill read and the saved write alike. raise click.ClickException(f"cannot read or write the todo file: {exc}") from exc + return written is not None def _occupancy_conflict(branch_name: str, slug: str, year: str | None) -> str | None: @@ -530,16 +547,16 @@ def _publication_asked(publish: bool, todo: str | None) -> bool: return publish -def _enter_topic_todo(topic: str, year: str | None) -> bool: +def _enter_topic_todo(topic: str, year: str | None, branch: str | None) -> str | None: """Run the traced todo-entry procedure — the unwrapped orchestration. Args: topic: Topic input — a branch name or an already-normalized slug. year: Optional year as four digits; ``None`` means the current year. + branch: The branch fact of the identity, or ``None``. Returns: - True when the saved text was written; False when the entry was - cancelled. + The final written text, or ``None`` when the entry was cancelled. """ resolved_year = year or current_year() @@ -554,10 +571,14 @@ def _enter_topic_todo(topic: str, year: str | None) -> bool: saved = edit_text(initial) if saved is None: - return False + return None + + identity = TopicIdentity(slug=normalize_topic_slug(topic), year=resolved_year, branch=branch) + draft = TopicHooks().amend_todo_entry(identity, saved) - _write_todo(topic, resolved_year, saved) - return True + _write_todo(topic, resolved_year, draft.text) + TopicHooks().emit_todo_entered(identity, draft.text) + return draft.text def _write_todo(name: str, year: str, todo: str) -> None: diff --git a/tests/topics/conftest.py b/tests/topics/conftest.py index 12964615..daec0ed0 100644 --- a/tests/topics/conftest.py +++ b/tests/topics/conftest.py @@ -1,10 +1,49 @@ -"""Local fixtures of the topics domain tests.""" +"""Local fixtures of the topics domain tests — the scale and the hooks environment. + +The domain tests read the status scale through the built-in fixture and +the lifecycle checkpoints through the platform environment: the two +outside points of a checkpoint delivery — the installed-distributions +mapping read by ``packages_distributions`` and the ``sys.modules`` entry +of a ``goga_tool_*`` package — are re-declared here in the same local +shape as the zone tests, so the registry and the delivery run for real +behind every checkpoint the domain fires. The autouse reset starts every +test with an unbuilt run registry, so no subscription leaks across tests. +""" from __future__ import annotations +import sys +from collections.abc import Callable, Sequence +from types import ModuleType +from typing import Any +from unittest import mock + import pytest from goga.history.statuses import Stage, StatusScale +ENUMERATION_TARGET = "goga.hooks.tools.packages.packages_distributions" +"""The attribute the enumeration reads — the single enumeration mock point.""" + +RUN_REGISTRY_TARGET = "goga.topics.hooks.events._RUN_REGISTRY" +"""The module attribute holding the shared run registry of the zone.""" + +TWO_TOOL_ENVIRONMENT: dict[str, list[str]] = { + "goga_tool_one": ["pkg-one"], + "goga_tool_two": ["pkg-two"], +} +"""The fixed environment of the checkpoint tests — two installed tool packages.""" + +TOPICS_ACTIONS: tuple[str, ...] = ( + "amend_creation", + "amend_todo_entry", + "topic_created", + "topic_deleted", + "topic_published", + "topic_switched", + "topic_todo_entered", +) +"""The seven topics addresses a recording pass subscribes by default.""" + @pytest.fixture def builtin_scale() -> StatusScale: @@ -26,3 +65,148 @@ def builtin_scale() -> StatusScale: Stage(name="done", filepath="completed/plan.md"), ] ) + + +@pytest.fixture(autouse=True) +def reset_run_registry(monkeypatch: pytest.MonkeyPatch) -> None: + """Start every domain test with an unbuilt run registry. + + The shared registry of the zone's ``events`` module is module state — + without the reset, a subscription installed by one test would leak + into every later test of the session. The reset pins the attribute to + None, so the first checkpoint of each test performs its own single + build over the environment that test pinned. + + Args: + monkeypatch: the pytest patcher restoring the attribute on teardown. + """ + monkeypatch.setattr(RUN_REGISTRY_TARGET, None) + + +def _tool_identity(module_name: str) -> str: + """The tool identity of a ``goga_tool_*`` module — the platform derivation. + + Args: + module_name: The top-level module name of the fake package. + + Returns: + The canonical hyphen form without the ``goga_tool_`` prefix. + """ + return module_name.removeprefix("goga_tool_").replace("_", "-") + + +@pytest.fixture +def pin_package_environment( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[dict[str, list[str]]], mock.MagicMock]: + """Factory: pin the installed-packages mapping the enumeration reads. + + ``mapping`` carries the shape of ``packages_distributions()`` — a + top-level module name mapped to the distributions providing it. Names + without the ``goga_tool_`` prefix stay in the mapping on purpose: they + prove the filter. Returns the boundary mock, so a test can also assert + how often the environment was read. + + Args: + monkeypatch: the pytest patcher restoring the boundary on teardown. + + Returns: + The pinning factory: mapping in, boundary mock out. + """ + + def _pin(mapping: dict[str, list[str]]) -> mock.MagicMock: + boundary = mock.MagicMock(return_value=mapping) + + monkeypatch.setattr(ENUMERATION_TARGET, boundary) + + return boundary + + return _pin + + +@pytest.fixture +def install_tool_package( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[str, Callable[[Any], None] | None], ModuleType]: + """Factory: install one fake ``goga_tool_*`` package into ``sys.modules``. + + ``register_hooks`` becomes the facade callback of the package; omitting it + leaves the facade without a callback — the quiet-skip condition. Each call + installs one package and each installation is undone on teardown — one + restored ``sys.modules`` entry per fake package. + + Args: + monkeypatch: the pytest patcher restoring ``sys.modules`` on teardown. + + Returns: + The installing factory: module name in, the installed module out. + """ + + def _install( + module_name: str, + register_hooks: Callable[[Any], None] | None = None, + ) -> ModuleType: + module = ModuleType(module_name) + + if register_hooks is not None: + module.register_hooks = register_hooks + + monkeypatch.setitem(sys.modules, module_name, module) + + return module + + return _install + + +@pytest.fixture +def recording_hooks( + pin_package_environment: Callable[[dict[str, list[str]]], mock.MagicMock], + install_tool_package: Callable[[str, Callable[[Any], None] | None], ModuleType], +) -> Callable[..., list[tuple[str, str, object]]]: + """Factory: subscribe recording hooks over the topics actions. + + The environment is pinned to the fixed two-tool mapping for the + fixture's own packages — a test pinning it explicitly overrides the + default with its own call. Each call installs one fake tool package + whose callback subscribes one recording hook per requested action, + named by the action; every delivery appends ``(tool, hook_name, + context)`` to the one shared records list — the tests assert the + fired actions, their order, and the facts of the delivered contexts + off that list. + + Args: + pin_package_environment: the enumeration-boundary pinning factory. + install_tool_package: the fake-package installing factory. + + Returns: + The subscribing factory: one action name or a sequence of them, + plus optionally the tool module name, in — the shared records + list out. + """ + pin_package_environment(TWO_TOOL_ENVIRONMENT) + + records: list[tuple[str, str, object]] = [] + + def _recorder(tool: str, action: str) -> Callable[[object], None]: + def hook(context: object) -> None: + records.append((tool, action, context)) + + return hook + + def _record( + actions: str | Sequence[str] = TOPICS_ACTIONS, + *, + module_name: str = "goga_tool_one", + ) -> list[tuple[str, str, object]]: + selected = [actions] if isinstance(actions, str) else list(actions) + tool = _tool_identity(module_name) + + def register_hooks(hooks: Any) -> None: + for action in selected: + hooks.subscribe("topics", action, action, _recorder(tool, action)) + + install_tool_package(module_name, register_hooks=register_hooks) + + return records + + return _record diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index d02c5855..f7aece23 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -9,8 +9,10 @@ year, switch)`` — the fresh-work creation procedure off an explicit base with its editor-sourced todo and its publication ask: the quarantined no-switch plant by default, the working-copy switch path under the flag -- ``enter_topic_todo(topic, year)`` — the editor session over the topic's - todo.md and the write of the saved text, without a commit +- ``enter_topic_todo(topic, year, branch)`` — the editor session over the + topic's todo.md and the write of the saved text, without a commit: the + saved text passes through the todo-entry amendment before the write and + the completed entry emits its notification after it The git boundary is mocked at the import point per the ``convention`` practice — no git binary and no repository are touched. The filesystem @@ -18,7 +20,10 @@ with the real history path routines; the scale is never assembled — creation is not a status consumer. The editor session is mocked with a shell script exported as ``$EDITOR`` per the ``editor`` practice and the TTY detection -with a ``sys.stdin`` stand-in — a real editor never launches in tests. +with a ``sys.stdin`` stand-in — a real editor never launches in tests. The +checkpoint scenarios stub ``edit_text`` on the creation module and run the +delivery for real over the platform-environment fixtures of the local +conftest — the recording hooks assert the fired actions and their facts. """ from __future__ import annotations @@ -29,6 +34,7 @@ import typing from collections.abc import Callable from pathlib import Path +from typing import Any from unittest import mock import click @@ -125,6 +131,44 @@ def _wire_slug_oracle( return listing +RecordedEntry = Callable[..., list[tuple[str, str, object]]] +"""The recording-hooks factory of the local conftest.""" + +InstallToolPackage = Callable[[str, Callable[[Any], None] | None], object] +"""The fake-package installing factory of the local conftest.""" + + +def _subscribe(*subscriptions: tuple[str, Callable[..., None]]) -> Callable[[Any], None]: + """Build a facade callback subscribing each hook on its topics action. + + Each pair is one subscription — the topics action name and the hook; + the hook's ``__name__`` is its hook name, so the walk warnings name the + functions the test declares. + + Args: + subscriptions: The (action, hook) pairs to subscribe. + + Returns: + The ``register_hooks`` callback of one fake tool package. + """ + + def register_hooks(hooks: Any) -> None: + for action, hook in subscriptions: + hooks.subscribe("topics", action, hook.__name__, hook) + + return register_hooks + + +def _stub_edit_text(monkeypatch: pytest.MonkeyPatch, saved: str | None) -> None: + """Stub the editor session on the creation module — a scripted save. + + Args: + monkeypatch: the pytest patcher restoring the session on teardown. + saved: The text the session returns — None is the cancelled entry. + """ + monkeypatch.setattr(creation, "edit_text", lambda _initial=None: saved) + + # --- Contract tests --- @@ -187,20 +231,23 @@ def test_check_branch_occupancy_signature(self) -> None: } def test_enter_topic_todo_signature(self) -> None: - """``enter_topic_todo(topic, year=None) -> bool`` — binds as declared.""" + """``enter_topic_todo(topic, year=None, branch=None) -> bool`` — binds as declared.""" signature = inspect.signature(enter_topic_todo) - assert list(signature.parameters) == ["topic", "year"] + assert list(signature.parameters) == ["topic", "year", "branch"] assert all( parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None + assert signature.parameters["branch"].default is None hints = typing.get_type_hints(enter_topic_todo) assert hints == { "topic": str, "year": str | None, + "branch": str | None, "return": bool, } - signature.bind("feature-foo", year="2026") + signature.bind("feature-foo", year="2026", branch="feature-foo") + signature.bind("feature-foo") def test_create_topic_signature(self) -> None: """``create_topic(branch_name, base_ref, todo, publish, commit_message, year, switch) -> str`` @@ -1030,6 +1077,79 @@ def test_enter_topic_todo_read_failure_is_clean_error( assert "cannot read or write the todo file" in raised.value.message assert not marker.exists() + def test_enter_topic_todo_writes_amended_text_and_emits_final( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + install_tool_package: InstallToolPackage, + ) -> None: + """The saved text passes through the amendment; the file and the + notification carry the same final amended text. + + The delivery order of the entry — amend before the write, emit + after it — makes the written content and the reported content one + value: the ``.usages/todo-entry.md`` clause made executable. + """ + + def amender(context: object) -> None: + context.amend("amended text") # type: ignore[attr-defined] + + monkeypatch.chdir(tmp_path) + _topic_dir(tmp_path, "2026", "feature-foo") + _stub_edit_text(monkeypatch, "saved text") + records = recording_hooks("topic_todo_entered") + install_tool_package("goga_tool_two", register_hooks=_subscribe(("amend_todo_entry", amender))) + + result = enter_topic_todo("feature-foo", year="2026", branch="feature-foo") + + assert result is True + todo_file = tmp_path / ".goga" / "history" / "2026" / "feature-foo" / "todo.md" + assert todo_file.read_text(encoding="utf-8") == "amended text\n" + assert len(records) == 1 + context = records[0][2] + assert context.text == "amended text" # type: ignore[attr-defined] + assert context.identity.branch == "feature-foo" # type: ignore[attr-defined] + + def test_enter_topic_todo_cancelled_entry_delivers_and_emits_nothing( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + ) -> None: + """A cancelled entry is a non-event — the amendment moment never + arrives, no checkpoint of the entry fires.""" + monkeypatch.chdir(tmp_path) + _topic_dir(tmp_path, "2026", "feature-foo") + _stub_edit_text(monkeypatch, None) + records = recording_hooks() + + result = enter_topic_todo("feature-foo", year="2026") + + assert result is False + todo_file = tmp_path / ".goga" / "history" / "2026" / "feature-foo" / "todo.md" + assert not todo_file.exists() + assert records == [] + + def test_enter_topic_todo_failed_write_emits_nothing( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + ) -> None: + """A failed write never emits — the write-then-emit order on the + failure path guarantees the event carries a written fact.""" + monkeypatch.chdir(tmp_path) + _topic_dir(tmp_path, "2026", "feature-foo") + _stub_edit_text(monkeypatch, "saved") + monkeypatch.setattr(creation, "_write_todo", mock.Mock(side_effect=OSError("disk full"))) + records = recording_hooks("topic_todo_entered") + + with pytest.raises(click.ClickException, match="cannot read or write the todo file"): + enter_topic_todo("feature-foo", year="2026") + + assert records == [] + # --- Infrastructure boundary --- From 496d66769857c01b93c96f5c9b34d75647ab37f8 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 17 Sep 2026 00:12:18 +0000 Subject: [PATCH 048/205] feat: wire the create_topic amendment and creation notification --- .goga/history/2026/add-topics-hooks/plan.md | 14 +- goga/topics/creation.py | 203 +++++++++++++++++--- tests/topics/test_creation.py | 130 ++++++++++++- 3 files changed, 299 insertions(+), 48 deletions(-) diff --git a/.goga/history/2026/add-topics-hooks/plan.md b/.goga/history/2026/add-topics-hooks/plan.md index 82139db2..fb8da30a 100644 --- a/.goga/history/2026/add-topics-hooks/plan.md +++ b/.goga/history/2026/add-topics-hooks/plan.md @@ -1258,13 +1258,13 @@ here. **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: the public signature, result lines, and error surface are unchanged — assert via the existing contract tests of `tests/topics/test_creation.py` (they must keep passing unmodified; expected failure at this stage comes only from the new wiring assertions below) -- [ ] **Code**: rework `_create_topic` in `goga/topics/creation.py` per the algorithm below — the amendment step between the ask and the path branches, the no-switch hash capture and emission, the switch-path emission, the publication delegation with the final values -- [ ] **Interface verification**: `pytest tests/topics/test_creation.py -v` — all pass -- [ ] **Logic tests**: the three design scenarios below — `test_create_topic_no_switch_emits_created_with_commit_hash` (positive), `test_create_topic_failed_preflight_fires_nothing` (negative), `test_create_topic_switch_path_amended_null_todo_degrades_gracefully` (edge) -- [ ] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass (do NOT fix test code) -- [ ] **Contract re-verification**: `topic_created` fires exactly once per successful creation (no-switch and switch paths emit here; the publication path's emission lives in the delegate); the amendment delivers exactly once, immediately before the first mutation; existing behavior (result lines, error surface, mutation order) unchanged -- [ ] **Lint**: `ruff check goga/topics` — fix formatting if necessary +- [x] **Contract tests**: the public signature, result lines, and error surface are unchanged — assert via the existing contract tests of `tests/topics/test_creation.py` (they must keep passing unmodified; expected failure at this stage comes only from the new wiring assertions below) +- [x] **Code**: rework `_create_topic` in `goga/topics/creation.py` per the algorithm below — the amendment step between the ask and the path branches, the no-switch hash capture and emission, the switch-path emission, the publication delegation with the final values +- [x] **Interface verification**: `pytest tests/topics/test_creation.py -v` — all pass +- [x] **Logic tests**: the three design scenarios below — `test_create_topic_no_switch_emits_created_with_commit_hash` (positive), `test_create_topic_failed_preflight_fires_nothing` (negative), `test_create_topic_switch_path_amended_null_todo_degrades_gracefully` (edge) +- [x] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [x] **Contract re-verification**: `topic_created` fires exactly once per successful creation (no-switch and switch paths emit here; the publication path's emission lives in the delegate); the amendment delivers exactly once, immediately before the first mutation; existing behavior (result lines, error surface, mutation order) unchanged +- [x] **Lint**: `ruff check goga/topics` — fix formatting if necessary Algorithm (from the design — includes decisions D3, D4, D5, D12): diff --git a/goga/topics/creation.py b/goga/topics/creation.py index fbc4a593..e2521ec5 100644 --- a/goga/topics/creation.py +++ b/goga/topics/creation.py @@ -14,10 +14,13 @@ session of the nested editor cell, every decision read-only before the first input and the first mutation, every conflict one clean error, and an optional publication ask that delegates to the fast cycle of the -publishing module — and the todo entry of a topic — the editor session -over the topic's todo.md and the write of the saved text, without a -commit: the saved text passes through the todo-entry amendment before -the write and the completed entry emits its notification after it. +publishing module — the creation amendment delivered immediately before +the first mutation of the chosen path and the creation notification +emitted after the path completes — and the todo entry of a topic — the +editor session over the topic's todo.md and the write of the saved +text, without a commit: the saved text passes through the todo-entry +amendment before the write and the completed entry emits its +notification after it. Topic identity and addressing belong to the history facade; the bounded git mutation belongs to the nested git cell; the editor session belongs to the nested editor cell; the lifecycle checkpoints belong to @@ -59,6 +62,13 @@ # visible to the user. _BOARD_HINT = "run 'goga topics board' to see the board" +# The clean error of the todo-less local creation — the resolved-todo +# guard and the nulled-amendment guard (D5) share it. +_LOCAL_TODO_ERROR = ( + "the local creation needs a todo — the board reads the topic through todo.md; " + "pass --todo/-t or --switch/-s to create on the spot without one" +) + def check_branch_occupancy(branch_name: str, slug: str, year: str | None = None) -> str | None: """Decide whether the entered branch name and the topic slug are free. @@ -207,20 +217,43 @@ def create_topic( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared signat resolved: ``click.confirm`` offers the publication (an empty answer reads the default no; Ctrl-C or EOF aborts); no ask otherwise - 6. The normal path — ``switch`` set: ``create_branch_at_commit`` + 6. The creation amendment — the identity via ``TopicIdentity`` + (the normalized slug, the resolved year, ``branch_name`` as + entered) and ``amend_creation`` over ``TopicHooks`` with the + path facts — ``checked_out`` as ``switch`` dictates, + ``published`` as the chosen path dictates — the draft commit + message of the path (the no-switch and the publication paths + build one; the switch path delivers ``None``) and the draft + todo when resolved; the amended values of the returned + ``CreationDraft`` replace the todo and the commit message + carried into the mutation steps + 7. The normal path without ``switch`` — a nulled final todo is + the same clean error as the todo-less creation (nothing has + mutated); otherwise the quarantined plant of ``publishing`` — + one commit carrying ``todo.md`` on the base commit with the + final todo content and the final commit message, the branch + planted at it, the captured commit hash — the working copy, + the index, and HEAD stay untouched and the caller stays on + their branch; then ``topic_created`` over ``TopicHooks`` — + the identity, ``checked_out`` False, ``published`` False, the + final todo, the final commit message, and the captured + commit hash + 8. The normal path under ``switch`` — ``create_branch_at_commit`` plants the branch at the base commit, ``checkout_local_branch`` switches to it (a failed checkout rolls the plant back — the ``publish_topic`` precedent), ``ensure_topic_dir`` creates the - topic directory of the year, and a resolved todo writes the - todo file ``todo.md`` — the write is the last action of the - path; ``switch`` unset: the quarantined plant of - ``publishing`` — one commit carrying ``todo.md`` on the base - commit, the branch planted at it, the caller stays on their - branch - 7. The publication path — the fast cycle of ``publishing`` via a - call-time import; the cycle re-runs its own preflight — the - delegation is deliberately whole - 8. Return the single result line + topic directory of the year, and a final todo writes the todo + file ``todo.md`` — the write is the last action of the path; + then ``topic_created`` — the identity, ``checked_out`` True, + ``published`` False, the final todo when written, and no + commit facts + 9. The publication path — the fast cycle of ``publishing`` via a + call-time import, delegated with the name, the amended todo, + the base, the amended template, and the year; the cycle re-runs + its own preflight — the delegation is deliberately whole, and + its checkpoints fire inside the delegated routine — nothing + fires here + 10. Return the single result line Requirements: Every decision — the preflight, the todo, the ask — precedes the @@ -237,6 +270,13 @@ def create_topic( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared signat resolved, and the topic directory exists before the file is written. The caller stays on their branch unless ``switch`` is set. + The creation amendment delivers exactly once per creation, + immediately before the first mutation of the chosen path, with the + draft content of that path; the identity-only form is valid. + ``topic_created`` fires exactly once per successful creation — + here on the no-switch and switch paths, from the delegated + publication routine on the publication path; a failed creation + fires nothing. Constraints: Do not validate branch-name characters — git owns name validity. @@ -429,40 +469,140 @@ def _create_topic( # noqa: PLR0913, PLR0917 — the unwrapped mirror of the dec # no-switch work exists in no tree — the board and the slug oracle # cannot see it. The publication enforces the same for its own # path. - raise click.ClickException( - "the local creation needs a todo — the board reads the topic through todo.md; " - "pass --todo/-t or --switch/-s to create on the spot without one" - ) - - if not _publication_asked(publish, resolved_todo): + raise click.ClickException(_LOCAL_TODO_ERROR) + + publishing = _publication_asked(publish, resolved_todo) + + # The creation amendment — delivered exactly once, immediately before + # the first mutation of the chosen path, with the path's draft facts. + # The identity comes from the operation's own data — the slug, the + # resolved year, the name as entered; no repository reads. + identity = TopicIdentity(slug=slug, year=resolved_year, branch=branch_name) + draft_message = _draft_commit_message(publishing, switch, commit_message, slug) + draft = TopicHooks().amend_creation( + identity, + checked_out=switch and not publishing, + published=publishing, + commit_message=draft_message, + todo=resolved_todo, + ) + # The final values of the returned holder replace the todo and the + # message carried into the mutation steps — the holder is fixed into + # the artifacts here, never by the walk itself. + final_todo = draft.todo + final_message = draft.commit_message + + if not publishing: if not switch: + if final_todo is None: + # D5 — a hook nulled the todo of a path that needs one: + # nothing has mutated yet, so the same clean error as the + # resolved-todo guard fires and the creation emits nothing. + raise click.ClickException(_LOCAL_TODO_ERROR) + # The no-switch plant goes through the same quarantined # mechanics as the publication — the call-time import breaks # the creation ↔ publishing import cycle exactly like the - # publication delegation below; the built-in message applies, - # ``commit_message`` stays publication-only. + # publication delegation below; a nulled message falls back + # to the helper's built-in default (D3). from .publishing import _plant_topic_branch # noqa: PLC0415 — breaks the creation ↔ publishing import cycle - _plant_topic_branch(branch_name, resolved_todo, base_commit, slug, resolved_year, None) + commit = _plant_topic_branch(branch_name, final_todo, base_commit, slug, resolved_year, final_message) + TopicHooks().emit_created( + identity, + checked_out=False, + published=False, + todo=final_todo, + commit_message=final_message or _applied_default_message(slug), + commit_hash=commit, + ) return f"Created branch {branch_name} and topic {resolved_year}/{slug}" - _enter_fresh_branch(branch_name, base_commit, resolved_todo, year, resolved_year) + _enter_fresh_branch(branch_name, base_commit, final_todo, year, resolved_year) + TopicHooks().emit_created( + identity, + checked_out=True, + published=False, + todo=final_todo, + commit_message=None, + commit_hash=None, + ) return f"Created branch {branch_name} and topic {resolved_year}/{slug}" # The publication delegates to the fast cycle through a call-time # import: publishing imports this module's occupancy oracles, so a # module-level import would be circular and crash the facade load in # either order. The cycle re-runs its own preflight — the delegation - # is deliberately whole, no partial pre-sharing of results. + # is deliberately whole, no partial pre-sharing of results. The + # amended todo and the amended template travel into the delegation + # (the helper's placeholder replacement is a no-op on an applied + # text); the delegated routine fires its own checkpoints after its + # push — nothing fires here. from .publishing import publish_topic # noqa: PLC0415 — breaks the creation ↔ publishing import cycle - return publish_topic(branch_name, resolved_todo, base_ref, commit_message, year) + return publish_topic(branch_name, final_todo, base_ref, final_message, year) + + +def _draft_commit_message( + publishing: bool, + switch: bool, + commit_message: str | None, + slug: str, +) -> str | None: + """Compose the draft commit message of the chosen path — the applied text. + + The commit-building paths deliver the message that would land in git — + the template with the ``{slug}`` placeholder already replaced — so a + tool amends the actual text (D4); the switch path builds no commit and + delivers ``None``. On the publication path the ``or`` predicate + deliberately normalizes an empty template to the built-in default, so + the delegated publication lands the default — the one documented + exception; a direct ``publish_topic`` call keeps its own ``is not + None`` predicate. + + Args: + publishing: True when the chosen path is the publication. + switch: True when the switch flag is set. + commit_message: The message template as entered — + publication-only. + slug: The normalized topic slug — the ``{slug}`` placeholder + value. + + Returns: + The applied draft message, or ``None`` on the switch path. + """ + if publishing: + from .publishing import _DEFAULT_COMMIT_MESSAGE # noqa: PLC0415 — breaks the creation ↔ publishing import cycle + + template = commit_message or _DEFAULT_COMMIT_MESSAGE + return template.replace("{slug}", slug) + + if switch: + return None + + return _applied_default_message(slug) + + +def _applied_default_message(slug: str) -> str: + """Apply the built-in domain default template to the slug. + + Args: + slug: The normalized topic slug — the ``{slug}`` placeholder + value. + + Returns: + The applied default — the message the no-switch path lands in git + and the fallback a nulled amendment falls back to (D3). + """ + from .publishing import _DEFAULT_COMMIT_MESSAGE # noqa: PLC0415 — breaks the creation ↔ publishing import cycle + + return _DEFAULT_COMMIT_MESSAGE.replace("{slug}", slug) def _enter_fresh_branch( branch_name: str, base_commit: str, - resolved_todo: str | None, + final_todo: str | None, year: str | None, resolved_year: str, ) -> None: @@ -473,7 +613,8 @@ def _enter_fresh_branch( Args: branch_name: Branch name as entered by the user. base_commit: The base commit hash the preflight resolved. - resolved_todo: The resolved todo text, or ``None`` for no todo. + final_todo: The final todo text — the amended value — or + ``None`` for no todo. year: The year argument as passed — ``None`` means the current year for the directory creation. resolved_year: Year as four digits — the directory and the todo @@ -495,8 +636,8 @@ def _enter_fresh_branch( raise ensure_topic_dir(branch_name, year) - if resolved_todo is not None: - _write_todo(branch_name, resolved_year, resolved_todo) + if final_todo is not None: + _write_todo(branch_name, resolved_year, final_todo) def _resolve_todo(todo: str | None) -> str | None: diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index f7aece23..a8f91ef6 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -493,7 +493,7 @@ def test_create_topic_no_switch_path_order(self, tmp_path: Path, monkeypatch: py assert result == "Created branch feature-foo and topic 2026/feature-foo" assert wired.mock_calls == [ mock.call.resolve_ref_commit("origin/main"), - mock.call.plant("feature-foo", "Fix.", "c0ffee", "feature-foo", "2026", None), + mock.call.plant("feature-foo", "Fix.", "c0ffee", "feature-foo", "2026", "goga: create topic feature-foo"), ] assert not (tmp_path / ".goga" / "history" / "2026").exists() @@ -528,7 +528,8 @@ def test_create_topic_base_passed_to_the_plant(self, tmp_path: Path, monkeypatch """The base is resolved once and the quarantined commit is built on it. The no-switch default hands the plant the resolved base commit and - the built-in message — the template argument stays None. + the final message of the amendment — the applied built-in default + when no hook amended it. """ monkeypatch.chdir(tmp_path) wired = _wire_creation(monkeypatch, base_commit="abc123") @@ -536,7 +537,7 @@ def test_create_topic_base_passed_to_the_plant(self, tmp_path: Path, monkeypatch create_topic("feat-a", "origin/main", todo="T", year="2026") wired.resolve_ref_commit.assert_called_once_with("origin/main") - wired.plant.assert_called_once_with("feat-a", "T", "abc123", "feat-a", "2026", None) + wired.plant.assert_called_once_with("feat-a", "T", "abc123", "feat-a", "2026", "goga: create topic feat-a") def test_create_topic_switch_path_plants_at_the_base_commit( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -556,8 +557,9 @@ def test_create_topic_publication_ask_yes_delegates(self, tmp_path: Path, monkey The delegation reaches ``publish_topic`` at its definition site — the call-time import resolves the patched attribute — with the - name, the resolved todo, the base, the template, and the year; - none of the local mutations runs. + name, the resolved todo, the base, the applied template (the + amendment's final message), and the year; none of the local + mutations runs. """ monkeypatch.chdir(tmp_path) wired = _wire_creation(monkeypatch) @@ -571,7 +573,9 @@ def test_create_topic_publication_ask_yes_delegates(self, tmp_path: Path, monkey assert result == "published line" confirm.assert_called_once_with("Publish the branch to origin?") - published.assert_called_once_with("feature-foo", "Fix.", "origin/main", None, "2026") + published.assert_called_once_with( + "feature-foo", "Fix.", "origin/main", "goga: create topic feature-foo", "2026" + ) wired.create_branch.assert_not_called() wired.checkout.assert_not_called() @@ -591,7 +595,9 @@ def test_create_topic_publication_ask_empty_answer_is_no( result = create_topic("feature-foo", "origin/main", todo="Fix.", year="2026") assert result == "Created branch feature-foo and topic 2026/feature-foo" - wired.plant.assert_called_once_with("feature-foo", "Fix.", "c0ffee", "feature-foo", "2026", None) + wired.plant.assert_called_once_with( + "feature-foo", "Fix.", "c0ffee", "feature-foo", "2026", "goga: create topic feature-foo" + ) wired.checkout.assert_not_called() def test_create_topic_failed_checkout_rolls_back_the_plant( @@ -745,6 +751,7 @@ def test_create_topic_publish_with_editor_todo_delegates_without_ask( The fast cycle must receive the resolved text (the editor's read-back, trailing newline and all), not the absent value option, and the confirm never fires: ``--publish`` is ask-free by contract. + The template travels applied — the amendment's final message. """ monkeypatch.chdir(tmp_path) wired = _wire_creation(monkeypatch) @@ -758,7 +765,9 @@ def test_create_topic_publish_with_editor_todo_delegates_without_ask( result = create_topic("feature-foo", "origin/main", publish=True, year="2026") assert result == "published line" - published.assert_called_once_with("feature-foo", "From editor.\n", "origin/main", None, "2026") + published.assert_called_once_with( + "feature-foo", "From editor.\n", "origin/main", "goga: create topic feature-foo", "2026" + ) confirm.assert_not_called() wired.create_branch.assert_not_called() wired.checkout.assert_not_called() @@ -873,7 +882,9 @@ def test_create_topic_no_switch_default_year_is_current( result = create_topic("Feature/Foo_Bar", "HEAD", todo="T") assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" - wired.plant.assert_called_once_with("Feature/Foo_Bar", "T", "c0ffee", "feature-foo-bar", "2026", None) + wired.plant.assert_called_once_with( + "Feature/Foo_Bar", "T", "c0ffee", "feature-foo-bar", "2026", "goga: create topic feature-foo-bar" + ) def test_create_topic_with_todo_value(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A free name with a todo value: the quarantined plant, nothing on disk. @@ -888,7 +899,8 @@ def test_create_topic_with_todo_value(self, tmp_path: Path, monkeypatch: pytest. assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" wired.plant.assert_called_once_with( - "Feature/Foo_Bar", "Payment retry", "c0ffee", "feature-foo-bar", "2026", None + "Feature/Foo_Bar", "Payment retry", "c0ffee", "feature-foo-bar", "2026", + "goga: create topic feature-foo-bar", ) wired.checkout.assert_not_called() assert not (tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar").exists() @@ -962,6 +974,104 @@ def test_create_topic_todo_write_failure_is_clean_error( # The traced order — the branch mutations run before the todo write. wired.create_branch.assert_called_once_with("Feature/Foo_Bar", "c0ffee") + def test_create_topic_no_switch_emits_created_with_commit_hash( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + ) -> None: + """The no-switch path amends before the plant and emits after it. + + The hash comes from the plant's existing return — no new git read — + and every fact comes from the operation's own data: the identity + from the slug, the resolved year, and the name as entered; the + message the commit lands is the message the notification reports. + """ + + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, current="main", base_commit="c0ffee") + wired.plant.return_value = "deadbeef" + _tty(monkeypatch) + _stub_edit_text(monkeypatch, "the todo") + monkeypatch.setattr(click, "confirm", mock.Mock(return_value=False)) + records = recording_hooks(("amend_creation", "topic_created")) + + result = create_topic("Feature/Foo_Bar", "HEAD", todo=None, year="2026") + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + wired.plant.assert_called_once_with( + "Feature/Foo_Bar", "the todo", "c0ffee", "feature-foo-bar", "2026", "goga: create topic feature-foo-bar" + ) + assert [entry[1] for entry in records] == ["amend_creation", "topic_created"] + amendment, created = records[0][2], records[1][2] + assert amendment.checked_out is False # type: ignore[attr-defined] + assert amendment.published is False # type: ignore[attr-defined] + assert amendment.commit_message == "goga: create topic feature-foo-bar" # type: ignore[attr-defined] + assert amendment.todo == "the todo" # type: ignore[attr-defined] + assert amendment.identity.slug == "feature-foo-bar" # type: ignore[attr-defined] + assert amendment.identity.branch == "Feature/Foo_Bar" # type: ignore[attr-defined] + assert created.checked_out is False # type: ignore[attr-defined] + assert created.published is False # type: ignore[attr-defined] + assert created.todo == "the todo" # type: ignore[attr-defined] + assert created.commit_message == "goga: create topic feature-foo-bar" # type: ignore[attr-defined] + assert created.commit_hash == "deadbeef" # type: ignore[attr-defined] + assert created.identity.home_path == ".goga/history/2026/feature-foo-bar" # type: ignore[attr-defined] + + def test_create_topic_failed_preflight_fires_nothing( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + ) -> None: + """A failed creation fires nothing — the amendment delivers only + immediately before the first mutation, never before the decisions.""" + monkeypatch.chdir(tmp_path) + _wire_creation(monkeypatch, current="main") + monkeypatch.setattr( + creation, + "check_branch_occupancy", + mock.Mock(return_value="branch 'Feature/Foo_Bar' already exists"), + ) + records = recording_hooks() + + with pytest.raises(click.ClickException, match="already exists"): + create_topic("Feature/Foo_Bar", "HEAD", todo="x", year="2026") + + assert records == [] + + def test_create_topic_switch_path_amended_null_todo_degrades_gracefully( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + install_tool_package: InstallToolPackage, + ) -> None: + """The switch path's todo is optional — a nulled amended todo writes + nothing and the notification reports the truth.""" + + def amender(context: object) -> None: + context.amend(None, None) # type: ignore[attr-defined] + + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, current="main") + records = recording_hooks("topic_created") + install_tool_package("goga_tool_two", register_hooks=_subscribe(("amend_creation", amender))) + + result = create_topic("Feature/Foo_Bar", "HEAD", todo="the todo", switch=True, year="2026") + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + wired.create_branch.assert_called_once_with("Feature/Foo_Bar", "c0ffee") + wired.checkout.assert_called_once_with("Feature/Foo_Bar") + assert len(records) == 1 + context = records[0][2] + assert context.todo is None # type: ignore[attr-defined] + assert context.checked_out is True # type: ignore[attr-defined] + assert context.published is False # type: ignore[attr-defined] + assert context.commit_message is None # type: ignore[attr-defined] + assert context.commit_hash is None # type: ignore[attr-defined] + todo_file = tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" / "todo.md" + assert not todo_file.exists() + # --- Logic tests: the todo entry of a topic --- From c6b8221bcaeb2839a5c73003345fdd3722b241e1 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 17 Sep 2026 00:15:50 +0000 Subject: [PATCH 049/205] feat: emit the publication pair from publish_topic --- .goga/history/2026/add-topics-hooks/plan.md | 14 ++-- goga/topics/publishing.py | 82 +++++++++++++++++++-- tests/topics/test_publishing.py | 71 +++++++++++++++++- 3 files changed, 154 insertions(+), 13 deletions(-) diff --git a/.goga/history/2026/add-topics-hooks/plan.md b/.goga/history/2026/add-topics-hooks/plan.md index fb8da30a..2d63714d 100644 --- a/.goga/history/2026/add-topics-hooks/plan.md +++ b/.goga/history/2026/add-topics-hooks/plan.md @@ -1408,13 +1408,13 @@ nothing fires. After the successful push: `topic_created` then **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: the public signature and result line are unchanged — the existing contract tests of `tests/topics/test_publishing.py` keep passing unmodified; add the facade re-export check if absent (expected to fail at this stage only for the new wiring) -- [ ] **Code**: rework `_publish_topic` in `goga/topics/publishing.py` per the algorithm below — the applied message computed once, the plant hash captured, the two emissions after the successful push -- [ ] **Interface verification**: `pytest tests/topics/test_publishing.py -v` — all pass -- [ ] **Logic tests**: the two design scenarios below — `test_publish_topic_emits_created_then_published_after_push` (positive), `test_publish_topic_rollback_fires_nothing` (negative) -- [ ] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass (do NOT fix test code) -- [ ] **Contract re-verification**: both contexts carry the same final message/hash/todo; rollback fires nothing; a direct call publishes without `amend_creation` (the creation amendment belongs to the creating orchestration) -- [ ] **Lint**: `ruff check goga/topics` — fix formatting if necessary +- [x] **Contract tests**: the public signature and result line are unchanged — the existing contract tests of `tests/topics/test_publishing.py` keep passing unmodified; add the facade re-export check if absent (expected to fail at this stage only for the new wiring) +- [x] **Code**: rework `_publish_topic` in `goga/topics/publishing.py` per the algorithm below — the applied message computed once, the plant hash captured, the two emissions after the successful push +- [x] **Interface verification**: `pytest tests/topics/test_publishing.py -v` — all pass +- [x] **Logic tests**: the two design scenarios below — `test_publish_topic_emits_created_then_published_after_push` (positive), `test_publish_topic_rollback_fires_nothing` (negative) +- [x] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [x] **Contract re-verification**: both contexts carry the same final message/hash/todo; rollback fires nothing; a direct call publishes without `amend_creation` (the creation amendment belongs to the creating orchestration) +- [x] **Lint**: `ruff check goga/topics` — fix formatting if necessary Algorithm (from the design): diff --git a/goga/topics/publishing.py b/goga/topics/publishing.py index be85f814..b0841919 100644 --- a/goga/topics/publishing.py +++ b/goga/topics/publishing.py @@ -8,11 +8,17 @@ conflict of the decision chain is one clean error — there is no re-ask; the mutation sequence is the quarantined commit build, the branch plant, and the push, and a failed publication rolls back fully — the planted -branch is deleted and nothing else was ever mutated. The commit message -default lives here as the built-in domain template. The quarantined +branch is deleted and nothing else was ever mutated. After the successful push +the routine emits the publication pair — the creation and the +publication notifications over the nested hooks zone, with the applied +commit message and the captured commit hash; a rolled-back publication +fires nothing, and the creation amendment belongs to the creating +orchestration. The commit message default lives here as the built-in +domain template. The quarantined commit build and the branch plant also serve the no-switch creation of ``creation`` through the shared plant helper. The occupancy oracles -belong to ``creation``; the bounded git mutations to the nested git cell. +belong to ``creation``; the bounded git mutations to the nested git cell; +the lifecycle checkpoints to the nested hooks zone. Git infrastructure failures surface as ``click.ClickException`` — the clean-error boundary of the domain. """ @@ -39,6 +45,7 @@ push_branch, resolve_ref_commit, ) +from .hooks import TopicHooks, TopicIdentity # The built-in commit message template of the fast path — the ``{slug}`` # placeholder is replaced with the topic slug. The domain owns the default, @@ -77,6 +84,48 @@ def publish_topic( Returns: One line describing the created and published work. + Algorithm: + 1. Normalize ``branch_name`` into a slug — an empty slug is one + clean error, before any mutation + 2. An empty todo, or the current branch hosting the same slug -> + clean error, before any mutation + 3. The occupancy oracles ``check_branch_occupancy`` then + ``check_slug_occupancy`` report a conflict -> clean error with + a hint to the board + 4. ``origin_configured`` reads False -> clean error with the + reason + 5. Resolve ``base_ref`` into its commit via ``resolve_ref_commit`` + — an unresolvable base is a clean error with the reason, before + any mutation + 6. Build the publication commit via ``_plant_topic_branch`` — one + quarantined commit carrying ``todo.md`` on the base commit with + the applied commit message — and capture the returned commit + hash + 7. Publish via ``push_branch``; a failed publication deletes the + planted branch via ``delete_local_branch`` and surfaces one + clean error carrying the reason — nothing fires on the failure + 8. After the successful push, emit over ``TopicHooks`` with the + identity via ``TopicIdentity`` — the normalized slug, the + resolved year, ``branch_name`` as entered: ``topic_created`` + (``checked_out`` False, ``published`` True, the final todo, + the applied commit message, the captured commit hash), then + ``topic_published`` (the same final commit message, commit + hash, and todo) + 9. Return the single result line + + Requirements: + Every decision is made before the first mutation; the mutation + sequence is the commit build, the branch plant, and the push. + A failed publication rolls back fully — the planted branch is + deleted and nothing else was ever mutated. + The creation amendment belongs to the creating orchestration — + this routine fires the publication checkpoints only; a direct + call publishes without ``amend_creation``. + The two publication checkpoints fire only after the push + succeeds, in the order ``topic_created`` then + ``topic_published``; a failed publication that rolls back fires + nothing. + Raises: click.ClickException: an empty slug, an empty todo, the current branch already hosting the slug, an occupancy conflict, a @@ -145,7 +194,14 @@ def _publish_topic( base_commit = resolve_ref_commit(base_ref) - _plant_topic_branch(branch_name, todo, base_commit, slug, resolved_year, commit_message) + # The applied message is composed once — the template with the + # ``{slug}`` placeholder already replaced — so the commit and both + # notifications carry one value; the plant helper's own placeholder + # replacement is a no-op on the applied text. A direct call keeps its + # ``is not None`` predicate: only ``None`` takes the built-in default + # (the delegated creation normalizes an empty template itself). + applied = (commit_message if commit_message is not None else _DEFAULT_COMMIT_MESSAGE).replace("{slug}", slug) + commit = _plant_topic_branch(branch_name, todo, base_commit, slug, resolved_year, applied) try: push_branch(branch_name) @@ -154,11 +210,27 @@ def _publish_topic( # spawn-level OS failure of the push alike leave nothing of this # cycle behind. A failure of the rollback itself is suppressed so # the original push reason surfaces; a branch left behind stays - # visible on the board. + # visible on the board. Nothing fires on the failure — a + # rolled-back publication leaves no event trail. with contextlib.suppress(subprocess.CalledProcessError, OSError): delete_local_branch(branch_name) raise + # The publication pair fires only after the successful push, in the + # fixed order, with the identical final facts — the identity from the + # operation's own data; no repository reads at a checkpoint. + identity = TopicIdentity(slug=slug, year=resolved_year, branch=branch_name) + hooks = TopicHooks() + hooks.emit_created( + identity, + checked_out=False, + published=True, + todo=todo, + commit_message=applied, + commit_hash=commit, + ) + hooks.emit_published(identity, commit_message=applied, commit_hash=commit, todo=todo) + return f"Created branch {branch_name} and published topic {resolved_year}/{slug}" diff --git a/tests/topics/test_publishing.py b/tests/topics/test_publishing.py index 89f30852..4692781b 100644 --- a/tests/topics/test_publishing.py +++ b/tests/topics/test_publishing.py @@ -9,7 +9,10 @@ stays real because it is a pure string transformation, and so does ``resolve_topic_file`` (a pure path composer). The recording doubles assert the decision-before-mutation order, the exact delegation arguments, and the -full rollback of a failed publication. +full rollback of a failed publication. The checkpoint scenarios run the +delivery for real over the platform-environment fixtures of the local +conftest — the recording hooks assert the publication pair, its order, and +the nothing-fired guarantee of the rollback. """ from __future__ import annotations @@ -18,6 +21,7 @@ import subprocess import sys import typing +from collections.abc import Callable from pathlib import Path from unittest import mock @@ -92,6 +96,10 @@ def _assert_no_mutation(cycle: _Cycle) -> None: cycle.delete_local_branch.assert_not_called() +RecordedEntry = Callable[..., list[tuple[str, str, object]]] +"""The recording-hooks factory of the local conftest.""" + + # --- Contract tests --- @@ -559,6 +567,67 @@ def test_publish_topic_empty_slug_is_clean_error(self, tmp_path: Path, monkeypat cycle.check_branch_occupancy.assert_not_called() _assert_no_mutation(cycle) + def test_publish_topic_emits_created_then_published_after_push( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + ) -> None: + """The publication pair fires only after the push, in the fixed order. + + Both contexts carry the identical final message, hash, and todo — + the hash comes from the plant's existing return, no new git read. + The recorders watch all seven addresses, so the two-entry trail + also pins that a direct call publishes without the creation + amendment — it belongs to the creating orchestration. + """ + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.commit_file_on_base.return_value = "cafe123" + records = recording_hooks() + + result = publish_topic("Feature/Foo_Bar", "the todo", "HEAD", year="2026") + + assert result == "Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar" + assert [entry[1] for entry in records] == ["topic_created", "topic_published"] + created, published = records[0][2], records[1][2] + assert created.checked_out is False # type: ignore[attr-defined] + assert created.published is True # type: ignore[attr-defined] + assert created.todo == "the todo" # type: ignore[attr-defined] + assert created.commit_message == "goga: create topic feature-foo-bar" # type: ignore[attr-defined] + assert created.commit_hash == "cafe123" # type: ignore[attr-defined] + assert created.identity.home_path == ".goga/history/2026/feature-foo-bar" # type: ignore[attr-defined] + assert created.identity.branch == "Feature/Foo_Bar" # type: ignore[attr-defined] + assert published.commit_message == "goga: create topic feature-foo-bar" # type: ignore[attr-defined] + assert published.commit_hash == "cafe123" # type: ignore[attr-defined] + assert published.todo == "the todo" # type: ignore[attr-defined] + assert published.identity.slug == "feature-foo-bar" # type: ignore[attr-defined] + + def test_publish_topic_rollback_fires_nothing( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + ) -> None: + """A rolled-back publication leaves no event trail. + + Tools would otherwise record a publication that does not exist; + the rollback itself still runs — the planted branch is deleted + before the clean error surfaces. + """ + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.push_branch.side_effect = subprocess.CalledProcessError( + 1, ["git", "push"], stderr="error: failed to push some refs" + ) + records = recording_hooks() + + with pytest.raises(click.ClickException): + publish_topic("Feature/Foo_Bar", "the todo", "HEAD", year="2026") + + assert records == [] + cycle.delete_local_branch.assert_called_once_with("Feature/Foo_Bar") + # --- Infrastructure boundary --- From ba2ad6d4b1d7314c947491d22d22f73a31c6b1fb Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 17 Sep 2026 00:21:09 +0000 Subject: [PATCH 050/205] feat: emit topic_switched from switch_topic --- .goga/history/2026/add-topics-hooks/plan.md | 14 +- goga/topics/switching.py | 68 +++++-- tests/topics/test_switching.py | 189 +++++++++++++++++++- 3 files changed, 247 insertions(+), 24 deletions(-) diff --git a/.goga/history/2026/add-topics-hooks/plan.md b/.goga/history/2026/add-topics-hooks/plan.md index 2d63714d..1f18aa35 100644 --- a/.goga/history/2026/add-topics-hooks/plan.md +++ b/.goga/history/2026/add-topics-hooks/plan.md @@ -1514,13 +1514,13 @@ fact step 7 passes into `enter_topic_todo`. **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: the public signature and result lines are unchanged — the existing contract tests of `tests/topics/test_switching.py` keep passing; extend the existing `enter_topic_todo` mock assertions for the new `branch=` keyword (expected to fail at this stage) -- [ ] **Code**: rework `_apply_candidate` to return `(line, outcome)` with the three-kind mapping, and `_switch_topic` per the algorithm below — the branch fact, the identity, the emission, the branch kwarg of the entry -- [ ] **Interface verification**: `pytest tests/topics/test_switching.py -v` — all pass -- [ ] **Logic tests**: the two design scenarios below — `test_switch_topic_emits_switched_for_every_outcome` (positive, parametrized over the three inventory scenarios plus the topic-less branch), `test_switch_todo_onto_topicless_branch_fires_nothing` (negative) -- [ ] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass (do NOT fix test code) -- [ ] **Contract re-verification**: `topic_switched` fires on every completed switch; branch-only identity when the candidate hosts no topic; identity facts from the operation's own data (candidate's hosted slug, resolved year, branch name — no git reads); the `todo` no-topic guard fires before any mutation and emits nothing -- [ ] **Lint**: `ruff check goga/topics` — fix formatting if necessary +- [x] **Contract tests**: the public signature and result lines are unchanged — the existing contract tests of `tests/topics/test_switching.py` keep passing; extend the existing `enter_topic_todo` mock assertions for the new `branch=` keyword (expected to fail at this stage) +- [x] **Code**: rework `_apply_candidate` to return `(line, outcome)` with the three-kind mapping, and `_switch_topic` per the algorithm below — the branch fact, the identity, the emission, the branch kwarg of the entry +- [x] **Interface verification**: `pytest tests/topics/test_switching.py -v` — all pass +- [x] **Logic tests**: the two design scenarios below — `test_switch_topic_emits_switched_for_every_outcome` (positive, parametrized over the three inventory scenarios plus the topic-less branch), `test_switch_todo_onto_topicless_branch_fires_nothing` (negative) +- [x] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [x] **Contract re-verification**: `topic_switched` fires on every completed switch; branch-only identity when the candidate hosts no topic; identity facts from the operation's own data (candidate's hosted slug, resolved year, branch name — no git reads); the `todo` no-topic guard fires before any mutation and emits nothing +- [x] **Lint**: `ruff check goga/topics` — fix formatting if necessary Algorithm (from the design): diff --git a/goga/topics/switching.py b/goga/topics/switching.py index c318aadf..ff84984d 100644 --- a/goga/topics/switching.py +++ b/goga/topics/switching.py @@ -4,10 +4,15 @@ ``location: switching.py``: one candidate of a switch-identifier resolution, the read-only resolver walking the same ref trees as the board, and the orchestrator that brings the repository onto the chosen -host branch by purely switching — with the todo flag it enters the todo -of the switched topic through the entry of ``creation.py`` after the -switch. Topic identity and statuses belong to the -history facade; the bounded git mutations belong to the nested git cell. +host branch by purely switching — emitting the switch notification over +the nested hooks zone after every completed switch, with the outcome +kind and the identity of the switched work (the branch-only form for a +branch hosting no topic) — and, with the todo flag, entering the todo of +the switched topic through the entry of ``creation.py`` after the +switch, passing the switched branch as the branch fact. Topic statuses +belong to the history facade; the bounded git mutations belong to the +nested git cell; the lifecycle checkpoints belong to the nested hooks +zone. Git infrastructure failures and the fatal scale-assembly ``ImportError`` surface as ``click.ClickException`` — the clean-error boundary of the domain; the interactive moments follow the ``click`` practice. @@ -37,6 +42,7 @@ is_working_tree_clean, list_branch_refs, ) +from .hooks import TopicHooks, TopicIdentity @dataclass(frozen=True, kw_only=True) @@ -152,14 +158,32 @@ def switch_topic(identifier: str, todo: bool = False, year: str | None = None) - 6. Local host -> check out the branch via ``checkout_local_branch``; remote-only host -> create the local branch from the remote-tracking ref via ``create_branch_from_remote_tracking`` - 7. With ``todo`` -> enter the todo of the topic via - ``enter_topic_todo`` — after the switch - 8. Return the single result line + — the outcome kind of the applied branch travels with the + result line + 7. Emit ``topic_switched`` over ``TopicHooks`` — the identity via + ``TopicIdentity``: the hosted slug of the chosen candidate + when it hosts one, the resolved year, and the branch the + working copy is on after the switch (the candidate's display + name, its short name for a remote-tracking candidate); the + branch-only identity when it hosts none — and the outcome + kind: already-on-branch, local-checkout, or + created-from-remote + 8. With ``todo`` -> enter the todo of the topic via + ``enter_topic_todo`` — after the switch, passing the switched + branch as the branch fact + 9. Return the single result line Requirements: Every mutation is local — no network, no fetch, no push. Nothing is mutated before the candidate choice is complete. The result is exactly one line. + ``topic_switched`` fires on every completed switch, every outcome + included; the identity degrades to branch-only when the chosen + candidate hosts no topic. + ``todo`` onto a branch hosting no topic keeps the clean + pre-mutation error and fires nothing. + The identity facts are the operation's own data — the hosted slug + of the chosen candidate, the resolved year, and the branch name. Constraints: Do not create a topic for a branch without one. @@ -326,6 +350,7 @@ def _switch_topic(identifier: str, todo: bool, year: str | None) -> str: if todo and not sys.stdin.isatty(): raise click.ClickException("the todo entry needs an interactive terminal") + resolved_year = year or current_year() candidates = resolve_switch_candidates(identifier, year) if not candidates: @@ -336,10 +361,21 @@ def _switch_topic(identifier: str, todo: bool, year: str | None) -> str: if todo and chosen.topic is None: raise click.ClickException(f"branch '{chosen.branch}' hosts no topic — switching creates nothing") - line = _apply_candidate(chosen) + line, outcome = _apply_candidate(chosen) + + # The switch notification fires after the completed switch — every + # outcome included, the idempotent one too — with the identity from + # the operation's own data: the hosted slug of the chosen candidate + # (the branch-only form without one), the resolved year, and the + # branch the working copy is on after the switch — the display name, + # the short name for a remote-tracking candidate; no repository read + # happens at the checkpoint. + branch_fact = _short_name(chosen.branch) if chosen.remote else chosen.branch + identity = TopicIdentity(slug=chosen.topic, year=resolved_year, branch=branch_fact) + TopicHooks().emit_switched(identity, outcome) if todo: - enter_topic_todo(chosen.topic, year) + enter_topic_todo(chosen.topic, year, branch=branch_fact) return line @@ -357,7 +393,7 @@ def _take_candidate(candidates: list[SwitchCandidate]) -> SwitchCandidate: return candidates[0] if len(candidates) == 1 else _choose_candidate(candidates) -def _apply_candidate(chosen: SwitchCandidate) -> str: +def _apply_candidate(chosen: SwitchCandidate) -> tuple[str, str]: """Bring the working copy onto the chosen candidate — the mutation tail of ``switch_topic``. @@ -365,24 +401,28 @@ def _apply_candidate(chosen: SwitchCandidate) -> str: chosen: The chosen candidate of the resolution. Returns: - The single result line of the outcome. + The single result line of the outcome and its outcome kind — + ``already-on-branch``, ``local-checkout``, or + ``created-from-remote`` — one kind per return branch, mapped + one-to-one onto the three outcomes of the switch; the lines are + unchanged. Raises: click.ClickException: a dirty working tree when a mutation is needed. """ if chosen.current: - return f"Already on branch {chosen.branch}" + return f"Already on branch {chosen.branch}", "already-on-branch" if not is_working_tree_clean(): raise click.ClickException("working tree is dirty — commit or stash before switching") if not chosen.remote: checkout_local_branch(chosen.branch) - return f"Switched to branch {chosen.branch}" + return f"Switched to branch {chosen.branch}", "local-checkout" create_branch_from_remote_tracking(BranchRef(name=chosen.branch, remote=True)) short = chosen.branch.partition("/")[2] - return f"Created branch {short} from {chosen.branch}" + return f"Created branch {short} from {chosen.branch}", "created-from-remote" def _choose_candidate(candidates: list[SwitchCandidate]) -> SwitchCandidate: diff --git a/tests/topics/test_switching.py b/tests/topics/test_switching.py index e3888e42..bcae0ff6 100644 --- a/tests/topics/test_switching.py +++ b/tests/topics/test_switching.py @@ -661,10 +661,10 @@ def test_switch_topic_todo_enters_after_switch( result = switch_topic("feature-foo", todo=True, year="2026") assert result == "Switched to branch feature-foo" - entry.assert_called_once_with("feature-foo", "2026") + entry.assert_called_once_with("feature-foo", "2026", branch="feature-foo") assert order.mock_calls == [ mock.call.checkout("feature-foo"), - mock.call.entry("feature-foo", "2026"), + mock.call.entry("feature-foo", "2026", branch="feature-foo"), ] def test_switch_topic_todo_idempotent_still_enters( @@ -687,7 +687,7 @@ def test_switch_topic_todo_idempotent_still_enters( result = switch_topic("feature-foo", todo=True) assert result == "Already on branch feature-foo" - entry.assert_called_once_with("feature-foo", None) + entry.assert_called_once_with("feature-foo", None, branch="feature-foo") cleanliness.assert_not_called() checkout.assert_not_called() creation.assert_not_called() @@ -766,6 +766,189 @@ def test_switch_topic_several_candidates_non_tty_with_todo( creation.assert_not_called() +# --- Logic tests: the switch checkpoint --- + + +RecordedEntry = Callable[..., list[tuple[str, str, object]]] +"""The recording-hooks factory of the local conftest.""" + + +def _outcome_scenario( + name: str, +) -> tuple[str, list[BranchRef], dict[str, list[str]], str | None, str | None]: + """Build the inventory of one outcome scenario. + + Args: + name: The scenario key — ``already-on``, ``local``, ``remote``, or + ``topicless``. + + Returns: + The switch identifier, the branch inventory, the ref trees, the + current branch, and the working-copy topic slug — the slug is set + only where the current branch hosts the topic, whose facts the + resolution reads from the working copy. + """ + topic_tree = [".goga/history/2026/feature-foo/plan.md"] + + if name == "already-on": + return ( + "feature-foo", + [BranchRef(name="feature-foo", remote=False), BranchRef(name="main", remote=False)], + {"feature-foo": ["README.md"], "main": ["README.md"]}, + "feature-foo", + "feature-foo", + ) + + if name == "local": + return ( + "feature-foo", + [BranchRef(name="feature-foo", remote=False), BranchRef(name="main", remote=False)], + {"feature-foo": topic_tree, "main": ["README.md"]}, + "main", + None, + ) + + if name == "remote": + return ( + "feature-foo", + [BranchRef(name="origin/feature-foo", remote=True)], + {"origin/feature-foo": topic_tree}, + None, + None, + ) + + return ( + "bare-branch", + [BranchRef(name="bare-branch", remote=False), BranchRef(name="main", remote=False)], + {"bare-branch": ["README.md"], "main": ["README.md"]}, + "main", + None, + ) + + +class TestSwitchTopicCheckpoints: + @pytest.mark.parametrize( + ("scenario", "expected_line", "expected_outcome", "expected_slug", "expected_branch"), + [ + pytest.param( + "already-on", + "Already on branch feature-foo", + "already-on-branch", + "feature-foo", + "feature-foo", + id="already-on", + ), + pytest.param( + "local", + "Switched to branch feature-foo", + "local-checkout", + "feature-foo", + "feature-foo", + id="local-checkout", + ), + pytest.param( + "remote", + "Created branch feature-foo from origin/feature-foo", + "created-from-remote", + "feature-foo", + "feature-foo", + id="created-from-remote", + ), + pytest.param( + "topicless", + "Switched to branch bare-branch", + "local-checkout", + None, + "bare-branch", + id="topicless-branch", + ), + ], + ) + def test_switch_topic_emits_switched_for_every_outcome( # noqa: PLR0913, PLR0917 — the parametrized scenario columns + self, + scenario: str, + expected_line: str, + expected_outcome: str, + expected_slug: str | None, + expected_branch: str, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + ) -> None: + """Every completed switch fires ``topic_switched`` exactly once. + + The idempotent already-on outcome emits like the two mutating + ones. The identity carries the hosted slug of the chosen + candidate — the branch-only form (slug and home path ``None``) + for a branch hosting no topic — and the branch the working copy + is on after the switch: the candidate's display name, its short + name for a remote-tracking candidate. The result lines stay + unchanged. + """ + monkeypatch.chdir(tmp_path) + identifier, inventory, trees, current, working_copy = _outcome_scenario(scenario) + if working_copy is not None: + _working_copy_topic(tmp_path, "2026", working_copy, ["plan.md"]) + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, current) + _cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) + records = recording_hooks("topic_switched") + + result = switch_topic(identifier, year="2026") + + assert result == expected_line + assert [entry[1] for entry in records] == ["topic_switched"] + context = records[0][2] + assert context.outcome == expected_outcome # type: ignore[attr-defined] + identity = context.identity # type: ignore[attr-defined] + assert identity.slug == expected_slug + assert identity.branch == expected_branch + assert identity.year == "2026" + assert identity.home_path == (None if expected_slug is None else f".goga/history/2026/{expected_slug}") + # The mutation of the scenario ran; the other one never does. + if scenario == "remote": + checkout.assert_not_called() + creation.assert_called_once_with(BranchRef(name="origin/feature-foo", remote=True)) + elif scenario == "already-on": + _cleanliness.assert_not_called() + checkout.assert_not_called() + creation.assert_not_called() + else: + checkout.assert_called_once_with(expected_branch) + creation.assert_not_called() + + def test_switch_todo_onto_topicless_branch_fires_nothing( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + ) -> None: + """The pre-mutation no-topic guard suppresses the switch + notification too — the only switch path that fires nothing.""" + monkeypatch.chdir(tmp_path) + _wire_resolution( + monkeypatch, + builtin_scale, + [BranchRef(name="bare-branch", remote=False), BranchRef(name="main", remote=False)], + {"bare-branch": ["README.md"], "main": ["README.md"]}, + "main", + ) + _cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + records = recording_hooks() + + with pytest.raises(click.ClickException, match="hosts no topic"): + switch_topic("bare-branch", todo=True, year="2026") + + assert records == [] + _cleanliness.assert_not_called() + checkout.assert_not_called() + creation.assert_not_called() + entry.assert_not_called() + + # --- Infrastructure boundary --- From c0745b22136dae44fefb1d71278d40e94c41927b Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 17 Sep 2026 00:26:57 +0000 Subject: [PATCH 051/205] feat: wire the ensure_topic fast-creation checkpoints and branch facts --- .goga/history/2026/add-topics-hooks/plan.md | 14 +- goga/topics/ensuring.py | 110 +++++++++--- tests/topics/test_ensuring.py | 189 ++++++++++++++++++-- 3 files changed, 270 insertions(+), 43 deletions(-) diff --git a/.goga/history/2026/add-topics-hooks/plan.md b/.goga/history/2026/add-topics-hooks/plan.md index 1f18aa35..31877f8f 100644 --- a/.goga/history/2026/add-topics-hooks/plan.md +++ b/.goga/history/2026/add-topics-hooks/plan.md @@ -1615,13 +1615,13 @@ feeds the `topic_created` emission. **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: the public signature and result line are unchanged — the existing contract tests of `tests/topics/test_ensuring.py` keep passing; extend the existing `enter_topic_todo` mock assertions for the new `branch=` keyword (expected to fail at this stage) -- [ ] **Code**: rework `_create_fresh_work` and `_enter_switched_todo` in `goga/topics/ensuring.py` per the algorithm below -- [ ] **Interface verification**: `pytest tests/topics/test_ensuring.py -v` — all pass -- [ ] **Logic tests**: the two design scenarios below — `test_ensure_fast_creation_amends_identity_only_and_emits_after_entry` (positive), `test_ensure_todo_on_topicless_branch_fires_only_the_entry_pair` (edge) -- [ ] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass (do NOT fix test code) -- [ ] **Contract re-verification**: the fast creation delivers the creation amendment exactly once, immediately before its first mutation, and emits `topic_created` after the creation completes (the identity-only amendment form is the norm on this path); directory creation under the todo flag of a topic-less branch fires no creation checkpoint — the todo entry alone fires its two; the todo entries pass the operation's branch fact; one registry across `ensure → switch → entry` (D1) -- [ ] **Lint**: `ruff check goga/topics` — fix formatting if necessary +- [x] **Contract tests**: the public signature and result line are unchanged — the existing contract tests of `tests/topics/test_ensuring.py` keep passing; extend the existing `enter_topic_todo` mock assertions for the new `branch=` keyword (expected to fail at this stage) +- [x] **Code**: rework `_create_fresh_work` and `_enter_switched_todo` in `goga/topics/ensuring.py` per the algorithm below +- [x] **Interface verification**: `pytest tests/topics/test_ensuring.py -v` — all pass +- [x] **Logic tests**: the two design scenarios below — `test_ensure_fast_creation_amends_identity_only_and_emits_after_entry` (positive), `test_ensure_todo_on_topicless_branch_fires_only_the_entry_pair` (edge) +- [x] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [x] **Contract re-verification**: the fast creation delivers the creation amendment exactly once, immediately before its first mutation, and emits `topic_created` after the creation completes (the identity-only amendment form is the norm on this path); directory creation under the todo flag of a topic-less branch fires no creation checkpoint — the todo entry alone fires its two; the todo entries pass the operation's branch fact; one registry across `ensure → switch → entry` (D1) +- [x] **Lint**: `ruff check goga/topics` — fix formatting if necessary Algorithm (from the design): diff --git a/goga/topics/ensuring.py b/goga/topics/ensuring.py index f38f52d6..12980a1f 100644 --- a/goga/topics/ensuring.py +++ b/goga/topics/ensuring.py @@ -5,10 +5,15 @@ repository onto the requested work — by switching when a branch hosts the identifier, by the fast creation from the current HEAD when nothing does; with the todo flag the todo entry of the ensured work runs after the -switch or the creation. The resolution and the switch orchestration belong -to the switching module; the occupancy oracles and the todo entry belong -to the creation module; the topic-directory creation belongs to the -history facade; the bounded git mutation belongs to the nested git cell. +switch or the creation. The fast creation delivers its creation amendment +over the nested hooks zone immediately before its first mutation — the +identity-only form — and emits the creation notification after the +creation completes; the todo entries pass the operation's branch fact. +The resolution and the switch orchestration belong to the switching +module; the occupancy oracles and the todo entry belong to the creation +module; the topic-directory creation belongs to the history facade; the +bounded git mutation belongs to the nested git cell; the lifecycle +checkpoints belong to the nested hooks zone. Git infrastructure failures and the fatal scale-assembly ``ImportError`` surface as ``click.ClickException`` — the clean-error boundary of the domain; the interactive moments follow the ``click`` practice. @@ -30,11 +35,13 @@ from .board import _short_name from .creation import ( _BOARD_HINT, + _enter_topic_todo, check_branch_occupancy, check_slug_occupancy, enter_topic_todo, ) from .git import create_and_switch_branch +from .hooks import TopicHooks, TopicIdentity from .switching import SwitchCandidate, resolve_switch_candidates, switch_topic @@ -59,20 +66,34 @@ def ensure_topic(identifier: str, todo: bool = False, year: str | None = None) - any action 2. Resolve the candidates via ``resolve_switch_candidates`` 3. No candidate -> the fast creation from the current HEAD: the - slug guard and the occupancy oracles are clean errors, then the - branch named as entered is created and switched to via - ``create_and_switch_branch``, the topic directory of the year is - created via ``ensure_topic_dir``, and with ``todo`` the todo of - the fresh topic is entered — the entry starts only after the - switch + slug guard and the occupancy oracles are clean errors; the + creation amendment is delivered over ``TopicHooks`` with the + identity via ``TopicIdentity`` — the normalized slug, the + resolved year, the branch name as entered — ``checked_out`` + True, ``published`` False, no draft commit message (the path + builds no commit), and no draft todo (the todo resolves later + through the entry); the branch named as entered is created and + switched to via ``create_and_switch_branch``, the topic + directory of the year is created via ``ensure_topic_dir``, and + with ``todo`` the todo of the fresh topic is entered via the + private mirror ``_enter_topic_todo``, passing the branch name + as the branch fact — the entry starts only after the switch; + after the creation completes, ``topic_created`` is emitted over + ``TopicHooks`` — the identity, ``checked_out`` True, + ``published`` False, the final todo when the entry resolved + one, and no commit facts 4. Otherwise -> the switch orchestration via ``switch_topic`` - without the entry; with ``todo`` the hosted topic comes from the - step-2 resolution candidate whose branch is the current branch - read via ``resolve_current_branch_name`` (a remote-tracking - candidate matches by its short name): a hosted topic is entered - via ``enter_topic_todo``; a hosting branch without one gets its - topic directory created via ``ensure_topic_dir`` — an empty slug - of its name is a clean error — then the fresh entry + without the entry — the switch notification fires inside it; + with ``todo`` the hosted topic comes from the step-2 resolution + candidate whose branch is the current branch read via + ``resolve_current_branch_name`` (a remote-tracking candidate + matches by its short name): a hosted topic is entered via + ``enter_topic_todo`` with the branch fact; a hosting branch + without one gets its topic directory created via + ``ensure_topic_dir`` — an empty slug of its name is a clean + error — then the fresh entry via ``enter_topic_todo`` with the + derived identity and the branch fact; no creation checkpoint + fires for the directory creation 5. Return the single result line Requirements: @@ -83,6 +104,14 @@ def ensure_topic(identifier: str, todo: bool = False, year: str | None = None) - With ``todo``, no step follows the todo write. Every mutation is local — no network, no fetch, no push. The result is exactly one line. + The fast creation delivers the creation amendment exactly once, + immediately before its first mutation — the identity-only form is + the norm on this path: the returned holder stays unread, an + amended todo does not land there (the entry's own todo-entry + amendment owns the written text) — and emits ``topic_created`` + after the creation completes. + The todo entries pass the operation's branch fact — the identifier + of the fast creation, the current branch of the switched work. Constraints: Do not ask about publication — the fast process publishes nothing. @@ -151,8 +180,14 @@ def _create_fresh_work(identifier: str, todo: bool, year: str | None) -> str: The branch keeps the name as entered and starts at git's default start point (the current HEAD); the topic directory takes the normalized slug. The decisions — the slug guard and the occupancy oracles — - precede the first mutation; the todo entry starts only after the - switch. + precede the first mutation; the creation amendment delivers + immediately before the branch creation (the first mutation) and + observes only — the identity-only form, the returned holder unread: + an amended todo does not land here (the entry's own todo-entry + amendment owns the written text) and the path builds no commit, so + there is no message to amend; the todo entry starts only after the + switch, and the creation notification closes the path after it with + the final todo the entry resolved. Args: identifier: The user input as entered — becomes the branch name. @@ -175,11 +210,36 @@ def _create_fresh_work(identifier: str, todo: bool, year: str | None) -> str: if conflict is not None: raise click.ClickException(f"{conflict} — {_BOARD_HINT}") + # The creation amendment — the identity-only form, delivered exactly + # once immediately before the branch creation (the first mutation). + # The returned holder stays unread on purpose: an amended todo does + # not land on this path (the entry's own todo-entry amendment owns the + # written text), and the path builds no commit — nothing to amend. + identity = TopicIdentity(slug=slug, year=resolved_year, branch=identifier) + TopicHooks().amend_creation( + identity, + checked_out=True, + published=False, + commit_message=None, + todo=None, + ) + create_and_switch_branch(identifier) ensure_topic_dir(identifier, year) - if todo: - enter_topic_todo(identifier, year) + final_todo = _enter_topic_todo(identifier, year, branch=identifier) if todo else None + + # The creation notification fires after the creation completes — after + # the entry, so the final todo it reports is the written one; the path + # builds no commit, so no commit fact is carried. + TopicHooks().emit_created( + identity, + checked_out=True, + published=False, + todo=final_todo, + commit_message=None, + commit_hash=None, + ) return f"Created branch {identifier} and topic {resolved_year}/{slug}" @@ -194,7 +254,9 @@ def _enter_switched_todo(candidates: list[SwitchCandidate], year: str | None) -> without a topic gets its topic directory created first — the fresh entry needs a place to land — unless its name normalizes to an empty slug, which is a clean error (the history facade's ``ValueError`` on - an empty slug never escapes the module). + an empty slug never escapes the module). The directory creation fires + no creation checkpoint; both entries pass the current branch — the + branch the working copy is on after the switch — as the branch fact. Args: candidates: The step-2 resolution candidates — the topic lookup @@ -206,14 +268,14 @@ def _enter_switched_todo(candidates: list[SwitchCandidate], year: str | None) -> topic = _hosted_topic_of_current(candidates, current) if topic is not None: - enter_topic_todo(topic, year) + enter_topic_todo(topic, year, branch=current) return if current is None or normalize_topic_slug(current) == "": raise click.ClickException(f"branch name '{current}' normalizes to an empty topic slug") ensure_topic_dir(current, year) - enter_topic_todo(current, year) + enter_topic_todo(current, year, branch=current) def _hosted_topic_of_current(candidates: list[SwitchCandidate], current: str | None) -> str | None: diff --git a/tests/topics/test_ensuring.py b/tests/topics/test_ensuring.py index 967dd767..9b85b33d 100644 --- a/tests/topics/test_ensuring.py +++ b/tests/topics/test_ensuring.py @@ -12,7 +12,9 @@ orchestration is the switching suite's concern); the fast creation mocks the occupancy oracles, ``create_and_switch_branch``, and the todo entry at their import points in ``ensuring`` — with the topic-directory creation real on a -``tmp_path`` tree where the design says so. The scale is the +``tmp_path`` tree where the design says so. The checkpoint scenarios stub +``edit_text`` on the creation module and run the todo entry for real, so the +entry's own checkpoint pair fires inside the ensure. The scale is the ``builtin_scale`` fixture. """ @@ -24,15 +26,57 @@ import typing from collections.abc import Callable from pathlib import Path +from typing import Any from unittest import mock import click import pytest from goga.history import current_year from goga.history.statuses import StatusScale -from goga.topics import SwitchCandidate, board, ensure_topic, ensuring, switching +from goga.topics import SwitchCandidate, board, creation, ensure_topic, ensuring, switching from goga.topics.git import BranchRef +RecordedEntry = Callable[..., list[tuple[str, str, object]]] +"""The recording-hooks factory of the local conftest.""" + +InstallToolPackage = Callable[[str, Callable[[Any], None] | None], object] +"""The fake-package installing factory of the local conftest.""" + + +def _subscribe(*subscriptions: tuple[str, Callable[..., None]]) -> Callable[[Any], None]: + """Build a facade callback subscribing each hook on its topics action. + + Each pair is one subscription — the topics action name and the hook; + the hook's ``__name__`` is its hook name, so the walk warnings name the + functions the test declares. + + Args: + subscriptions: The (action, hook) pairs to subscribe. + + Returns: + The ``register_hooks`` callback of one fake tool package. + """ + + def register_hooks(hooks: Any) -> None: + for action, hook in subscriptions: + hooks.subscribe("topics", action, hook.__name__, hook) + + return register_hooks + + +def _stub_edit_text(monkeypatch: pytest.MonkeyPatch, saved: str | None) -> None: + """Stub the editor session on the creation module — a scripted save. + + The real entry of the checkpoint scenarios runs in ``creation``, so the + session is stubbed at its owner. + + Args: + monkeypatch: the pytest patcher restoring the session on teardown. + saved: The text the session returns — None is the cancelled entry. + """ + monkeypatch.setattr(creation, "edit_text", lambda _initial=None: saved) + + # --- Shared scenario helpers --- @@ -150,6 +194,25 @@ def _wire_entry(monkeypatch: pytest.MonkeyPatch) -> mock.Mock: return entry +def _wire_mirror_entry(monkeypatch: pytest.MonkeyPatch, written: str | None = "the todo") -> mock.Mock: + """Patch the private todo-entry mirror at its import point in ``ensuring``. + + The fast creation enters its todo through the mirror of ``creation`` — + the final written text feeds the creation notification. + + Args: + monkeypatch: The patch fixture. + written: The final text the mirror answers — ``None`` is the + cancelled entry. + + Returns: + ``_enter_topic_todo`` as a recording mock. + """ + mirror = mock.Mock(return_value=written) + monkeypatch.setattr(ensuring, "_enter_topic_todo", mirror) + return mirror + + def _non_interactive(monkeypatch: pytest.MonkeyPatch) -> None: """Make stdin a non-terminal — the todo entry must abort cleanly.""" monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) @@ -231,7 +294,7 @@ def test_ensure_topic_fast_creation_from_current_head( trees = {"main": ["README.md"]} _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") create_and_switch, ensure_dir = _wire_fast_creation(monkeypatch) - entry = _wire_entry(monkeypatch) + entry = _wire_mirror_entry(monkeypatch) _interactive(monkeypatch) order = mock.Mock() order.attach_mock(create_and_switch, "create_and_switch") @@ -243,11 +306,11 @@ def test_ensure_topic_fast_creation_from_current_head( assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" create_and_switch.assert_called_once_with("Feature/Foo_Bar") ensure_dir.assert_called_once_with("Feature/Foo_Bar", "2026") - entry.assert_called_once_with("Feature/Foo_Bar", "2026") + entry.assert_called_once_with("Feature/Foo_Bar", "2026", branch="Feature/Foo_Bar") assert order.mock_calls == [ mock.call.create_and_switch("Feature/Foo_Bar"), mock.call.ensure_topic_dir("Feature/Foo_Bar", "2026"), - mock.call.entry("Feature/Foo_Bar", "2026"), + mock.call.entry("Feature/Foo_Bar", "2026", branch="Feature/Foo_Bar"), ] # The fast creation is local-only and asks nothing: the publication # primitives have no place here, and the old create_topic delegation @@ -443,10 +506,10 @@ def test_ensure_topic_switch_branch_without_topic_creates_dir_then_enters( assert result == "Already on branch feature-foo" switch.assert_called_once_with("feature-foo", todo=False, year="2026") ensure_dir.assert_called_once_with("feature-foo", "2026") - entry.assert_called_once_with("feature-foo", "2026") + entry.assert_called_once_with("feature-foo", "2026", branch="feature-foo") assert order.mock_calls == [ mock.call.ensure_topic_dir("feature-foo", "2026"), - mock.call.entry("feature-foo", "2026"), + mock.call.entry("feature-foo", "2026", branch="feature-foo"), ] def test_ensure_topic_todo_enters_resolved_topic_not_branch_slug( @@ -468,7 +531,7 @@ def test_ensure_topic_todo_enters_resolved_topic_not_branch_slug( result = ensure_topic("feature-x", todo=True, year="2026") assert result == "Switched to branch main" - entry.assert_called_once_with("feature-x", "2026") + entry.assert_called_once_with("feature-x", "2026", branch="main") ensure_dir.assert_not_called() assert not (tmp_path / ".goga" / "history" / "2026" / "main").exists() @@ -492,7 +555,7 @@ def test_ensure_topic_todo_matches_remote_candidate_by_short_name( result = ensure_topic("feature-x", todo=True, year="2026") assert result == "Created branch feature-x from origin/feature-x" - entry.assert_called_once_with("feature-x", "2026") + entry.assert_called_once_with("feature-x", "2026", branch="feature-x") ensure_dir.assert_not_called() def test_ensure_topic_todo_empty_slug_branch_without_topic_error( @@ -537,7 +600,7 @@ def test_ensure_topic_todo_idempotent_enters_the_hosted_topic( result = ensure_topic("feat/a", todo=True) assert result == "Already on branch feat/a" - entry.assert_called_once_with("feat-a", None) + entry.assert_called_once_with("feat-a", None, branch="feat/a") def test_ensure_topic_todo_non_tty_error_before_action( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -579,11 +642,113 @@ def test_ensure_topic_todo_current_branch_matching_no_candidate_creates_director assert result == "Switched to branch fresh-work" ensure_dir.assert_called_once_with("fresh-work", "2026") - entry.assert_called_once_with("fresh-work", "2026") + entry.assert_called_once_with("fresh-work", "2026", branch="fresh-work") assert order.mock_calls == [ mock.call.ensure_topic_dir("fresh-work", "2026"), - mock.call.entry("fresh-work", "2026"), + mock.call.entry("fresh-work", "2026", branch="fresh-work"), + ] + + +# --- Logic tests: the lifecycle checkpoints of the ensure --- + + +class TestEnsureTopicCheckpoints: + def test_ensure_fast_creation_amends_identity_only_and_emits_after_entry( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + install_tool_package: InstallToolPackage, + ) -> None: + """The fast-creation corner: the identity-only creation amendment + delivers immediately before the first mutation, the todo entry runs + for real and fires its own pair, and ``topic_created`` closes the + creation with the final written todo — all from one registry build. + """ + + def amend_witness(context: object) -> None: + calls.append("amend") + + calls: list[str] = [] + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + create_and_switch, _ensure_dir = _wire_fast_creation(monkeypatch, real_dir=True) + create_and_switch.side_effect = lambda _name: calls.append("create") + _stub_edit_text(monkeypatch, "fresh todo") + _interactive(monkeypatch) + install_tool_package("goga_tool_two", register_hooks=_subscribe(("amend_creation", amend_witness))) + records = recording_hooks(("amend_creation", "topic_created", "amend_todo_entry", "topic_todo_entered")) + + result = ensure_topic("New_Work", todo=True, year="2026") + + assert result == "Created branch New_Work and topic 2026/new-work" + # The amendment delivered exactly once, immediately before the + # branch creation — the first mutation of the path. + assert calls == ["amend", "create"] + assert [entry[1] for entry in records] == [ + "amend_creation", + "amend_todo_entry", + "topic_todo_entered", + "topic_created", ] + amendment = records[0][2] + assert amendment.checked_out is True # type: ignore[attr-defined] + assert amendment.published is False # type: ignore[attr-defined] + assert amendment.commit_message is None # type: ignore[attr-defined] + assert amendment.todo is None # type: ignore[attr-defined] + assert amendment.identity.slug == "new-work" # type: ignore[attr-defined] + assert amendment.identity.branch == "New_Work" # type: ignore[attr-defined] + entered = records[2][2] + assert entered.text == "fresh todo" # type: ignore[attr-defined] + assert entered.identity.branch == "New_Work" # type: ignore[attr-defined] + created = records[3][2] + assert created.todo == "fresh todo" # type: ignore[attr-defined] + assert created.commit_message is None # type: ignore[attr-defined] + assert created.commit_hash is None # type: ignore[attr-defined] + assert created.checked_out is True # type: ignore[attr-defined] + assert created.published is False # type: ignore[attr-defined] + todo_file = tmp_path / ".goga" / "history" / "2026" / "new-work" / "todo.md" + assert todo_file.read_text(encoding="utf-8") == "fresh todo\n" + + def test_ensure_todo_on_topicless_branch_fires_only_the_entry_pair( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + ) -> None: + """The marginal corner of the ensure contract: the directory + creation of a topic-less branch fires no creation checkpoint — the + switch's own event and the entry's two are everything that fires, + and the entry's identity is derived from the branch name.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="bare-branch", remote=False), BranchRef(name="main", remote=False)] + trees = {"bare-branch": ["README.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _cleanliness, _checkout, _remote_creation = _wire_mutations(monkeypatch, clean=True) + _wire_current(monkeypatch, "bare-branch") + _stub_edit_text(monkeypatch, "fresh") + _interactive(monkeypatch) + records = recording_hooks() + + result = ensure_topic("bare-branch", todo=True, year="2026") + + assert result == "Switched to branch bare-branch" + assert [entry[1] for entry in records] == ["topic_switched", "amend_todo_entry", "topic_todo_entered"] + switched = records[0][2] + assert switched.identity.slug is None # type: ignore[attr-defined] — the branch-only form + assert switched.identity.branch == "bare-branch" # type: ignore[attr-defined] + entered = records[2][2] + assert entered.text == "fresh" # type: ignore[attr-defined] + identity = entered.identity # type: ignore[attr-defined] + assert identity.slug == "bare-branch" + assert identity.branch == "bare-branch" + actions = {entry[1] for entry in records} + assert "amend_creation" not in actions + assert "topic_created" not in actions # --- Logic tests: the infrastructure boundary of the ensure --- From 141fe380bc3538d721204d2e4ed82f9771319178 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 17 Sep 2026 00:29:54 +0000 Subject: [PATCH 052/205] feat: emit topic_deleted per removed target --- .goga/history/2026/add-topics-hooks/plan.md | 14 +++--- goga/topics/deletion.py | 38 +++++++++++++--- tests/topics/test_deletion.py | 50 ++++++++++++++++++++- 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/.goga/history/2026/add-topics-hooks/plan.md b/.goga/history/2026/add-topics-hooks/plan.md index 31877f8f..96fddee2 100644 --- a/.goga/history/2026/add-topics-hooks/plan.md +++ b/.goga/history/2026/add-topics-hooks/plan.md @@ -1723,13 +1723,13 @@ loop after the directory removal. **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: the public signature and result line are unchanged — the existing contract tests of `tests/topics/test_deletion.py` keep passing (expected to fail at this stage only for the new wiring) -- [ ] **Code**: extend the per-target loop of `_delete_topics` in `goga/topics/deletion.py` per the algorithm below -- [ ] **Interface verification**: `pytest tests/topics/test_deletion.py -v` — all pass -- [ ] **Logic tests**: the design scenario below — `test_delete_topics_emits_per_target_after_full_removal` (positive; covers the directory-less and remote-only target shapes as edge cases) -- [ ] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass (do NOT fix test code) -- [ ] **Contract re-verification**: a target fires after its complete removal; targets fully removed before a later failure already fired theirs; a target whose removal fails midway fires nothing (the restore path raises before the emission); no commit hash carried -- [ ] **Lint**: `ruff check goga/topics` — fix formatting if necessary +- [x] **Contract tests**: the public signature and result line are unchanged — the existing contract tests of `tests/topics/test_deletion.py` keep passing (expected to fail at this stage only for the new wiring) +- [x] **Code**: extend the per-target loop of `_delete_topics` in `goga/topics/deletion.py` per the algorithm below +- [x] **Interface verification**: `pytest tests/topics/test_deletion.py -v` — all pass +- [x] **Logic tests**: the design scenario below — `test_delete_topics_emits_per_target_after_full_removal` (positive; covers the directory-less and remote-only target shapes as edge cases) +- [x] **Debugging**: `pytest tests/topics/ -x` — fix implementation code until all tests pass (do NOT fix test code) +- [x] **Contract re-verification**: a target fires after its complete removal; targets fully removed before a later failure already fired theirs; a target whose removal fails midway fires nothing (the restore path raises before the emission); no commit hash carried +- [x] **Lint**: `ruff check goga/topics` — fix formatting if necessary Algorithm (from the design): diff --git a/goga/topics/deletion.py b/goga/topics/deletion.py index 0e46107a..7c347019 100644 --- a/goga/topics/deletion.py +++ b/goga/topics/deletion.py @@ -9,10 +9,15 @@ scope, and collapses a local branch and its origin twin into one target assembled from the full inventory; the removal deletes the local branch, the origin twin, and the topic directory, restoring the local branch at -its captured commit when the remote deletion fails. Topic identity and +its captured commit when the remote deletion fails. Every fully removed +target emits the deletion notification over the nested hooks zone — the +branch-less identity with the removal composition; a target whose +removal fails midway fires nothing (the restore path raises before the +emission). Topic identity and addressing belong to the history facade; the ref inventory, the ref-tree reading, and the branch removals belong to the nested git -cell. Git infrastructure failures surface as +cell; the lifecycle checkpoints belong to the nested hooks zone. +Git infrastructure failures surface as ``click.ClickException`` — the clean-error boundary of the domain. """ @@ -42,6 +47,7 @@ read_ref_tree_paths, resolve_ref_commit, ) +from .hooks import TopicHooks, TopicIdentity @dataclass(frozen=True, kw_only=True) @@ -472,7 +478,9 @@ def delete_topics(targets: list[DeleteTarget], year: str | None = None) -> str: commit, delete the local branch, delete the origin twin, then remove the topic directory — on a remote failure restore the local branch at the captured commit before the error surfaces - 3. Return the single outcome line + 3. Emit the deletion notification of the target — after its full + removal, with the removal composition + 4. Return the single outcome line Requirements: The commit is captured before the local deletion — after it the @@ -483,6 +491,12 @@ def delete_topics(targets: list[DeleteTarget], year: str | None = None) -> str: captured commit, and a failure of the restore itself is suppressed so the original remote reason surfaces. + A target fires its deletion notification only after its complete + removal — targets removed before a later failure already fired + theirs, and a failure midway through a target raises before the + emission, so nothing fires for it. No deleted-commit hash is + carried. + The directory removal is idempotent on absence — a missing directory is not an error. @@ -532,13 +546,25 @@ def _delete_topics(targets: list[DeleteTarget], year: str | None) -> str: # suppressed so the original remote reason surfaces (the # ``publish_topic`` precedent). A remote-only target has # nothing to restore; targets removed before this one stay - # removed. + # removed. Nothing fires for the failing target — the raise + # precedes the emission below. if target.branch is not None: with contextlib.suppress(subprocess.CalledProcessError, OSError): create_branch_at_commit(target.branch, commit) raise - if target.has_dir: - remove_topic_dir(target.topic, resolved_year) + directory_removed = remove_topic_dir(target.topic, resolved_year) if target.has_dir else False + + # The deletion notification fires after the target's full removal — + # the identity carries no branch fact (the removal composition + # carries the branch names instead) and no deleted-commit hash; the + # facts come from the operation's own data, no git reads. + identity = TopicIdentity(slug=target.topic, year=resolved_year, branch=None) + TopicHooks().emit_deleted( + identity, + local_branch=target.branch, + origin_twin=target.remote, + directory_removed=directory_removed, + ) slugs = ", ".join(target.topic for target in targets) return f"Deleted {len(targets)} topic(s) of {resolved_year}: {slugs}" diff --git a/tests/topics/test_deletion.py b/tests/topics/test_deletion.py index f15f9e0c..2653d968 100644 --- a/tests/topics/test_deletion.py +++ b/tests/topics/test_deletion.py @@ -11,7 +11,10 @@ the ref-tree reading, the current branch, and the removal primitives are patched at ``goga.topics.deletion``. The disk tree is real on ``tmp_path`` via ``monkeypatch.chdir`` — ``collect_history_tree`` and (where the -scenario says so) ``remove_topic_dir`` run against it. +scenario says so) ``remove_topic_dir`` run against it. The checkpoint +scenario subscribes a recording tool package through the local conftest +fixtures, so the deletion notification runs behind the real registry and +delivery of the nested hooks zone. """ from __future__ import annotations @@ -33,6 +36,9 @@ from tests.conftest import is_kw_only_dataclass +RecordedEntry = Callable[..., list[tuple[str, str, object]]] +"""The recording-hooks factory of the local conftest.""" + # --- Shared scenario helpers --- @@ -805,3 +811,45 @@ def _boom(*args: object, **kwargs: object) -> bool: assert "cannot complete the deletion" in raised.value.message assert "disk full" in raised.value.message + + +# --- Logic tests: the lifecycle checkpoints of the deletion --- + + +class TestDeleteTopicsCheckpoints: + def test_delete_topics_emits_per_target_after_full_removal( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + ) -> None: + """Each target fires its deletion notification after its full + removal, in target order, with the removal composition — the + branch-less identity, the removed local branch and origin twin, + and the directory fact; the all-absent directory-less target + reports ``directory_removed`` False and still fires.""" + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "one") + targets = [ + DeleteTarget(topic="one", branch="one", remote="one", has_dir=True), + DeleteTarget(topic="two", branch=None, remote=None, has_dir=False), + ] + _wire_removal(monkeypatch, dir_side_effect=_remove_topic_dir) + records = recording_hooks("topic_deleted") + + line = delete_topics(targets, year="2026") + + assert line == "Deleted 2 topic(s) of 2026: one, two" + assert [entry[1] for entry in records] == ["topic_deleted", "topic_deleted"] + first, second = [entry[2] for entry in records] + assert first.local_branch == "one" # type: ignore[attr-defined] + assert first.origin_twin == "one" # type: ignore[attr-defined] + assert first.directory_removed is True # type: ignore[attr-defined] + assert first.identity.branch is None # type: ignore[attr-defined] + assert first.identity.home_path == ".goga/history/2026/one" # type: ignore[attr-defined] + assert second.local_branch is None # type: ignore[attr-defined] + assert second.origin_twin is None # type: ignore[attr-defined] + assert second.directory_removed is False # type: ignore[attr-defined] + assert second.identity.branch is None # type: ignore[attr-defined] + assert second.identity.home_path == ".goga/history/2026/two" # type: ignore[attr-defined] + assert not (tmp_path / ".goga" / "history" / "2026" / "one").exists() From 549b71252b71682495fe32a77882feb691657f43 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 17 Sep 2026 00:34:12 +0000 Subject: [PATCH 053/205] feat: document the seven topics hook actions --- .goga/history/2026/add-topics-hooks/plan.md | 8 +-- docs/features/hooks/hooks.md | 4 +- docs/features/hooks/index.md | 2 +- docs/features/tools/hooks.md | 2 +- docs/features/topics/hooks.md | 71 ++++++++++++++++++++- 5 files changed, 77 insertions(+), 10 deletions(-) diff --git a/.goga/history/2026/add-topics-hooks/plan.md b/.goga/history/2026/add-topics-hooks/plan.md index 96fddee2..8ad2044b 100644 --- a/.goga/history/2026/add-topics-hooks/plan.md +++ b/.goga/history/2026/add-topics-hooks/plan.md @@ -1796,10 +1796,10 @@ domain joins them. **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] Replace the stub `docs/features/topics/hooks.md` with the seven-action reference: the checkpoint moments (amend before fixation, emit after the moment), the context surfaces (the five notification contexts and the two amendment views with their fields), and the amendment contract (whole replacement, empty/whitespace rejection, soft failure, the advisory-amendment note of the ensure fast path) -- [ ] Add the topics domain (the seven actions with their error class) to the declared-actions lists in `docs/features/hooks/index.md`, `docs/features/hooks/hooks.md`, and `docs/features/tools/hooks.md` -- [ ] Verify: `mkdocs build` stays green (or the project's docs validation command), and every documented action name matches `declared_actions()` exactly -- [ ] No `mkdocs.yml` change — the page exists in the nav +- [x] Replace the stub `docs/features/topics/hooks.md` with the seven-action reference: the checkpoint moments (amend before fixation, emit after the moment), the context surfaces (the five notification contexts and the two amendment views with their fields), and the amendment contract (whole replacement, empty/whitespace rejection, soft failure, the advisory-amendment note of the ensure fast path) +- [x] Add the topics domain (the seven actions with their error class) to the declared-actions lists in `docs/features/hooks/index.md`, `docs/features/hooks/hooks.md`, and `docs/features/tools/hooks.md` +- [x] Verify: `mkdocs build` stays green (or the project's docs validation command), and every documented action name matches `declared_actions()` exactly +- [x] No `mkdocs.yml` change — the page exists in the nav ### Task 14: Cross-cell integration validation (integration tests) diff --git a/docs/features/hooks/hooks.md b/docs/features/hooks/hooks.md index 3676de7f..060cf0ee 100644 --- a/docs/features/hooks/hooks.md +++ b/docs/features/hooks/hooks.md @@ -16,7 +16,7 @@ def register_published(context): `hooks.subscribe(domain, action, name, hook)` registers one hook: -- `domain` + `action` — the action address: the semantic owner domain and the action name within it (`"statuses"` / `"register_statuses"` is the topic-status action — see [History — Hooks](../history/hooks.md); `"onboarding"` / `"declare_session"` and `"onboarding"` / `"amend_config"` are the onboarding-session actions a tool is invited into via `goga init -t ` — see [Init — Hooks](../init/hooks.md)). +- `domain` + `action` — the action address: the semantic owner domain and the action name within it (`"statuses"` / `"register_statuses"` is the topic-status action — see [History — Hooks](../history/hooks.md); `"onboarding"` / `"declare_session"` and `"onboarding"` / `"amend_config"` are the onboarding-session actions a tool is invited into via `goga init -t ` — see [Init — Hooks](../init/hooks.md); the seven `"topics"` addresses — `amend_creation`, `amend_todo_entry`, `topic_created`, `topic_published`, `topic_switched`, `topic_todo_entered`, `topic_deleted`, all soft — are the topic-lifecycle checkpoints: two amendments before the content is fixed and five notifications after their moments, see [Topics — Hooks](../topics/hooks.md)). - `name` — the hook name, unique per tool per address; registrations appear in the [`goga hooks`](cli.md) tree under their tool line. - `hook` — the callable executed when the action fires. @@ -33,7 +33,7 @@ The declaration order does not matter; names you did not declare receive nothing ## Error classes and diagnostics -Each action in the catalog fixes how a failing hook is treated. The topic-status and the onboarding actions are **soft**: a failing hook is skipped with a stderr warning naming the tool, the action, and the reason, and the command continues. A **hard** action stops the command at the first failing hook with a clean error — the class is chosen by the owner domain when it declares the action. +Each action in the catalog fixes how a failing hook is treated. The topic-status, the onboarding, and the topics actions are **soft**: a failing hook is skipped with a stderr warning naming the tool, the action, and the reason, and the command continues. A **hard** action stops the command at the first failing hook with a clean error — the class is chosen by the owner domain when it declares the action. At registration: a wrong address, an empty name, or a repeated name on the same address is refused with a stderr warning naming the tool, the action, and the reason — the registration is skipped, the rest apply. A crashing callback is a warning; the registrations made before the crash survive. A broken package import is the only fatal case: a clean error naming the package. diff --git a/docs/features/hooks/index.md b/docs/features/hooks/index.md index be480bf6..ec9a284d 100644 --- a/docs/features/hooks/index.md +++ b/docs/features/hooks/index.md @@ -8,7 +8,7 @@ The hooks domain is the mechanism behind every domain extension: a domain declar - **Tool packages extend domains with no goga code changes** — a package registers its hooks at run time; registration is never cached, so package edits apply from the next run without reinstall. - **Inspection** — `goga hooks` assembles the registry once and prints it as a tree: tool, domain, action — the fact of registration, including every refused registration with its reason. -The declared actions today: the status-scale registration of the [History](../history/hooks.md) domain and the two onboarding actions of the [Init](../init/hooks.md) domain (`onboarding/declare_session`, `onboarding/amend_config`, both soft — a tool reaches them via `goga init -t `). The authoring side — how a tool package writes its `register_hooks` callback — is the [registration contract](hooks.md). +The declared actions today: the status-scale registration of the [History](../history/hooks.md) domain, the two onboarding actions of the [Init](../init/hooks.md) domain (`onboarding/declare_session`, `onboarding/amend_config`, both soft — a tool reaches them via `goga init -t `), and the seven lifecycle actions of the [Topics](../topics/hooks.md) domain (`topics/amend_creation`, `topics/amend_todo_entry`, `topics/topic_created`, `topics/topic_published`, `topics/topic_switched`, `topics/topic_todo_entered`, `topics/topic_deleted`, all soft — two amendments before the content is fixed, five notifications after their moments). The authoring side — how a tool package writes its `register_hooks` callback — is the [registration contract](hooks.md). ## Model diff --git a/docs/features/tools/hooks.md b/docs/features/tools/hooks.md index c65a9b39..ba697343 100644 --- a/docs/features/tools/hooks.md +++ b/docs/features/tools/hooks.md @@ -15,4 +15,4 @@ def register_hooks(hooks): hooks.subscribe("statuses", "register_statuses", "published", register_published) ``` -The full registration contract — the hook signature (`context` / `self`), the error classes, the diagnostics — is the [Hooks domain](../hooks/hooks.md); the declared actions are listed per domain (today: [History — Hooks](../history/hooks.md)). The `main` entry point and its optional AST injection are covered in [CLI](cli.md#optional-injections). +The full registration contract — the hook signature (`context` / `self`), the error classes, the diagnostics — is the [Hooks domain](../hooks/hooks.md); the declared actions are listed per domain (today: [History — Hooks](../history/hooks.md), and the seven lifecycle actions of [Topics — Hooks](../topics/hooks.md) — `topics/amend_creation`, `topics/amend_todo_entry`, `topics/topic_created`, `topics/topic_published`, `topics/topic_switched`, `topics/topic_todo_entered`, `topics/topic_deleted`, all soft). The `main` entry point and its optional AST injection are covered in [CLI](cli.md#optional-injections). diff --git a/docs/features/topics/hooks.md b/docs/features/topics/hooks.md index ab068060..7f49a58e 100644 --- a/docs/features/topics/hooks.md +++ b/docs/features/topics/hooks.md @@ -1,5 +1,72 @@ # Topics — Hooks -The topics domain exposes **no hook actions** for tool packages today. +The topics domain exposes **seven hook actions** for tool packages — the lifecycle checkpoints of the topic flows. Five are **notifications**: read-only facts of a completed moment, delivered after the moment fully succeeds. Two are **amendments**: per-hook views over the content a flow is about to fix, delivered before the fixation. All seven are soft — a failing hook is skipped with a warning and the command continues. -Topic identity, addressing, and statuses belong to the [History](../history/index.md) domain — the one hook action of that surface (the status-scale registration) is declared there: see [History — Hooks](../history/hooks.md). The platform mechanism behind every action is covered in [Hooks](../hooks/index.md). +## The actions + +| Address | Error class | Fires | +|---|---|---| +| `topics / amend_creation` | soft | Before the first mutation of the chosen creation path — every decision of `goga topics create` made (the publication ask included), and the fast creation of `goga pipeline -t ` (identity-only, advisory — see below). | +| `topics / amend_todo_entry` | soft | After a todo entry saves in the editor and before `todo.md` is written — the `--todo` entries of `goga topics switch` and `goga pipeline -t --todo`. | +| `topics / topic_created` | soft | After a creation completes — the quarantined plant, the checked-out path, the publication, and the pipeline fast creation. | +| `topics / topic_published` | soft | After a successful publication push (`--publish`, or the ask answered yes). | +| `topics / topic_switched` | soft | After every completed switch — the idempotent already-on-branch outcome included. | +| `topics / topic_todo_entered` | soft | After `todo.md` is written with the final text. | +| `topics / topic_deleted` | soft | After each target's full removal — local branch, origin twin, and directory (`goga topics delete`). | + +A tool subscribes inside its `register_hooks` callback: + +```python +# inside the goga_tool_ package +def register_hooks(hooks): + hooks.subscribe("topics", "topic_created", "record", record_created) + hooks.subscribe("topics", "amend_creation", "stamper", stamp_message) + + +def record_created(context): + ... # read-only facts of the completed creation + + +def stamp_message(context): + context.amend(commit_message=f"[{context.identity.slug}] {context.commit_message}", todo=context.todo) +``` + +A failing moment fires nothing: a creation that fails its preflight, a publication whose push rolls back, a switch refused before its first mutation — the checkpoints of the moment never arrive. + +## The identity + +Every context carries one `TopicIdentity`: + +- `slug` — the normalized topic slug, or None in the **branch-only form**: a switch onto a branch hosting no topic. +- `home_path` — `.goga/history//` as a posix string (None when the slug is None). Composed from the identity inputs — a checkpoint never reads the repository. +- `branch` — the branch name as entered by the operation; None only in the deletion context, whose branch names travel in the removal composition instead. + +## The notification contexts + +Each notification delivers **the same context instance** to every subscribed tool — no per-tool copies, no stale facts. A hook observes the outcome and cannot alter it. + +- `topic_created` — `TopicCreated`: `identity`, `checked_out` (the path checked out the fresh branch), `published` (the path published the work), `todo` (the final text, or None when none resolved), `commit_message` and `commit_hash` (present exactly when the path builds a commit — the quarantined plant and the publication; None on the checked-out and fast-creation paths). +- `topic_published` — `TopicPublished`: `identity`, `commit_message`, `commit_hash`, `todo` — the facts of one successful push, identical to the paired `topic_created`. +- `topic_switched` — `TopicSwitched`: `identity`, `outcome` — exactly one of `local-checkout`, `created-from-remote`, `already-on-branch`. The identity degrades to the branch-only form when the switched branch hosts no topic. +- `topic_todo_entered` — `TopicTodoEntered`: `identity`, `text` — the final written text, after every amendment. No prior text is carried; a tool keeps its own state in its own `self` context. +- `topic_deleted` — `TopicDeleted`: `identity` (no branch fact), `local_branch` and `origin_twin` (each None when the target had none), `directory_removed`. No deleted-commit hash is carried. + +## The amendment views + +Each amendment checkpoint delivers a **fresh view per hook** over the live shared draft. The read-through attributes — `commit_message` / `todo` on the creation view, `text` on the entry view — read the live holder, so a later hook sees the committed amendments of the earlier hooks. + +- `amend_creation` — `CreationAmendment`: `identity`, `checked_out`, `published`, the reads `commit_message` / `todo`, and `amend(commit_message, todo)`. +- `amend_todo_entry` — `TodoEntryAmendment`: `identity`, the read `text`, and `amend(text)`. + +The amendment contract: + +- **Whole replacement** — `amend` buffers the complete new content; a field left out comes back as None, it is not kept as the previous value. The last committed buffer wins. +- **Per-hook commit** — the walk delivers the subscriptions in enumeration order; a hook's buffer commits only when the hook returns without raising, and two hooks of one tool never share a buffer or a failure. +- **Empty rejection** — a structurally present field that is empty or whitespace-only rejects the whole buffer (on the todo entry, a None text is rejected too). +- **Soft failure** — a raised hook, its discarded buffer, and a rejected buffer each warn on stderr naming the hook, the tool, the action, and the reason; the walk continues and the operation never breaks. +- **Content only** — an amendment transforms content; it cannot cancel, redirect, or defer the operation. +- **Identity-only form** — a creation path that builds no commit and resolved no todo still delivers `amend_creation` with both fields None; the tool decides whether to act. + +> **Advisory on the pipeline fast creation.** On the fast creation of `goga pipeline -t `, the creation amendment **observes only**: an amended todo does not land there — the todo resolves later through the entry's own `amend_todo_entry`, which owns the written text — and `commit_message` stays None (the path builds no commit). + +The platform mechanism behind the action (enumeration, the registry, delivery, inspection with `goga hooks`) is the [Hooks](../hooks/index.md) domain; the registration contract for tool authors is covered in [Hooks — The registration contract](../hooks/hooks.md); the flows that fire the checkpoints are covered in [CLI](cli.md). From 9a93d43819ca26f22807b69ffe16e833a0c9d9f4 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 17 Sep 2026 00:41:33 +0000 Subject: [PATCH 054/205] feat: verify the cross-cell integration of the topics hooks --- .goga/history/2026/add-topics-hooks/plan.md | 36 ++++++++++----------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/.goga/history/2026/add-topics-hooks/plan.md b/.goga/history/2026/add-topics-hooks/plan.md index 8ad2044b..a4ccfd05 100644 --- a/.goga/history/2026/add-topics-hooks/plan.md +++ b/.goga/history/2026/add-topics-hooks/plan.md @@ -1814,11 +1814,11 @@ guarantees and the goga tooling. **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] Run the full suite: `pytest tests/ -x` — green (all 26 named scenarios plus the existing tests; the platform, the zone, and the domain tests run together — proving no registry/subscription leak and no import cycle) -- [ ] Facade check: `python -c "from goga.topics.hooks import TopicHooks, TopicIdentity, CreationDraft, TodoEntryDraft, TopicCreated, TopicPublished, TopicSwitched, TopicTodoEntered, TopicDeleted, CreationAmendment, TodoEntryAmendment"` — passes -- [ ] Catalog surface: `goga hooks` lists the seven topics actions (all soft) with no command change -- [ ] Manifest validation: `goga lint` — 0 errors (stays at the design-time baseline); `goga schema goga/topics` resolves the `goga/topics/hooks` subcell and shows `goga/topics` importing it -- [ ] Behavior preservation sweep: the result lines, error messages, and mutation order of the six routines are unchanged — re-run the pre-existing domain tests untouched by the checkpoint additions and confirm no assertion was weakened to accommodate the wiring +- [x] Run the full suite: `pytest tests/ -x` — green (all 26 named scenarios plus the existing tests; the platform, the zone, and the domain tests run together — proving no registry/subscription leak and no import cycle) — 5595 passed +- [x] Facade check: `python -c "from goga.topics.hooks import TopicHooks, TopicIdentity, CreationDraft, TodoEntryDraft, TopicCreated, TopicPublished, TopicSwitched, TopicTodoEntered, TopicDeleted, CreationAmendment, TodoEntryAmendment"` — passes +- [x] Catalog surface: `goga hooks` lists the seven topics actions (all soft) with no command change (verified with a throwaway tool package: all seven listed under the topics domain, an unknown address rejected, exit 0; `goga/commands/**` untouched by the plan. Note: the `/opt/goga` installed distribution is a stale pre-plan snapshot — run the CLI as `python -m goga …` from the repo, as all prior tasks did) +- [x] Manifest validation: `goga lint` — 0 errors (stays at the design-time baseline: 77 cells); `goga schema goga/topics` resolves the `goga/topics/hooks` subcell and shows `goga/topics` importing it (dependency entry with the zone types and the `checkpoints` usage; the subcell query resolves all eleven types) +- [x] Behavior preservation sweep: the result lines, error messages, and mutation order of the six routines are unchanged — re-run the pre-existing domain tests untouched by the checkpoint additions and confirm no assertion was weakened to accommodate the wiring (150 untouched pre-existing domain tests re-ran green; the 20 changed ones changed only in the three planned categories — the `branch` signature contract, the applied-message argument of the documented empty-template exception, the `branch=` mock keyword — each replacement strictly stronger; no assertion line lost otherwise; error-path test bodies byte-identical) --- @@ -1838,16 +1838,16 @@ guarantees and the goga tooling. ## Completion Criteria -- [ ] Every contract entity is implemented in the correct `location` (`identity.py`, `contexts.py`, `amendments.py`, `events.py`, `__init__.py` of the zone; the six domain routines in their existing files) -- [ ] Every contract entity is accessible from the facade (the eleven zone names in `__all__`; the six routines from `goga.topics`) -- [ ] Properties and methods match the declared API (kw-only constructors; frozen identity/contexts; mutable holders/views with the private `_draft`/`_buffered` fields) -- [ ] Descriptions are reflected in behavior (the walks' commit/rejection rules, the emissions' same-instance delivery, the domain checkpoint moments per the traces) -- [ ] Contract dependencies are met (the five platform names and `resolve_topic_dir` import through the declared facades; no new import cycle) -- [ ] Re-exports are accessible from the facade (no DSL re-export blocks exist; the language-level facade obligations hold) -- [ ] Every coding task followed the TDD workflow (contract tests → code → verification → logic tests → debugging → re-verification → lint) -- [ ] Contract tests and logic tests cover facade, API, and behavior within each coding task (all 26 named scenarios plus per-task contract tests) -- [ ] Integration tests exist where cross-entity scenarios require them (Task 14; the cross-flow scenarios landed in Tasks 6, 10, 11) -- [ ] No package boundary was expanded (no new cells, no changes to `goga/commands/**` or the platform cells `goga/hooks/{dispatch,registry,tools}`) -- [ ] `CODEMANIFEST` files were not modified (contract is read-only) -- [ ] All validation commands pass -- [ ] Every Usages entry is mentioned in at least one task (`convention` — all tasks; `declaring-actions`, `per-tool-delivery`, `registering-hooks` — Tasks 1, 5, 6; `topic-paths` — Tasks 3, 7, 8, 9, 11; `checkpoints` — Tasks 7–13; `click` — Tasks 8, 10, 11; `editor-entry` — Tasks 7, 8; `refs-and-switching` — Tasks 8, 10, 11; `publishing` — Tasks 8, 9; `deleting` — Task 12; `topic-statuses` — untouched by this plan, noted in Usages Context) +- [x] Every contract entity is implemented in the correct `location` (`identity.py`, `contexts.py`, `amendments.py`, `events.py`, `__init__.py` of the zone; the six domain routines in their existing files) +- [x] Every contract entity is accessible from the facade (the eleven zone names in `__all__`; the six routines from `goga.topics`) +- [x] Properties and methods match the declared API (kw-only constructors; frozen identity/contexts; mutable holders/views with the private `_draft`/`_buffered` fields) +- [x] Descriptions are reflected in behavior (the walks' commit/rejection rules, the emissions' same-instance delivery, the domain checkpoint moments per the traces) +- [x] Contract dependencies are met (the five platform names and `resolve_topic_dir` import through the declared facades; no new import cycle) +- [x] Re-exports are accessible from the facade (no DSL re-export blocks exist; the language-level facade obligations hold) +- [x] Every coding task followed the TDD workflow (contract tests → code → verification → logic tests → debugging → re-verification → lint) +- [x] Contract tests and logic tests cover facade, API, and behavior within each coding task (all 26 named scenarios plus per-task contract tests) +- [x] Integration tests exist where cross-entity scenarios require them (Task 14; the cross-flow scenarios landed in Tasks 6, 10, 11) +- [x] No package boundary was expanded (no new cells, no changes to `goga/commands/**` or the platform cells `goga/hooks/{dispatch,registry,tools}`) +- [x] `CODEMANIFEST` files were not modified (contract is read-only) +- [x] All validation commands pass +- [x] Every Usages entry is mentioned in at least one task (`convention` — all tasks; `declaring-actions`, `per-tool-delivery`, `registering-hooks` — Tasks 1, 5, 6; `topic-paths` — Tasks 3, 7, 8, 9, 11; `checkpoints` — Tasks 7–13; `click` — Tasks 8, 10, 11; `editor-entry` — Tasks 7, 8; `refs-and-switching` — Tasks 8, 10, 11; `publishing` — Tasks 8, 9; `deleting` — Task 12; `topic-statuses` — untouched by this plan, noted in Usages Context) From f4e17fa9bfd3f29ccc4de21c8a1fa514bf14e0c2 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 17 Sep 2026 01:09:01 +0000 Subject: [PATCH 055/205] fix: address code review findings --- README.md | 2 +- docs/features/init/api.md | 6 +- docs/features/topics/api.md | 9 +- docs/features/topics/cli.md | 2 +- goga/topics/creation.py | 21 ++- goga/topics/deletion.py | 11 +- goga/topics/hooks/amendments.py | 5 + goga/topics/hooks/events.py | 12 +- goga/topics/publishing.py | 9 +- tests/topics/hooks/test_amendments.py | 10 ++ tests/topics/hooks/test_events.py | 70 +++++++++- tests/topics/test_creation.py | 192 ++++++++++++++++++++++++++ tests/topics/test_deletion.py | 50 +++++++ tests/topics/test_ensuring.py | 33 +++++ tests/topics/test_publishing.py | 19 +++ 15 files changed, 429 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 0aa74c76..d52a1635 100644 --- a/README.md +++ b/README.md @@ -443,7 +443,7 @@ A valid tool **must**: A tool **may** additionally expose an `install(user: str | None = None)` callable in its facade package: `goga install` calls it after a successful pip, passing the initiating user (`SUDO_USER` when goga itself runs under sudo, else the current OS user) only when the parameter is declared keyword-capable. A missing or non-callable `install` is skipped quietly. -A tool **may** also expose a `register_hooks(hooks)` callable to extend goga domains with its own hooks — today, the topic status scale and the onboarding session (`declare_session`/`amend_config`, reached via `goga init -t `). goga calls it when a command first reaches a hook checkpoint of the run, or when you inspect the registry with `goga hooks`; commands that use no hooks never call it: +A tool **may** also expose a `register_hooks(hooks)` callable to extend goga domains with its own hooks — today, the topic status scale, the onboarding session (`declare_session`/`amend_config`, reached via `goga init -t `), and the seven topic-lifecycle checkpoints of `topics` (two content amendments and five notifications; see [Topics — Hooks](https://qarium.github.io/goga/features/topics/hooks/)). goga calls it when a command first reaches a hook checkpoint of the run, or when you inspect the registry with `goga hooks`; commands that use no hooks never call it: ```python def register_hooks(hooks): diff --git a/docs/features/init/api.md b/docs/features/init/api.md index 01475b29..e0ada1e4 100644 --- a/docs/features/init/api.md +++ b/docs/features/init/api.md @@ -17,8 +17,9 @@ The facade re-exports the full contract surface: ```python from goga.onboarding import ( CreatedFile, FileGenerator, InitLogic, Question, QuestionGroup, - Questionnaire, SessionAnswers, SessionPlan, ToolParticipation, - apply_skips, assemble_session_plan, core_questions, + Questionnaire, SessionAnswers, SessionPlan, ToolContribution, + ToolDeclaration, ToolParticipation, apply_skips, assemble_session_plan, + core_questions, ) ``` @@ -26,6 +27,7 @@ from goga.onboarding import ( - `Questionnaire` — the survey engine: asks the plan's core sections and tool blocks, records every value at its plan path. - `FileGenerator` — the artifact generator: `.goga/config.yml`, the Dockerfile, the conventions download, and the tool configs under `.goga/tools//`. - `ToolParticipation` — the mediator delivering the two onboarding hook moments to the invited tools. +- `ToolDeclaration` / `ToolContribution` — the two hook-context surfaces a subscribed tool receives at those moments (see [Hooks](hooks.md)). - `Question` / `QuestionGroup` — the declarative question records; `SessionAnswers` — the answer accumulator; `SessionPlan` / `assemble_session_plan` / `apply_skips` — the plan layer; `core_questions` — the core tree builder; `CreatedFile` — one report entry with tool attribution. ## Example diff --git a/docs/features/topics/api.md b/docs/features/topics/api.md index 86ac900a..2cf69c28 100644 --- a/docs/features/topics/api.md +++ b/docs/features/topics/api.md @@ -1,6 +1,6 @@ # Topics — API -The facade of the domain package **`goga.topics`** — the work-tracker view of the history tree. Git access lives in the nested leaf cell `goga.topics.git`, the interactive todo entry in `goga.topics.editor`; both surface through this facade's routines. Identity, addressing, and statuses come from `goga.history` (see [History — API](../history/api.md)). +The facade of the domain package **`goga.topics`** — the work-tracker view of the history tree. Git access lives in the nested leaf cell `goga.topics.git`, the interactive todo entry in `goga.topics.editor`, the lifecycle hook checkpoints in `goga.topics.hooks`; all surface through this facade's routines. Identity, addressing, and statuses come from `goga.history` (see [History — API](../history/api.md)). The signatures below are the CODEMANIFEST contract of the cell. @@ -47,12 +47,15 @@ create_topic(branch_name: str, base_ref: str, todo: str | None = None, Create fresh work — a branch named verbatim at `base_ref` with the topic of the year. The default path plants one quarantined commit carrying the topic's `todo.md` (git plumbing, the working copy untouched) — the todo is required there. `switch=True` checks the branch out instead (the topic directory lands uncommitted, the todo optional). `publish=True` builds the same one-commit branch and pushes it to `origin` without switching; `commit_message` is the publication-only commit template. Returns the result line. ```python -enter_topic_todo(topic: str, year: str | None = None) -> bool +enter_topic_todo(topic: str, year: str | None = None, + branch: str | None = None) -> bool publish_topic(branch_name: str, todo: str, base_ref: str, commit_message: str | None = None, year: str | None = None) -> str ``` -`enter_topic_todo` opens the external editor on the topic's `todo.md` (`True` — saved, `False` — cancelled). `publish_topic` is the fast creation-and-publication cycle. +`enter_topic_todo` opens the external editor on the topic's `todo.md` (`True` — saved, `False` — cancelled); `branch` is the branch fact of the delivered event identity (`None` leaves the identity without one). `publish_topic` is the fast creation-and-publication cycle. + +The creation, publication, switch, ensure, todo-entry, and deletion routines all deliver the topics lifecycle checkpoints — the saved todo text and the commit message pass through the amendment hooks before they are fixed into the artifacts, and each completed moment emits its notification (see [Topics — Hooks](hooks.md)). ## Occupancy oracles diff --git a/docs/features/topics/cli.md b/docs/features/topics/cli.md index d23eeb34..181d8cc4 100644 --- a/docs/features/topics/cli.md +++ b/docs/features/topics/cli.md @@ -166,7 +166,7 @@ Every IDENTIFIER resolves first — a branch name, a topic slug, or their prefix | Code | Meaning | |------|---------| | `0` | Success — the board printed, the work created or published, the switch performed, the deletion done (including the idempotent switch and a declined deletion) | -| `1` | A clean domain error: an unresolvable or ambiguous identifier, no base for a creation, an occupied name, a missing todo under `--publish` or the no-switch creation, `--switch` together with `--publish`, a dirty working tree, merged work or the current branch hosting a deletion target, a failed publication or remote deletion, a git infrastructure failure, or a broken `goga_tool_*` package failing to import during status-scale assembly | +| `1` | A clean domain error: an unresolvable or ambiguous identifier, no base for a creation, an occupied name, a missing todo under `--publish` or the no-switch creation, `--switch` together with `--publish`, a dirty working tree, merged work or the current branch hosting a deletion target, a failed publication or remote deletion, a git infrastructure failure, or a broken `goga_tool_*` package failing to import during status-scale or hooks-registry assembly | | `2` | A usage error (unknown option, missing argument) | ## Notes diff --git a/goga/topics/creation.py b/goga/topics/creation.py index e2521ec5..ed476dfc 100644 --- a/goga/topics/creation.py +++ b/goga/topics/creation.py @@ -289,8 +289,9 @@ def create_topic( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared signat click.ClickException: an empty slug, the current branch hosting the slug, an occupancy conflict, an unresolvable base, no todo without a terminal, ``publish`` or the no-switch creation - without a todo, or a git infrastructure failure (its stderr - when git reports one, or a missing git binary). + without a todo, a git infrastructure failure (its stderr when + git reports one, or a missing git binary), or the fatal + ``ImportError`` of the hooks-registry assembly. click.Abort: Ctrl-C or EOF at the publication ask. """ try: @@ -300,6 +301,12 @@ def create_topic( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared signat raise click.ClickException(f"git failed: {detail}") from exc except FileNotFoundError as exc: raise click.ClickException(f"git is not available: {exc}") from exc + except ImportError as exc: + # The checkpoints of the creation build the run registry on first + # delivery — a broken ``goga_tool_*`` package is the platform's + # single fatal case and surfaces here as one clean error, the + # ``switch_topic`` and ``ensure_topic`` boundary. + raise click.ClickException(str(exc)) from exc except OSError as exc: # ``ensure_topic_dir`` propagates the mkdir failures — a stray file # named like the slug occupies no topic for the oracle, so the @@ -355,11 +362,17 @@ def enter_topic_todo(topic: str, year: str | None = None, branch: str | None = N Raises: click.ClickException: a failed editor session (the editor cell's - own clean error), or a filesystem failure of the read or the - write. + own clean error), a filesystem failure of the read or the + write, or the fatal ``ImportError`` of the hooks-registry + assembly. """ try: written = _enter_topic_todo(topic, year, branch) + except ImportError as exc: + # The entry's checkpoints build the run registry on first delivery + # — a broken ``goga_tool_*`` package is the platform's single fatal + # case and surfaces here as one clean error. + raise click.ClickException(str(exc)) from exc except OSError as exc: # The boundary covers the prefill read and the saved write alike. raise click.ClickException(f"cannot read or write the todo file: {exc}") from exc diff --git a/goga/topics/deletion.py b/goga/topics/deletion.py index 7c347019..a955d68f 100644 --- a/goga/topics/deletion.py +++ b/goga/topics/deletion.py @@ -506,8 +506,9 @@ def delete_topics(targets: list[DeleteTarget], year: str | None = None) -> str: Raises: click.ClickException: a git infrastructure failure (its stderr - when git reports one, or a missing git binary), or an OS - failure of the removal. + when git reports one, or a missing git binary), an OS failure + of the removal, or the fatal ``ImportError`` of the + hooks-registry assembly. """ try: return _delete_topics(targets, year) @@ -516,6 +517,12 @@ def delete_topics(targets: list[DeleteTarget], year: str | None = None) -> str: raise click.ClickException(f"git failed: {detail}") from exc except FileNotFoundError as exc: raise click.ClickException(f"git is not available: {exc}") from exc + except ImportError as exc: + # The per-target emissions build the run registry on first delivery + # — a broken ``goga_tool_*`` package is the platform's single fatal + # case and surfaces here as one clean error, the ``switch_topic`` + # and ``ensure_topic`` boundary. + raise click.ClickException(str(exc)) from exc except OSError as exc: raise click.ClickException(f"cannot complete the deletion: {exc}") from exc diff --git a/goga/topics/hooks/amendments.py b/goga/topics/hooks/amendments.py index 6304b478..7f7ca79e 100644 --- a/goga/topics/hooks/amendments.py +++ b/goga/topics/hooks/amendments.py @@ -153,6 +153,10 @@ class TodoEntryAmendment: identity: TopicIdentity _draft: TodoEntryDraft _buffered: str | None = field(default=None, init=False, repr=False) + _amended: bool = field(default=False, init=False, repr=False) + """Whether ``amend`` was called — separates a buffered None from a hook + that never amended, so the walk rejects the out-of-contract None buffer + with its warning instead of reading it as no amendment.""" @property def text(self) -> str: @@ -173,3 +177,4 @@ def amend(self, text: str) -> None: amendment transforms content only. """ self._buffered = text + self._amended = True diff --git a/goga/topics/hooks/events.py b/goga/topics/hooks/events.py index 398e722b..36f2395d 100644 --- a/goga/topics/hooks/events.py +++ b/goga/topics/hooks/events.py @@ -322,8 +322,13 @@ def amend_todo_entry(self, identity: TopicIdentity, text: str) -> TodoEntryDraft ) continue # the buffer of the failed hook is discarded - buffered = view._buffered - if buffered is not None and _rejected_text(buffered): + if not view._amended: + continue + + # The buffered None is the out-of-contract rejection case of + # this walk — the flag separates it from a hook that never + # amended, so the predicate's None arm stays reachable. + if _rejected_text(view._buffered): logger.warning( "hook %s of tool %s failed on %s.%s: %s", subscription.name, @@ -334,8 +339,7 @@ def amend_todo_entry(self, identity: TopicIdentity, text: str) -> TodoEntryDraft ) continue # the whole buffer is rejected - if buffered is not None: - holder._commit(buffered) + holder._commit(view._buffered) return holder diff --git a/goga/topics/publishing.py b/goga/topics/publishing.py index b0841919..8d03c0d3 100644 --- a/goga/topics/publishing.py +++ b/goga/topics/publishing.py @@ -130,7 +130,8 @@ def publish_topic( click.ClickException: an empty slug, an empty todo, the current branch already hosting the slug, an occupancy conflict, a missing origin remote, a git infrastructure failure (its - stderr when git reports one, or a missing git binary). + stderr when git reports one, or a missing git binary), or the + fatal ``ImportError`` of the hooks-registry assembly. """ try: return _publish_topic(branch_name, todo, base_ref, commit_message, year) @@ -139,6 +140,12 @@ def publish_topic( raise click.ClickException(f"git failed: {detail}") from exc except FileNotFoundError as exc: raise click.ClickException(f"git is not available: {exc}") from exc + except ImportError as exc: + # The publication checkpoints build the run registry on first + # delivery — a broken ``goga_tool_*`` package is the platform's + # single fatal case and surfaces here as one clean error, the + # ``switch_topic`` and ``ensure_topic`` boundary. + raise click.ClickException(str(exc)) from exc except OSError as exc: # An OS-level failure can strike at any phase — the quarantined # chain creating or removing its temporary index under ``.git``, or a diff --git a/tests/topics/hooks/test_amendments.py b/tests/topics/hooks/test_amendments.py index 0313f190..3240c956 100644 --- a/tests/topics/hooks/test_amendments.py +++ b/tests/topics/hooks/test_amendments.py @@ -140,6 +140,7 @@ def test_views_carry_the_wiring_fields_with_read_through_properties(self) -> Non "identity", "_draft", "_buffered", + "_amended", ] creation_hints = typing.get_type_hints(CreationAmendment) @@ -153,6 +154,7 @@ def test_views_carry_the_wiring_fields_with_read_through_properties(self) -> Non assert entry_hints["identity"] == TopicIdentity assert entry_hints["_draft"] is TodoEntryDraft assert entry_hints["_buffered"] == str | None + assert entry_hints["_amended"] is bool for cls, names in ( (CreationAmendment, ("commit_message", "todo")), @@ -177,6 +179,14 @@ def test_the_buffered_field_is_uninitialized_hidden_and_empty(self) -> None: assert buffered.repr is False assert buffered.default is None + # The amended marker of the entry view — the flag separating a + # buffered None from a hook that never amended. + amended = {field.name: field for field in dataclasses.fields(TodoEntryAmendment)}["_amended"] + + assert amended.init is False + assert amended.repr is False + assert amended.default is False + view = _creation_view(CreationDraft(commit_message="m", todo="t")) assert view._buffered is None diff --git a/tests/topics/hooks/test_events.py b/tests/topics/hooks/test_events.py index 3be9137a..13c3254d 100644 --- a/tests/topics/hooks/test_events.py +++ b/tests/topics/hooks/test_events.py @@ -389,14 +389,16 @@ def test_amend_todo_entry_none_buffer_never_commits( self, pin_package_environment: PinEnvironment, install_tool_package: InstallToolPackage, + caplog: pytest.LogCaptureFixture, ) -> None: """A None buffer value is the rejection case, never a committed text. The contract types ``text`` as ``str``; the out-of-contract None buffer is treated as the rejection case of this walk — the saved - text survives. On the creation side ``amend(None, None)`` is the - lawful identity-only form; this is the single predicate that tells - the two walks apart. + text survives and the empty-amendment warning fires, exactly like + the blank buffer. On the creation side ``amend(None, None)`` is + the lawful identity-only form; this is the single predicate that + tells the two walks apart. """ def buffer_none(context: object) -> None: @@ -405,9 +407,69 @@ def buffer_none(context: object) -> None: pin_package_environment(TWO_TOOL_ENVIRONMENT) install_tool_package("goga_tool_one", register_hooks=_register(("amend_todo_entry", buffer_none))) - draft = TopicHooks().amend_todo_entry(IDENTITY, "saved text") + with caplog.at_level(logging.WARNING): + draft = TopicHooks().amend_todo_entry(IDENTITY, "saved text") assert draft.text == "saved text" + expected_warning = ( + "hook buffer_none of tool one failed on topics.amend_todo_entry: " + "the buffered amendment is empty or whitespace-only" + ) + + assert expected_warning in caplog.text + + def test_amend_todo_entry_discards_buffer_of_raising_hook( + self, + pin_package_environment: PinEnvironment, + install_tool_package: InstallToolPackage, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A failing hook never breaks the entry and never leaks its buffer.""" + pin_package_environment(TWO_TOOL_ENVIRONMENT) + + def boom(context: object) -> None: + context.amend("boom text") # type: ignore[attr-defined] + raise RuntimeError("kaputt") + + def tail(context: object) -> None: + context.amend("late text") # type: ignore[attr-defined] + + install_tool_package("goga_tool_one", register_hooks=_register(("amend_todo_entry", boom))) + install_tool_package("goga_tool_two", register_hooks=_register(("amend_todo_entry", tail))) + + with caplog.at_level(logging.WARNING): + draft = TopicHooks().amend_todo_entry(IDENTITY, "saved text") + + assert draft.text == "late text" # the buffer of boom is gone + assert any( + "hook boom of tool one failed on topics.amend_todo_entry: kaputt" in record.message + for record in caplog.records + ) + + def test_amend_todo_entry_hard_class_stops_the_walk_with_clean_error( + self, + pin_package_environment: PinEnvironment, + install_tool_package: InstallToolPackage, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A hard-class catalog record turns a hook failure into a clean error (D7).""" + from goga.hooks.catalog import Action + from goga.topics.hooks import events + + monkeypatch.setattr( + events, + "declared_actions", + lambda: [Action(domain="topics", name="amend_todo_entry", error_class="hard")], + ) + + def boom(context: object) -> None: + raise RuntimeError("stop") + + pin_package_environment(TWO_TOOL_ENVIRONMENT) + install_tool_package("goga_tool_one", register_hooks=_register(("amend_todo_entry", boom))) + + with pytest.raises(ValueError, match=r"hook boom of tool one failed on topics\.amend_todo_entry: stop"): + TopicHooks().amend_todo_entry(IDENTITY, "saved text") def test_amend_todo_entry_walks_per_hook_and_commits_the_last_buffer( self, diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index a8f91ef6..3064a55e 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -1072,6 +1072,161 @@ def amender(context: object) -> None: todo_file = tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" / "todo.md" assert not todo_file.exists() + def test_create_topic_no_switch_amended_values_land_in_plant_and_event( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + install_tool_package: InstallToolPackage, + ) -> None: + """The committed content and the commit message are the final + amended values — the plant lands the amended pair in git and the + creation notification reports the same pair, not the draft.""" + + def amender(context: object) -> None: + context.amend("amended message", "amended todo") # type: ignore[attr-defined] + + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, current="main", base_commit="c0ffee") + wired.plant.return_value = "deadbeef" + install_tool_package("goga_tool_two", register_hooks=_subscribe(("amend_creation", amender))) + records = recording_hooks(("amend_creation", "topic_created")) + + result = create_topic("Feature/Foo_Bar", "HEAD", todo="the todo", year="2026") + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + wired.plant.assert_called_once_with( + "Feature/Foo_Bar", "amended todo", "c0ffee", "feature-foo-bar", "2026", "amended message" + ) + assert [entry[1] for entry in records] == ["amend_creation", "topic_created"] + created = records[1][2] + assert created.todo == "amended todo" # type: ignore[attr-defined] + assert created.commit_message == "amended message" # type: ignore[attr-defined] + assert created.commit_hash == "deadbeef" # type: ignore[attr-defined] + + def test_create_topic_no_switch_nulled_amended_todo_is_clean_error_before_mutations( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + install_tool_package: InstallToolPackage, + ) -> None: + """D5 — a hook nulled the todo of a path that needs one: the guard + fires before any mutation, the creation emits nothing, and the + error is the same clean one as the resolved-todo guard.""" + + def nuller(context: object) -> None: + context.amend("message only", None) # type: ignore[attr-defined] + + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, current="main") + install_tool_package("goga_tool_two", register_hooks=_subscribe(("amend_creation", nuller))) + records = recording_hooks() + + with pytest.raises(click.ClickException) as raised: + create_topic("Feature/Foo_Bar", "HEAD", todo="the todo", year="2026") + + assert raised.value.message == ( + "the local creation needs a todo — the board reads the topic through todo.md; " + "pass --todo/-t or --switch/-s to create on the spot without one" + ) + wired.plant.assert_not_called() + wired.create_branch.assert_not_called() + assert [entry[1] for entry in records] == ["amend_creation"] + + def test_create_topic_no_switch_nulled_amended_message_falls_back_to_default( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + install_tool_package: InstallToolPackage, + ) -> None: + """D3 — a hook nulled only the message: the plant lands the + built-in default template and the notification reports the same + default, one value in the commit and the event alike.""" + + def nuller(context: object) -> None: + context.amend(None, "amended todo") # type: ignore[attr-defined] + + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, current="main") + wired.plant.return_value = "deadbeef" + install_tool_package("goga_tool_two", register_hooks=_subscribe(("amend_creation", nuller))) + records = recording_hooks(("amend_creation", "topic_created")) + + result = create_topic("Feature/Foo_Bar", "HEAD", todo="the todo", year="2026") + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + # The plant receives the nulled message and applies its built-in + # default — the event reports the same applied default. + wired.plant.assert_called_once_with( + "Feature/Foo_Bar", "amended todo", "c0ffee", "feature-foo-bar", "2026", None + ) + created = records[1][2] + assert created.todo == "amended todo" # type: ignore[attr-defined] + assert created.commit_message == "goga: create topic feature-foo-bar" # type: ignore[attr-defined] + assert created.commit_hash == "deadbeef" # type: ignore[attr-defined] + + def test_create_topic_publication_path_fires_its_pair_through_the_delegation( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + install_tool_package: InstallToolPackage, + ) -> None: + """The publication branch delegates whole: the creation amendment + delivers here with the path's facts, the amended pair travels into + the delegated publication, and the delegation fires the + publication pair itself after its push — every checkpoint exactly + once, nothing fires twice. + + The delegated cycle re-runs its own preflight over publishing's + import points — the wiring pins them, so the real delegation runs + behind the recording hooks. + """ + + def amender(context: object) -> None: + seen.append((context.commit_message, context.todo)) # type: ignore[attr-defined] + context.amend("amended message", "amended todo") # type: ignore[attr-defined] + + seen: list[tuple[str | None, str | None]] = [] + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, current="main", base_commit="c0ffee") + wired.plant.return_value = "deadbeef" + monkeypatch.setattr(publishing, "resolve_current_branch_name", mock.Mock(return_value="main")) + monkeypatch.setattr(publishing, "check_branch_occupancy", mock.Mock(return_value=None)) + monkeypatch.setattr(publishing, "check_slug_occupancy", mock.Mock(return_value=None)) + monkeypatch.setattr(publishing, "origin_configured", mock.Mock(return_value=True)) + monkeypatch.setattr(publishing, "resolve_ref_commit", mock.Mock(return_value="c0ffee")) + monkeypatch.setattr(publishing, "push_branch", mock.Mock()) + install_tool_package("goga_tool_two", register_hooks=_subscribe(("amend_creation", amender))) + records = recording_hooks() + + result = create_topic("Feature/Foo_Bar", "HEAD", todo="the todo", publish=True, year="2026") + + assert result == "Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar" + assert [entry[1] for entry in records] == ["amend_creation", "topic_created", "topic_published"] + amendment = records[0][2] + assert amendment.checked_out is False # type: ignore[attr-defined] + assert amendment.published is True # type: ignore[attr-defined] + assert amendment.identity.branch == "Feature/Foo_Bar" # type: ignore[attr-defined] + # The applied default template is the draft the hook received — + # the recorder's own content reads show the final amended pair. + assert seen == [("goga: create topic feature-foo-bar", "the todo")] + # The amended pair is what the delegated plant lands in git. + wired.plant.assert_called_once_with( + "Feature/Foo_Bar", "amended todo", "c0ffee", "feature-foo-bar", "2026", "amended message" + ) + created, published = records[1][2], records[2][2] + assert created.checked_out is False # type: ignore[attr-defined] + assert created.published is True # type: ignore[attr-defined] + assert created.todo == "amended todo" # type: ignore[attr-defined] + assert created.commit_message == "amended message" # type: ignore[attr-defined] + assert created.commit_hash == "deadbeef" # type: ignore[attr-defined] + assert published.commit_message == "amended message" # type: ignore[attr-defined] + assert published.commit_hash == "deadbeef" # type: ignore[attr-defined] + assert published.todo == "amended todo" # type: ignore[attr-defined] + # --- Logic tests: the todo entry of a topic --- @@ -1415,3 +1570,40 @@ def test_stray_file_at_topic_path_surfaces_as_clean_error( # The traced order — the branch mutations run before the directory. wired.create_branch.assert_called_once_with("feat-x", "c0ffee") wired.checkout.assert_called_once_with("feat-x") + + def test_create_topic_broken_tool_package_import_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The fatal ``ImportError`` of the hooks-registry assembly keeps + its package name in the clean error — the amendment checkpoint + builds the registry on first delivery, before any mutation.""" + monkeypatch.chdir(tmp_path) + _wire_creation(monkeypatch, current="main") + broken = ImportError("package goga_tool_bad failed to import: boom") + hooks = mock.Mock() + hooks.return_value.amend_creation.side_effect = broken + monkeypatch.setattr(creation, "TopicHooks", hooks) + + with pytest.raises(click.ClickException) as raised: + create_topic("Feature/Foo_Bar", "HEAD", todo="T", year="2026") + + assert raised.value.message == "package goga_tool_bad failed to import: boom" + + def test_enter_topic_todo_broken_tool_package_import_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The entry's checkpoint pair builds the registry on first + delivery — the fatal ``ImportError`` keeps its package name in the + clean error.""" + monkeypatch.chdir(tmp_path) + _topic_dir(tmp_path, "2026", "feature-foo") + _stub_edit_text(monkeypatch, "saved") + broken = ImportError("package goga_tool_bad failed to import: boom") + hooks = mock.Mock() + hooks.return_value.amend_todo_entry.side_effect = broken + monkeypatch.setattr(creation, "TopicHooks", hooks) + + with pytest.raises(click.ClickException) as raised: + enter_topic_todo("feature-foo", year="2026") + + assert raised.value.message == "package goga_tool_bad failed to import: boom" diff --git a/tests/topics/test_deletion.py b/tests/topics/test_deletion.py index 2653d968..0e514b54 100644 --- a/tests/topics/test_deletion.py +++ b/tests/topics/test_deletion.py @@ -660,6 +660,24 @@ def test_detached_head_skips_the_current_branch_guard( assert targets == [DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True)] + def test_broken_tool_package_import_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The fatal ``ImportError`` of the hooks-registry assembly keeps + its package name in the clean error.""" + monkeypatch.chdir(tmp_path) + target = DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=False) + _wire_removal(monkeypatch) + broken = ImportError("package goga_tool_bad failed to import: boom") + hooks = mock.Mock() + hooks.return_value.emit_deleted.side_effect = broken + monkeypatch.setattr(deletion, "TopicHooks", hooks) + + with pytest.raises(click.ClickException) as raised: + delete_topics([target], year="2026") + + assert raised.value.message == "package goga_tool_bad failed to import: boom" + # --- Logic tests: the confirmed removal --- @@ -853,3 +871,35 @@ def test_delete_topics_emits_per_target_after_full_removal( assert second.identity.branch is None # type: ignore[attr-defined] assert second.identity.home_path == ".goga/history/2026/two" # type: ignore[attr-defined] assert not (tmp_path / ".goga" / "history" / "2026" / "one").exists() + + def test_delete_topics_failure_path_emits_only_for_removed_targets( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + ) -> None: + """The emission follows the removal, not the attempt: a target + removed before a later failure already fired its notification, and + the failing target — whose remote deletion failed and whose local + branch was restored — fires nothing, the error surfaces after.""" + monkeypatch.chdir(tmp_path) + first = DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=False) + second = DeleteTarget(topic="feature-bar", branch="feature-bar", remote="feature-bar", has_dir=False) + wired = _wire_removal(monkeypatch) + wired.remote.side_effect = [ + None, + subprocess.CalledProcessError(128, "git push", stderr=b"deny second"), + ] + records = recording_hooks("topic_deleted") + + with pytest.raises(click.ClickException, match="deny second"): + delete_topics([first, second], year="2026") + + # Exactly one notification — the fully removed first target; the + # failing second target never reaches its emission. + assert [entry[1] for entry in records] == ["topic_deleted"] + context = records[0][2] + assert context.local_branch == "feature-foo" # type: ignore[attr-defined] + assert context.identity.slug == "feature-foo" # type: ignore[attr-defined] + # The failing target's restore ran before the error surfaced. + assert wired.order.mock_calls[-1] == mock.call.restore("feature-bar", "c123") diff --git a/tests/topics/test_ensuring.py b/tests/topics/test_ensuring.py index 9b85b33d..b43bd798 100644 --- a/tests/topics/test_ensuring.py +++ b/tests/topics/test_ensuring.py @@ -713,6 +713,39 @@ def amend_witness(context: object) -> None: todo_file = tmp_path / ".goga" / "history" / "2026" / "new-work" / "todo.md" assert todo_file.read_text(encoding="utf-8") == "fresh todo\n" + def test_ensure_fast_creation_leaves_the_amendment_holder_unread( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + recording_hooks: RecordedEntry, + install_tool_package: InstallToolPackage, + ) -> None: + """The creation amendment of the fast path is advisory only — the + returned holder stays unread, so a hook's amended todo never lands + in the written todo.md; the entry's own amendment owns the text.""" + + def injector(context: object) -> None: + context.amend(None, "injected todo") # type: ignore[attr-defined] + + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _wire_fast_creation(monkeypatch, real_dir=True) + _stub_edit_text(monkeypatch, "fresh todo") + _interactive(monkeypatch) + install_tool_package("goga_tool_two", register_hooks=_subscribe(("amend_creation", injector))) + records = recording_hooks(("amend_creation", "topic_created")) + + result = ensure_topic("New_Work", todo=True, year="2026") + + assert result == "Created branch New_Work and topic 2026/new-work" + todo_file = tmp_path / ".goga" / "history" / "2026" / "new-work" / "todo.md" + assert todo_file.read_text(encoding="utf-8") == "fresh todo\n" + created = records[1][2] + assert created.todo == "fresh todo" # type: ignore[attr-defined] — the written text, not the injection + def test_ensure_todo_on_topicless_branch_fires_only_the_entry_pair( self, builtin_scale: StatusScale, diff --git a/tests/topics/test_publishing.py b/tests/topics/test_publishing.py index 4692781b..a59364ee 100644 --- a/tests/topics/test_publishing.py +++ b/tests/topics/test_publishing.py @@ -683,3 +683,22 @@ def test_publish_topic_oserror_push_rolls_back_too(self, tmp_path: Path, monkeyp assert raised.value.message.startswith("cannot complete the publication:") cycle.delete_local_branch.assert_called_once_with("Feature/Foo_Bar") + + def test_broken_tool_package_import_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The fatal ``ImportError`` of the hooks-registry assembly keeps + its package name in the clean error — the registry builds lazily at + the first emission, after the push already succeeded.""" + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + broken = ImportError("package goga_tool_bad failed to import: boom") + hooks = mock.Mock() + hooks.return_value.emit_created.side_effect = broken + monkeypatch.setattr(publishing, "TopicHooks", hooks) + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "the todo", "HEAD", year="2026") + + assert raised.value.message == "package goga_tool_bad failed to import: boom" + cycle.push_branch.assert_called_once_with("Feature/Foo_Bar") From 68658512d12272cb4c44fe414b1039c4d3d289ee Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 17 Sep 2026 01:35:05 +0000 Subject: [PATCH 056/205] fix: address code review findings --- goga/topics/hooks/CODEMANIFEST | 3 +- goga/topics/hooks/events.py | 81 +++++++++++++++++-------------- tests/topics/hooks/test_events.py | 55 +++++++++++++++++++++ 3 files changed, 101 insertions(+), 38 deletions(-) diff --git a/goga/topics/hooks/CODEMANIFEST b/goga/topics/hooks/CODEMANIFEST index 70aacf21..4e7ddf0e 100644 --- a/goga/topics/hooks/CODEMANIFEST +++ b/goga/topics/hooks/CODEMANIFEST @@ -421,7 +421,8 @@ Annotations: | amendment: the buffer replaces the holder content — except when a structurally present field of the buffer is empty or whitespace-only, which rejects the whole buffer - 5. A hook that raised: its buffer is discarded + 5. A hook that raised or returned a buffer the walk cannot + process: its buffer is discarded 6. Both rejection cases emit the warning hook of tool failed on topics.amend_creation: — the raised error for the diff --git a/goga/topics/hooks/events.py b/goga/topics/hooks/events.py index 36f2395d..4a065ae0 100644 --- a/goga/topics/hooks/events.py +++ b/goga/topics/hooks/events.py @@ -169,7 +169,8 @@ def amend_creation( replaces the holder content whole — except when a structurally present field is empty or whitespace-only, which rejects the whole buffer - 5. A hook that raised: its buffer is discarded + 5. A hook that raised or returned a buffer the walk cannot + process: its buffer is discarded 6. Both rejection cases emit the warning naming the hook, the tool, the action, and the reason, and the walk continues 7. Return the holder @@ -212,6 +213,26 @@ def amend_creation( registry.self_context(subscription.tool), ) subscription.hook(**arguments) + + # Inside the intercept on purpose: the buffer is hook + # content, so a buffer the walk cannot process — a field + # of an out-of-contract type — fails that hook alone. + buffered = view._buffered + if buffered is None: + continue + + if _blank(buffered[0]) or _blank(buffered[1]): + logger.warning( + "hook %s of tool %s failed on %s.%s: %s", + subscription.name, + subscription.tool, + _DOMAIN, + _CREATION_ACTION, + _EMPTY_AMENDMENT, + ) + continue # the whole buffer is rejected + + holder._commit(buffered) except Exception as reason: if error_class == "hard": raise ValueError( @@ -229,23 +250,6 @@ def amend_creation( ) continue # the buffer of the failed hook is discarded - buffered = view._buffered - if buffered is None: - continue - - if _blank(buffered[0]) or _blank(buffered[1]): - logger.warning( - "hook %s of tool %s failed on %s.%s: %s", - subscription.name, - subscription.tool, - _DOMAIN, - _CREATION_ACTION, - _EMPTY_AMENDMENT, - ) - continue # the whole buffer is rejected - - holder._commit(buffered) - return holder def amend_todo_entry(self, identity: TopicIdentity, text: str) -> TodoEntryDraft: @@ -305,6 +309,28 @@ def amend_todo_entry(self, identity: TopicIdentity, text: str) -> TodoEntryDraft registry.self_context(subscription.tool), ) subscription.hook(**arguments) + + # Inside the intercept on purpose: the buffer is hook + # content, so a buffer the walk cannot process — a value + # of an out-of-contract type — fails that hook alone. + if not view._amended: + continue + + # The buffered None is the out-of-contract rejection case of + # this walk — the flag separates it from a hook that never + # amended, so the predicate's None arm stays reachable. + if _rejected_text(view._buffered): + logger.warning( + "hook %s of tool %s failed on %s.%s: %s", + subscription.name, + subscription.tool, + _DOMAIN, + _ENTRY_ACTION, + _EMPTY_AMENDMENT, + ) + continue # the whole buffer is rejected + + holder._commit(view._buffered) except Exception as reason: if error_class == "hard": raise ValueError( @@ -322,25 +348,6 @@ def amend_todo_entry(self, identity: TopicIdentity, text: str) -> TodoEntryDraft ) continue # the buffer of the failed hook is discarded - if not view._amended: - continue - - # The buffered None is the out-of-contract rejection case of - # this walk — the flag separates it from a hook that never - # amended, so the predicate's None arm stays reachable. - if _rejected_text(view._buffered): - logger.warning( - "hook %s of tool %s failed on %s.%s: %s", - subscription.name, - subscription.tool, - _DOMAIN, - _ENTRY_ACTION, - _EMPTY_AMENDMENT, - ) - continue # the whole buffer is rejected - - holder._commit(view._buffered) - return holder def emit_created( # noqa: PLR0913, PLR0917 — the six facts are the declared checkpoint signature diff --git a/tests/topics/hooks/test_events.py b/tests/topics/hooks/test_events.py index 13c3254d..332c8c92 100644 --- a/tests/topics/hooks/test_events.py +++ b/tests/topics/hooks/test_events.py @@ -285,6 +285,34 @@ def blank(context: object) -> None: assert expected_warning in caplog.text + def test_amend_creation_discards_buffer_of_unprocessable_type( + self, + pin_package_environment: PinEnvironment, + install_tool_package: InstallToolPackage, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A buffer field of an out-of-contract type fails that hook alone — never the walk.""" + + def numeric(context: object) -> None: + context.amend(123, "fine text") # type: ignore[arg-type, attr-defined] + + def tail(context: object) -> None: + context.amend("late", "late-t") # type: ignore[attr-defined] + + pin_package_environment(TWO_TOOL_ENVIRONMENT) + install_tool_package("goga_tool_one", register_hooks=_register(("amend_creation", numeric))) + install_tool_package("goga_tool_two", register_hooks=_register(("amend_creation", tail))) + + with caplog.at_level(logging.WARNING): + draft = TopicHooks().amend_creation(IDENTITY, False, False, "orig", "orig todo") + + assert draft.commit_message == "late" # the buffer of numeric is gone + assert draft.todo == "late-t" + assert any( + "hook numeric of tool one failed on topics.amend_creation:" in record.message and "int" in record.message + for record in caplog.records + ) + def test_amend_creation_without_subscriptions_returns_original_values( self, pin_package_environment: PinEnvironment, @@ -446,6 +474,33 @@ def tail(context: object) -> None: for record in caplog.records ) + def test_amend_todo_entry_discards_buffer_of_unprocessable_type( + self, + pin_package_environment: PinEnvironment, + install_tool_package: InstallToolPackage, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A text buffer of an out-of-contract type fails that hook alone — never the entry.""" + + def numeric(context: object) -> None: + context.amend(123) # type: ignore[arg-type, attr-defined] + + def tail(context: object) -> None: + context.amend("late text") # type: ignore[attr-defined] + + pin_package_environment(TWO_TOOL_ENVIRONMENT) + install_tool_package("goga_tool_one", register_hooks=_register(("amend_todo_entry", numeric))) + install_tool_package("goga_tool_two", register_hooks=_register(("amend_todo_entry", tail))) + + with caplog.at_level(logging.WARNING): + draft = TopicHooks().amend_todo_entry(IDENTITY, "saved text") + + assert draft.text == "late text" # the buffer of numeric is gone + assert any( + "hook numeric of tool one failed on topics.amend_todo_entry:" in record.message and "int" in record.message + for record in caplog.records + ) + def test_amend_todo_entry_hard_class_stops_the_walk_with_clean_error( self, pin_package_environment: PinEnvironment, From 5489b856ad125ecacc4b683b3d880edaf58d262b Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 17 Sep 2026 02:13:06 +0000 Subject: [PATCH 057/205] chore: archive the completed add-topics-hooks plan --- .goga/history/2026/add-topics-hooks/{ => completed}/plan.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .goga/history/2026/add-topics-hooks/{ => completed}/plan.md (100%) diff --git a/.goga/history/2026/add-topics-hooks/plan.md b/.goga/history/2026/add-topics-hooks/completed/plan.md similarity index 100% rename from .goga/history/2026/add-topics-hooks/plan.md rename to .goga/history/2026/add-topics-hooks/completed/plan.md From 06781473b70f3988aae29ef7000b1104b40d991e Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 17 Sep 2026 16:08:44 +0300 Subject: [PATCH 058/205] docs: unify domain hook usages under registering-hooks.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add goga/topics/.usages/registering-hooks.md — the tool-author guide to the seven topics actions - rename history registering-statuses.md and onboarding tool-contexts.md to registering-hooks.md - import each domain practice into its emitting command cell (topics, history, init); drop the aggregator links from goga tool --- goga/commands/history/CODEMANIFEST | 4 + goga/commands/init/CODEMANIFEST | 3 + goga/commands/tool/CODEMANIFEST | 5 - goga/commands/topics/CODEMANIFEST | 5 + ...ering-statuses.md => registering-hooks.md} | 2 +- ...{tool-contexts.md => registering-hooks.md} | 13 +- goga/topics/.usages/registering-hooks.md | 128 ++++++++++++++++++ 7 files changed, 146 insertions(+), 14 deletions(-) rename goga/history/.usages/{registering-statuses.md => registering-hooks.md} (97%) rename goga/onboarding/.usages/{tool-contexts.md => registering-hooks.md} (90%) create mode 100644 goga/topics/.usages/registering-hooks.md diff --git a/goga/commands/history/CODEMANIFEST b/goga/commands/history/CODEMANIFEST index 529df2d5..02d00192 100644 --- a/goga/commands/history/CODEMANIFEST +++ b/goga/commands/history/CODEMANIFEST @@ -16,6 +16,7 @@ Imports: - topic-statuses - history-tree - prune + - registering-hooks From: goga/history Usages: @@ -37,6 +38,9 @@ Annotations: | handover from the group to the subcommands, echo, and exit-code propagation. + Use the `registering-hooks` practice for the tool-package status + registration behind the status scale the subcommands assemble. + This cell is the CLI surface of the history domain: a thin wrapper that resolves inputs, delegates every computation to the domain routines, and renders the results. No path building, no slug grammar, no status diff --git a/goga/commands/init/CODEMANIFEST b/goga/commands/init/CODEMANIFEST index ed3c4b41..9cc48b01 100644 --- a/goga/commands/init/CODEMANIFEST +++ b/goga/commands/init/CODEMANIFEST @@ -6,6 +6,7 @@ Imports: - ToolParticipation Usages: - onboarding-usage + - registering-hooks From: goga/onboarding - Types: - Scaffold @@ -29,6 +30,8 @@ Annotations: | The command is the integration point of two independent domains — onboarding and scaffold. Use `onboarding-usage` to understand the InitLogic API. Use `scaffold-usage` to understand the Scaffold API. + Use the `registering-hooks` practice for the onboarding session this + command starts and the tool-package hooks invited into it. --- diff --git a/goga/commands/tool/CODEMANIFEST b/goga/commands/tool/CODEMANIFEST index 77d7677a..77491985 100644 --- a/goga/commands/tool/CODEMANIFEST +++ b/goga/commands/tool/CODEMANIFEST @@ -4,9 +4,6 @@ Imports: Usages: - loading From: goga/ast - - Usages: - - registering-statuses - From: goga/history Usages: click: .goga/usages/cooks/click.md @@ -16,8 +13,6 @@ Annotations: | The `conventions` practice governs codebase navigation, the REPL development cycle, debugging and testing, test infrastructure, and project-wide development principles. Apply `click` to implement the CLI command. The dispatcher resolves the tool package and forwards captured arguments together with the optional injections the tool entry point declares. - Use the `registering-statuses` practice for the topic-status registration - a tool package performs alongside its entry point. --- diff --git a/goga/commands/topics/CODEMANIFEST b/goga/commands/topics/CODEMANIFEST index f6c3916d..423fc33e 100644 --- a/goga/commands/topics/CODEMANIFEST +++ b/goga/commands/topics/CODEMANIFEST @@ -13,6 +13,7 @@ Imports: - creating - publishing - deleting + - registering-hooks From: goga/topics - Types: - load_project_config @@ -47,6 +48,10 @@ Annotations: | the domain. Use the `project-configuration` practice for the schema of the topics section. + Use the `registering-hooks` practice for the + lifecycle events this command group emits and the tool-package hooks + subscribed to them. + The creation inputs resolve their values at this layer: the base — a flag beats the topics section — `TopicsConfig` — of the project configuration read via `load_project_config`, which beats the diff --git a/goga/history/.usages/registering-statuses.md b/goga/history/.usages/registering-hooks.md similarity index 97% rename from goga/history/.usages/registering-statuses.md rename to goga/history/.usages/registering-hooks.md index 9e48dac2..0408cbd8 100644 --- a/goga/history/.usages/registering-statuses.md +++ b/goga/history/.usages/registering-hooks.md @@ -1,4 +1,4 @@ -# history — registering topic statuses +# history — registering hooks How a `goga_tool_*` package attaches its own statuses to the topic status scale. For tool package authors; no goga code changes are needed. diff --git a/goga/onboarding/.usages/tool-contexts.md b/goga/onboarding/.usages/registering-hooks.md similarity index 90% rename from goga/onboarding/.usages/tool-contexts.md rename to goga/onboarding/.usages/registering-hooks.md index 263b7810..eab19e4a 100644 --- a/goga/onboarding/.usages/tool-contexts.md +++ b/goga/onboarding/.usages/registering-hooks.md @@ -1,10 +1,8 @@ -# Onboarding contexts — goga/onboarding +# onboarding — registering hooks -## Domain - -What a tool package receives inside a `goga init` session and the member -contract of the two onboarding actions. Target audience: authors of -`goga_tool_*` packages that need project configuration. +What a `goga_tool_*` package receives inside an onboarding session and the +member contract of the two onboarding actions. For tool package authors +that need project configuration; no goga code changes are needed. ## Subscribing @@ -75,7 +73,6 @@ def amend_config(context): ## Failure behavior - An exception in a hook drops the tool's whole contribution with a - warning naming the tool and the reason; `goga init` continues and exits - 0. + warning naming the tool and the reason; the session continues. - A broken package import is the single fatal case — a clean session error naming the package. diff --git a/goga/topics/.usages/registering-hooks.md b/goga/topics/.usages/registering-hooks.md new file mode 100644 index 00000000..daec1d75 --- /dev/null +++ b/goga/topics/.usages/registering-hooks.md @@ -0,0 +1,128 @@ +# topics — registering hooks + +How a `goga_tool_*` package subscribes its hooks to the lifecycle +events of the topics domain. For tool package authors; no goga code +changes are needed. + +The domain opens seven soft actions. Five are notifications — the +read-only facts of a completed moment, delivered after the moment fully +succeeds. Two are amendments — a per-hook view over the content a flow +is about to fix, delivered before the fixation. Every failing hook is +skipped with a log warning naming the hook, the tool, the action, and +the reason; the command continues — no topics hook can break a command. + +## The events + +| Address | Fires | +|---|---| +| `topics / amend_creation` | Before the first mutation of the chosen creation path — every path decision of `create_topic` made, the publication ask included — and the fast creation of `ensure_topic` (identity-only, advisory — see below). | +| `topics / amend_todo_entry` | After a todo entry saves in the editor and before `todo.md` is written — the optional todo entry of a switch. | +| `topics / topic_created` | After a creation completes — the quarantined plant, the checked-out path, the publication, and the fast creation of `ensure_topic`. | +| `topics / topic_published` | After a successful publication push, paired with the `topic_created` of the same cycle. | +| `topics / topic_switched` | After every completed switch — the idempotent already-on-branch outcome included. | +| `topics / topic_todo_entered` | After `todo.md` is written with the final text. | +| `topics / topic_deleted` | After each target's full removal — local branch, origin twin, and directory. | + +A failing moment fires nothing: a creation that fails its preflight, a +publication whose push rolls back, a switch refused before its first +mutation — the events of the moment never arrive. + +## Subscribe + +```python +# inside the goga_tool_ package +def register_hooks(hooks): + hooks.subscribe("topics", "topic_created", "record", record_created) + hooks.subscribe("topics", "amend_creation", "stamper", stamp_message) +``` + +- `domain` — always `"topics"`. +- `action` — the event name from the table above. +- `name` — the hook name, unique per tool per address. +- `hook` — the callable executed when the event fires. + +A hook receives values only for the parameters it declares by the fixed +offered names: `context` — the delivered object of the event, read +attributes and call methods freely, attribute assignment is blocked; +`self` — the isolated context of your tool, one instance links all its +hook invocations of a run, freely mutable. The declaration order does +not matter; names you did not declare receive nothing. + +```python +def record_created(context): + ... # read-only facts of the completed creation + + +def stamp_message(context): + context.amend(commit_message=f"[{context.identity.slug}] {context.commit_message}", todo=context.todo) +``` + +## The identity + +Every context carries one `TopicIdentity`: `slug` — the normalized +topic slug, or None in the branch-only form (a switch onto a branch +hosting no topic); `home_path` — `.goga/history//` as a +posix string, None when the slug is None; `branch` — the branch name as +entered by the operation, None only in the deletion context. + +## The notification contexts + +Each notification delivers the same context instance to every +subscribed tool — no per-tool copies, no stale facts. A hook observes +the outcome and cannot alter it. + +- `topic_created` — `TopicCreated`: `identity`, `checked_out` (the path + checked out the fresh branch), `published` (the path published the + work), `todo` (the final text, or None when none resolved), + `commit_message` and `commit_hash` (present exactly when the path + builds a commit — the quarantined plant and the publication; None on + the checked-out and fast-creation paths). +- `topic_published` — `TopicPublished`: `identity`, `commit_message`, + `commit_hash`, `todo` — the facts of one successful push, identical + to the paired `topic_created`. +- `topic_switched` — `TopicSwitched`: `identity`, `outcome` — exactly + one of `local-checkout`, `created-from-remote`, `already-on-branch`. + The identity degrades to the branch-only form when the switched + branch hosts no topic. +- `topic_todo_entered` — `TopicTodoEntered`: `identity`, `text` — the + final written text, after every amendment. No prior text is carried; + keep your own state in your own `self` context. +- `topic_deleted` — `TopicDeleted`: `identity` (no branch fact), + `local_branch` and `origin_twin` (each None when the target had + none), `directory_removed`. No deleted-commit hash is carried. + +## The amendment views + +Each amendment delivers a fresh view per hook over the live shared +draft. The read-through attributes — `commit_message` / `todo` on +`CreationAmendment`, `text` on `TodoEntryAmendment` — read the live +holder, so a later hook sees the committed amendments of the earlier +hooks. + +- `amend_creation` — `CreationAmendment`: `identity`, `checked_out`, + `published`, the reads `commit_message` / `todo`, and + `amend(commit_message, todo)`. +- `amend_todo_entry` — `TodoEntryAmendment`: `identity`, the read + `text`, and `amend(text)`. + +The amendment contract: + +- Whole replacement — `amend` buffers the complete new content; a field + left out comes back as None, it is not kept as the previous value. + The last committed buffer wins. +- Per-hook commit — the walk delivers the subscriptions in enumeration + order; a hook's buffer commits only when the hook returns without + raising, and two hooks of one tool never share a buffer or a failure. +- Empty rejection — a structurally present field that is empty or + whitespace-only rejects the whole buffer (on the todo entry, a None + text is rejected too); a raised hook and a rejected buffer each warn + in the log and the walk continues. +- Content only — an amendment transforms content; it cannot cancel, + redirect, or defer the operation. +- Identity-only form — a creation path that builds no commit and + resolved no todo still delivers `amend_creation` with both fields + None; the tool decides whether to act. +- Advisory on the fast creation of `ensure_topic`: there the creation amendment + observes only — an amended todo does not land (the todo resolves + later through `amend_todo_entry`, which owns the written text), and + `commit_message` stays None. From c127de7f27f582254c08d0c75b4835ed25c8cb1d Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 17 Sep 2026 16:08:55 +0300 Subject: [PATCH 059/205] chore: bump AFM_VERSION to 1.1.4 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index d4eca77e..56e562a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG AFM_VERSION=1.1.2 +ARG AFM_VERSION=1.1.4 ARG RALPHEX_VERSION=1.6 ARG PYTHON_VERSION=3.12 ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 From 19719e810ad48b38667c6a917bc3078a710ca711 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 17 Sep 2026 16:21:36 +0300 Subject: [PATCH 060/205] docs: sync mkdocs traceability for the 2.0.0 cells and cover init tool invitations --- .goga/tools/mkdocs/traceability.yml | 15 ++++++++++++++- docs/features/init/index.md | 1 + 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.goga/tools/mkdocs/traceability.yml b/.goga/tools/mkdocs/traceability.yml index 8549cee5..9f490649 100644 --- a/.goga/tools/mkdocs/traceability.yml +++ b/.goga/tools/mkdocs/traceability.yml @@ -29,6 +29,7 @@ docs/index.md: - goga/docker docs/getting-started.md: - goga/onboarding + - goga/commands/init - goga/config - goga/config/git - goga/scaffold @@ -58,12 +59,13 @@ docs/features/topics/configuration.md: - goga/config/project - goga/commands/topics docs/features/topics/hooks.md: - - goga/history/statuses + - goga/topics/hooks - goga/hooks docs/features/topics/api.md: - goga/topics - goga/topics/git - goga/topics/editor + - goga/topics/hooks docs/features/history/index.md: - goga/history @@ -150,6 +152,7 @@ docs/features/tools/configuration.md: docs/features/tools/hooks.md: - goga/hooks - goga/commands/install + - goga/topics/hooks docs/features/tools/api.md: - goga/commands/tool @@ -211,8 +214,13 @@ docs/features/init/configuration.md: - goga/config docs/features/init/hooks.md: - goga/onboarding + - goga/onboarding/participation docs/features/init/api.md: - goga/onboarding + - goga/onboarding/questions + - goga/onboarding/survey + - goga/onboarding/participation + - goga/onboarding/generator docs/features/usages/index.md: - goga/usages @@ -262,6 +270,8 @@ docs/features/contract/api.md: docs/features/hooks/index.md: - goga/hooks - goga/commands/hooks + - goga/onboarding + - goga/topics/hooks docs/features/hooks/cli.md: - goga/commands/hooks - goga/hooks @@ -273,6 +283,8 @@ docs/features/hooks/hooks.md: - goga/hooks/dispatch - goga/hooks/registry - goga/hooks/tools + - goga/onboarding + - goga/topics/hooks docs/features/hooks/api.md: - goga/hooks - goga/hooks/catalog @@ -313,6 +325,7 @@ docs/configuration/project.md: docs/configuration/home.md: - goga/config/home - goga/docker + - goga/pipeline docs/configuration/agents.md: - goga/config - goga/agents/wrapper diff --git a/docs/features/init/index.md b/docs/features/init/index.md index d6fd8cda..14ad29c3 100644 --- a/docs/features/init/index.md +++ b/docs/features/init/index.md @@ -5,6 +5,7 @@ Interactive project initialization, with optional template scaffolding. The init domain turns an empty directory into a goga project. Which tasks it solves: - **Configure a project** — `goga init` walks an interactive questionnaire: the language, the container image, the agents, the initial `.goga/config.yml` sections, the optional Dockerfile — and writes `.goga/config.yml` (plus the Dockerfile) from the answers. +- **Invite tool packages** — `goga init -t ` (repeatable) invites installed tool packages into the session: each invited tool declares its own question block (asked after the core sections) and contributes its config files under `.goga/tools//` (see [Hooks](hooks.md)). - **Scaffold from a template** — `goga init ` starts from a [copier](https://copier.readthedocs.io/) repo template (optionally pinned with `#ref` or `--ref`) and then asks only the questions the template left open. - **Migrate a scaffolded project** — `goga init --upgrade` migrates an existing scaffolded project to the current generator version. From d2a18616df17d6c6d742c448e3d68746f27307ee Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 17 Sep 2026 23:34:45 +0300 Subject: [PATCH 061/205] feat: up afm version --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 56e562a6..7b65b9b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG AFM_VERSION=1.1.4 +ARG AFM_VERSION=1.1.7 ARG RALPHEX_VERSION=1.6 ARG PYTHON_VERSION=3.12 ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 From 751c2b93310a718452a443ca2815155ccabb6d12 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 18 Sep 2026 10:07:55 +0300 Subject: [PATCH 062/205] feat: up afm version --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7b65b9b4..38e5cd1d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG AFM_VERSION=1.1.7 +ARG AFM_VERSION=1.1.8 ARG RALPHEX_VERSION=1.6 ARG PYTHON_VERSION=3.12 ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 From d76b44ee2315a7825ffde7bbcb4b7d27120e5a5a Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 18 Sep 2026 15:08:54 +0000 Subject: [PATCH 063/205] feat: add pipeline hooks zone cell and specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add goga/pipeline/hooks zone — CODEMANIFEST with the checkpoint surface and .usages/checkpoints.md practice - register three pipeline actions in the hooks catalog: amend_workflow (hard), run_created and run_completed (soft) - wire the zone into goga/pipeline CODEMANIFEST — PipelineCard provenance, hooks imports, and zone annotations - sync consumer usages (describe-pipeline, run-pipeline, pipeline-cli) and add the registering-hooks.md tool-author guide - record the add-pipeline-hooks document chain (prd, task, adr, arch, design, plan) under .goga/history - require full phase details in question files in the development pipeline config --- .goga/history/2026/add-pipeline-hooks/adr.md | 102 ++ .goga/history/2026/add-pipeline-hooks/arch.md | 1287 ++++++++++++++ .../history/2026/add-pipeline-hooks/design.md | 1533 ++++++++++++++++ .goga/history/2026/add-pipeline-hooks/plan.md | 1545 +++++++++++++++++ .goga/history/2026/add-pipeline-hooks/prd.md | 457 +++++ .goga/history/2026/add-pipeline-hooks/task.md | 264 +++ goga/assets/pipelines/development.yml | 1 + goga/hooks/catalog/CODEMANIFEST | 12 + goga/pipeline/.usages/describe-pipeline.md | 19 +- goga/pipeline/.usages/pipeline-cli.md | 5 +- goga/pipeline/.usages/registering-hooks.md | 101 ++ goga/pipeline/.usages/run-pipeline.md | 29 +- goga/pipeline/CODEMANIFEST | 209 ++- goga/pipeline/hooks/.usages/checkpoints.md | 88 + goga/pipeline/hooks/CODEMANIFEST | 534 ++++++ 15 files changed, 6139 insertions(+), 47 deletions(-) create mode 100644 .goga/history/2026/add-pipeline-hooks/adr.md create mode 100644 .goga/history/2026/add-pipeline-hooks/arch.md create mode 100644 .goga/history/2026/add-pipeline-hooks/design.md create mode 100644 .goga/history/2026/add-pipeline-hooks/plan.md create mode 100644 .goga/history/2026/add-pipeline-hooks/prd.md create mode 100644 .goga/history/2026/add-pipeline-hooks/task.md create mode 100644 goga/pipeline/.usages/registering-hooks.md create mode 100644 goga/pipeline/hooks/.usages/checkpoints.md create mode 100644 goga/pipeline/hooks/CODEMANIFEST diff --git a/.goga/history/2026/add-pipeline-hooks/adr.md b/.goga/history/2026/add-pipeline-hooks/adr.md new file mode 100644 index 00000000..4578f302 --- /dev/null +++ b/.goga/history/2026/add-pipeline-hooks/adr.md @@ -0,0 +1,102 @@ +# Pipeline hooks: workflow-overlay amendment with field-level authored-wins, pre-launch and any-exit notifications + +The pipeline domain opens to installed `goga_tool_*` packages through three hooks-platform +actions: `pipeline/amend_workflow` (hard — the platform's first hard action), `pipeline/run_created` +(soft), and `pipeline/run_completed` (soft). Tools act **only on the workflow layer**, contributing +an overlay with authored-wins per slot; the pipeline-file itself is never modified. Decisions were +settled in a discovery interview on 18/09/26 and take precedence over the earlier PRD wording +(the PRD must be re-aligned — see Consequences). + +## Decisions + +1. **Boundary.** A tool influences the workflow layer only, with the full workflow-instruction + vocabulary, declaratively, through the same compiler validation as authored instructions. The + pipeline-file is not subject to modification; raw compiled-flow mutation is not opened. +2. **Overlay model — authored-wins per slot.** Authored means: workflow-file content plus the + runner's explicit skips merged before the layer. + - `prompt` — append: authored text first, tool texts concatenated in tool enumeration order; + when no authored prompt exists, a tool text becomes the prompt. + - `memory` — whole-block slot: an authored block is unbeatable; when absent, tools contribute + whole blocks with later-tool-wins; no field-level merging (method-conditional fields make + field merges structurally unsafe). + - `stages` — per-field: authored fields win; a tool fills unset fields; a stage with no authored + entry may be fully defined by a tool. `skip` is an ordinary field: an authored skip + (workflow-file or merged runner skip) is unbeatable; an unset skip is tool-fillable — including + removing a pipeline-file stage not protected by an authored skip. + - `extend` — tools add new entries; a name already occupied by an authored stage wins and the + tool entry is not applied; among tools, later wins. +3. **`amend_workflow` delivery.** Per-tool staged commit (the onboarding precedent, not topics' + per-hook): buffered declarative contributions via methods of the delivered context; a tool's + whole contribution commits only after every hook of that tool succeeds; a failure discards the + tool's contribution and the hard error class stops the command before launch, naming the tool + and the action. +4. **`amend_workflow` read surface.** Pipeline identity (name, description, project/user source); + the workflow decision (explicitly disabled / explicit name / auto-match applied / silent miss, + plus the resolved name when applicable); the **original authored workflow** — post decision, + post runner-skip merge, pre-layer, read-only, identical for every tool (no staged-application + visibility: tools are mutually blind); the current work identity. Identical modifications by + several tools: the last in enumeration order wins. +5. **`run_created` — fires immediately before the runner launch** (after compilation and prompt + materialization). Carries: pipeline identity; workflow decision; the final effective workflow + (authored instructions plus committed tool contributions, read-only); the final composition + (stages in execution order, as the card shows); provenance (tools whose contributions + committed); the current work identity (topics-shaped: branch plus topic slug/year when hosted, + branch-only form otherwise, no status delivered then); the work's history status at the moment + (maximal present statuses of both axes, built-in and tool); the run's runtime dir. The runner's + skips are not a separate fact (already reflected in the composition). +6. **`run_completed` — fires on every launch-attempt return**: zero, non-zero, and spawn failures + (126/127) alike. Same facts as `run_created` with the status recomputed at the completion + moment, plus the outcome: the actual exit code and the runtime dir path — the diagnostics + surface for tools. Completion is a fact, not a success claim. +7. **Card equivalence and disable semantics.** The card composes through the same layer and shows + provenance; no run events fire in card form; the flat list and overview involve no hooks at + all. An explicit `--no-workflow` / `GOGA_WORKFLOW_DISABLED` disables the layer entirely. A + silent auto-match miss keeps the layer active: tools contribute onto the empty authored + workflow, as if an empty workflow-file existed. + +## Considered Options + +- **Stage-presence protection** (a tool can never remove an authored stage — the literal PRD + R2.4/SC3 reading): rejected. Protection operates at the overlay-field level; the authored skip, + not stage presence, is what wins. Predictability is preserved by observability (card + + provenance), not by removal of power. +- **`run_finished` firing only on a zero exit** (PRD D1/R3.2): rejected. Completion ≠ success; + firing on any exit with the code and runtime dir gives tools a diagnostics surface for failed + runs. +- **`run_started` after a successful launch** (PRD R3.1): rejected. `run_created` fires pre-launch; + the name deliberately claims no launch (`started`/`launched` were rejected for that reason). +- **Per-hook commit granularity** (the topics precedent): rejected in favor of per-tool (R2.6, + onboarding precedent). +- **`extend_workflow` / `amend_composition` naming**: rejected. `amend_workflow` continues the + `amend_*` family and names the real power — all four sections, not only `extend`. + +## Consequences + +- **The PRD must be re-aligned to this ADR** so the documents do not drift: the author-guarantee + wording (R2.4, UX guarantee, SC3), the event names and timing (R3.1, R3.2, R3.5, D1, SC5), and + the notification context list (runner skips removed, effective workflow and runtime dir added). +- `amend_workflow` is the platform's first hard action — "every action is soft" stops being an + invariant; catalog records remain additive and published records stay untouched. +- Tool-author documentation follows the established pattern: a pipeline-domain + `registering-hooks` usage plus filling the negative stub `docs/features/pipelines/hooks.md` + (Address | Error class | Fires table, context members, failure semantics); the mkdocs nav slot + already exists. +- With no tool packages installed, every pipeline form behaves exactly as before (zero impact). + +## Unresolved (later stages) + +- Exact context member names, signatures, and contribution-method contracts. +- The cell that owns the pipeline hooks zone (the `topics/hooks` precedent exists; cell + boundaries are outside discovery scope). +- Provenance display format in card and run output. +- The actual PRD text edits listed above. + +--- + +Author: trifonovmixail +CreatedAt: 18/09/26 +Description: | + Decision record of the discovery interview for opening the pipeline domain + to tool package integrations: the workflow-overlay amendment model, the + three actions with their error classes, the event timeline, and the PRD + re-alignment directive. diff --git a/.goga/history/2026/add-pipeline-hooks/arch.md b/.goga/history/2026/add-pipeline-hooks/arch.md new file mode 100644 index 00000000..1c836d99 --- /dev/null +++ b/.goga/history/2026/add-pipeline-hooks/arch.md @@ -0,0 +1,1287 @@ +# Architecture Plan — pipeline hooks + +Author: Goga +CreatedAt: 18/09/26 +Topic branch: add-pipeline-hooks +Decision source: `.goga/history/2026/add-pipeline-hooks/adr.md` (ADR wins over PRD; PRD re-aligned) +Task source: `.goga/history/2026/add-pipeline-hooks/task.md` + +--- + +## Topic + +- Short name: **pipeline-hooks** +- Plan path: `.goga/history/2026/add-pipeline-hooks/arch.md` + +Opening the pipeline domain to installed `goga_tool_*` packages through three +hooks-platform actions (`pipeline/amend_workflow` hard — the platform's first +hard action; `pipeline/run_created`, `pipeline/run_completed` soft), the +authored-wins workflow overlay layer, the run/card integration with provenance, +tool-author documentation, and tests. + +--- + +## Implementation Order + +1. **`goga/hooks/catalog` (MODIFY)** — data-only additive records; no Imports; + every consumer (platform dispatch, `goga hooks`) reads it. No dependencies + of its own — first. +2. **`goga/pipeline/hooks` (CREATE)** — the pipeline hooks zone. Depends on + `goga/hooks` (platform facade) and `goga/pipeline/workflow` (instruction + models) — both exist unchanged, so the zone builds immediately after the + catalog records it resolves against exist. +3. **`goga/pipeline` (MODIFY)** — run/card integration. Depends on the zone + (imports its facade and `checkpoints` practice) and on `goga/history` + (branch/topic/status facts) — designed after its dependency exists. +4. **Documentation** — fill the negative stub `docs/features/pipelines/hooks.md` + and sync mkdocs traceability; the tool-author usage + (`goga/pipeline/.usages/registering-hooks.md`) is a cell-3 artifact shipped + with step 3. +5. **Tests** — per project conventions, alongside each artifact (checklist + below). + +Design order note: cells were designed leaves-to-root; the zone never imports +`goga/pipeline` (the parent imports the zone) — the cross-import rule holds. + +--- + +## Artifacts + +### Cell 1 — `goga/hooks/catalog` (MODIFY, data-only) + +**CODEMANIFEST diff** — the only change is three bullets appended to the +`Requirements` list of the `declared_actions` annotations (after the +`amend_todo_entry` bullet). Nothing else in the file changes; published +records stay untouched. + +Add: + +```yaml + - The catalog carries the pipeline workflow-amendment action — the + record domain="pipeline", name="amend_workflow", error_class="hard": + the first failing hook of the action stops the command with a clean + error naming the tool — the platform's first hard action + - The catalog carries the pipeline run-creation notification action — + the record domain="pipeline", name="run_created", error_class="soft": + a failing hook of the action is skipped with a warning and the command + continues + - The catalog carries the pipeline run-completion notification action — + the record domain="pipeline", name="run_completed", error_class="soft": + a failing hook of the action is skipped with a warning and the command + continues +``` + +**.usages/ files** — none (unchanged). + +--- + +### Cell 2 — `goga/pipeline/hooks` (CREATE) + +**CODEMANIFEST** — full content: + +```yaml +Imports: + - Types: + - HookRegistry + - wrap_context + - build_hook_arguments + - emit_hook_event + - declared_actions + Usages: + - declaring-actions + - per-tool-delivery + - registering-hooks + From: goga/hooks + - Types: + - WorkflowDocument + From: goga/pipeline/workflow + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and + testing in the project + + This cell owns the hooks zone of the pipeline domain: the fact + vocabulary of the run events, the read-and-contribute amendment + context with its per-tool staged commit, the authored-wins workflow + overlay, and the checkpoint surface that delivers the amendment and + emits the two run notifications over the platform facade. One registry + per run carries every checkpoint of a command — the checkpoints never + multiply the package enumeration. Every context is built from the + operation data the caller passes — no repository reads happen here. + The amendment action is hard — the platform's first: the first + failing tool stops the command with a clean error naming the tool and + the action, and the tool's whole contribution is discarded. The two + notifications are soft — a failing hook warns naming the tool, the + action, and the reason, and the run's exit code is unaffected. Tools + are mutually blind — every amendment view reads the original authored + workflow, never a staged state. + Use the `per-tool-delivery` practice for the staged delivery loop of + the amendment checkpoint — its loop skeleton, primitives, and + tool-grouped commit apply as written. + Use the `declaring-actions` practice for the emission contract of the + notification checkpoints. + Use the `registering-hooks` practice for the hook signature and the + failure handling behind every checkpoint. + Use relative imports. + +--- + +"PipelineIdentity(name: str, display_name: str = \"\", description: str, source: str)": + location: identity.py + annotations: | + The identity vocabulary of every pipeline event — the discovered + name, the authored header facts, and the source of the + pipeline-file. + + `name`: the discovered pipeline name — the file stem without the + .yml extension + `display_name`: the authored pipeline name from the DSL header; may + differ from the discovered stem; empty when the + header names none + `description`: the pipeline description from the DSL header + `source`: the origin of the pipeline-file — exactly project or user + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - `name` is non-empty and carries no path separators and no .yml + suffix + - `source` is exactly project or user + - Pure facts — the constructing operation passes resolved values; + nothing is read here + properties: + "name -> str": | + The discovered pipeline name — the file stem without the .yml + extension. + "display_name -> str": | + The authored pipeline name from the DSL header; may differ from + the discovered stem. + "description -> str": | + The pipeline description from the DSL header. + "source -> str": | + The origin of the pipeline-file — project or user. + +"WorkflowDecision(kind: str, workflow_name: str | None)": + location: identity.py + annotations: | + The workflow decision of one composition — the outcome of the + resolution and the resolved name. + + `kind`: exactly one of disabled, explicit, auto-match, silent-miss + `workflow_name`: the resolved workflow name — present for explicit + and auto-match, None otherwise + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - `kind` is exactly one of the four fixed values + - Pure facts — the decision mirrors the resolution the operation + already made + properties: + "kind -> str": | + The outcome of the workflow resolution — disabled, explicit, + auto-match, or silent-miss. + "workflow_name -> str | None": | + The resolved workflow name, or None when no name resolved. + +"WorkIdentity(branch: str, slug: str | None = None, year: str | None = None)": + location: identity.py + annotations: | + The topics-shaped identity of the current work — the branch, with + the topic slug and year when the branch hosts a topic. + + `branch`: the current branch name as resolved by the operation + `slug`: the normalized topic slug — present when the branch hosts a + topic + `year`: the resolved year as four digits — present when the branch + hosts a topic + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The hosting decision and every resolution happen in the + constructing operation — nothing is read here + - The branch-only form — `slug` and `year` None — serves a branch + hosting no topic + properties: + "branch -> str": | + The current branch name as resolved by the operation. + "slug -> str | None": | + The normalized topic slug, or None in the branch-only form. + "year -> str | None": | + The resolved year as four digits, or None in the branch-only form. + +"CompositionStage(id: str, title: str)": + location: contexts.py + annotations: | + One row of the final composition — the stage identity and its + display title, as the card shows them. + + `id`: the stage identifier + `title`: the stage display title + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "id -> str": | + The stage identifier. + "title -> str": | + The stage display title. + +"ToolContribution(tool: str, document: WorkflowDocument)": + location: overlay.py + annotations: | + The committed contribution of one tool — the pairing of the tool + identity with its declarative document. + + `tool`: the tool identity assigned by the platform + `document`: the committed contribution — a `WorkflowDocument`-shaped + set of instructions + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "tool -> str": | + The tool identity assigned by the platform. + "document -> WorkflowDocument": | + The committed contribution of the tool. + +"WorkflowOverlay(workflow: WorkflowDocument | None, provenance: list[str])": + location: overlay.py + annotations: | + The result of the amendment layer — the effective workflow and its + provenance. + + `workflow`: the final effective workflow — None only in the + passthrough case: no authored workflow and no committed + contribution + `provenance`: the tools whose contributions committed, in + enumeration order + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - A None workflow with a non-empty provenance never occurs — a + committed contribution always yields a document + properties: + "workflow -> WorkflowDocument | None": | + The final effective workflow, or None in the passthrough case. + "provenance -> list[str]": | + The tools whose contributions committed, in enumeration order. + +"WorkflowAmendment(pipeline: PipelineIdentity, decision: WorkflowDecision, workflow: WorkflowDocument | None, work: WorkIdentity)": + location: amendments.py + annotations: | + The read-and-contribute view of one tool — the delivered facts of + the amendment checkpoint and the buffer of one tool's contribution. + + `pipeline`: the identity of the pipeline being composed + `decision`: the workflow decision of the operation + `workflow`: the original authored workflow — post decision, post + runner-skip merge, pre-layer; read-only and identical + for every tool; None when no workflow resolved + `work`: the current work identity + + Apply the `convention` practice for the data-model rules and + intra-package imports. + Use the `registering-hooks` practice for the hook signature that + receives this view. + + Requirements: + - The reads deliver the original facts — no staged-application + state exists; a tool never sees another tool's contribution + - The buffered contribution belongs to this tool alone + properties: + "pipeline -> PipelineIdentity": | + The identity of the pipeline being composed. + "decision -> WorkflowDecision": | + The workflow decision of the operation. + "workflow -> WorkflowDocument | None": | + The original authored workflow, read-only and identical for every + tool; None when no workflow resolved. + "work -> WorkIdentity": | + The current work identity. + methods: + "contribute(document: WorkflowDocument)": | + Buffer one declarative contribution of this tool. + + `document`: the complete contribution — a `WorkflowDocument`-shaped + set of instructions (prompt, stages, extend, memory) + using the same vocabulary an authored workflow-file + uses + + Requirements: + - The call buffers into the buffer of this tool alone and changes + nothing until the delivery commits it + - The replacement is whole — a later call replaces the earlier + buffered document + - A document with no prompt, no stages, no extend, and no memory + is an empty contribution — the delivery discards it with a + warning + + Constraints: + - Do not cancel, redirect, or defer the operation — a + contribution transforms the workflow layer only + +"merge_workflow_overlay(base: WorkflowDocument | None, contributions: list[ToolContribution]) -> overlay: WorkflowOverlay": + location: overlay.py + annotations: | + The authored-wins overlay merge — compose the effective workflow + from the authored base and the committed tool contributions. + + `base`: the authored workflow after the decision and the + runner-skip merge; None is the empty base — a silent + auto-match miss keeps the layer active + `contributions`: the committed contributions in enumeration order + `overlay`: the effective workflow with its provenance + + Apply the `convention` practice for docstring style and + intra-package imports. + + Algorithm: + 1. Take `base` as the authored layer — None is the empty base + 2. prompt: place the authored prompt first, then append the prompt + of every committed contribution in enumeration order; with no + authored prompt the first tool text becomes the prompt + 3. memory: keep the authored block when present — it is unbeatable; + otherwise the block of the later contributing tool wins; no + field-level merging + 4. stages: for each stage name take the authored entry as the + ground — an authored field is never overwritten; a field the + author left unset takes the value of the later contributing tool + that sets it; a stage with no authored entry is fully defined by + the tools + 5. skip is an ordinary stage field: an authored skip — from the + workflow-file or the merged runner skip — is unbeatable; an + unset skip takes a contributing skip; skip=False overrides + nothing + 6. extend: keep the authored entries; a contribution entry under an + authored name is not applied; among contributing tools the later + entry wins per name + 7. Compose the provenance from the identities of the committed + contributions in enumeration order + 8. Return the `WorkflowOverlay` — the workflow None only when the + base is None and no contribution committed + + Requirements: + - Pure — the inputs stay unmutated; the result is a new document + - The prompt concatenation joins the non-empty texts with a single + blank line between consecutive texts — the authored prompt first, + then each committed contribution text in enumeration order; no other + separators, prefixes, or suffixes are added + - Deterministic — the same inputs give the same overlay + - The result stays declarative — it passes the same compilation + validation an authored workflow passes + + Constraints: + - Do not read or write the filesystem + - Do not invent instructions absent from the inputs + - Do not mutate `base`, the contributions, or their maps + +"RunCreated(pipeline: PipelineIdentity, decision: WorkflowDecision, workflow: WorkflowDocument | None, composition: list[CompositionStage], provenance: list[str], work: WorkIdentity, statuses: list[str], runtime_dir: str)": + location: contexts.py + annotations: | + The read-only context of the run-creation notification — the facts + of the composition at the moment immediately before the runner + launch. + + `pipeline`: the identity of the running pipeline + `decision`: the workflow decision of the operation + `workflow`: the final effective workflow — the authored instructions + plus the committed tool contributions + `composition`: the ordered stages of the final composition — one row + per compiled stage, as the card shows + `provenance`: the tools whose contributions committed, in + enumeration order + `work`: the current work identity + `statuses`: the maximal present statuses of the work's topic at the + moment — both axes, built-in and tool + `runtime_dir`: the run's runtime directory as a posix string + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Read-only facts of the composed moment — a hook observes and + cannot alter + properties: + "pipeline -> PipelineIdentity": | + The identity of the running pipeline. + "decision -> WorkflowDecision": | + The workflow decision of the operation. + "workflow -> WorkflowDocument | None": | + The final effective workflow — authored instructions plus the + committed tool contributions. + "composition -> list[CompositionStage]": | + The ordered stages of the final composition, as the card shows + them. + "provenance -> list[str]": | + The tools whose contributions committed, in enumeration order. + "work -> WorkIdentity": | + The current work identity. + "statuses -> list[str]": | + The maximal present statuses of the work's topic at the moment. + "runtime_dir -> str": | + The run's runtime directory as a posix string. + +"RunCompleted(pipeline: PipelineIdentity, decision: WorkflowDecision, workflow: WorkflowDocument | None, composition: list[CompositionStage], provenance: list[str], work: WorkIdentity, statuses: list[str], runtime_dir: str, exit_code: int)": + location: contexts.py + annotations: | + The read-only context of the run-completion notification — the same + facts recomputed at the completion moment, plus the outcome of the + launch attempt. + + `pipeline`: the identity of the running pipeline + `decision`: the workflow decision of the operation + `workflow`: the final effective workflow the run executed + `composition`: the ordered stages of the executed composition + `provenance`: the tools whose contributions committed + `work`: the current work identity + `statuses`: the maximal present statuses recomputed at the + completion moment + `runtime_dir`: the run's runtime directory as a posix string + `exit_code`: the actual exit code of the launch attempt — zero, + non-zero, or a spawn failure (126/127) + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Read-only facts of the completed attempt — completion is a fact, + not a success claim + properties: + "pipeline -> PipelineIdentity": | + The identity of the running pipeline. + "decision -> WorkflowDecision": | + The workflow decision of the operation. + "workflow -> WorkflowDocument | None": | + The final effective workflow the run executed. + "composition -> list[CompositionStage]": | + The ordered stages of the executed composition. + "provenance -> list[str]": | + The tools whose contributions committed, in enumeration order. + "work -> WorkIdentity": | + The current work identity. + "statuses -> list[str]": | + The maximal present statuses recomputed at the completion moment. + "runtime_dir -> str": | + The run's runtime directory as a posix string. + "exit_code -> int": | + The actual exit code of the launch attempt. + +"PipelineHooks()": + location: events.py + annotations: | + The checkpoint surface of the pipeline domain — the amendment + delivery and the two run notifications over the platform facade. + + Apply the `convention` practice for the code style and + intra-package imports. + Use the `per-tool-delivery` practice for the staged delivery loop of + the amendment checkpoint. + Use the `declaring-actions` practice for the emission contract of + the notification checkpoints. + Use the `registering-hooks` practice for the registration contract + behind every checkpoint. + + Requirements: + - Cheap construction — no enumeration and no imports happen at + construction + - One `HookRegistry` per run carries every checkpoint of a command + — the assembly runs once per run whatever the number of + checkpoints + - Every context is built from the values the caller passes — no + repository reads happen at a checkpoint + methods: + "amend_workflow(pipeline: PipelineIdentity, decision: WorkflowDecision, workflow: WorkflowDocument | None, work: WorkIdentity) -> overlay: WorkflowOverlay": | + Deliver the workflow-amendment checkpoint and return the + effective workflow with its provenance. + + `pipeline`: the identity of the pipeline being composed + `decision`: the workflow decision of the operation + `workflow`: the authored workflow after the decision and the + runner-skip merge; None when no workflow resolved + `work`: the current work identity + `overlay`: the effective workflow and the contributing tools + + Use the `per-tool-delivery` practice for the delivery loop. + + Algorithm: + 1. Resolve the address domain="pipeline", action="amend_workflow" + against `declared_actions` + 2. Walk the subscriptions of the address per tool in enumeration + order: build the tool's `WorkflowAmendment` view over the + delivered facts — every tool reads the same original + `workflow` — wrap it via `wrap_context`, project the call + arguments via `build_hook_arguments` with the tool's own + context, and call each hook of the tool + 3. A tool whose every hook returned without raising and whose + buffer carries a non-empty contribution commits as one + `ToolContribution` + 4. A tool with a raising hook is a hard failure: a clean error + naming the tool and the action stops the command at the first + failure; the tool's contribution is discarded + 5. A tool whose buffered document is empty — no prompt, no + stages, no extend, no memory — is a content no-op: a warning + naming the tool, the action, and the reason, the contribution + discarded, the walk continues + 6. Merge the committed contributions onto `workflow` via + `merge_workflow_overlay` and return the overlay + + Requirements: + - The commit granularity is the tool — a tool's whole + contribution commits only after every hook of the tool succeeds + - An address without subscriptions returns the passthrough + overlay — the workflow passed in, an empty provenance + - The merged result passes the same compilation validation an + authored workflow passes + + Constraints: + - Do not apply any contribution outside the single merge after + the walk + - Do not skip a subscriber of the address + - Do not read repositories or the filesystem at the checkpoint + "emit_run_created(pipeline: PipelineIdentity, decision: WorkflowDecision, overlay: WorkflowOverlay, composition: list[CompositionStage], work: WorkIdentity, statuses: list[str], runtime_dir: str)": | + Emit the run-creation notification — the facts of the composition + immediately before the runner launch. + + `pipeline`: the identity of the running pipeline + `decision`: the workflow decision of the operation + `overlay`: the amendment result — the effective workflow and the + provenance + `composition`: the ordered stages of the final composition + `work`: the current work identity + `statuses`: the maximal present statuses at the moment + `runtime_dir`: the run's runtime directory as a posix string + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `RunCreated` context from the values — the effective + workflow and the provenance read from `overlay` + 2. Emit the address domain="pipeline", action="run_created" via + `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + - A failing hook is skipped with a warning under the soft error + class of the action — the launch proceeds + "emit_run_completed(pipeline: PipelineIdentity, decision: WorkflowDecision, overlay: WorkflowOverlay, composition: list[CompositionStage], work: WorkIdentity, statuses: list[str], runtime_dir: str, exit_code: int)": | + Emit the run-completion notification — the recomputed facts of + the finished launch attempt. + + `pipeline`: the identity of the running pipeline + `decision`: the workflow decision of the operation + `overlay`: the amendment result of the run + `composition`: the ordered stages of the executed composition + `work`: the current work identity + `statuses`: the maximal present statuses recomputed at the + completion moment + `runtime_dir`: the run's runtime directory as a posix string + `exit_code`: the actual exit code of the launch attempt + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `RunCompleted` context from the values + 2. Emit the address domain="pipeline", action="run_completed" + via `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + - The emission happens on every launch-attempt return path — + zero, non-zero, and spawn failures alike + - A failing hook warns under the soft error class — the exit code + of the run is never affected + +--- + +Author: Goga +CreatedAt: 18/09/26 +Description: | + Owner of the pipeline domain hooks zone — the run-event facts, the + workflow amendment with its overlay, and the checkpoint surface over + the hooks platform. +``` + +**.usages file** — `goga/pipeline/hooks/.usages/checkpoints.md` (full content): + +```markdown +# pipeline — amending workflows and emitting run checkpoints + +How the pipeline flows consume the hooks zone of the pipeline domain: +delivering the workflow amendment before compilation and emitting the two +run notifications around the runner launch. For the run coordination and +the card over the pipeline facade. + +## The checkpoint surface + +One `PipelineHooks` object serves every checkpoint of a command — the +surface shares one registry per run, so a command that reaches several +checkpoints enumerates the tool packages once. + +```python +from goga.pipeline.hooks import PipelineHooks + +hooks = PipelineHooks() +``` + +## Resolve the facts in the operation + +Every context is built from the values the caller passes — the checkpoint +reads no repository. Resolve the facts before the delivery: + +- `PipelineIdentity` — the discovered pipeline name, the authored header + name and description, and the source (`project` or `user`). +- `WorkflowDecision` — the outcome of the workflow resolution: `disabled`, + `explicit`, `auto-match`, or `silent-miss`, with the resolved workflow + name when applicable. +- `WorkIdentity` — the current branch with the topic slug and year when + the branch hosts a topic; the branch-only form otherwise. + +## Amend before compilation + +Deliver the amendment after the runner-skip merge and before +`compile_flow`; compile the effective workflow the delivery returns. + +```python +overlay = hooks.amend_workflow( + pipeline=identity, + decision=decision, + workflow=merged_workflow, # None is valid — a silent miss keeps the layer active + work=work, +) +compile_flow(overlay.workflow, ...) +``` + +- A tool contributes one declarative `WorkflowDocument`; authored intent + wins per slot — the prompt appends, the memory block is whole, stage + fields fill only what the author left unset, extend entries add. +- The amendment action is hard: the first failing tool stops the command + with a clean error naming the tool and the action; the tool's whole + contribution is discarded. +- An address without subscriptions returns the passthrough overlay — the + workflow stays what was passed, the provenance is empty. With no tool + packages installed every run composes exactly what was passed. + +## Emit around the launch + +Emit the creation immediately before the runner launch (after compilation +and prompt materialization) and the completion on every launch-attempt +return — zero, non-zero, and spawn failures alike. + +```python +hooks.emit_run_created( + pipeline=identity, decision=decision, overlay=overlay, + composition=stages, work=work, statuses=statuses, + runtime_dir=runtime_dir, +) +exit_code = run_flow(...) +statuses = resolve_topic_status(topic_dir, scale) # recompute at the moment +hooks.emit_run_completed( + pipeline=identity, decision=decision, overlay=overlay, + composition=stages, work=work, statuses=statuses, + runtime_dir=runtime_dir, exit_code=exit_code, +) +``` + +- Both notifications are fire-and-forget: a failing hook warns naming the + tool, the action, and the reason; the run's exit code is unaffected. +- `composition` carries the ordered stages as the card shows them; build + it from the compiled stages of the same compilation the run executes. + +## The card form + +The card composes through the same amendment with the same precedence and +reports `overlay.provenance` as the contributing tools. No run events fire +in card form. +``` + +--- + +### Cell 3 — `goga/pipeline` (MODIFY) + +**CODEMANIFEST diff** — six localized changes; everything else unchanged. + +**3.1 Imports** — append two groups to the existing `Imports` list (the five +existing groups stay verbatim): + +```yaml + - Types: + - PipelineHooks + - PipelineIdentity + - WorkflowDecision + - WorkIdentity + - CompositionStage + Usages: + - checkpoints + From: goga/pipeline/hooks + - Types: + - resolve_current_branch_name + - resolve_topic_dir + - resolve_topic_status + - assemble_status_scale + Usages: + - topic-paths + - topic-statuses + From: goga/history +``` + +**3.2 Global Annotations** — insert one paragraph after the +`GOGA_SKIP_STAGES=` paragraph: + +```yaml + The run coordination and the card compose through the pipeline hooks zone: + the workflow amendment is delivered after the runner-skip merge and before + compilation, the run notifications fire around the runner launch, and the + card reports the contributing tools. An explicit workflow disable turns + the layer off — the raw authored DSL composes and no amendment delivers; + a silent auto-match miss keeps the layer active onto the empty base. The + branch, topic, and status facts resolve in the operation before the + delivery — the checkpoints read nothing. With no tool packages installed + the overlay is the passthrough — every form behaves exactly as before. + Use the `checkpoints` practice for the checkpoint surface of the zone. + Use the `topic-paths` practice for the topic directory resolution behind + the work identity and the `topic-statuses` practice for the status facts + of the run events. +``` + +**3.3 `run_pipeline`** — replace the full annotations block (signature and +`location` unchanged): + +```yaml + annotations: | + Resolve a pipeline name to an absolute file path via `list_pipelines`, + resolve an optional workflow via `resolve_workflow` from the environment + decision, deliver the workflow amendment through the pipeline hooks zone, + compile the pipeline-file (extended by the effective workflow) into an + afm flow-file at runtime via `compile_flow`, materialize the four + agent prompt files (defaults plus inline overrides) into the runtime + prompts directory, emit the run-creation facts, launch afm through + `run_flow`, and emit the run-completion facts on its return. This is the + run coordination routine — it performs discovery, workflow resolution, + path resolution, fact resolution, amendment delivery, compilation, and + prompt materialization; the actual subprocess execution lives in + `run_flow`. + + `name`: pipeline name without extension + `project_dir`: project-level pipelines directory (same meaning as in `list_pipelines`) + `user_dir`: user-level pipelines directory (same meaning as in `list_pipelines`) + `port`: TCP port forwarded to afm run --port via `run_flow` + (allocated by the host-side caller) + `parallel`: optional cap on concurrently executing stages, forwarded to + `run_flow` as its max_parallel argument. When None (default) — + afm runs unbounded (run_flow omits --max-parallel). Read from + the in-container CLI --parallel flag + `exit_code`: 0 on success, non-zero on error (missing pipeline, missing + binary, afm failure, structural DSL error, workflow parse + error, materialization error). 127 means afm is not on PATH + inside the container. + + Apply `convention` for error-handling style and docstring formatting. + Apply `parse-workflow` for the workflow-file contract consumed through + `resolve_workflow`. + Apply `compile-flow` for the compilation step contract and the documents + tuple. + Apply `default_prompts` for resolving the packaged default prompt files. + Apply `run-flow` for the subprocess launch contract. + Apply `checkpoints` for the amendment delivery and the two emissions of + the pipeline hooks zone. + Apply `topic-paths` for the work identity resolution and + `topic-statuses` for the status facts of the run events. + + Algorithm: + 1. Discover pipelines via `list_pipelines` and find the entry whose name + matches + 2. If no match — report that the pipeline is missing and return a + non-zero exit code + 3. Build the absolute pipeline path from the matching entry's source + directory and the pipeline name + 4. Resolve the in-container runtime directory from the AFM_DIR + environment variable; when unset raise a readable "AFM_DIR not set" + error; resolve the value to an absolute path + 5. Compose the output flow path inside that directory + 6. Read the workflow decision from the environment — + GOGA_WORKFLOW_DISABLED="1" disables the workflow, otherwise + GOGA_WORKFLOW_NAME names an explicit workflow — and resolve via + `resolve_workflow` with the pipeline name + 7. Read GOGA_SKIP_STAGES from the environment (unset/empty — no skip); + when non-empty split into names and apply via `apply_skip_stages` + onto the resolved workflow + 8. Resolve the amendment facts from the operation's own data: the + `PipelineIdentity` (discovered name, authored header name and + description, entry source), the `WorkflowDecision` (disabled / + explicit / auto-match / silent-miss with the resolved name), and the + `WorkIdentity` (current branch via `resolve_current_branch_name`; + the hosting topic slug and year via `resolve_topic_dir` when its + directory exists, the branch-only form otherwise) + 9. Unless the decision is disabled, deliver the amendment via the + `PipelineHooks` checkpoint surface with the authored workflow after + the skip merge — receiving the overlay result; a disabled decision + delivers nothing (the layer is off) + 10. Resolve the in-container project name via `resolve_project_name` + (None when the git origin remote is unavailable). Compile via + `compile_flow` with the overlay workflow (the resolved workflow when + the layer is off), the in-container project root (Path.cwd()) as + root_dir, and the project name; receive the documents tuple; + structural errors propagate unchanged + 11. Materialize agent prompts atomically (validate-all, then wipe, then + write): resolve the default prompts directory per `default_prompts`; + for each overridable role (planner, executor, reviewer) require an + inline override from the documents tuple or an existing default file + (stem via `translate_role`); require the summary default; then reset + /prompts/ and write exactly four files — overrides where + present, defaults otherwise, summary always from the default + 12. Order the compiled stages via `order_stages` and build one + `CompositionStage` per ordered stage — id from the `FlowStage` id, + title from the `FlowStage` name + 13. Resolve the work statuses — the maximal present statuses of the + hosting topic via `assemble_status_scale` and `resolve_topic_status`; + an empty list in the branch-only form + 14. Emit the run-creation facts via the checkpoint surface immediately + before the launch: the identity, the decision, the overlay, the + composition, the work identity, the statuses, and the runtime dir as + a posix string + 15. Launch afm via `run_flow` with the compiled flow-file path, `port`, + and max_parallel=`parallel` + 16. On every return of `run_flow` — zero, non-zero, and spawn failures + alike — recompute the work statuses at the completion moment and + emit the run-completion facts with the actual exit code + 17. Return the exit code returned by `run_flow` + + Requirements: + - Always pass the absolute pipeline path to `compile_flow` — never the + bare name + - Always pass the absolute compiled flow path to `run_flow` — never the + bare name or the DSL path + - Always forward `port` to `run_flow`; forward `parallel` (None + propagates — no --max-parallel flag) + - Read AFM_DIR, GOGA_WORKFLOW_DISABLED, GOGA_WORKFLOW_NAME, and + GOGA_SKIP_STAGES directly from the process environment + - GOGA_WORKFLOW_DISABLED="1" takes precedence over GOGA_WORKFLOW_NAME + - Workflow resolution and parsing go through `resolve_workflow` — one + rule set shared with the card + - The amendment delivery and its precedence are the same rule set the + card applies — the same flags compose the same overlay in both forms + - A missing workflow-file is a silent miss, not an error; the layer + stays active onto the empty base + - An explicit disable turns the layer off — no delivery happens and the + raw authored DSL composes + - Skip merges onto any resolved workflow and applies to a workflow-less + pipeline; unknown skip names surface as the compiler's structural + error + - The branch, topic, and status facts resolve in the operation before + the delivery — the checkpoints read nothing + - `run_created` fires immediately before the runner launch; + `run_completed` fires on every launch-attempt return path — no exit + path skips the completion emission + - A missing pipeline and a structural composition error fire no events + — the return happens before the checkpoints + - The runtime dir fact is the resolved AFM_DIR path as a posix string + - With no tool packages installed the overlay is the passthrough — the + compiled workflow, the prompts, and the output behave exactly as + before + - /prompts/ contains exactly four files after step 11 succeeds; + validation precedes any wipe or write — atomicity guarantees no + partial state on disk + - Inline prompt overrides come exclusively from the documents tuple + header roles; the override is a full file replacement — no merge, no + concatenation + - Default prompt files resolve from the installed package location per + `default_prompts` — never from AFM_DIR, CWD, or an environment + variable + - root_dir resolution is CWD-based (Path.cwd() resolves to /workspace + inside the goga container); project_name resolves in-container via + `resolve_project_name` + - Do not mutate the documents tuple returned by `compile_flow` — read + the inline prompt overrides as-is + + Constraints: + - Do not invoke afm directly outside `run_flow` + - Do not invoke the compiler outside `compile_flow` + - Do not invoke the workflow parser outside `parse_workflow` + - Do not allocate the port — the caller allocates it + - Do not default `parallel` — None means unbounded + - Do not accept relative `project_dir` or `user_dir` + - Do not mask or wrap exceptions from `compile_flow` or `parse_workflow` + — structural errors propagate with their readable messages + - Do not resolve git or topic facts inside a hook delivery — the + operation owns the resolution + - Do not skip the completion emission on any launch-attempt return path + - Do not write prompts inside the project directory or /workspace — + always /prompts/ + - Do not write inline prompt overrides into the compiled flow-file + - Do not delete skipped stages or rewrite depends_on — `compile_flow` + does both + - Do not write or generate a workflow-file for skip — the merge is + in-memory only +``` + +**3.4 `describe_pipeline`** — replace the full annotations block (signature +and `location` unchanged): + +```yaml + annotations: | + Compose the card of a single pipeline: name, description, the + post-workflow stage composition as it would execute, and the tools + that contributed to it. + + `name`: pipeline name without extension + `project_dir`: project-level pipelines directory (absolute) + `user_dir`: user-level pipelines directory (absolute) + `workflow`: optional explicit workflow name (without the .yml extension) + `no_workflow`: when True, workflow application is disabled + `card`: `PipelineCard` — the pipeline name and description from the DSL + header, one `CardStage` per stage in execution order, and the + provenance of the amendment + + Apply `compile-flow` for the compilation contract and the documents tuple. + Apply `checkpoints` for the amendment delivery of the pipeline hooks + zone. + Apply `topic-paths` for the work identity resolution behind the + amendment facts. + Apply `convention` for docstring style and intra-package imports. + + Algorithm: + 1. Discover entries via `list_pipelines` and locate the matching name; + on no match report the missing pipeline with a readable error + 2. Resolve the workflow via `resolve_workflow` with the pipeline name and + the workflow flags + 3. Resolve the amendment facts (the `PipelineIdentity`, the + `WorkflowDecision`, and the `WorkIdentity` — the current branch via + `resolve_current_branch_name`; the hosting topic slug and year via + `resolve_topic_dir` when its directory exists, the branch-only form + otherwise) and, unless the decision is disabled, deliver the + amendment via the `PipelineHooks` checkpoint surface with the resolved + workflow — receiving the overlay result + 4. Compile the pipeline-file via `compile_flow` into a temporary flow-file + located in a system temporary directory — outside the project + directory and outside every runtime directory — with the overlay + workflow, and receive the documents tuple + 5. Order the compiled stages via `order_stages` + 6. Build the card: name and description from the parsed pipeline document + header; one `CardStage` per ordered stage — id from the `FlowStage` + id, title from the `FlowStage` name (the display label); the card + provenance from the overlay + 7. Discard the temporary flow-file and return the card + + Requirements: + - The stage composition equals the composition a run of the same pipeline + with the same workflow flags would execute — the same amendment layer + with the same precedence and the same compilation machine produce + both + - The card names the contributing tools — the provenance is empty when + nothing contributed + - An explicit workflow disable turns the layer off — no delivery + happens and the raw authored DSL composes + - A silent auto-match miss keeps the layer active — the delivery runs + onto the empty base + - Loop-expanded stage copies appear as separate stages + - The run-only GOGA_SKIP_STAGES environment variable is not read — the + CLI skip channel is a run concern; workflow-file skip directives DO + apply through the shared compilation machine (part of the composition + a run with the same workflow flags executes) + - The temporary flow-file lives outside the project and runtime + directories and is removed afterwards + - The card name and description are the authored DSL header values + + Constraints: + - Do not launch afm and do not run any stage — the card is read-only + - Do not emit run events in card form + - Do not write into the project directory or any runtime directory + - Do not re-parse the pipeline-file — header data comes from the documents + tuple + - Do not reorder stages beyond `order_stages` +``` + +**3.5 `PipelineCard`** — replace the type block: + +```yaml +"PipelineCard(name: str, description: str, stages: list[CardStage], provenance: list[str] = [])": + location: pipeline_card.py + annotations: | + Describe the card of a single pipeline: the authored name and + description, the ordered stage rows, and the tools whose contributions + shaped the composition. + + `name`: pipeline name from the DSL header + `description`: pipeline description from the DSL header + `stages`: stage rows in execution order — one per compiled stage + `provenance`: the tools whose contributions committed into the + composition, in enumeration order; empty when none + contributed + + Build with the standard library dataclasses module and + @dataclass(kw_only=True) (per `convention`). + + Requirements: + - Use @dataclass(kw_only=True) + - `provenance` defaults to an empty list — constructions without it + remain valid + - `provenance` defaults to an empty list via + field(default_factory=list) in the implementation; the signature + default `[]` is a DSL representation, the actual default factory is + applied at construction + + properties: + "name -> str": | + Pipeline name from the DSL header. + "description -> str": | + Pipeline description from the DSL header. + "stages -> list[CardStage]": | + Stage rows in execution order; loop-expanded copies appear as + separate rows. + "provenance -> list[str]": | + The tools whose contributions committed into the composition, in + enumeration order; empty when none contributed. +``` + +**3.6 `pipeline_cli`** — replace the card-template requirement bullet in the +`Requirements` list (all other bullets, the signature, Algorithm, and +Constraints unchanged): + +```yaml + - The card template: a "name:" line, a "description:" line, a blank line, + a "---" separator, a blank line, then per ordered stage the marker line + "* :" and a "title:" line indented by four spaces; the separator + block is printed even when the card carries no stages; when the card + provenance is non-empty, one blank line and one "tools:" field line + follow the stage blocks — the contributing tools comma-separated in + provenance order; an empty provenance adds nothing — the output stays + byte-identical to the provenance-free card +``` + +**.usages file** — `goga/pipeline/.usages/registering-hooks.md` (CREATE, +full content): + +```markdown +# pipeline — registering hooks + +How a `goga_tool_*` package subscribes its hooks to the pipeline domain +actions. For tool package authors; no goga code changes are needed. + +The domain opens three actions. One is an amendment — a read-and-contribute +view over the workflow a run is about to execute, delivered before +compilation; it is the platform's first hard action. Two are notifications — +the read-only facts of the run, delivered immediately before the runner +launch and on every launch-attempt return. + +## The events + +| Address | Error class | Fires | +|---|---|---| +| `pipeline / amend_workflow` | hard | After the workflow resolution and the runner-skip merge, before compilation — in the run form and in the card form alike. | +| `pipeline / run_created` | soft | Immediately before the runner launch — after compilation and prompt materialization. | +| `pipeline / run_completed` | soft | On every launch-attempt return — zero, non-zero, and spawn failures (126/127) alike. | + +A failing moment fires nothing: a missing pipeline and a structural +composition error return before any checkpoint. + +## Subscribe + +```python +# inside the goga_tool_ package +def register_hooks(hooks): + hooks.subscribe("pipeline", "amend_workflow", "hardening", add_hardening) + hooks.subscribe("pipeline", "run_completed", "reporter", report_run) +``` + +- `domain` — always `"pipeline"`. +- `action` — the event name from the table above. +- `name` — the hook name, unique per tool per address. +- `hook` — the callable executed when the event fires. + +A hook receives values only for the parameters it declares by the fixed +offered names: `context` — the delivered object of the event, read +attributes and call methods freely, attribute assignment is blocked; +`self` — the isolated context of your tool. The declaration order does +not matter; names you did not declare receive nothing. + +## The amendment view + +`amend_workflow` delivers a `WorkflowAmendment` view per tool. The +reads: `pipeline` — the identity of the running pipeline; `decision` — +the workflow decision (disabled / explicit / auto-match / silent-miss, +with the resolved name); `workflow` — the original authored workflow +after the decision and the runner-skip merge, read-only and identical +for every tool (None when no workflow resolved); `work` — the current +work identity. + +```python +def add_hardening(context): + context.contribute(hardening_workflow) +``` + +- `contribute(document)` buffers one declarative `WorkflowDocument`-shaped + contribution — the same instruction vocabulary an authored + workflow-file uses (prompt, stages, extend, memory). +- Your tool's contribution commits only after every hook of your tool + returns without raising; a repeat call replaces your buffer whole. +- Authored intent wins per slot: the prompt appends (authored first, tool + texts in enumeration order), the memory block is whole (an authored + block is unbeatable), stage fields fill only what the author left + unset, extend entries add under fresh names. `skip` is an ordinary + field — an authored skip is unbeatable, an unset skip is yours to set, + including removing a pipeline-file stage the author did not protect. +- A failing hook of the amendment stops the command with a clean error + naming your tool and the action; your whole contribution is discarded. +- The merged workflow passes the same compilation validation as an + authored one — a contribution naming an unknown stage surfaces as the + compiler's structural error. + +## The run notifications + +Both notifications deliver read-only facts; a failing hook warns naming +your tool, the action, and the reason — the run's exit code is never +affected. + +- `run_created` — `RunCreated`: `pipeline`, `decision`, `workflow` (the + final effective workflow — authored instructions plus the committed + tool contributions), `composition` (the ordered stages as the card + shows them), `provenance` (the tools whose contributions committed), + `work`, `statuses` (the maximal present topic statuses at the moment), + `runtime_dir`. +- `run_completed` — `RunCompleted`: the same facts recomputed at the + completion moment, plus `exit_code` — the actual exit code of the + launch attempt. Completion is a fact, not a success claim. + +## Integration scenarios + +- **Artifact → status on completion** — subscribe to `run_completed`, + read `work` and `exit_code`, register your status on the statuses + domain keyed by your artifact. +- **Run reporting and automation** — subscribe to `run_created` and + `run_completed`, read the facts, keep state in your `self` context. +- **Workflow personalization** — subscribe to `amend_workflow`, read + `workflow`, contribute your declarative adjustments. +- **On-the-fly composition add-ons** — contribute `extend` entries with + fresh stage names; authored names always win. +``` + +**Existing .usages sync** — three shipped practices of the cell drift with +the integration and are updated in place (no structural rewrites): + +- `goga/pipeline/.usages/describe-pipeline.md` — document the `provenance` + field of `PipelineCard` in "The models"; state in the intro and the + workflow-equivalence section that the card composes through the pipeline + hooks zone with the same precedence a run applies, that the card names the + contributing tools, and that no run events fire in card form. +- `goga/pipeline/.usages/pipeline-cli.md` — extend the `--info` card format + with the conditional `tools:` field line — printed only when the card + provenance is non-empty, the contributing tools comma-separated in + provenance order; an empty provenance adds nothing (byte-identical card). +- `goga/pipeline/.usages/run-pipeline.md` — insert the amendment delivery + between the skip merge and compilation in the composition flow, and note + the two run events (`run_created` immediately before the launch, + `run_completed` on every launch-attempt return). + +--- + +### Non-cell deliverables (task items 6–7) + +- **`docs/features/pipelines/hooks.md`** — fill the negative stub with the + address | error class | fires table, the context members, and the failure + semantics (source material: the `registering-hooks` usage above; nav slot + already exists in `mkdocs.yml`). +- **MkDocs traceability** — sync after the docs fill; no navigation changes + required. +- **Tests** — per `.goga/usages/conventions.md`: unit coverage for the + overlay merge semantics (per-slot precedence, enumeration order, per-tool + commit/discard), the event timeline (pre-launch creation, any-exit + completion with code and runtime dir incl. 126/127), card/run equivalence, + and the zero-impact guarantee. + +--- + +## Dependency Map + +``` + [unchanged leaves] [modified] [new] + + goga/pipeline/workflow ──T: WorkflowDocument──────────┐ + │ + goga/hooks (facade) ──T: HookRegistry, wrap_context, + build_hook_arguments, emit_hook_event, + declared_actions + U: declaring-actions, + per-tool-delivery, registering-hooks──────────┤ + ▼ + goga/pipeline/hooks (CREATE) + 11 types + .usages/checkpoints.md + │ + │ T: PipelineHooks, PipelineIdentity, + │ WorkflowDecision, WorkIdentity, + │ CompositionStage + U: checkpoints + ▼ + goga/history ──T: resolve_current_branch_name,──────► goga/pipeline (MODIFY) + resolve_topic_dir, resolve_topic_status, │ + assemble_status_scale + U: topic-paths, ├──► goga/pipeline/compiler (unchanged) + topic-statuses └──► goga/afm (unchanged) + + goga/hooks/catalog (MODIFY: +3 records) — consumed through the platform; + published records and the remaining platform cells unchanged +``` + +No circular dependency: the zone never imports `goga/pipeline`; the parent +imports the zone. `goga/history` imports no pipeline cell. + +--- + +## Verification Checklist + +**`goga/hooks/catalog` (after step 1)** +- `goga lint` passes; the three records are additive; the published records + and `Action` are untouched +- `declared_actions()` ordering stays domain-then-name with the new records +- unit tests: catalog completeness and deterministic ordering + +**`goga/pipeline/hooks` (after step 2)** +- `goga lint` passes on the new CODEMANIFEST; the facade `__init__` exposes + all eleven contract types through `__all__` +- facade check: `python -c "from goga.pipeline.hooks import PipelineHooks"` +- unit tests: per-slot precedence (prompt append order, memory whole-block, + stages per-field with `skip` as an ordinary field, extend add with + later-tool-wins), enumeration order, per-tool commit/discard, hard-stop + with a clean error naming tool and action, empty-buffer warning + + continue, mutual blindness of the amendment views, passthrough overlay, + emission context members, soft-warn semantics, no repository reads at the + checkpoints + +**`goga/pipeline` (after step 3)** +- `goga lint` passes; run/card tests: the amendment delivers between the + skip merge and `compile_flow`; `run_created` fires immediately before + `run_flow`; `run_completed` fires on zero, non-zero, and 126/127 returns + with the recomputed statuses and the actual exit code; no events on a + missing pipeline or a structural composition error +- card/run equivalence: the same flags produce the same composition and the + same provenance in both forms; the card names the contributing tools +- disable semantics: an explicit disable delivers nothing and composes the + raw DSL; a silent auto-match miss keeps the layer active +- zero impact: with no tool packages installed, every form's output, + errors, and exit codes are byte-identical to the pre-change behavior + (empty provenance adds no line to the card) +- the three existing usage files of the cell are in sync with the new + surface (describe-pipeline models list `provenance`; pipeline-cli card + format carries the conditional `tools:` line; run-pipeline flow carries + the amendment step and the two events) + +**Documentation (after step 4)** +- `docs/features/pipelines/hooks.md` answers each integration scenario from + the problem (artifact → status, run reporting/automation, workflow + personalization, on-the-fly add-ons) from moments, context members, and + failure semantics alone; mkdocs nav and traceability in sync + +**Final** +- `pytest tests/ -x` green; `ruff check goga/` clean; AC1–AC9 of the task + re-verified against the implementation diff --git a/.goga/history/2026/add-pipeline-hooks/design.md b/.goga/history/2026/add-pipeline-hooks/design.md new file mode 100644 index 00000000..62584b1b --- /dev/null +++ b/.goga/history/2026/add-pipeline-hooks/design.md @@ -0,0 +1,1533 @@ +# Design Document: `add-pipeline-hooks` + + + +Complete architectural specification for the pipeline domain hooks zone: the +`amend_workflow` amendment action (the platform's first hard action), the +`run_created` / `run_completed` notifications, the authored-wins workflow +overlay, and the wiring of both pipeline flows (run and card) through the +new checkpoint surface. + +Contracts are materialized and lint-clean (`goga lint`: 78 cells, 0 errors). +This document specifies **what to implement and how** — module layouts, +algorithms at field level, error channels, and the full test stack. The +implementation order stays with the planning stage. + +--- + +## Contract Changes + +### Changed CODEMANIFEST Files + +- `goga/hooks/catalog/CODEMANIFEST`: three action records appended to the + `declared_actions` requirements — `pipeline/amend_workflow` (hard, the + platform's first), `pipeline/run_created` (soft), + `pipeline/run_completed` (soft). Purely additive; `Action` and the + published records untouched. +- `goga/pipeline/hooks/CODEMANIFEST`: **created** — the hooks zone cell. 11 + types across five modules; Imports from `goga/hooks` (5 types + 3 + practices) and `goga/pipeline/workflow` (`WorkflowDocument`). +- `goga/pipeline/CODEMANIFEST`: two Imports groups appended (hooks zone: 6 + types + `checkpoints`; history: 4 types + `topic-paths` + + `topic-statuses`); hooks-zone paragraph in the global Annotations; + `PipelineCard` gains `provenance`; `describe_pipeline` algorithm extended + to 7 steps; `run_pipeline` algorithm extended to 17 steps; + `pipeline_cli` card template gains the conditional `tools:` line. + +### New Entities + +All in the zone `goga/pipeline/hooks` (facade `__init__.py` exposes exactly +these via `__all__`, alphabetical): + +- `PipelineIdentity(name, display_name="", description, source)` — + `identity.py`. The identity vocabulary of every pipeline event. +- `WorkflowDecision(kind, workflow_name)` — `identity.py`. The outcome of + the workflow resolution: `disabled` / `explicit` / `auto-match` / + `silent-miss`. +- `WorkIdentity(branch, slug=None, year=None)` — `identity.py`. The + topics-shaped identity of the current work. +- `CompositionStage(id, title)` — `contexts.py`. One row of the final + composition as the card shows it. +- `ToolContribution(tool, document)` — `overlay.py`. The committed + contribution of one tool. +- `WorkflowOverlay(workflow, provenance)` — `overlay.py`. The result of the + amendment layer. +- `merge_workflow_overlay(base, contributions) -> overlay` — `overlay.py`. + The authored-wins overlay merge (Routine). +- `WorkflowAmendment(pipeline, decision, workflow, work)` — `amendments.py`. + The read-and-contribute view of one tool; method `contribute(document)`. +- `RunCreated(...)` / `RunCompleted(...)` — `contexts.py`. The read-only + run-notification fact bundles. +- `PipelineHooks()` — `events.py`. The checkpoint surface: + `amend_workflow`, `emit_run_created`, `emit_run_completed`. + +### Changed Entities + +- `PipelineCard` (`goga/pipeline/pipeline_card.py`) — gains + `provenance: list[str]` (default `field(default_factory=list)`). +- `describe_pipeline` (`goga/pipeline/describe_pipeline.py`) — resolves the + amendment facts, delivers the amendment (unless disabled), compiles with + the overlay workflow, reports `overlay.provenance` on the card. +- `run_pipeline` (`goga/pipeline/run_pipeline.py`) — 17-step coordination: + fact resolution, amendment delivery between skip merge and compilation, + composition build, status resolution, emissions around the launch. +- `pipeline_cli` (`goga/pipeline/cli.py`) — the card form renders the + conditional `tools:` field line; both failure paths also render the hard + amendment error cleanly. +- `declared_actions` catalog (`goga/hooks/catalog/catalog.py`) — three new + `Action` records. + +### Deleted Entities + +None. + +### Usages and Annotations Changes + +- `goga/pipeline/hooks/.usages/checkpoints.md` — created (checkpoint-surface + consumption: fact resolution, amend-before-compile, emit-around-launch, + card form). +- `goga/pipeline/.usages/registering-hooks.md` — created (tool-author guide: + events table, subscribe, amendment view, run notifications, integration + scenarios). +- `goga/pipeline/.usages/describe-pipeline.md`, `run-pipeline.md`, + `pipeline-cli.md` — synced to the new surface (provenance, amendment + layer, run events, `tools:` line). + +--- + +## Applied Fixes + +Three CODEMANIFEST defects surfaced during validation/tracing; each was +proposed to the user via the stage dialog and approved (answers A/A/A). + +### Fixed CODEMANIFEST Defects + +1. `goga/pipeline/CODEMANIFEST` (`run_pipeline` step 8, + `describe_pipeline` step 3 + constraint) — **authored header facts had + no source at fact-resolution time**. Before: "authored header name and + description" with no mechanism (both operations build the identity + before `compile_flow` returns the documents tuple). After: the steps + name the source — "read via `parse_dsl` from the pipeline-file text" + (the established `describe_pipelines` pattern; + `describe_pipelines.py:63`) — and the `describe_pipeline` constraint is + scoped: "Do not re-parse the pipeline-file **for the card fields** ... + the single early `parse_dsl` read of step 3 serves the amendment facts + only". (reason: annotation insufficient for implementation) +2. `goga/pipeline/CODEMANIFEST` (same two steps) — **the None branch was + undefined**. `resolve_current_branch_name() -> str | None` (detached + HEAD / missing git / non-repo) vs `WorkIdentity(branch: str)`. After: + "the literal \"unknown\" when it resolves None" — a non-fatal sentinel; + a hard error would regress detached-HEAD runs even with zero tool + packages installed, violating "every form behaves exactly as before". + (reason: interface ↔ interface mismatch) +3. `goga/pipeline/CODEMANIFEST` (Imports + `run_pipeline` step 9 + + `describe_pipeline` step 3) — **the disabled branch had no overlay + value**. Step 14 emits "the overlay", but a disabled decision "delivers + nothing" and `WorkflowOverlay` was not imported. After: + `WorkflowOverlay` added to the zone Types Imports; both steps state "a + disabled decision delivers nothing and the overlay is the passthrough + `WorkflowOverlay` of the merged workflow". (reason: annotation + reference without a resolvable source) + +All edits re-validated: `goga lint` 78 cells / 0 errors; `goga schema` +shows `goga/pipeline → goga/pipeline/hooks` with the six types; no cycles. + +--- + +## Entity Interaction and Data Flow + +### Interaction Diagram + +``` + goga/commands/pipeline (host CLI, docker boundary) + │ docker run + env + ▼ + goga/pipeline (in-container zone) + ┌──────────────────────────────────────────────────┐ + │ pipeline_cli ── run form ──▶ run_pipeline │ + │ │ card form ──▶ describe_pipeline │ + │ │ │ │ + │ ▼ ▼ │ + │ PipelineCard(+provenance) (1) fact resolution │ + │ ▲ │ parse_dsl │ + │ │ │ resolve_workflow + apply_skip_stages + │ │ │ resolve_current_branch_name / ──▶ goga/history + │ │ │ resolve_topic_dir (topic-paths, + │ │ │ (statuses: assemble_status_scale / topic-statuses) + │ │ │ resolve_topic_status) │ + │ │ ▼ │ + │ │ goga/pipeline/hooks (the zone) │ + │ │ PipelineHooks ── amend_workflow ──▶ WorkflowAmendment (per tool) + │ │ │ │ contribute() │ + │ │ │ ▼ │ + │ │ │ merge_workflow_overlay ◀─ ToolContribution* │ + │ │ │ │ │ + │ │ │ ▼ WorkflowOverlay │ + │ │ │ emit_run_created ──▶ RunCreated ─┐ │ + │ │ │ emit_run_completed ─▶ RunCompleted ├─▶ goga/hooks + │ └────────┤ │ (emit_hook_event, + │ compile_flow(overlay.workflow) ◀───────────────────┘ wrap_context, + │ │ ▲ build_hook_arguments, + │ ▼ │ HookRegistry, + │ order_stages ──┘ CompositionStage[] declared_actions) + │ │ ▲ + │ ▼ │ + │ run_flow (goga/afm) ── exit_code ──▶ emit_run_completed│ + └──────────────────────────────────────────────────┘ goga/hooks/catalog + (+3 Action records) +``` + +### Data Flows + +**Flow 1 — run form (the 17 steps of `run_pipeline`):** + +1. `list_pipelines` → `PipelineEntry` match → absolute `pipeline_path`; + missing → stderr message, `return 1` (no events). +2. `AFM_DIR` env → `afm_dir` (unset → `RuntimeError("AFM_DIR not set")`); + `runtime_dir = afm_dir.as_posix()`. +3. `GOGA_WORKFLOW_DISABLED` / `GOGA_WORKFLOW_NAME` env → decision inputs → + `resolve_workflow(name, workflow_name, no_workflow)` → `WorkflowDocument | None`. +4. `GOGA_SKIP_STAGES` split → `apply_skip_stages(workflow, skips)` → + merged `workflow` (None-safe; empty split → unchanged). +5. Facts: `pipeline_path.read_text()` → `parse_dsl(text)[0]` → header + (`name`, `description`) → `PipelineIdentity`; + kind-derivation (below) → `WorkflowDecision`; + `branch = resolve_current_branch_name() or "unknown"` → guarded + `resolve_topic_dir(branch)` → `WorkIdentity`. +6. `hooks = PipelineHooks()` (one instance for the whole run). + `decision.kind != "disabled"` → + `overlay = hooks.amend_workflow(pipeline=..., decision=..., workflow=..., work=...)`; + else `overlay = WorkflowOverlay(workflow=merged_workflow, provenance=[])`. +7. `resolve_project_name()` → `compile_flow(pipeline_path, flow_path, + workflow=overlay.workflow, root_dir=str(Path.cwd().resolve()), + project_name=...)` → `(pipeline_doc, flow_doc)`; structural errors + propagate (no events). +8. Prompt materialization (unchanged steps: validate-all → wipe → write + four files into `/prompts/`). +9. `order_stages(flow_doc.stages)` → `composition = + [CompositionStage(id=s.id, title=s.name) for s in ordered]`. +10. Statuses (hosting form only): `scale = assemble_status_scale()`, + `statuses = resolve_topic_status(topic_dir, scale)`; branch-only → + `statuses = []` (no scale assembly). +11. `hooks.emit_run_created(pipeline=identity, decision=decision, + overlay=overlay, composition=composition, work=work, + statuses=statuses, runtime_dir=runtime_dir)`. +12. `exit_code = run_flow(flow_path, port, max_parallel=parallel)` + (spawn failures are return codes 126/127 — returns, not raises). +13. Hosting form: `statuses = resolve_topic_status(topic_dir, scale)` + recomputed (one scale, two reads); branch-only stays `[]`. + `hooks.emit_run_completed(..., exit_code=exit_code)`; `return exit_code`. + +**Flow 2 — card form (`describe_pipeline`, 7 steps):** discovery → +`resolve_workflow` (CLI flags; no `GOGA_SKIP_STAGES` read) → facts +(`parse_dsl` header + unknown-branch sentinel + guarded topic dir) → +delivery unless disabled (else passthrough overlay) → `compile_flow` into +a temp dir with `overlay.workflow` → `order_stages` → `PipelineCard(... +provenance=overlay.provenance)` → temp dir discarded. **No events, no +statuses, no launch.** + +**Flow 3 — the amendment delivery (inside the zone):** see the +`PipelineHooks.amend_workflow` algorithm below. + +### Entity Dependencies + +Design order (leaves → root, already fixed by the contract): + +1. `goga/hooks` platform + `goga/pipeline/workflow` (exist, untouched). +2. Zone data models: `identity.py` → `contexts.py` (imports identity + types + `WorkflowDocument`) → `overlay.py` (imports + `WorkflowDocument`) → `amendments.py` (imports identity + + `WorkflowDocument`). +3. `events.py` (`PipelineHooks`) — imports the platform facade + (`from ...hooks import HookRegistry, build_hook_arguments, emit_hook_event, + wrap_context, declared_actions` — three dots: `goga.pipeline.hooks` → + `goga`), `..workflow` for nothing (contexts carry it), and the local + models (`from .amendments import WorkflowAmendment`, etc.). +4. Catalog records (`goga/hooks/catalog/catalog.py`) — additive, + independent. +5. Consumers: `pipeline_card.py` (field), `describe_pipeline.py`, + `run_pipeline.py` (import `from .hooks import ...`; history via + `from ..history import ...`), `cli.py` (template + catch). + +Import-cycle safety (verified by trace): importing `goga.pipeline.hooks` +first executes `goga/pipeline/__init__.py` (partial), which imports +`.run_pipeline` → `.hooks` → `..workflow`; `goga.pipeline.workflow` and +`goga.hooks` never import back into `goga.pipeline` submodules loaded by +its `__init__`, so the partially-initialized parent is never re-entered — +the same shape as the existing `.compiler → ..workflow` edge. The zone +MUST use relative imports only (contract: "Use relative imports"). + +--- + +## Code Stack Trace + +### Trace: `PipelineHooks()` (construction) + +1. **Input**: module-level or per-operation construction + (`hooks = PipelineHooks()`). +2. Sets `self._registry: HookRegistry | None = None`. → checkpoint: no + enumeration, no imports, no repository reads — cheap construction ✓. +3. **Output**: the checkpoint surface of one command; the registry builds + lazily on the first checkpoint that needs it. + +#### Checkpoint Summary +- Cheap construction: passed (mirrors `HookRegistry`'s own lazy build and + onboarding's `ToolParticipation.__init__`). + +### Trace: `PipelineHooks.amend_workflow` + +1. **Input**: the operation (run step 9 / card step 3) calls + `amend_workflow(pipeline: PipelineIdentity, decision: WorkflowDecision, + workflow: WorkflowDocument | None, work: WorkIdentity)`. +2. `self._ensure_registry()` → `HookRegistry()` + `build_once()` → the + single build per run; `ImportError` (broken tool package import) + propagates — the single fatal case. → checkpoint: one registry per + `PipelineHooks` instance ✓. +3. Resolve the address against `declared_actions()`: + `Action(domain="pipeline", name="amend_workflow")` → `error_class + == "hard"`. Unknown address → `ValueError` (unreachable once the + catalog record exists; the resolution is the contract's step 1). + → checkpoint: the record exists in `catalog.py` after the build ✓. +4. `registry.subscriptions_for("pipeline", "amend_workflow")` grouped per + tool (`dict.setdefault(tool, []).append(sub)` — enumeration order + preserved). Empty → skip to the merge with `[]`. → checkpoint: + passthrough requirement reachable ✓. +5. Per tool: `amendment = WorkflowAmendment(pipeline=..., decision=..., + workflow=workflow, work=work)` — every tool reads the SAME original + `workflow` object; buffer `_contribution = None`. + `proxy = wrap_context(amendment)`; per subscription of the tool: + `sub.hook(**build_hook_arguments(sub.hook, proxy, + registry.self_context(tool)))`. → checkpoint: reads/calls pass through + the proxy, attribute assignment raises (`delivery.py:61`) — the only + write channel is `contribute()` ✓. +6. A hook raising → **hard**: `raise ValueError(f"hook {sub.name} of tool + {tool} failed on pipeline.amend_workflow: {reason}") from reason` — + the exact message format of the platform (`emit.py:97`); the walk stops + at the first failure; the tool's view (buffer) is dropped. + → checkpoint: "clean error naming the tool and the action" ✓. +7. All hooks returned → read `amendment._contribution`: + `None` → the tool never contributed — silent, no commit, no warning; + empty document (`prompt is None and not stages and not extend and + memory is None`) → `logger.warning` naming tool + action + reason, + discard, walk continues; + else → commit `ToolContribution(tool=tool, document=...)`. + → checkpoint: commit granularity is the tool ✓. +8. **Output**: `merge_workflow_overlay(workflow, contributions)` → the + `WorkflowOverlay` returned to the operation. → checkpoint: types line + up end-to-end ✓. + +#### Checkpoint Summary +- Type flow (facts in → `ToolContribution` list → `WorkflowOverlay` out): + passed. +- Per-tool-delivery conformance: passed (loop skeleton identical to + `participation.py:130-139`, failure leg replaced by the hard raise). +- Mutually-blind tools: passed (every view wraps the same unmodified + `workflow`; buffers are per-view). + +### Trace: `PipelineHooks.emit_run_created` / `emit_run_completed` + +1. **Input**: the operation (run steps 14/16) passes the fact bundle; + `overlay: WorkflowOverlay` (from `amend_workflow` or the disabled + passthrough). +2. `context = RunCreated(pipeline=..., decision=..., workflow=overlay.workflow, + composition=..., provenance=overlay.provenance, work=..., statuses=..., + runtime_dir=...)` — one shared instance (`RunCompleted` additionally + carries `exit_code`). +3. `emit_hook_event(self._ensure_registry(), "pipeline", "run_created", + context_for=lambda _tool: context)`. → checkpoint: returning the same + instance shares the read-only context — the `declaring-actions` + pattern ✓; the emission resolves the address (soft), wraps, projects, + calls, and converts failures to warnings — nothing for the zone to add + (`emit.py:81-108`) ✓. +4. **Output**: `None` — fire-and-forget; the run's exit code unaffected. + +#### Checkpoint Summary +- Soft failure handling: passed (delegated entirely to `emit_hook_event`). +- Registry sharing: passed (same lazy `_ensure_registry` as + `amend_workflow` — one build even across amendment + two emissions). +- Boundary note: `assemble_status_scale` (history) builds its own registry + internally — a run that resolves statuses enumerates tool packages a + second time through the history cell. Pre-existing platform behavior, + outside this feature's contracts; recorded, not changed. + +### Trace: `merge_workflow_overlay` + +1. **Input**: `base: WorkflowDocument | None` (post-decision, + post-skip-merge), `contributions: list[ToolContribution]` (enumeration + order). +2. `contributions` empty → short-circuit: + `WorkflowOverlay(workflow=base, provenance=[])` — the passed workflow + object itself, zero rebuild. → checkpoint: passthrough + "A None + workflow with a non-empty provenance never occurs" (`(None, [])`) ✓. +3. prompt: `texts = [t for t in [base.prompt if base else None] + + [c.document.prompt for c in contributions] if t]` → `"\n\n".join`; + `None` when no texts. → checkpoint: authored first, enumeration order, + single blank line between consecutive texts, empty strings drop ✓. +4. memory: `base.memory if base and base.memory is not None else None`; + then per contribution `if c.document.memory is not None: memory = + c.document.memory` — the later tool wins, no field-level merge ✓. +5. stages (field table below): authored names first, then fresh + contribution names; authored-set fields block everything; unset fields + take the LATER tool's value. New `WorkflowStage` instances — inputs + never mutated ✓. +6. extend: `merged = dict(base.extend) if base else {}`; per contribution + entry: under an authored name → dropped; otherwise later entry wins ✓. +7. provenance: `[c.tool for c in contributions]` ✓. +8. **Output**: `WorkflowOverlay(workflow=WorkflowDocument(prompt=..., + stages=..., extend=..., memory=...), provenance=...)`; workflow `None` + only when base is `None` and no contribution committed (unreachable + here — the empty list short-circuited) ✓. + +**Field "is set" semantics (the authored-wins table):** + +| Field | SET when | Note | +|---|---|---| +| `agent`, `prompt`, `loop`, `skills`, `approve`, `notes`, `reflect`, `memory` | value is not `None` | `None` = unset | +| `manual` | value is not `None` | three-state: `True` (force) and `False` (explicit cancel) are BOTH set; absence = unset | +| `skip` | value is `True` | `skip=False` overrides nothing — only a positive skip is authored intent | + +Stage merge (per name): + +``` +authored = base.stages.get(name) if base else None +values = {f: getattr(authored, f) for f in FIELDS} if authored + else {f: DEFAULT[f] for f in FIELDS} # None / skip=False +authored_set = {f: SET(f, values[f]) for f in FIELDS} +for c in contributions: # enumeration order + cs = c.document.stages.get(name) + if cs is None: continue + for f in FIELDS: + if SET(f, getattr(cs, f)) and not authored_set[f]: + values[f] = getattr(cs, f) # later tool wins +merged_stages[name] = WorkflowStage(**values) +``` + +#### Checkpoint Summary +- Purity: passed (new `WorkflowStage`/`WorkflowDocument` instances; field + values by reference — the repo's shallow-copy convention, as in + `apply_skip_stages.py:78`; nothing is mutated). +- Declarativeness: passed (the result uses only `WorkflowDocument`-shaped + values — it compiles through the unchanged `compile_flow`). +- Determinism: passed (pure function of the inputs; enumeration order is + the only ordering input). + +### Trace: `WorkflowAmendment.contribute` + +1. **Input**: a tool hook calls `context.contribute(document)` through the + delivery proxy (`registering-hooks` example: `context.contribute(hardening_workflow)`). +2. `self._contribution = document` — whole replacement; a later call + replaces the earlier buffered document ✓. +3. **Output**: `None`. The buffer commits only when the delivery reads it + after every hook of the tool returned (trace above, step 7). + → checkpoint: `contribute` is reachable through the proxy (method + call passes through `__getattr__`), and it is the ONLY write channel ✓. + +### Trace: `run_pipeline` / `describe_pipeline` / `pipeline_cli` + +Covered by Data Flows 1-2 and the entity algorithms below. Additional +checkpoints verified during tracing: + +- `apply_skip_stages(None, skips)` constructs a skip-only document; + `apply_skip_stages(x, [])` returns `x` unchanged (`apply_skip_stages.py:73-75, + 78-82`) — the card's skip-blindness and the run's merge order hold ✓. +- `WorkflowDecision` kind derivation (the operation recomputes what + `resolve_workflow` does not report): + `no_workflow` → `("disabled", None)`; + else explicit name given (`workflow_name not in (None, "")`) and a + document resolved → `("explicit", workflow_name)`; + no explicit name and a document resolved → `("auto-match", name)`; + document `None` (explicit-missing / auto-miss / containment escape) → + `("silent-miss", None)`. Matches the contract's four fixed values and + "workflow_name present for explicit and auto-match" ✓. +- `resolve_topic_dir` raising `ValueError` (a fully non-ASCII branch name + normalizes to an empty slug, `paths.py:60-61`) — the operation guards: + `except ValueError: topic_dir = None` → branch-only form. "The + branch-only form otherwise" covers it semantically ✓. +- Emission ordering: `run_created` fires after prompts materialize and + before `run_flow`; `run_completed` fires on every return path of + `run_flow` — 126/127 spawn failures are return codes + (`run_pipeline.py` docstring contract), so a sequential flow suffices; + an exception escaping `run_flow` has no exit code to report and + propagates without a completion emission (boundary, documented) ✓. +- No-events guarantees: missing pipeline returns at step 1; structural + errors propagate at step 7 (compile) — both before any checkpoint ✓. + +--- + +## Algorithm Design + +### `PipelineHooks` (events.py) + +**Responsibility**: the checkpoint surface of the pipeline domain — the +amendment delivery and the two run notifications over the platform facade, +sharing one lazily-built registry per instance. + +**Algorithm:** +``` +_ensure_registry(): +1. IF self._registry is None: + - registry = HookRegistry(); registry.build_once(); self._registry = registry +2. RETURN self._registry # ImportError propagates (single fatal case) + +amend_workflow(pipeline, decision, workflow, work): +1. record = the declared_actions() entry (domain="pipeline", action="amend_workflow") + → absent: raise ValueError("unknown hook action: pipeline.amend_workflow") +2. groups = subscriptions_for("pipeline", "amend_workflow") grouped per tool + (enumeration order) +3. IF groups empty: RETURN merge_workflow_overlay(workflow, []) # passthrough +4. FOR tool, subs in groups.items(): + a. amendment = WorkflowAmendment(pipeline=pipeline, decision=decision, + workflow=workflow, work=work) # fresh view, fresh buffer + b. proxy = wrap_context(amendment) + c. FOR sub in subs: + - sub.hook(**build_hook_arguments(sub.hook, proxy, registry.self_context(tool))) + - ON Exception AS reason: + raise ValueError(f"hook {sub.name} of tool {tool} failed on " + f"pipeline.amend_workflow: {reason}") from reason + # hard: stop at the first failure; the tool's buffer dies with its view + d. IF amendment._contribution is None: continue # never contributed — silent + e. IF empty(amendment._contribution): # prompt None ∧ stages {} ∧ extend {} ∧ memory None + - logger.warning("tool %s contributed an empty document to " + "pipeline.amend_workflow: discarded", tool) + - continue + f. contributions.append(ToolContribution(tool=tool, document=amendment._contribution)) +5. RETURN merge_workflow_overlay(workflow, contributions) +``` + +**Errors:** +- `ValueError` (hard hook failure) → propagates through `run_pipeline` / + `describe_pipeline` → rendered by `pipeline_cli` as a clean stderr + message, exit non-zero; nothing has been compiled, written, or launched. +- `ImportError` (broken tool package) → propagates from `build_once` — + the single fatal platform case; identical treatment — rendered by + `pipeline_cli` as a clean stderr message, exit non-zero. + +**Edge cases:** +- No tool packages installed → registry builds empty → step 3 short-circuit + → the overlay is the passthrough — output byte-identical to a + pre-hooks run. +- A tool subscribed but silent → no commit, no warning. +- `BaseException` (e.g. `KeyboardInterrupt`) → not intercepted (the + platform convention: intercept `Exception` only). + +### `merge_workflow_overlay` (overlay.py) + +**Responsibility**: compose the effective workflow from the authored base +and the committed contributions — authored intent wins per slot. + +**Algorithm:** (see the trace above for the full field table) +``` +1. IF contributions empty: RETURN WorkflowOverlay(workflow=base, provenance=[]) +2. prompt = "\n\n".join(non-empty texts: authored first, then contributions in order) or None +3. memory = authored block when present; else the LATER contributing tool's block +4. stages = per name (authored names first, then fresh names): + authored-set fields never overwritten; + unset fields take the later contributing tool's value; + no authored entry → fully tool-defined WorkflowStage +5. extend = authored entries kept; a contribution entry under an authored + name dropped; among tools the later entry wins per name +6. provenance = [c.tool for c in contributions] +7. RETURN WorkflowOverlay(WorkflowDocument(prompt, stages, extend, memory), provenance) +``` + +**Errors:** none — pure function; invalid shapes cannot occur (committed +contributions are non-empty by construction; structural validation of the +merged result belongs to `compile_flow`, which raises its +`StructuralError` unchanged, e.g. a contribution naming an unknown stage). + +**Edge cases:** +- `base` None + contributions → a document exists (every committed + contribution is non-empty). +- Authored `skip=True` (from the workflow-file or the merged runner skip) + → unbeatable; a contributing `skip=False` overrides nothing. +- Authored `manual=False` → SET (explicit cancel) — tools cannot reopen it. +- Prompt of `""` (empty string) → drops out of the concatenation. + +### `WorkflowAmendment` (amendments.py) + +**Responsibility**: the read-and-contribute view of one tool. + +**Algorithm:** +``` +contribute(document): +1. self._contribution = document # whole replacement; no validation here + # (the delivery checks emptiness post-hoc) +``` +`_contribution: WorkflowDocument | None` is a private +`field(init=False, default=None, repr=False)` — not contract surface; read +only by the delivery (same package). + +**Errors:** none raised; a bad document surfaces at the consumer +(`compile_flow`) or as the empty-contribution warning. + +**Edge cases:** repeat calls replace; the buffer is per-tool (per view). + +### `run_pipeline` (run_pipeline.py) — changed + +**Responsibility**: the run coordination — now 17 steps (Data Flow 1). + +**Algorithm:** the existing 11 steps renumbered with four insertions — +new step 8 (facts), new step 9 (delivery), new steps 12-14 (composition, +statuses, creation emission), step 16 (completion emission). Exact texts +live in `goga/pipeline/CODEMANIFEST`; the field-level decisions: + +- facts: `header, _, _ = parse_dsl(pipeline_path.read_text())`; + `PipelineIdentity(name=match.name, display_name=header.name, + description=header.description, source=match.source.value)`; +- decision: the kind-derivation table in the trace above; +- work: `branch = resolve_current_branch_name() or "unknown"`; + `try: topic_dir = resolve_topic_dir(branch)` / `except ValueError: + topic_dir = None`; `topic_dir.is_dir()` → `WorkIdentity(branch=branch, + slug=topic_dir.name, year=topic_dir.parent.name)` else `WorkIdentity(branch=branch)`; +- disabled: `overlay = WorkflowOverlay(workflow=workflow, provenance=[])` + (the merged workflow; no delivery, no registry build from the amendment + side — the first registry build happens at the creation emission); +- statuses: hosting form only — one `assemble_status_scale()`, two + `resolve_topic_status(topic_dir, scale)` reads (creation moment, + completion moment). + +**Errors:** unchanged channels plus the hard `ValueError` from the +amendment (before any compile/write/launch) and the `ImportError` from the +registry build. + +**Edge cases:** detached HEAD / non-repo / missing git → `"unknown"` +branch, branch-only work; non-ASCII branch → `ValueError`-guarded +branch-only form; disabled layer → passthrough overlay; no subscriptions → +byte-identical behavior. + +### `describe_pipeline` (describe_pipeline.py) — changed + +**Responsibility**: the card composition through the same amendment layer. + +**Algorithm:** 7 steps (Data Flow 2); facts identical to the run form +(`parse_dsl` header, kind derivation, unknown-branch sentinel, guarded +topic dir); delivery unless disabled; `compile_flow(..., +workflow=overlay.workflow)` in the temp dir; card +`provenance=overlay.provenance`. + +**Errors:** unchanged channels + the hard `ValueError`. + +**Edge cases:** disabled → passthrough overlay, `provenance=[]`; +`GOGA_SKIP_STAGES` never read (card answers composition, not a specific +run's skips). + +### `PipelineCard` (pipeline_card.py) — changed + +Add `provenance: list[str] = field(default_factory=list)` (import +`field`); docstring line for it. Existing constructions compile unchanged +(the default). Two cards never share the list (factory per instance). + +### `pipeline_cli` (cli.py) — changed + +`_run_card`: after the stage loop — + +```python +if card.provenance: + print() + print(f"tools: {', '.join(card.provenance)}") +``` + +Uniform rule: the tools block is one blank line + one field line whenever +provenance is non-empty (with zero stages it follows the separator's +blank line — deterministic, and the empty-provenance output stays +byte-identical in every form). + +Failure rendering: add `ValueError` and `ImportError` to the caught +tuples of `_run_card` and `_run_execution` (the hard amendment stops both +forms; the registry build's fatal `ImportError` stops every form — +platform precedent `history.py:114` catches `(ValueError, ImportError)`; +contract step 5: "Render an operation failure as a clean readable message +to stderr (no traceback)"). + +### `declared_actions` catalog (catalog.py) — changed + +Append to `_DECLARED_ACTIONS`: + +```python +Action(domain="pipeline", name="amend_workflow", error_class="hard"), +Action(domain="pipeline", name="run_created", error_class="soft"), +Action(domain="pipeline", name="run_completed", error_class="soft"), +``` + +`declared_actions()` sorts by `(domain, name)` → the pipeline block +orders `amend_workflow`, `run_completed`, `run_created` between +`onboarding` and `statuses`. Purely additive. + +### Zone data models (identity.py, contexts.py, overlay.py) + +`@dataclass(kw_only=True)` per `convention`; snake_case fields; +type-hinted; docstrings in the repo style (Args sections mirroring the +CODEMANIFEST annotations). No behavior: `PipelineIdentity` / +`WorkflowDecision` / `WorkIdentity` / `CompositionStage` / +`ToolContribution` / `WorkflowOverlay` / `RunCreated` / `RunCompleted` are +pure fact carriers. Contract invariants worth runtime guards (repo +convention `__post_init__`, cf. `PipelineEntry`): `PipelineIdentity` — +`source in ("project", "user")`; `WorkflowDecision` — `kind in +("disabled", "explicit", "auto-match", "silent-miss")`. The zone +`__init__.py` facade exposes the 11 names alphabetically in `__all__` +(the checklist's "facade import check"). + +--- + +## Cross-cutting Concerns + +- **Error handling**: three channels, never mixed — hard amendment: + `ValueError` in the platform message format, stops the command before + any side effect; soft notifications: `logger.warning` naming tool + + action + reason inside `emit_hook_event`, the run's exit code + unaffected; structural composition errors (`StructuralError`, + `WorkflowSyntaxError`, `yaml.YAMLError`): propagate unchanged from the + unchanged compiler/parser surface. The CLI renders every channel as a + clean stderr line, exit non-zero. +- **Logging**: `logger = logging.getLogger(__name__)` per module (repo + convention). Zone warnings: the empty-contribution discard + (`events.py`); emission failures warn inside the platform. Debug lines + (`describe_pipeline.py:112` style) may log provenance/composition — + additive, debug level. +- **Validation**: no validation inside the checkpoints (facts in, facts + out — "the checkpoints read nothing"); emptiness validation at the + commit boundary; structural validation of the merged overlay delegated + to `compile_flow`; identity/decision literals guarded in + `__post_init__`. +- **Caching**: one `HookRegistry` per `PipelineHooks` instance (lazy + `build_once`), one `assemble_status_scale()` per run. Nothing cached + across runs (platform rule). +- **Concurrency**: none introduced — the delivery walk is sequential by + design (enumeration order is semantic: prompt order, later-wins). +- **Purity**: `merge_workflow_overlay` never mutates its inputs; + `apply_skip_stages` already copy-on-write; the operations treat the + documents tuple and the overlay as read-only after construction. + +--- + +## Usages Analysis + +### `convention` +- **What it provides**: project code style, docstring style, REPL cycle, + test infrastructure rules. +- **Where used**: global Annotations of both changed manifests; every + type annotation of the zone ("Apply the `convention` practice..."). +- **Why chosen**: the project-wide baseline practice. +- **How exactly**: `.goga/usages/conventions.md` — kw_only dataclasses, + relative imports, docstring structure, test placement. + +### Imported Usages + +- `per-tool-delivery` from `goga/hooks` — the staged delivery loop of + `amend_workflow`: loop skeleton, primitives, tool-grouped commit. + Path: `goga/hooks/.usages/per-tool-delivery.md`. Applied as written; + the soft-discard leg is replaced by the hard raise (the action's + catalog error class drives it — the practice itself defers to the + catalog: "Treat a failure per the action's error class"). +- `declaring-actions` from `goga/hooks` — the emission contract of the + two notifications. Path: `goga/hooks/.usages/declaring-actions.md`. + `emit_hook_event(HookRegistry(), domain, action, context_for=...)` + with a shared-instance `context_for`. +- `registering-hooks` from `goga/hooks` — the hook signature and failure + behavior behind every checkpoint (context/self injection, fixed offered + names). Path: `goga/hooks/.usages/registering-hooks.md`. +- `checkpoints` from `goga/pipeline/hooks` (consumed by `goga/pipeline`) + — the checkpoint surface consumption patterns (fact resolution, + amend-before-compile, emit-around-launch). Path: + `goga/pipeline/hooks/.usages/checkpoints.md`. Referenced by the zone + paragraph of the global Annotations and by `run_pipeline` / + `describe_pipeline`. +- `topic-paths` from `goga/history` — the branch read and topic-dir + resolution behind `WorkIdentity`. Path: + `goga/history/.usages/topic-paths.md`. Slug/year derive from the + returned directory (`topic_dir.name`, `topic_dir.parent.name`) — no + extra import needed. +- `topic-statuses` from `goga/history` — the status facts of the run + events (`assemble_status_scale` + `resolve_topic_status`, single + assembly, nested artifacts honored). Path: + `goga/history/.usages/topic-statuses.md`. + +Every connected practice is referenced in at least one annotation +(verified during the audit) — no orphaned practices, no unreferenced +imports. + +--- + +## `.usages/` Update + +### Cell: `goga/pipeline/hooks` + +- **`checkpoints`** → `goga/pipeline/hooks/.usages/checkpoints.md` + - Status: current (created at apply stage; verified against this + design — amend-before-compile, emit-around-launch, passthrough, card + form all match). + - Additions needed: none. + - Updates needed: none. + +### Cell: `goga/pipeline` + +- **`registering-hooks`** → `goga/pipeline/.usages/registering-hooks.md` + - Status: current (created at apply stage; events table, hook + signature, amendment semantics, integration scenarios match the + contracts and this design). +- **`describe-pipeline`** → current (provenance field, hooks-zone + composition, no-run-events statement synced). +- **`run-pipeline`** → current (amendment + run-events sections synced; + hard-action and no-events guarantees match). +- **`pipeline-cli`** → current (`tools:` line spec matches the uniform + blank+field rule). +- No new files needed — the zone-level consumer documentation lives in + the zone's own `.usages/` (checkpoints), and the tool-author + documentation in the domain's (registering-hooks); the category + structure of both cells is preserved. + +--- + +## Test Stack Trace + +### General Setup + +- New package `tests/pipeline/hooks/` (`__init__.py` + `conftest.py`), + mirroring `tests/pipeline/compiler/` and `tests/pipeline/workflow/`. +- `tests/pipeline/hooks/conftest.py` re-exports the platform boundary + fixtures (cross-package import precedent: `tests/test_cli.py:20`): + +```python +from tests.hooks.conftest import install_tool_package, pin_package_environment # noqa: F401 +``` + +- Tool-package simulation: `pin_package_environment({"goga_tool_demo": + ["demo-dist"]})` + `install_tool_package("goga_tool_demo", + register_hooks=...)` — the platform code under test runs for real. +- Operation-level tests mock at module boundaries only + (`mock.patch.object(_run_pipeline_module, "compile_flow"/"run_flow")` — + the established pattern in `tests/pipeline/test_run_pipeline.py`). +- History facts in operation tests: a real `.goga/history///` + tree under `tmp_path` + `monkeypatch.chdir(tmp_path)` (the + `isolated_cwd` fixture); the git branch read is mocked + (`resolve_current_branch_name`) since tmp dirs are not repos. The tree + is built under `current_year()` (or with `current_year` mocked to a + fixed value) — never under a hardcoded year literal; assertions derive + the expected year the same way, so the suite survives a year boundary. + +### Source File Registry + +- `goga/pipeline/hooks/__init__.py`, `identity.py`, `contexts.py`, + `overlay.py`, `amendments.py`, `events.py` +- `goga/hooks/catalog/catalog.py` +- `goga/pipeline/pipeline_card.py`, `describe_pipeline.py`, + `run_pipeline.py`, `cli.py` +- Tests: `tests/pipeline/hooks/test_{identity,overlay,amendments,events}.py`, + `tests/pipeline/test_run_pipeline_hooks.py`, and extensions to + `tests/pipeline/test_{describe_pipeline,pipeline_card,pipeline_cli}.py` + and `tests/hooks/catalog/test_catalog.py`. + +--- + +### Positive Tests + +#### `test_merge_prompt_concatenates_authored_first_then_tools` + +**Setup**: `base = WorkflowDocument(prompt="authored")`; contributions: +`[ToolContribution("t1", WorkflowDocument(prompt="one")), +ToolContribution("t2", WorkflowDocument(prompt="two"))]`. + +**Input**: `merge_workflow_overlay(base, contributions)` + +**Trace**: +``` +merge_workflow_overlay(base, contributions) + → texts = ["authored", "one", "two"] # step 2, empties dropped + → prompt = "authored\n\none\n\ntwo" # single blank line joins + → WorkflowOverlay(provenance=["t1", "t2"]) +``` + +**Assertions**: +``` +overlay.workflow.prompt == "authored\n\none\n\ntwo" +overlay.provenance == ["t1", "t2"] +``` + +**Sufficiency**: fixes the prompt precedence and separator contract — a +regression here changes every compiled flow-file's top prompt. + +#### `test_merge_stage_fields_fill_only_unset_later_tool_wins` + +**Setup**: +`base.stages = {"build": WorkflowStage(agent="author-agent", loop=2)}`; +`t1` contributes `WorkflowStage(agent="t1-agent", skills=["s1"])`; +`t2` contributes `WorkflowStage(agent="t2-agent", loop=5)`. + +**Input**: `merge_workflow_overlay(base, [tc(t1), tc(t2)])` + +**Trace**: +``` +stage "build": authored agent set → blocks both tools + authored loop=2 set → blocks t2 + skills unset → t1 sets ["s1"] (t2 sets none) +→ WorkflowStage(agent="author-agent", loop=2, skills=["s1"]) +``` + +**Assertions**: +``` +overlay.workflow.stages["build"].agent == "author-agent" +overlay.workflow.stages["build"].loop == 2 +overlay.workflow.stages["build"].skills == ["s1"] +base.stages["build"].skills is None # purity — input untouched +``` + +**Sufficiency**: the authored-wins + later-tool-wins rule is the core +merge semantics; this pins both directions at once. + +#### `test_merge_skip_false_overrides_nothing_authored_skip_unbeatable` + +**Setup**: `base.stages = {"build": WorkflowStage(skip=True)}`; +`t1` contributes `WorkflowStage(skip=False)`; fresh name `"deploy"`: +authored has no entry, `t1` contributes `WorkflowStage(skip=True)`. + +**Input**: `merge_workflow_overlay(base, [tc(t1)])` + +**Trace**: +``` +"build": authored skip=True SET → t1's False blocked +"deploy": no authored entry → t1's skip=True fills +``` + +**Assertions**: +``` +overlay.workflow.stages["build"].skip is True +overlay.workflow.stages["deploy"].skip is True +``` + +**Sufficiency**: `skip=False` is the merge's sharpest edge (three-state +semantics); prevents a tool "un-skipping" an authored/runner skip. + +#### `test_merge_memory_authored_block_unbeatable_and_later_tool_wins` + +**Setup**: case A — `base.memory = WorkflowMemory(max_rules=5)`, `t1` +contributes `WorkflowMemory(max_rules=99)`; case B — `base.memory=None`, +`t1` and `t2` both contribute blocks (`max_rules=7` / `max_rules=9`). + +**Input**: both merges. + +**Trace**: +``` +A: authored block present → kept whole (max_rules=5) +B: no authored block → later tool t2 wins (max_rules=9) +``` + +**Assertions**: +``` +A: overlay.workflow.memory.max_rules == 5 +B: overlay.workflow.memory.max_rules == 9 +``` + +**Sufficiency**: memory is whole-block, never field-merged — both legs. + +#### `test_merge_extend_authored_names_win_and_later_tool_wins` + +**Setup**: +`base = WorkflowDocument(extend={"audit": WorkflowExtendStage( +after=["build"], body={"title": "Audit"})})`; +`t1` contributes `extend={"audit": WorkflowExtendStage(before=["build"], +body={"title": "X"}), "notify": WorkflowExtendStage(after=["deploy"], +body={"title": "N1"})}`; +`t2` contributes `extend={"notify": WorkflowExtendStage(after=["audit"], +body={"title": "N2"})}`. + +**Input**: `merge_workflow_overlay(base, [tc(t1), tc(t2)])` + +**Trace**: +``` +"audit": under an authored name → t1's entry dropped, the authored + after=["build"] entry kept +"notify": fresh name → t1 sets it, t2 replaces (later tool wins) +``` + +**Assertions**: +``` +overlay.workflow.extend["audit"].after == ["build"] +overlay.workflow.extend["notify"].after == ["audit"] +len(overlay.workflow.extend) == 2 +base.extend["audit"].after == ["build"] # purity — input untouched +``` + +**Sufficiency**: pins the last authored-wins slot — extend; a regression +here silently reorders or drops new stages in every compiled flow with +extend entries. + +#### `test_merge_empty_base_tools_build_the_document` + +**Setup**: base `None`; `t1` contributes `WorkflowDocument(prompt="one", +stages={"build": WorkflowStage(agent="a")})`; `t2` contributes +`WorkflowDocument(prompt="two")`. + +**Input**: `merge_workflow_overlay(None, [tc(t1), tc(t2)])` + +**Trace**: +``` +contributions non-empty → no passthrough short-circuit +no authored layer → texts ["one", "two"] → prompt "one\n\ntwo" +stage "build": no authored entry → fully tool-defined (agent="a") +provenance ["t1", "t2"]; the document exists (every contribution non-empty) +``` + +**Assertions**: +``` +overlay.workflow is not None +overlay.workflow.prompt == "one\n\ntwo" +overlay.workflow.stages["build"].agent == "a" +overlay.provenance == ["t1", "t2"] +``` + +**Sufficiency**: the silent-miss + contributing-tool composition — pins +the "first tool text becomes the prompt" rule, the fully tool-defined +stage, and the invariant "a None workflow with a non-empty provenance +never occurs". + +#### `test_merge_passthrough_short_circuit_returns_base_object` + +**Setup**: `base = WorkflowDocument(prompt="x")`. + +**Input**: `merge_workflow_overlay(base, [])` and +`merge_workflow_overlay(None, [])`. + +**Trace**: +``` +contributions empty → WorkflowOverlay(workflow=base, provenance=[]) +``` + +**Assertions**: +``` +overlay.workflow is base # the passed object itself +overlay.provenance == [] +merge(None, []).workflow is None +``` + +**Sufficiency**: the no-tool-packages guarantee — byte-identical runs. + +#### `test_amend_workflow_commits_per_tool_and_merges` + +**Setup**: pinned environment with two tool packages. The first +subscribes `("pipeline", "amend_workflow", "hardening", hook)` where +`hook(context)` reads `context.pipeline.name`, `context.decision.kind`, +`context.workflow` (recording `id(context.workflow)` into its `self` +context) and calls `context.contribute(WorkflowDocument(prompt="harden"))`. +The second (`goga_tool_second`) subscribes the same address with a hook +that records `id(context.workflow)` and `context.workflow.prompt` into +its own `self` AFTER contributing `WorkflowDocument(prompt="second")`. + +**Input**: `PipelineHooks().amend_workflow(pipeline=PipelineIdentity( +name="deploy", description="d", source="project"), decision=..., +workflow=WorkflowDocument(prompt="authored"), work=WorkIdentity(branch="b"))` + +**Trace**: +``` +amend_workflow(...) + → registry.build_once() # real enumeration, one build + → subscriptions_for("pipeline", "amend_workflow") → 2 subscriptions, 2 tools + → per tool: WorkflowAmendment(view over the SAME workflow) → wrap_context + → build_hook_arguments → hook(context=proxy) # only "context" declared + → contribute(...) sets the tool's own buffer + → both non-empty → ToolContribution per tool + → merge → overlay +``` + +**Assertions**: +``` +overlay.workflow.prompt == "authored\n\nharden\n\nsecond" +overlay.provenance == [tool_id_of("goga_tool_demo"), tool_id_of("goga_tool_second")] +first_seen_id == second_seen_id # the same original workflow object +second_seen_prompt == "authored" # the first tool's contribution invisible +``` + +**Sufficiency**: the full delivery path over the real platform — registry, +view, proxy, projection, buffer, commit, merge — plus the mutually-blind +guarantee: every view wraps the same original workflow, no tool sees +another's contribution, buffers are per-view (the guard against the layer +silently becoming staged accumulation). + +#### `test_amend_workflow_registry_built_once_across_checkpoints` + +**Setup**: pinned environment (boundary mock returned by +`pin_package_environment`); a tool subscribing both `amend_workflow` and +`run_completed`. + +**Input**: one `PipelineHooks` instance: `amend_workflow(...)` then +`emit_run_completed(...)`. + +**Trace**: +``` +amend → _ensure_registry (build #1) +emit_run_completed → _ensure_registry (no rebuild) +``` + +**Assertions**: +``` +boundary.call_count == 1 # packages_distributions read exactly once +``` + +**Sufficiency**: the "one registry per run" requirement — enumeration is +the expensive side effect. + +#### `test_run_pipeline_full_event_sequence_around_launch` + +**Setup**: `afm_dir` fixture (AFM_DIR → tmp); a minimal real pipeline file +in `project_dir`; workflow absent (silent-miss); pinned env with a tool +subscribing `run_created` and `run_completed`, recording received facts +into its `self` context; `compile_flow` and `run_flow` mocked +(`run_flow` → `3`); `resolve_current_branch_name` mocked → +`"feature-demo"`; tmp `.goga/history//feature-demo/` +with `todo.md`. + +**Input**: `run_pipeline("deploy", project_dir, user_dir, port=50321)` + +**Trace**: +``` +run_pipeline(...) + → facts: identity (parse_dsl header), decision ("silent-miss", None), + work ("feature-demo", "feature-demo", current_year()) + → amend (silent-miss keeps the layer active) → passthrough (no subs on + amend_workflow for this tool) → overlay.workflow None + → compile_flow(workflow=None ...) # mocked documents tuple + → order_stages → composition [CompositionStage("build", "Build")] + → statuses ["todo"] # real scale + topic dir + → emit_run_created (records: composition ids, runtime_dir posix) + → run_flow → 3 + → statuses recomputed → emit_run_completed (exit_code=3) + → return 3 +``` + +**Assertions**: +``` +result == 3 +created_facts["pipeline"].name == "deploy" +created_facts["decision"].kind == "silent-miss" +created_facts["composition"] == [CompositionStage(id="build", title="Build")] +created_facts["statuses"] == ["todo"] +created_facts["runtime_dir"] == afm_dir.as_posix() +completed_facts["exit_code"] == 3 +order: created recorded before run_flow called, completed after +``` + +**Sufficiency**: the feature's headline behavior — the event sequence and +fact fidelity of a real run. + +#### `test_describe_pipeline_reports_provenance_through_same_layer` + +**Setup**: pinned env with a tool contributing `prompt="tool-text"` via +`amend_workflow`; a workflow file in `.goga/workflows/deploy.yml` with +`prompt: "authored"`. + +**Input**: `describe_pipeline("deploy", project_dir, user_dir, +workflow=None, no_workflow=False)` — real `compile_flow` into the temp +dir (no mocks beyond the tool env). + +**Trace**: +``` +describe_pipeline(...) + → resolve_workflow → auto-match → WorkflowDocument(prompt="authored") + → facts → amend → overlay(prompt="authored\n\ntool-text", [""]) + → compile_flow(overlay.workflow) → temp flow-file → discarded + → PipelineCard(provenance=[""]) +``` + +**Assertions**: +``` +card.provenance == [""] +card.name == +``` + +**Sufficiency**: card == run composition guarantee with tools installed. + +#### `test_cli_card_renders_tools_line_and_stays_byte_identical_without_it` + +**Setup**: two `PipelineCard`s — one with `provenance=["t1", "t2"]`, one +default; `capsys`. + +**Input**: render both through the card path (factor the rendering via +the `_run_card` flow with a stubbed `describe_pipeline`). + +**Trace**: +``` +card with provenance → stage blocks, blank, "tools: t1, t2" +card without → stage blocks only +``` + +**Assertions**: +``` +out_with.endswith("\ntools: t1, t2\n") +out_without does not contain "tools:" +out_without == # byte-identical +``` + +**Sufficiency**: the CLI surface contract — additive only. + +#### `test_catalog_carries_the_three_pipeline_records` + +**Setup**: none (pure catalog read). + +**Input**: `declared_actions()` + +**Trace**: +``` +_DECLARED_ACTIONS (13 records) → sorted by (domain, name) +``` + +**Assertions**: +``` +[("pipeline", "amend_workflow", "hard"), + ("pipeline", "run_completed", "soft"), + ("pipeline", "run_created", "soft")] all present; +pipeline block ordered between onboarding and statuses +``` + +**Sufficiency**: the address triple must exist exactly once, correctly +classed — the emission resolves against it. + +--- + +### Negative Tests + +#### `test_amend_workflow_hard_failure_stops_command_and_discards` + +**Setup**: pinned env; tool subscribes `amend_workflow` with +`hook(context)` raising `RuntimeError("boom")` after calling +`context.contribute(WorkflowDocument(prompt="x"))`; a second tool +subscribes the same address with a working hook. + +**Input**: `PipelineHooks().amend_workflow(...)` (facts as above). + +**Trace**: +``` +amend_workflow(...) + → tool #1 hook raises + → ValueError("hook ... of tool ... failed on pipeline.amend_workflow: boom") + → walk stops — tool #2 never called +``` + +**Assertions**: +``` +pytest.raises(ValueError, match="pipeline.amend_workflow: boom") +merge never ran → no overlay returned; tool #2's hook not called +``` + +**Sufficiency**: the platform's first hard action — failure isolation, +message shape, first-failure stop. + +#### `test_run_pipeline_hard_amendment_renders_clean_error_no_launch` + +**Setup**: as the positive run test, but the tool's `amend_workflow` hook +raises; `run_flow` mocked with a call recorder. + +**Input**: `pipeline_cli` argv `["deploy", "--port", "50321"]` (or +`run_pipeline` directly + the CLI wrapper for the message). + +**Trace**: +``` +run → step 9 amend → ValueError → propagates +run_flow.assert_not_called(); prompts dir untouched; no events emitted +``` + +**Assertions**: +``` +capsys err contains "pipeline.amend_workflow" and "boom"; no traceback +exit code != 0 +``` + +**Sufficiency**: the hard path must fail before any side effect and +render cleanly (the `ValueError` catch addition). + +#### `test_emit_soft_failure_warns_and_never_affects_exit_code` + +**Setup**: pinned env; tool's `run_completed` hook raises; `run_flow` +mocked → `0`. + +**Input**: full `run_pipeline` flow. + +**Trace**: +``` +emit_run_completed → emit_hook_event intercepts → logger.warning +run continues → return 0 +``` + +**Assertions**: +``` +result == 0 +caplog contains a warning naming the tool, "run_completed", "boom" +``` + +**Sufficiency**: soft class — the run's outcome is never hostage to a +reporter. + +#### `test_spawn_failure_still_emits_completion_with_code` + +**Setup**: as the positive run test (pinned env, event-recording tool +keeping facts in its `self` context); `compile_flow` mocked; +`run_flow` mocked → `127` (the afm binary missing from PATH). + +**Input**: `run_pipeline("deploy", project_dir, user_dir, port=50321)` + +**Trace**: +``` +run_pipeline(...) + → facts → amend → compile (mocked) → prompts materialize + → emit_run_created + → run_flow → 127 # a return code, not an exception + → statuses recomputed → emit_run_completed(exit_code=127) + → return 127 +``` + +**Assertions**: +``` +result == 127 +completed_facts["exit_code"] == 127 +completed recorded after created; no exception raised +``` + +**Sufficiency**: pins the requirement's sharpest leg — "every +launch-attempt return path" includes spawn failures; a reporter relying +on `run_completed` must hear about a failed launch too. + +#### `test_missing_pipeline_and_structural_error_fire_no_events` + +**Setup**: event-recording tool installed; case A — unknown pipeline +name; case B — a malformed workflow file (valid name resolution path). + +**Input**: `run_pipeline("nope", ...)`; `run_pipeline("deploy", ...)` with +the malformed workflow. + +**Trace**: +``` +A: return 1 at discovery — checkpoints unreached +B: WorkflowSyntaxError propagates from resolve_workflow (step 6) — before + the delivery +``` + +**Assertions**: +``` +A: result == 1; zero events recorded +B: pytest.raises(WorkflowSyntaxError); zero events recorded +``` + +**Sufficiency**: the no-events guarantee for failing moments. + +--- + +### Edge Case Tests + +#### `test_work_identity_unknown_branch_and_empty_slug_guard` + +**Setup**: `resolve_current_branch_name` mocked → `None`; then → +`"Ветка"` (fully non-ASCII — every character drops, slug empty). + +**Input**: `run_pipeline` fact resolution (observed via the recorded +`work` of `emit_run_created`). + +**Trace**: +``` +None → branch "unknown" → resolve_topic_dir("unknown") missing → branch-only +"Ветка" → normalize_topic_slug("Ветка") == "" → resolve_topic_dir raises + ValueError → guarded → branch-only +(a partial-ASCII name such as "Бranched" slugs to "ranched" — it covers the + missing-directory leg, not the guard) +``` + +**Assertions**: +``` +work.branch == "unknown" / work.slug is None / work.year is None (case A) +work.branch == "Ветка" / slug is None / year is None (case B) +no exception; events still fired +``` + +**Sufficiency**: the two approved defect fixes at their exact seams — no +run ever fails on git identity. + +#### `test_empty_contribution_discarded_with_warning_silent_tool_ok` + +**Setup**: pinned env with two tools: one contributes +`WorkflowDocument()` (all defaults), one subscribes but never calls +`contribute`. + +**Input**: `amend_workflow(...)` with `base=WorkflowDocument(prompt="a")`. + +**Trace**: +``` +tool #1: buffer set, empty → warning, discard +tool #2: buffer None → silent +merge with [] → passthrough +``` + +**Assertions**: +``` +overlay.workflow.prompt == "a"; overlay.provenance == [] +caplog has exactly one discard warning (names tool #1) +``` + +**Sufficiency**: the empty/silent distinction — warning exactly when a +tool tried and produced nothing. + +#### `test_disabled_decision_skips_delivery_compiles_raw_and_still_emits` + +**Setup**: env `GOGA_WORKFLOW_DISABLED=1`; event-recording tool with an +amendment hook that would fail loudly if called; `run_flow` → `0`; +`GOGA_SKIP_STAGES` unset. + +**Input**: `run_pipeline("deploy", ...)`. + +**Trace**: +``` +decision ("disabled", None) → no amend call +overlay = WorkflowOverlay(workflow=None, provenance=[]) +compile_flow(workflow=None) +emit_run_created(decision.kind == "disabled", overlay.provenance == []) +emit_run_completed(exit_code=0) +``` + +**Assertions**: +``` +amend hook not called +created_facts["decision"].kind == "disabled" +created_facts["provenance"] == [] +result == 0 +``` + +**Sufficiency**: the layer-off leg — raw composition, live events. + +#### `test_describe_pipeline_disabled_reports_raw_composition` + +**Setup**: a workflow file `.goga/workflows/deploy.yml` exists +(`prompt: "authored"`); pinned env with a tool whose `amend_workflow` +hook would fail loudly if called; `isolated_cwd`. + +**Input**: `describe_pipeline("deploy", project_dir, user_dir, +workflow=None, no_workflow=True)` — real `compile_flow` into the temp +dir. + +**Trace**: +``` +step 2: resolve_workflow(..., no_workflow=True) → None + → decision ("disabled", None) +step 3: disabled → no delivery, overlay is the passthrough + (workflow=None, provenance=[]) +step 4: compile the raw authored DSL → card without the tool layer +``` + +**Assertions**: +``` +amend hook not called +card.provenance == [] +card.stages == +no exception +``` + +**Sufficiency**: the card-form disabled leg is its own code path in +`describe_pipeline` — pins "card == run composition" with the layer off +and the empty-provenance guarantee. + +#### `test_workflow_decision_kind_derivation_matrix` + +**Setup**: four env configurations (disabled; explicit+exists; explicit+ +missing; no-name auto-match hit and miss). + +**Input**: `run_pipeline` fact resolution (observed via recorded facts). + +**Trace**: +``` +disabled → ("disabled", None) +explicit+exists → ("explicit", "ci") +explicit+missing → ("silent-miss", None) +auto-match hit → ("auto-match", "deploy"); miss → ("silent-miss", None) +``` + +**Assertions**: each recorded `decision` matches the table exactly. + +**Sufficiency**: the kind vocabulary drives tool behavior — a mis-derived +kind silently misinforms every subscriber. + +#### `test_pipeline_card_provenance_default_factory_isolated` + +**Setup**: two `PipelineCard(name="a", description="d", stages=[])` +constructions (no provenance). + +**Input**: mutate `card_a.provenance.append("x")`. + +**Trace**: +``` +default_factory=list per construction → independent lists +``` + +**Assertions**: +``` +card_b.provenance == [] +``` + +**Sufficiency**: the classic shared-mutable-default regression the field +exists to avoid. + +#### `test_zone_facade_exports_exactly_the_contract` + +**Setup**: import `goga.pipeline.hooks`. + +**Input**: `sorted(goga.pipeline.hooks.__all__)`. + +**Assertions**: +``` +__all__ == ["CompositionStage", "PipelineHooks", "PipelineIdentity", + "RunCompleted", "RunCreated", "ToolContribution", + "WorkIdentity", "WorkflowAmendment", "WorkflowDecision", + "WorkflowOverlay", "merge_workflow_overlay"] +each name importable from the package root +``` + +**Sufficiency**: the facade IS the contract surface (Python rules: +only `__all__` names constitute it). + +#### `test_statuses_recomputed_at_completion_and_branch_only_stays_empty` + +**Setup**: hosting form — `todo.md` present, a `completed/plan.md` +written by a fake "run" (the `run_flow` mock writes it); branch-only — +no topic dir. + +**Input**: both runs with an event-recording tool. + +**Trace**: +``` +hosting: created statuses ["todo"] → completed statuses ["done"] + (completed/plan.md outranks todo — maximal-present recomputed at + the completion moment) +branch-only: [] at both moments; assemble_status_scale never called + (assert via the enumeration boundary) +``` + +**Assertions**: +``` +created_facts["statuses"] == ["todo"] +completed_facts["statuses"] == ["done"] +branch-only: both == [] and boundary call_count == 1 # only the pipeline registry build +``` +**Sufficiency**: completion is a fact of the completion moment, not a +replay of the creation snapshot — and the branch-only form costs no scale +assembly. + +**Sufficiency**: completion is a fact of the completion moment, not a +replay of the creation snapshot — and the branch-only form costs no scale +assembly. + +--- + +## Additional Instructions for the Implementation Agent + +- Implement in dependency order: catalog records → zone data models + (`identity.py` → `contexts.py` → `overlay.py` → `amendments.py`) → + `events.py` + facade `__init__.py` → `pipeline_card.py` → + `describe_pipeline.py` → `run_pipeline.py` → `cli.py`. +- Relative imports only inside the zone (`from ...hooks import` for the + platform, `from ..workflow import` never needed directly by `events.py` + beyond type hints, `from .identity import ...` locally). +- Do not touch `goga/pipeline/workflow`, `goga/hooks` platform modules, + or the compiler — the zone and the two operations are the whole change + surface (plus catalog records and the card/CLI edits). +- The hard-failure message format must copy the platform's + (`emit.py:97-99`) verbatim shape: hook name, tool, address, reason. +- `mock.patch.object(, "run_flow"/"compile_flow")` at the + consumer module — never patch deep modules (per the recorded feedback + on module-shadowed mocks). +- Deferred from the plan's non-cell deliverables (unchanged): + `docs/features/pipelines/hooks.md` + mkdocs traceability belong to the + documentation task items, not the code cells. diff --git a/.goga/history/2026/add-pipeline-hooks/plan.md b/.goga/history/2026/add-pipeline-hooks/plan.md new file mode 100644 index 00000000..55a4a5ca --- /dev/null +++ b/.goga/history/2026/add-pipeline-hooks/plan.md @@ -0,0 +1,1545 @@ +# Plan: `add-pipeline-hooks` + + + +Result of compiling the reviewed design document (`.goga/history/2026/add-pipeline-hooks/design.md`, +post design-review with 9 approved fixes) into ralphex-executable tasks. This format is compatible +with ralphex execution. + +--- + +## Purpose + +Implement the pipeline domain hooks zone and wire both pipeline flows through it: + +- **`goga/pipeline/hooks`** — the new zone cell (11 contract entities across five modules): + the fact vocabulary of the run events (`identity.py`, `contexts.py`), the authored-wins + workflow overlay (`overlay.py`), the read-and-contribute amendment view (`amendments.py`), + and the checkpoint surface `PipelineHooks` (`events.py`) delivering the platform's first + **hard** action `pipeline/amend_workflow` and the two soft notifications + `pipeline/run_created` / `pipeline/run_completed` over the `goga/hooks` platform facade. +- **`goga/hooks/catalog`** — three additive `Action` records. +- **`goga/pipeline`** — `PipelineCard.provenance`, the card form (`describe_pipeline`) and the + run form (`run_pipeline`, now 17 steps) composed through the amendment layer, and the CLI + card `tools:` line plus clean rendering of the hard amendment error. + +The dominant gap: none of this code exists — the zone directory holds only its `CODEMANIFEST` +(created and lint-clean at the apply stage), and the consumers are still at their pre-feature +11-step / 5-step shapes. The strategy is the design's dependency order: catalog records → +zone data models (`identity` → `contexts` → `overlay` → `amendments`) → `events.py` + facade → +`pipeline_card` → `describe_pipeline` → `run_pipeline` → `cli`, each task TDD +(contract tests first), each task verifiable in one session. + +**With no tool packages installed the overlay is the passthrough — every form behaves exactly +as before** (byte-identical CLI output, unchanged exit codes). That guarantee is pinned by +tests, not assumed. + +## Context + +### Interaction Diagram + +Transfer of the design's entity interaction and data flow diagram (verbatim): + +``` + goga/commands/pipeline (host CLI, docker boundary) + │ docker run + env + ▼ + goga/pipeline (in-container zone) + ┌──────────────────────────────────────────────────┐ + │ pipeline_cli ── run form ──▶ run_pipeline │ + │ │ card form ──▶ describe_pipeline │ + │ │ │ │ + │ ▼ ▼ │ + │ PipelineCard(+provenance) (1) fact resolution │ + │ ▲ │ parse_dsl │ + │ │ │ resolve_workflow + apply_skip_stages + │ │ │ resolve_current_branch_name / ──▶ goga/history + │ │ │ resolve_topic_dir (topic-paths, + │ │ │ (statuses: assemble_status_scale / topic-statuses) + │ │ │ resolve_topic_status) │ + │ │ ▼ │ + │ │ goga/pipeline/hooks (the zone) │ + │ │ PipelineHooks ── amend_workflow ──▶ WorkflowAmendment (per tool) + │ │ │ │ contribute() │ + │ │ │ ▼ │ + │ │ │ merge_workflow_overlay ◀─ ToolContribution* │ + │ │ │ │ │ + │ │ │ ▼ WorkflowOverlay │ + │ │ │ emit_run_created ──▶ RunCreated ─┐ │ + │ │ │ emit_run_completed ─▶ RunCompleted ├─▶ goga/hooks + │ └────────┤ │ (emit_hook_event, + │ compile_flow(overlay.workflow) ◀───────────────────┘ wrap_context, + │ │ ▲ build_hook_arguments, + │ ▼ │ HookRegistry, + │ order_stages ──┘ CompositionStage[] declared_actions) + │ │ ▲ + │ ▼ │ + │ run_flow (goga/afm) ── exit_code ──▶ emit_run_completed│ + └──────────────────────────────────────────────────┘ goga/hooks/catalog + (+3 Action records) +``` + +### Contract Surface + +**Cell `goga/pipeline/hooks`** (facade `goga/pipeline/hooks/__init__.py` exposes exactly these +11 names via `__all__`, alphabetical; Python rules: only `__all__` names constitute the facade): + +**Entity: `PipelineIdentity(name: str, display_name: str = "", description: str, source: str)`** +- Type: class (dataclass, `kw_only=True`) +- Declared `location`: `identity.py` +- Facade obligation: importable from `goga.pipeline.hooks` +- Properties: `name -> str` (discovered stem, no `.yml` suffix), `display_name -> str` + (authored header name, empty when header names none), `description -> str` (DSL header), + `source -> str` (exactly `project` or `user`) +- Semantic requirements: pure facts — nothing is read here; `name` non-empty, no path + separators, no `.yml` suffix; `source in ("project", "user")` guarded in `__post_init__` + (repo convention, cf. `PipelineEntry`) +- Imported dependencies: none +- Annotation context: `convention` for data-model rules + +**Entity: `WorkflowDecision(kind: str, workflow_name: str | None)`** +- Type: class (dataclass, `kw_only=True`) +- Declared `location`: `identity.py` +- Properties: `kind -> str` (exactly one of `disabled`, `explicit`, `auto-match`, + `silent-miss`), `workflow_name -> str | None` (present for explicit and auto-match, None + otherwise) +- Semantic requirements: mirrors the resolution the operation already made; `kind` literal + guarded in `__post_init__` + +**Entity: `WorkIdentity(branch: str, slug: str | None = None, year: str | None = None)`** +- Type: class (dataclass, `kw_only=True`) +- Declared `location`: `identity.py` +- Properties: `branch -> str`, `slug -> str | None` (normalized topic slug), `year -> str | None` + (four digits) +- Semantic requirements: hosting decision happens in the constructing operation; the + branch-only form (`slug`/`year` None) serves a branch hosting no topic + +**Entity: `CompositionStage(id: str, title: str)`** +- Type: class (dataclass, `kw_only=True`) +- Declared `location`: `contexts.py` +- Properties: `id -> str`, `title -> str` — one row of the final composition as the card + shows it + +**Entity: `RunCreated(pipeline, decision, workflow, composition, provenance, work, statuses, runtime_dir)`** +- Type: class (dataclass, `kw_only=True`; read-only fact bundle) +- Declared `location`: `contexts.py` +- Properties: `pipeline -> PipelineIdentity`, `decision -> WorkflowDecision`, + `workflow -> WorkflowDocument | None` (the effective workflow), + `composition -> list[CompositionStage]`, `provenance -> list[str]`, + `work -> WorkIdentity`, `statuses -> list[str]`, `runtime_dir -> str` (posix string) +- Semantic requirements: read-only facts of the composed moment — a hook observes and + cannot alter +- Imported dependencies: `PipelineIdentity`/`WorkflowDecision`/`WorkIdentity` (local), + `WorkflowDocument` from `goga/pipeline/workflow` + +**Entity: `RunCompleted(... same eight ..., exit_code: int)`** +- Type: class (dataclass, `kw_only=True`); same fields as `RunCreated` plus `exit_code` + (zero, non-zero, or a spawn failure 126/127) — facts recomputed at the completion moment +- Declared `location`: `contexts.py` + +**Entity: `ToolContribution(tool: str, document: WorkflowDocument)`** +- Type: class (dataclass, `kw_only=True`) +- Declared `location`: `overlay.py` +- Properties: `tool -> str` (platform-assigned identity), `document -> WorkflowDocument` + +**Entity: `WorkflowOverlay(workflow: WorkflowDocument | None, provenance: list[str])`** +- Type: class (dataclass, `kw_only=True`) +- Declared `location`: `overlay.py` +- Properties: `workflow -> WorkflowDocument | None` (None only in the passthrough case), + `provenance -> list[str]` (committed tools, enumeration order) +- Semantic requirements: a None workflow with a non-empty provenance never occurs + +**Routine: `merge_workflow_overlay(base: WorkflowDocument | None, contributions: list[ToolContribution]) -> overlay: WorkflowOverlay`** +- Type: function +- Declared `location`: `overlay.py` +- Semantic requirements (authored-wins merge — full algorithm in Task 5): pure (inputs never + mutated, new instances); deterministic; prompt joins non-empty texts with a single blank + line (authored first, then contributions in enumeration order); memory whole-block + (authored unbeatable, else later tool wins); stage fields: authored-set never overwritten, + unset fields take the later contributing tool's value; `skip=False` overrides nothing; + extend: authored names win, among tools later entry wins; result stays declarative + (compiles through the unchanged `compile_flow`) +- Constraints: no filesystem reads/writes; no mutation of `base`, the contributions, or + their maps; no invented instructions + +**Entity: `WorkflowAmendment(pipeline, decision, workflow, work)`** +- Type: class (dataclass, `kw_only=True`) — the read-and-contribute view of one tool +- Declared `location`: `amendments.py` +- Properties: `pipeline -> PipelineIdentity`, `decision -> WorkflowDecision`, + `workflow -> WorkflowDocument | None` (the original authored workflow — post decision, + post runner-skip merge, pre-layer; read-only and identical for every tool), + `work -> WorkIdentity` +- Method: `contribute(document: WorkflowDocument)` — buffers one declarative contribution; + whole replacement (a later call replaces the earlier buffered document); an empty document + (no prompt, no stages, no extend, no memory) is discarded by the delivery with a warning; + does not cancel, redirect, or defer the operation +- Internal (not contract surface): `_contribution: WorkflowDocument | None` — a private + `field(init=False, default=None, repr=False)`, read only by the delivery (same package) +- Requirements: no staged-application state exists — a tool never sees another tool's + contribution; the buffer belongs to this tool alone + +**Entity: `PipelineHooks()`** +- Type: class +- Declared `location`: `events.py` +- Requirements: cheap construction (no enumeration, no imports at construction); one + `HookRegistry` per run carries every checkpoint of a command (lazy `build_once`); + every context built from caller-passed values — no repository reads at a checkpoint +- Methods: + - `amend_workflow(pipeline, decision, workflow, work) -> overlay: WorkflowOverlay` — the + hard amendment delivery (full algorithm in Task 7) + - `emit_run_created(pipeline, decision, overlay, composition, work, statuses, runtime_dir)` + — fire-and-forget soft emission; failing hook warns, launch proceeds + - `emit_run_completed(pipeline, decision, overlay, composition, work, statuses, runtime_dir, exit_code)` + — fires on every launch-attempt return path; the run's exit code is never affected +- Imported dependencies (platform facade, relative): `HookRegistry`, `wrap_context`, + `build_hook_arguments`, `emit_hook_event`, `declared_actions` from `goga/hooks`; + `WorkflowDocument` from `goga/pipeline/workflow`; local models from `.amendments`, + `.contexts`, `.identity`, `.overlay` + +**Cell `goga/hooks/catalog`** (additive change): + +**Routine: `declared_actions()`** (existing, `location: catalog.py`) +- Requirements gain three records: `pipeline/amend_workflow` (**hard** — the platform's + first), `pipeline/run_completed` (soft), `pipeline/run_created` (soft). `Action` and the + ten published records are untouched; the catalog grows to 13 records. + +**Cell `goga/pipeline`** (changed entities): + +**Entity: `PipelineCard(name, description, stages, provenance: list[str] = [])`** +- Declared `location`: `pipeline_card.py` — gains `provenance` implemented as + `field(default_factory=list)` (the DSL `[]` default is a representation; the factory is + applied at construction — two cards never share the list); constructions without it + remain valid + +**Routine: `describe_pipeline(name, project_dir, user_dir, workflow, no_workflow) -> card: PipelineCard`** +- Declared `location`: `describe_pipeline.py` — algorithm extended to 7 steps (Task 9): + fact resolution via one early `parse_dsl` read, delivery unless disabled, compilation with + the overlay workflow, `provenance=overlay.provenance` on the card; no events, no statuses, + no launch; `GOGA_SKIP_STAGES` never read + +**Routine: `run_pipeline(name, project_dir, user_dir, port, parallel=None) -> exit_code: int`** +- Declared `location`: `run_pipeline.py` — algorithm extended to 17 steps (Task 10): fact + resolution, amendment delivery between the skip merge and compilation, composition build, + status resolution, emissions around the launch + +**Routine: `pipeline_cli(argv: list[str]) -> exit_code: int`** +- Declared `location`: `cli.py` — the card template gains the conditional `tools:` line; + both failure paths (`_run_card`, `_run_execution`) render the hard amendment error + (`ValueError`) and the registry build's fatal `ImportError` cleanly (no traceback) + +### Re-exports + +None — no `->Name: {}` blocks exist in any of the three manifests. + +### Usages Context + +- **`convention`** (`.goga/usages/conventions.md`, both changed manifests): kw_only + dataclasses, relative intra-package imports, Google-style docstrings, `logging.getLogger(__name__)`, + blank-line block separation, test placement `tests//test_.py`, test naming + `test__`, venv execution. Applied in every task. +- **`argparse`** (inline, `goga/pipeline` manifest): stdlib argparse, `list`/`run` + subcommands, flag surface — unchanged by this feature; the CLI task touches only the card + template and the catch tuples. +- **`cli_entrypoint`** (inline): `__main__.py` stays a thin runpy wrapper — NOT touched by + this plan. +- **`default_prompts`** (inline): the four packaged prompt files and the atomic + validate-all → wipe → write materialization — run step 11, unchanged; the new steps + bracket it without touching it. + +### Imported Usages + +- **`per-tool-delivery`** from `goga/hooks` (`goga/hooks/.usages/per-tool-delivery.md`): + the staged delivery loop of `amend_workflow` — loop skeleton, public primitives + (`HookRegistry`, `subscriptions_for`, `self_context`, `wrap_context`, `build_hook_arguments`), + tool-grouped commit. Applied as written; the soft-discard leg is replaced by the hard + raise (the action's catalog error class drives it — the practice itself defers to the + catalog: "Treat a failure per the action's error class"). → Task 7. +- **`declaring-actions`** from `goga/hooks` (`goga/hooks/.usages/declaring-actions.md`): + the emission contract of the two notifications — `emit_hook_event(registry, domain, + action, context_for=...)` with a shared-instance `context_for`. → Task 7. +- **`registering-hooks`** from `goga/hooks` (`goga/hooks/.usages/registering-hooks.md`): + the hook signature (`self`/`context` injection, fixed offered names) and failure behavior + behind every checkpoint. → Tasks 6, 7 (and the test setups of Tasks 9-11). +- **`checkpoints`** from `goga/pipeline/hooks` (`goga/pipeline/hooks/.usages/checkpoints.md`): + the checkpoint surface consumption patterns — fact resolution in the operation, + amend-before-compile, emit-around-launch, the card form. → Tasks 9, 10. +- **`topic-paths`** from `goga/history` (`goga/history/.usages/topic-paths.md`): the branch + read and topic-dir resolution behind `WorkIdentity`; slug/year derive from the returned + directory (`topic_dir.name`, `topic_dir.parent.name`). → Tasks 9, 10. +- **`topic-statuses`** from `goga/history` (`goga/history/.usages/topic-statuses.md`): the + status facts of the run events (`assemble_status_scale` + `resolve_topic_status`, single + assembly, nested artifacts honored). → Task 10. +- Compiler/afm/docker/workflow imports (`compile-flow`, `parse-dsl`, `serialize-flow`, + `memory-emission`, `run-flow`, `ensure-in-docker`, `parse-workflow`, `memory`): unchanged + consumption, context only. + +### Local Usages + +None planned. Per the design's `.usages/` Update (verified against the workspace): + +- `goga/pipeline/hooks/.usages/checkpoints.md` — exists, current; additions needed: none. +- `goga/pipeline/.usages/registering-hooks.md` — exists, current; additions needed: none. +- `goga/pipeline/.usages/{describe-pipeline,run-pipeline,pipeline-cli}.md` — exist, synced + to the new surface; updates needed: none. + +No usage-file creation or update tasks. (`docs/features/pipelines/hooks.md` + mkdocs +traceability are deferred to documentation task items, not the code cells — per the design.) + +### External Dependencies + +- stdlib: `dataclasses`, `logging`, `tempfile`, `pathlib`, `sys` — no new third-party code. +- `pyyaml` (`yaml.YAMLError` channels — existing). +- Test tools: `pytest` (>= 8.0), `pytest-cov`, `ruff` (>= 0.15) — all in the project venv. +- The `afm` binary is reached only through `run_flow` and is mocked in every test that + would launch; the platform enumeration boundary + (`goga.hooks.tools.packages.packages_distributions`) is pinned by fixtures, never by + installing real tool packages. + +## Facts + +- Contracts are materialized and lint-clean: `goga lint` → 78 cells, 0 errors. The three + CODEMANIFESTs (`goga/pipeline/hooks` created; `goga/pipeline`, `goga/hooks/catalog` + changed) are **read-only** for the implementation agent. +- `goga/pipeline/hooks/` currently contains ONLY `CODEMANIFEST` + `.usages/checkpoints.md`. + All five modules and the facade are missing. +- Catalog today: 10 records in `_DECLARED_ACTIONS` (`goga/hooks/catalog/catalog.py:40-51`), + sorted by `(domain, name)` — onboarding(2), statuses(1), topics(7). Adding the pipeline + block places it between `onboarding` and `statuses`. +- `run_pipeline` is currently an 11-step routine (discovery → AFM_DIR → workflow → skip → + compile → prompts → `run_flow`); `describe_pipeline` is a 5-step routine. The contracts + now specify 17 and 7 steps respectively. +- `PipelineCard` (`goga/pipeline/pipeline_card.py`) has exactly `name`, `description`, + `stages` — no `provenance`. +- `cli.py` `_run_card`/`_run_execution` catch + `(StructuralError, WorkflowSyntaxError, RuntimeError, yaml.YAMLError, OSError, UnicodeDecodeError)` + — no `ValueError`, no `ImportError`. Platform precedent for the pair: + `history.py:114` catches `(ValueError, ImportError)`. +- Platform message format (`goga/hooks/dispatch/emit.py:96-99`, hard leg): + `f"hook {subscription.name} of tool {subscription.tool} failed on {domain}.{action}: {exc}"` + raised `from exc`. The zone copies this shape verbatim. +- Delivery proxy (`goga/hooks/dispatch/delivery.py:54-61`): reads and calls pass through + `__getattr__`, attribute assignment/deletion raise `AttributeError` — `contribute()` is + reachable through the proxy (a method call), and it is the ONLY write channel. +- Registry API: `HookRegistry.build_once()` (`state.py:54`), + `subscriptions_for(domain, action)` (`state.py:110`, enumeration order), + `self_context(tool)` (`state.py:127`). Single-build guarantee per instance. +- Per-tool loop precedent: `goga/onboarding/participation/participation.py:130-139` — + the zone's loop skeleton with the failure leg replaced by the hard raise. +- `apply_skip_stages(None, skips)` constructs a skip-only document; + `apply_skip_stages(x, [])` returns `x` unchanged (`apply_skip_stages.py:73-75, 78-82`). +- `resolve_topic_dir` raises `ValueError` when the input normalizes to an empty slug + (`goga/history/paths.py:60-61`); `resolve_current_branch_name() -> str | None`; + `assemble_status_scale() -> StatusScale` (`statuses/assembly.py:38`); + `resolve_topic_status(topic_dir: Path, scale: StatusScale) -> list[str]` + (`goga/history/status.py:35`); `current_year()` on the `goga.history` facade. +- Workflow models (`goga/pipeline/workflow/`): `WorkflowDocument(prompt, stages, extend, + memory)` with `field(default_factory=dict)` maps; `WorkflowStage` fields in fixed order + `agent, prompt, loop, skills, skip=False, approve, manual, notes, reflect, memory` — + `manual` is three-state (`None`/`True`/`False`), `skip=False` is the default and means + "not skipped"; `WorkflowExtendStage(before, after, agent, loop, approve, body)`; + `WorkflowMemory(method, path, max_rules, commit, mode)`. +- `parse_dsl(text)` returns `(header, _, _)` with `header.name` / `header.description` — + the established fact-resolution read (`describe_pipelines.py:63`). +- Test infrastructure: `tests/pipeline/conftest.py` provides `isolated_cwd`; the platform + boundary fixtures `pin_package_environment` / `install_tool_package` live in + `tests/hooks/conftest.py` (cross-package import precedent: `tests/test_cli.py:20`); the + `afm_dir` fixture is local to `tests/pipeline/test_run_pipeline.py:35` (mirror it in the + new hooks test file); `mock.patch.object(_run_pipeline_module, ...)` with + `sys.modules["goga.pipeline.run_pipeline"]` is the established mock pattern + (`goga.pipeline.run_pipeline` is shadowed in the package `__init__` — string patch paths + through the facade fail on Python 3.10). +- Import-cycle safety (verified by the design's trace): importing `goga.pipeline.hooks` + first executes `goga/pipeline/__init__.py` (partial), which imports `.run_pipeline` → + `.hooks` → `..workflow`; `goga.pipeline.workflow` and `goga.hooks` never import back into + `goga.pipeline` submodules loaded by its `__init__` — the same shape as the existing + `.compiler → ..workflow` edge. The zone MUST use relative imports only. +- Boundary note (recorded, not changed): `assemble_status_scale` (history) builds its own + registry internally — a run that resolves statuses enumerates tool packages a second + time through the history cell. Pre-existing platform behavior, outside these contracts. + +## Gap Analysis + +- **Missing contract entities**: all 11 zone entities (five modules + facade); the zone + facade `__init__.py` does not exist. +- **Missing facade exposure**: `goga.pipeline.hooks.__all__` with the 11 names. +- **Incorrect `location` placement**: none — no zone code exists to misplace; consumer + files already sit at their declared locations. +- **API mismatches**: `PipelineCard` lacks `provenance`; `run_pipeline`/`describe_pipeline` + lack the amendment/emission steps; `pipeline_cli` lacks the `tools:` line and the + `ValueError`/`ImportError` catches; the catalog lacks the three pipeline records. +- **Behavioral mismatches**: same as above — with the contracts materialized, code and + contract diverge on exactly the changed surface. +- **Existing code that can be reused**: everything else — `resolve_workflow`, + `apply_skip_stages`, `compile_flow`, `order_stages`, `list_pipelines`, the platform + facade, the history facade, the prompt materialization block (run step 11). The zone is + purely additive around them. +- **Test coverage gaps**: `tests/pipeline/hooks/` does not exist; + `tests/pipeline/test_run_pipeline_hooks.py` does not exist; the catalog, card, + describe, and CLI test files predate the feature (their existing tests must stay green — + they pin the byte-identical no-tools behavior). +- **Missing visibility in workspace or git**: the zone `CODEMANIFEST` + + `.usages/checkpoints.md`, `registering-hooks.md`, and the three synced pipeline usages + are untracked/modified in git — expected; committing is outside this plan. + +--- + +## Tasks + +> **Package ordering rule**: coding tasks for each package are completed before starting the next. Within each coding task, contract tests are written first (TDD workflow). Package order: `goga/hooks/catalog` → `goga/pipeline/hooks` → `goga/pipeline` → integration verification. + +### Task 1: Catalog records for the three pipeline actions (TDD coding) + +The `goga/hooks/catalog` cell declares `declared_actions()` — the single source of known +subscription addresses. This task appends the three pipeline-domain records to +`_DECLARED_ACTIONS` in `goga/hooks/catalog/catalog.py`: `pipeline/amend_workflow` (hard — +the platform's first hard action), `pipeline/run_completed` (soft), `pipeline/run_created` +(soft). Purely additive: `Action` and the ten published records are untouched. The catalog +grows to 13 records; `declared_actions()` sorts by `(domain, name)`, so the pipeline block +orders `amend_workflow`, `run_completed`, `run_created` between `onboarding` and `statuses`. +No behavior beyond the data. The address resolution of every later checkpoint depends on +these records existing, hence the task comes first. + +**Usages relevant to this task:** +- `convention`: docstring style, no new imports needed (dataclass `Action` already imported). + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: extend `tests/hooks/catalog/test_catalog.py` (the file already + exists with `TestCatalogContract` / logic classes) with the design scenario: + + ``` + test_catalog_carries_the_three_pipeline_records + + Setup: none (pure catalog read). + Input: declared_actions() + Trace: _DECLARED_ACTIONS (13 records) → sorted by (domain, name) + Assertions: + [("pipeline", "amend_workflow", "hard"), + ("pipeline", "run_completed", "soft"), + ("pipeline", "run_created", "soft")] all present; + pipeline block ordered between onboarding and statuses + ``` + + Also assert the total record count is 13 and that the ten pre-existing records are + unchanged (regression pin). Expected to fail at this stage — the records do not exist. +- [ ] **Code**: append to `_DECLARED_ACTIONS` in `goga/hooks/catalog/catalog.py`: + + ```python + Action(domain="pipeline", name="amend_workflow", error_class="hard"), + Action(domain="pipeline", name="run_created", error_class="soft"), + Action(domain="pipeline", name="run_completed", error_class="soft"), + ``` + + (list order is irrelevant — `declared_actions()` sorts — but keep the file's existing + grouping style.) +- [ ] **Interface verification**: `python -m pytest tests/hooks/catalog/test_catalog.py -q` + — all pass, including the pre-existing tests. +- [ ] **Logic tests**: covered by the scenario above (presence, error classes, ordering, + count); add nothing speculative. +- [ ] **Debugging**: `python -m pytest tests/hooks/catalog -q` — fix implementation code + until all tests pass (do NOT fix test code). +- [ ] **Contract re-verification**: `declared_actions()` still returns every record, + complete and unfiltered, deterministic; `Action` untouched; the module docstring still + matches. +- [ ] **Lint**: `python -m ruff check goga/hooks/catalog tests/hooks/catalog && python -m ruff format --check goga/hooks/catalog tests/hooks/catalog` — fix formatting if necessary. + +### Task 2: Zone package skeleton and test scaffolding (infrastructure) + +Create the `goga/pipeline/hooks` Python package (the cell directory exists with its +`CODEMANIFEST` and `.usages/checkpoints.md`; only the Python package is missing) and the +mirrored test package `tests/pipeline/hooks/`. The facade `__init__.py` starts as a +documented placeholder and grows incrementally — each later module task adds its names, and +Task 7 completes it to exactly the 11 contract names (the precedent: the workflow cell's +facade was "Built incrementally: each entity task adds its module's import and `__all__` +entry"). The test `conftest.py` re-exports the platform boundary fixtures exactly as the +design's General Setup specifies. + +**Usages relevant to this task:** +- `convention`: test placement (`tests/pipeline/hooks/test_.py`), every test + directory carries `__init__.py`, relative imports inside the package. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] Create `goga/pipeline/hooks/__init__.py` — package docstring naming the zone (the + hooks zone of the pipeline domain) and an empty `__all__: list[str] = []` for now; no + imports yet (the modules do not exist). Relative imports only, once they appear. +- [ ] Create `tests/pipeline/hooks/__init__.py` (empty) and `tests/pipeline/hooks/conftest.py`: + + ```python + from tests.hooks.conftest import install_tool_package, pin_package_environment # noqa: F401 + ``` + + (cross-package import precedent: `tests/test_cli.py:20`; the fixtures pin the two + platform boundary points — `packages_distributions` and the `sys.modules` entry of a + `goga_tool_*` package — so the platform code under test runs for real). +- [ ] Verify collection: `python -m pytest tests/pipeline/hooks --collect-only -q` — the + package collects cleanly (zero tests is expected at this stage). +- [ ] Verify package importability: `python -c "import goga.pipeline.hooks"` — no error + (the partially-initialized-parent edge is safe: `goga/pipeline/__init__.py` does not + import the zone yet). +- [ ] Lint: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting if necessary. + +### Task 3: Zone identity models — `identity.py` (TDD coding) + +Implement the three identity entities of the zone in `goga/pipeline/hooks/identity.py`: +`PipelineIdentity(name: str, display_name: str = "", description: str, source: str)`, +`WorkflowDecision(kind: str, workflow_name: str | None)`, and +`WorkIdentity(branch: str, slug: str | None = None, year: str | None = None)` — the +identity vocabulary of every pipeline event. All three are `@dataclass(kw_only=True)` per +`convention`, snake_case fields, type-hinted (mandatory), docstrings in the repo style +(Args sections mirroring the CODEMANIFEST annotations). No behavior: pure fact carriers. +Two contract invariants get runtime guards via `__post_init__` (repo convention, cf. +`PipelineEntry`): `PipelineIdentity` — `source in ("project", "user")` (and the `name` +rules: non-empty, no path separators, no `.yml` suffix); `WorkflowDecision` — `kind in +("disabled", "explicit", "auto-match", "silent-miss")`. Add the three names to the facade. + +**Usages relevant to this task:** +- `convention`: kw_only dataclasses, Google-style docstrings, relative imports + (`from __future__ import annotations`; `str | None` fields follow the existing + `WorkflowStage` precedent under the future-annotations import). + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: create `tests/pipeline/hooks/test_identity.py`: + - importability from the facade after this task: `from goga.pipeline.hooks import + PipelineIdentity, WorkflowDecision, WorkIdentity` (fails now — expected); + - each model is a `kw_only` dataclass (positional construction raises `TypeError`; + cf. `is_kw_only_dataclass` helper from `tests/conftest.py` used by the catalog tests); + - declared field names, order, and defaults exactly per the signatures — + `PipelineIdentity`: `name, display_name="", description, source`; + `WorkflowDecision`: `kind, workflow_name`; + `WorkIdentity`: `branch, slug=None, year=None`. +- [ ] **Code**: create `goga/pipeline/hooks/identity.py` with the three dataclasses and + the `__post_init__` guards (`ValueError` on a bad `source` literal, a bad `kind` + literal, and invalid `name` input — non-empty, no `/`/`\\`, no `.yml` suffix). +- [ ] **Code**: add the three names to `goga/pipeline/hooks/__init__.py` imports and + `__all__` (keep `__all__` alphabetical). +- [ ] **Interface verification**: `python -m pytest tests/pipeline/hooks/test_identity.py -q` + — all pass. +- [ ] **Logic tests** (same file): + - `PipelineIdentity(source="elsewhere")` raises `ValueError`; `source="project"` and + `source="user"` construct; + - `PipelineIdentity(name="dir/x")` / `name="x.yml"` / `name=""` raise `ValueError`; + `display_name` defaults to `""`; + - `WorkflowDecision(kind="bogus")` raises; all four literal kinds construct; + `workflow_name=None` accepted; + - `WorkIdentity(branch="b")` alone constructs the branch-only form + (`slug is None`, `year is None`). +- [ ] **Debugging**: `python -m pytest tests/pipeline/hooks -q` — fix implementation code + until all tests pass (do NOT fix test code). +- [ ] **Contract re-verification**: fields/properties match the declared API; pure facts — + no repository reads anywhere in the module; facade exposes the three names. +- [ ] **Lint**: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting, apply decomposition if necessary. + +### Task 4: Zone run-event contexts — `contexts.py` (TDD coding) + +Implement `goga/pipeline/hooks/contexts.py`: `CompositionStage(id: str, title: str)` (one +row of the final composition as the card shows it), `RunCreated(pipeline, decision, +workflow, composition, provenance, work, statuses, runtime_dir)`, and `RunCompleted` (the +same eight fields plus `exit_code: int` — the facts recomputed at the completion moment, +including the spawn-failure codes 126/127). All `@dataclass(kw_only=True)`, all fields +required (no defaults — the signatures carry none), read-only fact bundles: a hook observes +and cannot alter. `WorkflowDocument` imports from `..workflow`; the local identity types +from `.identity`. The contract tests for these models live in +`tests/pipeline/hooks/test_events.py` (per the design's Source File Registry — the file is +created here and extended by Task 7, which owns the emission behavior). + +**Usages relevant to this task:** +- `convention`: kw_only dataclasses, docstrings, relative imports + (`from ..workflow import WorkflowDocument` for type hints). + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: create `tests/pipeline/hooks/test_events.py` (data-model + contract block; Task 7 appends the delivery/emission classes): + - the three names importable from `goga.pipeline.hooks` (fails now — expected); + - `kw_only` enforced (positional construction raises `TypeError`); + - field names and order exactly per the signatures: + `CompositionStage(id, title)`; `RunCreated(pipeline, decision, workflow, composition, + provenance, work, statuses, runtime_dir)`; `RunCompleted` = the same eight plus + `exit_code` last. +- [ ] **Code**: create `goga/pipeline/hooks/contexts.py` with the three dataclasses + (docstrings mirroring the CODEMANIFEST property annotations). +- [ ] **Code**: add `CompositionStage`, `RunCreated`, `RunCompleted` to the facade + `__init__.py` and `__all__` (alphabetical). +- [ ] **Interface verification**: `python -m pytest tests/pipeline/hooks/test_events.py -q` + — all pass. +- [ ] **Logic tests**: construction carries every field verbatim (build a + `RunCreated`/`RunCompleted` from identity/decision/workflow fixtures and assert each + attribute round-trips; `RunCompleted.exit_code` accepts 0, 3, and 127); dataclass + equality of two identically-built contexts holds. +- [ ] **Debugging**: `python -m pytest tests/pipeline/hooks -q` — fix implementation code + until all tests pass (do NOT fix test code). +- [ ] **Contract re-verification**: read-only facts — no methods, no behavior, no defaults + beyond the declared signatures; facade exposes the names. +- [ ] **Lint**: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting, apply decomposition if necessary. + +### Task 5: The authored-wins overlay — `overlay.py` (TDD coding) + +Implement `goga/pipeline/hooks/overlay.py`: the data models `ToolContribution(tool: str, +document: WorkflowDocument)` and `WorkflowOverlay(workflow: WorkflowDocument | None, +provenance: list[str])`, and the Routine `merge_workflow_overlay(base: WorkflowDocument | +None, contributions: list[ToolContribution]) -> WorkflowOverlay` — compose the effective +workflow from the authored base and the committed contributions, authored intent winning +per slot. This is the semantic heart of the feature. + +The verified algorithm (transfer from the design — implement exactly this): + +``` +1. IF contributions empty: RETURN WorkflowOverlay(workflow=base, provenance=[]) + — the passed workflow object itself, zero rebuild (the no-tool-packages guarantee) +2. prompt = "\n\n".join(non-empty texts: authored first, then contributions in order) or None +3. memory = authored block when present — unbeatable; otherwise the LATER contributing + tool's block; no field-level merging +4. stages = per name (authored names first, then fresh names): + authored-set fields never overwritten; + unset fields take the later contributing tool's value; + no authored entry → fully tool-defined WorkflowStage +5. extend = authored entries kept; a contribution entry under an authored name dropped; + among tools the later entry wins per name +6. provenance = [c.tool for c in contributions] +7. RETURN WorkflowOverlay(WorkflowDocument(prompt, stages, extend, memory), provenance) +``` + +Field "is set" semantics (the authored-wins table): + +| Field | SET when | Note | +|---|---|---| +| `agent`, `prompt`, `loop`, `skills`, `approve`, `notes`, `reflect`, `memory` | value is not `None` | `None` = unset | +| `manual` | value is not `None` | three-state: `True` (force) and `False` (explicit cancel) are BOTH set; absence = unset | +| `skip` | value is `True` | `skip=False` overrides nothing — only a positive skip is authored intent | + +Stage merge (per name — `FIELDS` is the `WorkflowStage` field set, `DEFAULT[f]` is +`None` / `skip=False`): + +``` +authored = base.stages.get(name) if base else None +values = {f: getattr(authored, f) for f in FIELDS} if authored + else {f: DEFAULT[f] for f in FIELDS} # None / skip=False +authored_set = {f: SET(f, values[f]) for f in FIELDS} +for c in contributions: # enumeration order + cs = c.document.stages.get(name) + if cs is None: continue + for f in FIELDS: + if SET(f, getattr(cs, f)) and not authored_set[f]: + values[f] = getattr(cs, f) # later tool wins +merged_stages[name] = WorkflowStage(**values) +``` + +Constraints: pure — new `WorkflowStage`/`WorkflowDocument` instances (field values by +reference, the repo's shallow-copy convention as in `apply_skip_stages.py:78`; nothing +mutated); no filesystem; deterministic; the result stays declarative (it compiles through +the unchanged `compile_flow`). No errors raised — invalid shapes cannot occur (committed +contributions are non-empty by construction; structural validation of the merged result +belongs to `compile_flow`, e.g. a contribution naming an unknown stage surfaces as its +`StructuralError`). `WorkflowOverlay` requirement: a None workflow with a non-empty +provenance never occurs — `workflow` is `None` only when `base` is `None` and no +contribution committed (unreachable past the empty short-circuit). + +**Usages relevant to this task:** +- `convention`: kw_only dataclasses for the two models, Google-style docstring for the + Routine, relative imports (`from ..workflow import WorkflowDocument, WorkflowStage`). + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: create `tests/pipeline/hooks/test_overlay.py`: + - `ToolContribution`, `WorkflowOverlay`, `merge_workflow_overlay` importable from + `goga.pipeline.hooks` (fails now — expected); + - both models `kw_only` with the declared fields; + - `merge_workflow_overlay` signature: parameters `base`, `contributions`, return + `WorkflowOverlay` (inspect.signature). +- [ ] **Code**: create `goga/pipeline/hooks/overlay.py` — the two dataclasses and the + merge implementing the algorithm above exactly (blank-line-joined prompt, whole-block + memory, per-field stage merge with the SET table, authored-names-win extend, + enumeration-order provenance, empty short-circuit returning the passed object). +- [ ] **Code**: add `ToolContribution`, `WorkflowOverlay`, `merge_workflow_overlay` to the + facade `__init__.py` and `__all__` (alphabetical). +- [ ] **Interface verification**: `python -m pytest tests/pipeline/hooks/test_overlay.py -q` + — contract tests pass. +- [ ] **Logic tests** (same file — the design's scenarios, verbatim): + + ``` + test_merge_prompt_concatenates_authored_first_then_tools + Setup: base = WorkflowDocument(prompt="authored"); contributions: + [ToolContribution("t1", WorkflowDocument(prompt="one")), + ToolContribution("t2", WorkflowDocument(prompt="two"))]. + Input: merge_workflow_overlay(base, contributions) + Trace: + texts = ["authored", "one", "two"] # empties dropped + prompt = "authored\n\none\n\ntwo" # single blank line joins + WorkflowOverlay(provenance=["t1", "t2"]) + Assertions: + overlay.workflow.prompt == "authored\n\none\n\ntwo" + overlay.provenance == ["t1", "t2"] + ``` + + ``` + test_merge_stage_fields_fill_only_unset_later_tool_wins + Setup: base.stages = {"build": WorkflowStage(agent="author-agent", loop=2)}; + t1 contributes WorkflowStage(agent="t1-agent", skills=["s1"]); + t2 contributes WorkflowStage(agent="t2-agent", loop=5). + Input: merge_workflow_overlay(base, [tc(t1), tc(t2)]) + Trace: + stage "build": authored agent set → blocks both tools + authored loop=2 set → blocks t2 + skills unset → t1 sets ["s1"] (t2 sets none) + → WorkflowStage(agent="author-agent", loop=2, skills=["s1"]) + Assertions: + overlay.workflow.stages["build"].agent == "author-agent" + overlay.workflow.stages["build"].loop == 2 + overlay.workflow.stages["build"].skills == ["s1"] + base.stages["build"].skills is None # purity — input untouched + ``` + + ``` + test_merge_skip_false_overrides_nothing_authored_skip_unbeatable + Setup: base.stages = {"build": WorkflowStage(skip=True)}; + t1 contributes WorkflowStage(skip=False); fresh name "deploy": + authored has no entry, t1 contributes WorkflowStage(skip=True). + Input: merge_workflow_overlay(base, [tc(t1)]) + Trace: + "build": authored skip=True SET → t1's False blocked + "deploy": no authored entry → t1's skip=True fills + Assertions: + overlay.workflow.stages["build"].skip is True + overlay.workflow.stages["deploy"].skip is True + ``` + + ``` + test_merge_memory_authored_block_unbeatable_and_later_tool_wins + Setup: case A — base.memory = WorkflowMemory(max_rules=5), t1 + contributes WorkflowMemory(max_rules=99); case B — base.memory=None, + t1 and t2 both contribute blocks (max_rules=7 / max_rules=9). + Input: both merges. + Trace: + A: authored block present → kept whole (max_rules=5) + B: no authored block → later tool t2 wins (max_rules=9) + Assertions: + A: overlay.workflow.memory.max_rules == 5 + B: overlay.workflow.memory.max_rules == 9 + ``` + + ``` + test_merge_extend_authored_names_win_and_later_tool_wins + Setup: base = WorkflowDocument(extend={"audit": WorkflowExtendStage( + after=["build"], body={"title": "Audit"})}); + t1 contributes extend={"audit": WorkflowExtendStage(before=["build"], + body={"title": "X"}), "notify": WorkflowExtendStage(after=["deploy"], + body={"title": "N1"})}; + t2 contributes extend={"notify": WorkflowExtendStage(after=["audit"], + body={"title": "N2"})}. + Input: merge_workflow_overlay(base, [tc(t1), tc(t2)]) + Trace: + "audit": under an authored name → t1's entry dropped, the authored + after=["build"] entry kept + "notify": fresh name → t1 sets it, t2 replaces (later tool wins) + Assertions: + overlay.workflow.extend["audit"].after == ["build"] + overlay.workflow.extend["notify"].after == ["audit"] + len(overlay.workflow.extend) == 2 + base.extend["audit"].after == ["build"] # purity — input untouched + ``` + + ``` + test_merge_empty_base_tools_build_the_document + Setup: base None; t1 contributes WorkflowDocument(prompt="one", + stages={"build": WorkflowStage(agent="a")}); t2 contributes + WorkflowDocument(prompt="two"). + Input: merge_workflow_overlay(None, [tc(t1), tc(t2)]) + Trace: + contributions non-empty → no passthrough short-circuit + no authored layer → texts ["one", "two"] → prompt "one\n\ntwo" + stage "build": no authored entry → fully tool-defined (agent="a") + provenance ["t1", "t2"]; the document exists (every contribution non-empty) + Assertions: + overlay.workflow is not None + overlay.workflow.prompt == "one\n\ntwo" + overlay.workflow.stages["build"].agent == "a" + overlay.provenance == ["t1", "t2"] + ``` + + ``` + test_merge_passthrough_short_circuit_returns_base_object + Setup: base = WorkflowDocument(prompt="x"). + Input: merge_workflow_overlay(base, []) and merge_workflow_overlay(None, []). + Trace: contributions empty → WorkflowOverlay(workflow=base, provenance=[]) + Assertions: + overlay.workflow is base # the passed object itself + overlay.provenance == [] + merge(None, []).workflow is None + ``` + +- [ ] **Debugging**: `python -m pytest tests/pipeline/hooks -q` — fix implementation code + until all tests pass (do NOT fix test code). +- [ ] **Contract re-verification**: purity (no input mutated anywhere — the assertions + pin it), determinism, declarative result shape (`WorkflowDocument` with `stages` as + `dict[str, WorkflowStage]`, `extend` as `dict[str, WorkflowExtendStage]`); facade + exposes the three names. +- [ ] **Lint**: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting, apply decomposition if necessary. + +### Task 6: The amendment view — `amendments.py` (TDD coding) + +Implement `goga/pipeline/hooks/amendments.py`: `WorkflowAmendment(pipeline: +PipelineIdentity, decision: WorkflowDecision, workflow: WorkflowDocument | None, work: +WorkIdentity)` — the read-and-contribute view of one tool. `@dataclass(kw_only=True)`, +four read-only fact fields (the original authored workflow — post decision, post +runner-skip merge, pre-layer — identical for every tool), plus the method: + +``` +contribute(document): +1. self._contribution = document # whole replacement; no validation here + # (the delivery checks emptiness post-hoc) +``` + +`_contribution: WorkflowDocument | None` is a private `field(init=False, default=None, +repr=False)` — not contract surface; read only by the delivery (same package). Errors: +none raised; a bad document surfaces at the consumer (`compile_flow`) or as the +empty-contribution warning. Edge cases: repeat calls replace; the buffer is per-tool (per +view). + +**Usages relevant to this task:** +- `registering-hooks` (`goga/hooks/.usages/registering-hooks.md`): the hook signature that + receives this view — `context` (this object, delivered through the read-only proxy: + read attributes and call methods freely, attribute assignment blocked) and `self` (the + tool's isolated context). `contribute` is reachable through the proxy and is the ONLY + write channel. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: create `tests/pipeline/hooks/test_amendments.py`: + - `WorkflowAmendment` importable from `goga.pipeline.hooks` (fails now — expected); + - `kw_only`, fields exactly `pipeline, decision, workflow, work`; + - `contribute` is a public method with signature `(document)`; + - `_contribution` is `init=False`, default `None`, excluded from `repr`. +- [ ] **Code**: create `goga/pipeline/hooks/amendments.py` (imports: + `from .identity import PipelineIdentity, WorkflowDecision, WorkIdentity`; + `from ..workflow import WorkflowDocument`). +- [ ] **Code**: add `WorkflowAmendment` to the facade `__init__.py` and `__all__`. +- [ ] **Interface verification**: `python -m pytest tests/pipeline/hooks/test_amendments.py -q` + — all pass. +- [ ] **Logic tests**: `contribute(WorkflowDocument(prompt="a"))` sets the buffer; + a second `contribute(WorkflowDocument(prompt="b"))` replaces it whole + (`_contribution.prompt == "b"`); a fresh view starts with `_contribution is None`; + `contribute` returns `None`. +- [ ] **Debugging**: `python -m pytest tests/pipeline/hooks -q` — fix implementation code + until all tests pass (do NOT fix test code). +- [ ] **Contract re-verification**: no staged-application state (the four fields are the + constructor facts, unchanged by `contribute`); the buffer belongs to this view alone. +- [ ] **Lint**: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting, apply decomposition if necessary. + +### Task 7: The checkpoint surface — `events.py` + facade completion (TDD coding) + +Implement `goga/pipeline/hooks/events.py`: `PipelineHooks` — the checkpoint surface of the +pipeline domain. Construction is cheap (`self._registry: HookRegistry | None = None`; no +enumeration, no imports, no repository reads). One lazily-built `HookRegistry` per +instance carries every checkpoint: + +``` +_ensure_registry(): +1. IF self._registry is None: + - registry = HookRegistry(); registry.build_once(); self._registry = registry +2. RETURN self._registry # ImportError propagates (single fatal case) + +amend_workflow(pipeline, decision, workflow, work): +1. record = the declared_actions() entry (domain="pipeline", action="amend_workflow") + → absent: raise ValueError("unknown hook action: pipeline.amend_workflow") +2. groups = subscriptions_for("pipeline", "amend_workflow") grouped per tool + (enumeration order) +3. IF groups empty: RETURN merge_workflow_overlay(workflow, []) # passthrough +4. FOR tool, subs in groups.items(): + a. amendment = WorkflowAmendment(pipeline=pipeline, decision=decision, + workflow=workflow, work=work) # fresh view, fresh buffer + b. proxy = wrap_context(amendment) + c. FOR sub in subs: + - sub.hook(**build_hook_arguments(sub.hook, proxy, registry.self_context(tool))) + - ON Exception AS reason: + raise ValueError(f"hook {sub.name} of tool {tool} failed on " + f"pipeline.amend_workflow: {reason}") from reason + # hard: stop at the first failure; the tool's buffer dies with its view + d. IF amendment._contribution is None: continue # never contributed — silent + e. IF empty(amendment._contribution): # prompt None ∧ stages {} ∧ extend {} ∧ memory None + - logger.warning("tool %s contributed an empty document to " + "pipeline.amend_workflow: discarded", tool) + - continue + f. contributions.append(ToolContribution(tool=tool, document=amendment._contribution)) +5. RETURN merge_workflow_overlay(workflow, contributions) +``` + +The hard-failure message copies the platform's format (`emit.py:96-99`) verbatim shape: +hook name, tool, address, reason. `BaseException` (e.g. `KeyboardInterrupt`) is not +intercepted (platform convention: intercept `Exception` only). The emissions: + +``` +emit_run_created(pipeline, decision, overlay, composition, work, statuses, runtime_dir): +1. context = RunCreated(pipeline=..., decision=..., workflow=overlay.workflow, + composition=..., provenance=overlay.provenance, work=..., statuses=..., + runtime_dir=...) — one shared instance +2. emit_hook_event(self._ensure_registry(), "pipeline", "run_created", + context_for=lambda _tool: context) + → None — fire-and-forget; soft failures warn inside the platform + +emit_run_completed(..., exit_code): +1. context = RunCompleted(... the same facts ..., exit_code=exit_code) +2. emit_hook_event(self._ensure_registry(), "pipeline", "run_completed", + context_for=lambda _tool: context) +``` + +Errors: `ValueError` (hard hook failure) and `ImportError` (broken tool package, from +`build_once`) propagate — both rendered by `pipeline_cli` as clean stderr messages +(Task 11). Edge cases: no tool packages installed → registry builds empty → passthrough +(byte-identical runs); a tool subscribed but silent → no commit, no warning. This task also +completes the facade to exactly the 11 contract names. + +**Usages relevant to this task:** +- `per-tool-delivery` (`goga/hooks/.usages/per-tool-delivery.md`): the staged delivery + loop — applied as written; the soft-discard leg is replaced by the hard raise (the + action's catalog error class drives it — the practice defers to the catalog). +- `declaring-actions` (`goga/hooks/.usages/declaring-actions.md`): the emission contract — + `emit_hook_event(registry, domain, action, context_for=...)`; returning the same + instance shares the read-only context. +- `registering-hooks` (`goga/hooks/.usages/registering-hooks.md`): registration and + failure behavior behind every checkpoint. +- Platform import surface (relative): `from ...hooks import HookRegistry, + build_hook_arguments, emit_hook_event, wrap_context, declared_actions` (three dots: + `goga.pipeline.hooks` → `goga`); local models via `from .amendments import ...` etc. + The zone never imports `..workflow` in `events.py` beyond type hints (contexts carry it). + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: extend `tests/pipeline/hooks/test_events.py`: + - `PipelineHooks` importable from the facade; methods `amend_workflow`, + `emit_run_created`, `emit_run_completed` exist with the declared signatures + (`inspect.signature`, `self` excluded); + - construction performs no enumeration (a `pin_package_environment({"goga_tool_demo": + ["demo-dist"]})` boundary with `call_count == 0` right after `PipelineHooks()`); + - facade completion: + + ``` + test_zone_facade_exports_exactly_the_contract + Setup: import goga.pipeline.hooks. + Input: sorted(goga.pipeline.hooks.__all__) + Assertions: + __all__ == ["CompositionStage", "PipelineHooks", "PipelineIdentity", + "RunCompleted", "RunCreated", "ToolContribution", + "WorkIdentity", "WorkflowAmendment", "WorkflowDecision", + "WorkflowOverlay", "merge_workflow_overlay"] + each name importable from the package root + ``` + +- [ ] **Code**: create `goga/pipeline/hooks/events.py` implementing the algorithms above + (`logger = logging.getLogger(__name__)`; `mock`-free; relative imports only). +- [ ] **Code**: complete the facade `__init__.py` to the 11 names. +- [ ] **Interface verification**: `python -m pytest tests/pipeline/hooks/test_events.py -q` + — all pass. +- [ ] **Logic tests** (same file; tool-package simulation via + `pin_package_environment({"goga_tool_demo": ["demo-dist"]})` + + `install_tool_package("goga_tool_demo", register_hooks=...)` — the platform code under + test runs for real; design scenarios verbatim): + + ``` + test_amend_workflow_commits_per_tool_and_merges + Setup: pinned environment with two tool packages. The first subscribes + ("pipeline", "amend_workflow", "hardening", hook) where hook(context) reads + context.pipeline.name, context.decision.kind, context.workflow (recording + id(context.workflow) into its self context) and calls + context.contribute(WorkflowDocument(prompt="harden")). The second + (goga_tool_second) subscribes the same address with a hook that records + id(context.workflow) and context.workflow.prompt into its own self AFTER + contributing WorkflowDocument(prompt="second"). + Input: PipelineHooks().amend_workflow(pipeline=PipelineIdentity( + name="deploy", description="d", source="project"), decision=..., + workflow=WorkflowDocument(prompt="authored"), work=WorkIdentity(branch="b")) + Trace: + registry.build_once() # real enumeration, one build + subscriptions_for("pipeline", "amend_workflow") → 2 subscriptions, 2 tools + per tool: WorkflowAmendment(view over the SAME workflow) → wrap_context + → build_hook_arguments → hook(context=proxy) # only "context" declared + → contribute(...) sets the tool's own buffer + both non-empty → ToolContribution per tool + merge → overlay + Assertions: + overlay.workflow.prompt == "authored\n\nharden\n\nsecond" + overlay.provenance == [tool_id_of("goga_tool_demo"), tool_id_of("goga_tool_second")] + first_seen_id == second_seen_id # the same original workflow object + second_seen_prompt == "authored" # the first tool's contribution invisible + ``` + + ``` + test_amend_workflow_registry_built_once_across_checkpoints + Setup: pinned environment (boundary mock returned by pin_package_environment); + a tool subscribing both amend_workflow and run_completed. + Input: one PipelineHooks instance: amend_workflow(...) then emit_run_completed(...). + Trace: amend → _ensure_registry (build #1); emit_run_completed → _ensure_registry + (no rebuild) + Assertions: boundary.call_count == 1 # packages_distributions read exactly once + ``` + + ``` + test_amend_workflow_hard_failure_stops_command_and_discards + Setup: pinned env; tool subscribes amend_workflow with hook(context) raising + RuntimeError("boom") after calling context.contribute(WorkflowDocument(prompt="x")); + a second tool subscribes the same address with a working hook. + Input: PipelineHooks().amend_workflow(...) (facts as above). + Trace: tool #1 hook raises → ValueError("hook ... of tool ... failed on + pipeline.amend_workflow: boom") → walk stops — tool #2 never called + Assertions: + pytest.raises(ValueError, match="pipeline.amend_workflow: boom") + merge never ran → no overlay returned; tool #2's hook not called + ``` + + ``` + test_empty_contribution_discarded_with_warning_silent_tool_ok + Setup: pinned env with two tools: one contributes WorkflowDocument() (all defaults), + one subscribes but never calls contribute. + Input: amend_workflow(...) with base=WorkflowDocument(prompt="a"). + Trace: tool #1: buffer set, empty → warning, discard; tool #2: buffer None → silent; + merge with [] → passthrough + Assertions: + overlay.workflow.prompt == "a"; overlay.provenance == [] + caplog has exactly one discard warning (names tool #1) + ``` + +- [ ] **Debugging**: `python -m pytest tests/pipeline/hooks -q` — fix implementation code + until all tests pass (do NOT fix test code). +- [ ] **Contract re-verification**: commit granularity is the tool; an address without + subscriptions returns the passthrough overlay (the passed workflow, empty provenance); + no repository/filesystem reads at any checkpoint; one registry per instance across + amendment + emissions; the facade is exactly the 11 names. +- [ ] **Lint**: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting, apply decomposition if necessary. + +### Task 8: `PipelineCard.provenance` (TDD coding) + +Add the `provenance` field to `PipelineCard` in `goga/pipeline/pipeline_card.py`: +`provenance: list[str] = field(default_factory=list)` (import `field` from +`dataclasses`), a docstring line for it (the tools whose contributions committed into the +composition, in enumeration order; empty when none contributed). Existing constructions +compile unchanged (the default); two cards never share the list (factory per instance). +`CardStage` is untouched. + +**Usages relevant to this task:** +- `convention`: dataclass field style; the DSL signature default `[]` is a representation — + the actual default factory is applied at construction (the `WorkflowDocument` map-fields + precedent). + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: extend `tests/pipeline/test_pipeline_card.py`: + - the `PipelineCard` field set is now exactly `name, description, stages, provenance`; + - construction without `provenance` remains valid (existing tests already pin this — + they must stay green unchanged: the regression proof of the additive default). +- [ ] **Code**: add the field + docstring line to `goga/pipeline/pipeline_card.py`. +- [ ] **Interface verification**: `python -m pytest tests/pipeline/test_pipeline_card.py -q` + — all pass. +- [ ] **Logic tests** (design scenario, verbatim): + + ``` + test_pipeline_card_provenance_default_factory_isolated + Setup: two PipelineCard(name="a", description="d", stages=[]) constructions + (no provenance). + Input: mutate card_a.provenance.append("x"). + Trace: default_factory=list per construction → independent lists + Assertions: card_b.provenance == [] + ``` + +- [ ] **Debugging**: `python -m pytest tests/pipeline/test_pipeline_card.py -q` — fix + implementation code until all tests pass (do NOT fix test code). +- [ ] **Contract re-verification**: `PipelineCard` remains a `kw_only` dataclass; the + field order ends with `provenance`; the facade `goga.pipeline.PipelineCard` unchanged. +- [ ] **Lint**: `python -m ruff check goga/pipeline/pipeline_card.py tests/pipeline/test_pipeline_card.py && python -m ruff format --check goga/pipeline/pipeline_card.py tests/pipeline/test_pipeline_card.py` — fix formatting if necessary. + +### Task 9: The card form through the amendment layer — `describe_pipeline.py` (TDD coding) + +Rewire `goga/pipeline/describe_pipeline.py` to the 7-step contract. Current state: 5 steps +(locate → resolve → compile → order → card). The change inserts the amendment layer: + +1. Discover entries via `list_pipelines` and locate the matching name; on no match report + the missing pipeline with a readable error (unchanged). +2. Resolve the workflow via `resolve_workflow` with the pipeline name and the workflow + flags (unchanged; CLI flags — no `GOGA_SKIP_STAGES` read, ever). +3. Resolve the amendment facts (the `PipelineIdentity` — the authored header name and + description read via `parse_dsl` from the pipeline-file text; the `WorkflowDecision`; + and the `WorkIdentity` — the current branch via `resolve_current_branch_name`, the + literal `"unknown"` when it resolves None; the hosting topic slug and year via + `resolve_topic_dir` when its directory exists, the branch-only form otherwise) and, + unless the decision is disabled, deliver the amendment via the `PipelineHooks` + checkpoint surface with the resolved workflow — receiving the overlay result; a + disabled decision delivers nothing and the overlay is the passthrough `WorkflowOverlay` + of the resolved workflow. +4. Compile the pipeline-file via `compile_flow` into a temporary flow-file located in a + system temporary directory with the overlay workflow, and receive the documents tuple. +5. Order the compiled stages via `order_stages` (unchanged). +6. Build the card: name and description from the parsed pipeline document header (the + documents tuple — NOT a re-parse); one `CardStage` per ordered stage; the card + provenance from the overlay. +7. Discard the temporary flow-file and return the card. + +Fact-resolution details (identical to the run form): `header, _, _ = +parse_dsl(pipeline_path.read_text())`; +`PipelineIdentity(name=match.name, display_name=header.name, +description=header.description, source=match.source.value)`; the kind-derivation +matrix — `no_workflow` → ("disabled", None); explicit name given (`workflow_name +not in (None, "")`) and a document resolved → ("explicit", workflow_name); no +explicit name and a document resolved → ("auto-match", name); document None +(explicit-missing / auto-miss / containment escape) → ("silent-miss", None); +`branch = resolve_current_branch_name() or "unknown"`; +`try: topic_dir = resolve_topic_dir(branch)` / `except ValueError: topic_dir = None`; +`topic_dir.is_dir()` → `WorkIdentity(branch=branch, slug=topic_dir.name, +year=topic_dir.parent.name)` else `WorkIdentity(branch=branch)`. + +Constraints: no afm launch, no run events, no writes into project/runtime directories; do +not re-parse the pipeline-file for the card fields — the single early `parse_dsl` read of +step 3 serves the amendment facts only. **No events, no statuses, no launch.** + +**Usages relevant to this task:** +- `checkpoints` (`goga/pipeline/hooks/.usages/checkpoints.md`): the card form section — + compose through the same amendment with the same precedence, report + `overlay.provenance`, no run events. +- `topic-paths` (`goga/history/.usages/topic-paths.md`): the branch read and topic-dir + resolution behind the work identity; slug/year from the returned directory. +- `convention`: docstring style, relative imports (`from .hooks import PipelineHooks, +PipelineIdentity, ...`; `from ..history import resolve_current_branch_name, +resolve_topic_dir`). + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: extend `tests/pipeline/test_describe_pipeline.py` — the + signature is unchanged (existing tests pin it); add the new-surface pin: the returned + card carries `provenance == []` on the no-tools path (deterministic via + `pin_package_environment({})` — the registry builds empty, the overlay is the + passthrough). +- [ ] **Code**: rewire `goga/pipeline/describe_pipeline.py` to the 7 steps above + (hooks instance scoped to the call; `logger.debug` may add provenance/composition — + additive, debug level). +- [ ] **Interface verification**: `python -m pytest tests/pipeline/test_describe_pipeline.py -q` + — all pass, including the pre-existing tests (they pin the no-tools composition and + must stay green). +- [ ] **Logic tests** (design scenarios, verbatim): + + ``` + test_describe_pipeline_reports_provenance_through_same_layer + Setup: pinned env with a tool contributing prompt="tool-text" via amend_workflow; + a workflow file in .goga/workflows/deploy.yml with prompt: "authored". + Input: describe_pipeline("deploy", project_dir, user_dir, workflow=None, + no_workflow=False) — real compile_flow into the temp dir (no mocks beyond the + tool env). + Trace: + resolve_workflow → auto-match → WorkflowDocument(prompt="authored") + facts → amend → overlay(prompt="authored\n\ntool-text", [""]) + compile_flow(overlay.workflow) → temp flow-file → discarded + PipelineCard(provenance=[""]) + Assertions: + card.provenance == [""] + card.name == + ``` + + ``` + test_describe_pipeline_disabled_reports_raw_composition + Setup: a workflow file .goga/workflows/deploy.yml exists (prompt: "authored"); + pinned env with a tool whose amend_workflow hook would fail loudly if called; + isolated_cwd. + Input: describe_pipeline("deploy", project_dir, user_dir, workflow=None, + no_workflow=True) — real compile_flow into the temp dir. + Trace: + step 2: resolve_workflow(..., no_workflow=True) → None + → decision ("disabled", None) + step 3: disabled → no delivery, overlay is the passthrough + (workflow=None, provenance=[]) + step 4: compile the raw authored DSL → card without the tool layer + Assertions: + amend hook not called + card.provenance == [] + card.stages == + no exception + ``` + +- [ ] **Debugging**: `python -m pytest tests/pipeline/test_describe_pipeline.py tests/pipeline -q` + — fix implementation code until all tests pass (do NOT fix test code). +- [ ] **Contract re-verification**: the stage composition equals the composition a run of + the same pipeline with the same workflow flags would execute; the silent auto-match miss + keeps the layer active onto the empty base; `GOGA_SKIP_STAGES` never read; the temp + flow-file lives outside the project and runtime directories and is removed. +- [ ] **Lint**: `python -m ruff check goga/pipeline/describe_pipeline.py tests/pipeline/test_describe_pipeline.py && python -m ruff format --check goga/pipeline/describe_pipeline.py tests/pipeline/test_describe_pipeline.py` — fix formatting, apply decomposition if necessary. + +### Task 10: The run form through the amendment layer — `run_pipeline.py` (TDD coding) + +Rewire `goga/pipeline/run_pipeline.py` to the 17-step contract. The existing 11 steps +renumber with four insertions — new step 8 (facts), new step 9 (delivery), new steps 12-14 +(composition, statuses, creation emission), step 16 (completion emission): + +1. `list_pipelines` → `PipelineEntry` match → absolute `pipeline_path`; missing → stderr + message, `return 1` (no events). +2. `AFM_DIR` env → `afm_dir` (unset → `RuntimeError("AFM_DIR not set")`); + `runtime_dir = afm_dir.as_posix()`. +3. `GOGA_WORKFLOW_DISABLED` / `GOGA_WORKFLOW_NAME` env → decision inputs → + `resolve_workflow(name, workflow_name, no_workflow)` → `WorkflowDocument | None`. +4. `GOGA_SKIP_STAGES` split → `apply_skip_stages(workflow, skips)` → merged `workflow` + (None-safe; empty split → unchanged). +5. Facts: `pipeline_path.read_text()` → `parse_dsl(text)[0]` → header (`name`, + `description`) → `PipelineIdentity`; kind-derivation (below) → `WorkflowDecision`; + `branch = resolve_current_branch_name() or "unknown"` → guarded `resolve_topic_dir(branch)` + → `WorkIdentity`. +6. `hooks = PipelineHooks()` (one instance for the whole run). + `decision.kind != "disabled"` → `overlay = hooks.amend_workflow(pipeline=..., + decision=..., workflow=..., work=...)`; else `overlay = WorkflowOverlay(workflow= + merged_workflow, provenance=[])` (no delivery, no registry build from the amendment + side — the first registry build happens at the creation emission). +7. `resolve_project_name()` → `compile_flow(pipeline_path, flow_path, + workflow=overlay.workflow, root_dir=str(Path.cwd().resolve()), project_name=...)` → + `(pipeline_doc, flow_doc)`; structural errors propagate (no events). +8. Prompt materialization (unchanged steps: validate-all → wipe → write four files into + `/prompts/`). +9. `order_stages(flow_doc.stages)` → `composition = [CompositionStage(id=s.id, + title=s.name) for s in ordered]`. +10. Statuses (hosting form only): `scale = assemble_status_scale()`, `statuses = + resolve_topic_status(topic_dir, scale)`; branch-only → `statuses = []` (no scale + assembly). +11. `hooks.emit_run_created(pipeline=identity, decision=decision, overlay=overlay, + composition=composition, work=work, statuses=statuses, runtime_dir=runtime_dir)`. +12. `exit_code = run_flow(flow_path, port, max_parallel=parallel)` (spawn failures are + return codes 126/127 — returns, not raises). +13. Hosting form: `statuses = resolve_topic_status(topic_dir, scale)` recomputed (one + scale, two reads); branch-only stays `[]`. + `hooks.emit_run_completed(..., exit_code=exit_code)`; `return exit_code`. + +Kind-derivation matrix (the operation recomputes what `resolve_workflow` does not +report): + +``` +no_workflow → ("disabled", None) +explicit name given (workflow_name not in (None, "")) + and a document resolved → ("explicit", workflow_name) +no explicit name and a document resolved → ("auto-match", name) +document None (explicit-missing / auto-miss / +containment escape) → ("silent-miss", None) +``` + +Work-identity edge handling: detached HEAD / missing git / non-repo → `"unknown"` branch, +branch-only work; a fully non-ASCII branch (empty slug) → `resolve_topic_dir` raises +`ValueError` → guarded `topic_dir = None` → branch-only form. Errors: unchanged channels +plus the hard `ValueError` from the amendment (before any compile/write/launch) and the +`ImportError` from the registry build. An exception escaping `run_flow` has no exit code +to report and propagates without a completion emission (boundary, documented). + +**Usages relevant to this task:** +- `checkpoints` (`goga/pipeline/hooks/.usages/checkpoints.md`): fact resolution in the + operation, amend-before-compile, emit-around-launch (creation after prompts + materialize, completion on every return path), statuses recompute at the completion + moment. +- `topic-paths` (`goga/history/.usages/topic-paths.md`): branch read, topic-dir + resolution, slug/year from the returned directory. +- `topic-statuses` (`goga/history/.usages/topic-statuses.md`): one `assemble_status_scale()` + per run, two `resolve_topic_status` reads, nested artifacts honored (`completed/plan.md` + outranks `todo.md`). +- `compile-flow`, `parse-dsl`, `default_prompts`, `run-flow`, `convention`: unchanged + consumption; the documents tuple is read-only after construction. + +**Testing rules for this task** (design General Setup, binding): the new operation-level +test file is `tests/pipeline/test_run_pipeline_hooks.py` (mirror a local `afm_dir` +fixture from `tests/pipeline/test_run_pipeline.py:35`); mock at module boundaries only — +`mock.patch.object(_run_pipeline_module, "compile_flow"/"run_flow")` with +`_run_pipeline_module = sys.modules["goga.pipeline.run_pipeline"]` (the module is shadowed +in the package `__init__`; string patch paths fail on Python 3.10); the git branch read is +mocked (`resolve_current_branch_name` on the same module) since tmp dirs are not repos; +history trees are real `.goga/history///` structures under `tmp_path` + +`monkeypatch.chdir(tmp_path)` (`isolated_cwd`); the tree is built under `current_year()` +(or with `current_year` mocked to a fixed value) — never a hardcoded year literal, and +assertions derive the expected year the same way, so the suite survives a year boundary; +the platform env is pinned (`pin_package_environment` + `install_tool_package`) so the +registry, delivery, and emissions run for real. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: create `tests/pipeline/test_run_pipeline_hooks.py` — the + signature is unchanged (existing `tests/pipeline/test_run_pipeline.py` contract tests + pin it; they must stay green); pin the new import wiring: the module now imports + `PipelineHooks` & co. from `.hooks` and the four history names from `..history` + (attribute presence on the module). +- [ ] **Code**: rewire `goga/pipeline/run_pipeline.py` to the 17 steps (insert the fact + resolution after the skip merge, the delivery before `resolve_project_name`/compile, + the composition/statuses/creation before `run_flow`, the recomputed-statuses completion + after it; update the docstring's step numbering and the Raises section with the hard + `ValueError`/`ImportError` channels). +- [ ] **Interface verification**: `python -m pytest tests/pipeline/test_run_pipeline_hooks.py tests/pipeline/test_run_pipeline.py tests/pipeline/test_run_pipeline_workflow.py -q` + — all pass (the pre-existing suites are the no-tools regression proof). +- [ ] **Logic tests** (design scenarios, verbatim): + + ``` + test_run_pipeline_full_event_sequence_around_launch + Setup: afm_dir fixture (AFM_DIR → tmp); a minimal real pipeline file in project_dir; + workflow absent (silent-miss); pinned env with a tool subscribing run_created and + run_completed, recording received facts into its self context; compile_flow and + run_flow mocked (run_flow → 3); resolve_current_branch_name mocked → "feature-demo"; + tmp .goga/history//feature-demo/ with todo.md. + Input: run_pipeline("deploy", project_dir, user_dir, port=50321) + Trace: + facts: identity (parse_dsl header), decision ("silent-miss", None), + work ("feature-demo", "feature-demo", current_year()) + amend (silent-miss keeps the layer active) → passthrough (no subs on + amend_workflow for this tool) → overlay.workflow None + compile_flow(workflow=None ...) # mocked documents tuple + order_stages → composition [CompositionStage("build", "Build")] + statuses ["todo"] # real scale + topic dir + emit_run_created (records: composition ids, runtime_dir posix) + run_flow → 3 + statuses recomputed → emit_run_completed (exit_code=3) + return 3 + Assertions: + result == 3 + created_facts["pipeline"].name == "deploy" + created_facts["decision"].kind == "silent-miss" + created_facts["composition"] == [CompositionStage(id="build", title="Build")] + created_facts["statuses"] == ["todo"] + created_facts["runtime_dir"] == afm_dir.as_posix() + completed_facts["exit_code"] == 3 + order: created recorded before run_flow called, completed after + ``` + + ``` + test_spawn_failure_still_emits_completion_with_code + Setup: as the positive run test (pinned env, event-recording tool keeping facts in + its self context); compile_flow mocked; run_flow mocked → 127 (the afm binary + missing from PATH). + Input: run_pipeline("deploy", project_dir, user_dir, port=50321) + Trace: + facts → amend → compile (mocked) → prompts materialize + emit_run_created + run_flow → 127 # a return code, not an exception + statuses recomputed → emit_run_completed(exit_code=127) + return 127 + Assertions: + result == 127 + completed_facts["exit_code"] == 127 + completed recorded after created; no exception raised + ``` + + ``` + test_missing_pipeline_and_structural_error_fire_no_events + Setup: event-recording tool installed; case A — unknown pipeline name; case B — a + malformed workflow file (valid name resolution path). + Input: run_pipeline("nope", ...); run_pipeline("deploy", ...) with the malformed + workflow. + Trace: + A: return 1 at discovery — checkpoints unreached + B: WorkflowSyntaxError propagates from resolve_workflow (step 6) — before the + delivery + Assertions: + A: result == 1; zero events recorded + B: pytest.raises(WorkflowSyntaxError); zero events recorded + ``` + + ``` + test_work_identity_unknown_branch_and_empty_slug_guard + Setup: resolve_current_branch_name mocked → None; then → "Ветка" (fully non-ASCII — + every character drops, slug empty). + Input: run_pipeline fact resolution (observed via the recorded work of + emit_run_created). + Trace: + None → branch "unknown" → resolve_topic_dir("unknown") missing → branch-only + "Ветка" → normalize_topic_slug("Ветка") == "" → resolve_topic_dir raises + ValueError → guarded → branch-only + (a partial-ASCII name such as "Бranched" slugs to "ranched" — it covers the + missing-directory leg, not the guard) + Assertions: + work.branch == "unknown" / work.slug is None / work.year is None (case A) + work.branch == "Ветка" / slug is None / year is None (case B) + no exception; events still fired + ``` + + ``` + test_workflow_decision_kind_derivation_matrix + Setup: four env configurations (disabled; explicit+exists; explicit+missing; no-name + auto-match hit and miss). + Input: run_pipeline fact resolution (observed via recorded facts). + Trace: + disabled → ("disabled", None) + explicit+exists → ("explicit", "ci") + explicit+missing → ("silent-miss", None) + auto-match hit → ("auto-match", "deploy"); miss → ("silent-miss", None) + Assertions: each recorded decision matches the table exactly. + ``` + + ``` + test_disabled_decision_skips_delivery_compiles_raw_and_still_emits + Setup: env GOGA_WORKFLOW_DISABLED=1; event-recording tool with an amendment hook + that would fail loudly if called; run_flow → 0; GOGA_SKIP_STAGES unset. + Input: run_pipeline("deploy", ...). + Trace: + decision ("disabled", None) → no amend call + overlay = WorkflowOverlay(workflow=None, provenance=[]) + compile_flow(workflow=None) + emit_run_created(decision.kind == "disabled", overlay.provenance == []) + emit_run_completed(exit_code=0) + Assertions: + amend hook not called + created_facts["decision"].kind == "disabled" + created_facts["provenance"] == [] + result == 0 + ``` + + ``` + test_statuses_recomputed_at_completion_and_branch_only_stays_empty + Setup: hosting form — todo.md present, a completed/plan.md written by a fake "run" + (the run_flow mock writes it); branch-only — no topic dir. + Input: both runs with an event-recording tool. + Trace: + hosting: created statuses ["todo"] → completed statuses ["done"] + (completed/plan.md outranks todo — maximal-present recomputed at the + completion moment) + branch-only: [] at both moments; assemble_status_scale never called + (assert via the enumeration boundary) + Assertions: + created_facts["statuses"] == ["todo"] + completed_facts["statuses"] == ["done"] + branch-only: both == [] and boundary call_count == 1 # only the pipeline registry build + ``` + + ``` + test_emit_soft_failure_warns_and_never_affects_exit_code + Setup: pinned env; tool's run_completed hook raises; run_flow mocked → 0. + Input: full run_pipeline flow. + Trace: emit_run_completed → emit_hook_event intercepts → logger.warning; run + continues → return 0 + Assertions: + result == 0 + caplog contains a warning naming the tool, "run_completed", "boom" + ``` + +- [ ] **Debugging**: `python -m pytest tests/pipeline -q` — fix implementation code until + all tests pass (do NOT fix test code). +- [ ] **Contract re-verification**: every requirement of the 17-step contract — absolute + paths to `compile_flow`/`run_flow`; `port`/`parallel` forwarding unchanged; env reads + exactly `AFM_DIR`, `GOGA_WORKFLOW_DISABLED`, `GOGA_WORKFLOW_NAME`, `GOGA_SKIP_STAGES`; + DISABLED precedence; skip merge before delivery; delivery before compile; creation + after prompt materialization and before launch; completion on every return path; the + runtime dir fact as posix string; with no tool packages the passthrough — output + exactly as before. +- [ ] **Lint**: `python -m ruff check goga/pipeline/run_pipeline.py tests/pipeline/test_run_pipeline_hooks.py && python -m ruff format --check goga/pipeline/run_pipeline.py tests/pipeline/test_run_pipeline_hooks.py` — fix formatting, apply decomposition if necessary. + +### Task 11: CLI card `tools:` line and clean hard-error rendering — `cli.py` (TDD coding) + +Two additive edits in `goga/pipeline/cli.py`. (1) The card form `_run_card`: after the +stage loop — + +```python +if card.provenance: + print() + print(f"tools: {', '.join(card.provenance)}") +``` + +Uniform rule: the tools block is one blank line + one field line whenever provenance is +non-empty (with zero stages it follows the separator's blank line — deterministic, and the +empty-provenance output stays byte-identical in every form). (2) Failure rendering: add +`ValueError` and `ImportError` to the caught tuples of `_run_card` and `_run_execution` +(the hard amendment stops both forms; the registry build's fatal `ImportError` stops every +form — platform precedent `history.py:114` catches `(ValueError, ImportError)`; contract +step 5: "Render an operation failure as a clean readable message to stderr (no +traceback)"). The parser, dispatch, `__main__` delegation, and the docker guard are +untouched. + +**Usages relevant to this task:** +- `argparse`: the parser surface is unchanged — this task touches only the card template + and the catch tuples. +- `cli_entrypoint`: `__main__.py` stays a thin wrapper — not touched. +- `convention`: docstring style; relative imports unchanged. + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] **Contract tests**: extend `tests/pipeline/test_pipeline_cli.py` — the card template + requirement: "when the card provenance is non-empty, one blank line and one `tools:` + field line follow the stage blocks — the contributing tools comma-separated in + provenance order; an empty provenance adds nothing — the output stays byte-identical to + the provenance-free card" (fails now — expected). +- [ ] **Code**: add the `tools:` block to `_run_card`; add `ValueError` and `ImportError` + to the `_run_card` and `_run_execution` caught tuples. +- [ ] **Interface verification**: `python -m pytest tests/pipeline/test_pipeline_cli.py -q` + — all pass, including every pre-existing template test (byte-identity regression). +- [ ] **Logic tests** (design scenarios, verbatim): + + ``` + test_cli_card_renders_tools_line_and_stays_byte_identical_without_it + Setup: two PipelineCards — one with provenance=["t1", "t2"], one default; capsys. + Input: render both through the card path (factor the rendering via the _run_card + flow with a stubbed describe_pipeline). + Trace: + card with provenance → stage blocks, blank, "tools: t1, t2" + card without → stage blocks only + Assertions: + out_with.endswith("\ntools: t1, t2\n") + out_without does not contain "tools:" + out_without == # byte-identical + ``` + + ``` + test_run_pipeline_hard_amendment_renders_clean_error_no_launch + Setup: as the positive run test, but the tool's amend_workflow hook raises; run_flow + mocked with a call recorder. + Input: pipeline_cli argv ["deploy", "--port", "50321"] (or run_pipeline directly + + the CLI wrapper for the message). + Trace: + run → step 9 amend → ValueError → propagates + run_flow.assert_not_called(); prompts dir untouched; no events emitted + Assertions: + capsys err contains "pipeline.amend_workflow" and "boom"; no traceback + exit code != 0 + ``` + +- [ ] **Debugging**: `python -m pytest tests/pipeline/test_pipeline_cli.py tests/pipeline -q` + — fix implementation code until all tests pass (do NOT fix test code). +- [ ] **Contract re-verification**: every template requirement (flat list, overview, + card, tools line); no traceback for any operation failure; `--port`/`--parallel` + behavior untouched. +- [ ] **Lint**: `python -m ruff check goga/pipeline/cli.py tests/pipeline/test_pipeline_cli.py && python -m ruff format --check goga/pipeline/cli.py tests/pipeline/test_pipeline_cli.py` — fix formatting if necessary. + +### Task 12: Integration verification of the wired flows (integration tests) + +Final cross-entity gate. The feature's cross-entity behavior is covered by the scenario +suites of Tasks 7-11 (zone delivery over the real platform, run/card flows through the +zone, history facts, CLI rendering — with only module-boundary mocks, per the design's +General Setup). This task verifies the composed whole and guards the read-only surfaces. + +**Usages relevant to this task:** +- `convention`: full-suite execution in the venv; test classification (contract/logic/ + integration all present). + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] Run the full suite: `python -m pytest` — every test green (the pre-feature suites + are the byte-identical/no-tools regression proof; any failure is a defect, not a + fixture problem). +- [ ] Verify the zone facade: `python -c "import goga.pipeline.hooks as h; assert sorted(h.__all__) == ['CompositionStage', 'PipelineHooks', 'PipelineIdentity', 'RunCompleted', 'RunCreated', 'ToolContribution', 'WorkIdentity', 'WorkflowAmendment', 'WorkflowDecision', 'WorkflowOverlay', 'merge_workflow_overlay']"` + — the facade IS the contract surface. +- [ ] Verify the cell graph: `goga lint` — 78 cells, 0 errors (contracts and cells stay + consistent). +- [ ] Verify the untouched surfaces: `git status`/`git diff` — `goga/pipeline/workflow`, + the `goga/hooks` platform modules (dispatch/registry/tools), the compiler, and every + `CODEMANIFEST` show no implementation changes (the three manifests carry only the + apply-stage contract edits already in the working tree). +- [ ] Lint the whole: `python -m ruff check goga tests && python -m ruff format --check goga tests`. + +--- + +## Validation Commands + +- `python -m pytest tests/hooks/catalog tests/pipeline/hooks tests/pipeline/test_run_pipeline_hooks.py tests/pipeline/test_describe_pipeline.py tests/pipeline/test_pipeline_card.py tests/pipeline/test_pipeline_cli.py -q`: Targeted run of every suite this plan touches (run in the project venv) +- `python -m pytest`: Run all tests (testpaths `tests/`; the full pre-feature suites are the no-tools regression proof) +- `python -m ruff check goga tests && python -m ruff format --check goga tests`: Lint check over the changed surface and the whole +- `goga lint`: Cell contract lint — 78 cells, 0 errors +- `python -c "import goga.pipeline.hooks as h; assert sorted(h.__all__) == ['CompositionStage', 'PipelineHooks', 'PipelineIdentity', 'RunCompleted', 'RunCreated', 'ToolContribution', 'WorkIdentity', 'WorkflowAmendment', 'WorkflowDecision', 'WorkflowOverlay', 'merge_workflow_overlay']"`: Verify that all facade entities are importable + +--- + +## Completion Criteria + +- [ ] Every contract entity is implemented in the correct `location` (11 zone entities across `identity.py`, `contexts.py`, `overlay.py`, `amendments.py`, `events.py`; catalog records in `catalog.py`; consumer edits in `pipeline_card.py`, `describe_pipeline.py`, `run_pipeline.py`, `cli.py`) +- [ ] Every contract entity is accessible from the facade (`goga.pipeline.hooks.__all__` — exactly the 11 names) +- [ ] Properties and methods match the declared API (signatures, defaults, `kw_only`) +- [ ] Descriptions are reflected in behavior (authored-wins merge, hard/soft error classes, mutually-blind tools, one registry per run, emissions around the launch, kind-derivation matrix) +- [ ] Contract dependencies are met (platform facade imports, `WorkflowDocument` from `..workflow`, history facade imports in the operations) +- [ ] Re-exports are accessible from the facade (none declared — vacuously true) +- [ ] Every coding task followed the TDD workflow (contract tests → code → verification → logic tests → debugging → re-verification → lint) +- [ ] Contract tests and logic tests cover facade, API, and behavior within each coding task +- [ ] Integration tests exist where cross-entity scenarios require them (Tasks 7-11 scenario suites over the real platform + Task 12 composed verification) +- [ ] No package boundary was expanded (no new cells beyond the contract-created zone; `goga/pipeline/workflow`, `goga/hooks` platform modules, and the compiler untouched) +- [ ] `CODEMANIFEST` files were not modified (contract is read-only) +- [ ] All validation commands pass +- [ ] Every Usages entry is mentioned in at least one task (`convention` — all tasks; `per-tool-delivery`, `declaring-actions`, `registering-hooks` — Tasks 6-7; `checkpoints` — Tasks 9-10; `topic-paths` — Tasks 9-10; `topic-statuses` — Task 10; `argparse`/`cli_entrypoint` — Task 11; `default_prompts`/`compile-flow`/`parse-dsl`/`run-flow` — Tasks 9-10) diff --git a/.goga/history/2026/add-pipeline-hooks/prd.md b/.goga/history/2026/add-pipeline-hooks/prd.md new file mode 100644 index 00000000..e2d5b10d --- /dev/null +++ b/.goga/history/2026/add-pipeline-hooks/prd.md @@ -0,0 +1,457 @@ +# Opening the Pipeline Domain to Tool Package Integrations + +## Problem + +Third-party tool package authors cannot integrate with the pipeline domain — +the core execution domain of goga (discovery, composition with workflows, +compilation, launch, completion). The product already offers a domain +extension surface for installed tool packages — the topics, onboarding, and +statuses domains are open — but pipelines accepts no tool participation: +a tool can neither observe pipeline moments nor contribute its logic to +pipeline operation. + +Blocked demand exists today: a tool cannot connect a pipeline run to the +history status model through the artifacts the run produces. Statuses can be +registered by tools, but nothing can advance them from pipeline activity. +The ambition beyond that is larger: tools should be able to personalize +workflow application per project and per user, and to build composition +add-ons on the fly. + +Consequence: any integration between pipelines and a tool's logic requires +changes inside goga itself. The ecosystem cannot self-serve on the core +domain, the platform's extension promise is asymmetric, and a tool author +facing an integration task around pipelines has no defined path — every +"how do I integrate with pipelines" scenario is currently unanswerable. + +## Users + +### Primary: tool package author (integration developer) + +A developer extending goga by writing an installed `goga_tool_*` package. +Python author; reads the goga usage docs; subscribes through the package's +`register_hooks` facade callback; already familiar with the hooks platform +from topics, onboarding, and statuses. + +- **Trying to:** solve an integration task around pipelines — connect run + artifacts to the history status model, observe pipeline moments for + reporting or automation, personalize workflow application, or build + composition add-ons for their users. +- **When:** while designing and iterating on the tool package; edits apply + from the next run without reinstall. +- **What matters:** a complete, predictable contract — which pipeline + moments exist, what each moment delivers, what a hook may change and what + it cannot, how hook failures are treated, and how tool logic composes with + project-authored workflow-files. No integration scenario should be left + without a documented answer. +- **Constraints:** runs inside the goga container at the trust level of the + installation; cannot assume goga code changes; tool identity is assigned + by goga. + +### Secondary: pipeline runner (project developer) + +Uses goga in a project; runs `goga pipeline NAME`; installs tool packages +into the environment. + +- **Trying to:** get the pipeline executed as expected, with installed tools + adding value (statuses advancing, integrations firing) — and still + understand what the tools did to the run. +- **What matters:** runs keep working with tools installed — authored + behavior stays predictable, tool participation is observable and + diagnosable, and a failing tool degrades the way the declared error class + promises. + +### Secondary: pipeline/workflow author (project member) + +Authors pipeline-files and workflow-files in the project; often the same +person as the runner, a distinct role when composition is authored by one +member and run by others. + +- **Trying to:** author the pipeline composition declaratively and have it + mean the same thing on every machine, including machines with tools + installed. +- **What matters:** predictability and precedence — whether and how tool + participation changes the authored composition must be defined, not + accidental. + +## Goals + +Priority order: G1 → G2 → G3. When openness and predictability conflict, +integration power wins; G3 is preserved as the residual guarantee, not as a +veto. + +- **G1 — Openness.** A tool package author can connect their logic to + pipeline operation — observe run moments and participate where the domain + allows it — without changes inside goga, exactly as they already can in + topics, onboarding, and statuses. +- **G2 — Contract completeness.** The space of realistic integration + scenarios is covered definitively: artifacts → history status model, + reporting and automation over pipeline moments, workflow + personalization, and on-the-fly composition add-ons. A tool author + understands from the contract how to solve their task — no scenario is + left unanswered. +- **G3 — Trustworthy runs.** Opening the domain does not break the pipeline + experience: for the author and runner the pipeline stays predictable, + tool participation is observable and diagnosable, and tool failures + degrade according to the declared semantics without corrupting runs. + +## User Experience + +### Established decisions + +- **D1 Moment set.** One amendment point — the workflow layer delivered + before compilation — plus two notifications: `run_created` (immediately + before the runner launch, after compilation and prompt materialization) + and `run_completed` (on every launch-attempt return: zero, non-zero, and + spawn failures 126/127 alike, carrying the actual exit code and the + runtime dir path). Completion is a fact, not a success claim. +- **D2 Layer power and precedence.** Tools contribute with the full + workflow-instruction vocabulary; precedence is authored-wins per slot: + `prompt` appends (authored text first, tool texts in tool enumeration + order); `memory` is a whole-block slot (an authored block is unbeatable; + when absent, tools contribute whole blocks with later-tool-wins); the + `stages` block is per-field (authored fields win, a tool fills unset + fields; `skip` is an ordinary field — an authored skip is unbeatable, an + unset skip is tool-fillable, including removing a pipeline-file stage not + protected by an authored skip); `extend` adds new entries (a name + already occupied by an authored stage wins; among tools, later wins). +- **D3 Error classes.** The workflow amendment is hard: a failing tool + stops the launch before the runner starts, with a clean error naming the + tool and the action. Notifications are soft: a failing hook warns and + the run is unaffected. +- **D4 Card equivalence and provenance.** The card composes through the + same layer — it shows the tool-extended composition and names the + contributing tools. +- **D5 Disable semantics.** An explicit workflow disable (`--no-workflow` / + `GOGA_WORKFLOW_DISABLED`) disables the tools layer too — the raw authored + DSL composition composes. A silent auto-match miss keeps the tools layer + active. + +### Tool package author + +**Entry.** The author opens the pipeline hooks usage document — a table of +the domain's actions: the address (domain `pipeline` + action name), when +each fires, what its context carries, and what a hook may change. + +**Primary flow.** In their `goga_tool_*` package they subscribe inside +`register_hooks`. They verify with `goga hooks` — their pipeline +subscriptions appear in the inspection tree. Edits apply from the next +command; no reinstall. + +**Building an integration.** + +- *To amend the workflow:* subscribe to the amendment action; receive the + resolved composition facts — pipeline identity (name, description, + project/user source), the workflow decision (explicitly disabled / + explicit name / auto-match applied / silent miss, plus the resolved name + when applicable), the original authored workflow (post decision, post + runner-skip merge, pre-layer, read-only, identical for every tool), and + the current work identity — and contribute workflow-shaped instructions + with the full vocabulary: new stages, per-stage overrides, memory + directives, stage skips. +- *To react to runs:* subscribe to the created/completed notifications; + read the final effective workflow, the final composition facts, + provenance, the current work identity, that work's history status at the + moment, and the run's runtime dir; the completion event adds the actual + exit code. The artifact → status integration builds here. + +**Failure behavior.** + +- A wrong address, an empty name, or a name collision on the same address: + the registration is skipped with a warning naming the tool and the + reason; the remaining registrations apply. +- A crashing amendment hook: the command stops before the runner launches, + with a clean error naming the tool and the action (hard). +- A structurally invalid contribution (the hook returns, the contribution + fails validation): the compiler's clean structural error surfaces before + any launch. +- A crashing notification hook: a warning naming the tool, the action, and + the reason; the run's outcome is unaffected (soft). +- A broken package import: the single fatal case — a clean error naming + the package. + +### Pipeline runner + +Commands are unchanged. With tools installed: + +- The card shows the composition that would really execute, extended by + tools, with the contributors named (provenance). The run start names the + contributing tools the same way. +- **No tools installed:** nothing changes — no registration, no layer, no + output differences. +- **A tool fails while extending:** the launch stops before the runner with + a clean error naming the tool; nothing heavy ever starts; retry after + fixing or removing the tool. +- **A tool fails in a notification:** a warning in the log; the run's + outcome and exit code are unaffected. +- **Explicit `--no-workflow`:** the raw authored DSL composition — the + tools layer is off too. +- **Missing pipeline / structural DSL error:** today's clean errors; no + events fire (the moment never happened — the command stops before any + launch attempt). +- **The runner exits non-zero (or fails to spawn, 126/127):** the run + reports failure as today; the completion event fires with the actual + exit code and the runtime dir path — the diagnostics surface for tools. + +### Pipeline/workflow author + +**Guarantee.** Authored intent wins per slot: pipeline-file content, +workflow-file instructions, and the runner's explicit stage skips are +never overridden by tool contributions. The authored skip — not stage +presence — is what wins: a pipeline-file stage not protected by an +authored skip may be removed by a tool filling the unset skip field. +Machines without tools compose identically to machines with tools for +everything the author defined. + +**Observability.** The card's provenance shows which tools extended the +composition; `goga hooks` shows what is installed and subscribed. + +### States and feedback + +- The card and run output remain the single source of composition truth; + provenance names the contributing tools. +- Warnings and errors always name the tool, the action, and the reason. +- Registration is never cached — tool edits apply on the next command. +- Run notifications fire only in the run form; the card involves the + amendment layer but never fires run events; the flat list and overview + involve no hooks at all. + +## Requirements + +### Domain actions + +- **R1.1** The pipeline domain must open exactly three actions on the hooks + platform: the workflow amendment `amend_workflow` and the two run + notifications `run_created` and `run_completed`. +- **R1.2** The amendment action must be declared hard: the first failing + tool stops the command with a clean error before any launch, naming the + tool and the action. The two notifications must be declared soft: a + failing hook warns and the run is unaffected. +- **R1.3** Subscriptions follow the platform envelope — + `subscribe("pipeline", action, name, hook)`; a wrong address, an empty + name, or a name collision is skipped with a warning naming the tool and + the reason; the remaining registrations apply. + +### Workflow amendment + +- **R2.1** The amendment layer must be delivered at composition time — + after the workflow decision resolves and the runner's explicit skips + merge, before compilation — in both the card form and the run form. +- **R2.2** The delivered context must carry: the pipeline identity (name, + description, project/user source), the workflow decision (explicitly + disabled / explicit name / auto-match applied / silent miss, plus the + resolved name when applicable), the original authored workflow — post + decision, post runner-skip merge, pre-layer, read-only, identical for + every tool (no staged-application visibility: tools are mutually blind) + — and the current work identity. The runner's skips are not a separate + fact: they are already merged into the authored workflow. +- **R2.3** A hook may contribute instructions from the full + workflow-instruction vocabulary: new stages, per-stage overrides, memory + directives, and stage skips. +- **R2.4** Authored-wins per slot: pipeline-file content, workflow-file + instructions, and the runner's explicit skips must never be overridden by + a tool; a tool instruction applies only where no authored instruction + exists. `skip` is an ordinary field: an authored skip (workflow-file or + merged runner skip) is unbeatable; an unset skip is tool-fillable — + including removing a pipeline-file stage not protected by an authored + skip. +- **R2.5** Tools contribute in enumeration order; within the un-authored + space a later tool's instruction overwrites an earlier tool's + instruction for the same slot; identical modifications by several tools — + the last in enumeration order wins; `prompt` appends (authored text + first, tool texts concatenated in enumeration order). +- **R2.6** A tool's whole contribution must commit only after every hook of + that tool for the action succeeds; a failing tool's contribution is + discarded and the hard error class stops the command naming the tool and + the action. +- **R2.7** A structurally invalid contribution must surface as the + compiler's clean structural error before any launch — tool contributions + pass through the same validation as authored ones. +- **R2.8** The composed output must name the tools whose contributions + committed (provenance), in the card and at the run start. + +### Run notifications + +- **R3.1** `run_created` must fire immediately before the runner launch — + after compilation and prompt materialization — carrying the pipeline + identity, the workflow decision, the final effective workflow (authored + instructions plus committed tool contributions, read-only), the final + composition facts (stage list in execution order), provenance, the + current work identity, that work's history status at the start moment, + and the run's runtime dir. The runner's skips are not a separate fact + (already reflected in the composition). +- **R3.2** `run_completed` must fire on every launch-attempt return — + zero, non-zero, and spawn failures (126/127) alike — carrying the same + facts as `run_created` with the status recomputed at the completion + moment, plus the outcome: the actual exit code and the runtime dir path. + Completion is a fact, not a success claim. +- **R3.3** When the current branch hosts no topic, the work identity + degrades to its branch-only form and no history status is delivered; the + tool decides whether to act. +- **R3.4** A failing notification hook must warn naming the tool, the + action, and the reason; the run's own outcome and exit code are + unaffected. +- **R3.5** No event may fire for a moment that never happened: a missing + pipeline, a workflow parse error, or a structural composition error stops + the command before any launch attempt — `run_created` never fires. A + launch attempt that returns — including a spawn failure (126/127) — is a + real completion moment: `run_completed` fires with the actual exit code. + +### Card equivalence + +- **R4.1** The card must compose through the same amendment layer with the + same precedence: what the card shows with given flags is exactly what a + run with the same flags executes. +- **R4.2** The card must display the provenance of the composition — which + tools contributed. +- **R4.3** The flat list and overview forms must not involve hooks: no + composition, no registration, no layer. + +### Disable semantics + +- **R5.1** An explicit workflow disable (`--no-workflow` / + `GOGA_WORKFLOW_DISABLED`) must disable the amendment layer entirely — + the raw authored DSL composition composes. +- **R5.2** A silent auto-match miss must keep the amendment layer active: + tools contribute onto the empty authored workflow. + +### Diagnostics and iteration + +- **R6.1** `goga hooks` must show the pipeline subscriptions of the + installed tool packages. +- **R6.2** Registration is never cached: package edits apply from the next + command without reinstall. +- **R6.3** A broken tool-package import must surface as a clean fatal error + naming the package — the only fatal case of the platform. +- **R6.4** Every warning and error of the domain must name the tool, the + action, and the reason. + +### Zero impact + +- **R7.1** With no tool packages installed, every pipeline form must behave + exactly as before: same output, same errors, same exit codes, no + registration activity. + +### Consistency + +- **R8.1** Facts delivered to hooks must be built from the operation's own + data — no git reads at a checkpoint. +- **R8.2** For the same flags, card facts and run facts must agree — + workflow decision, skips, provenance. + +## Constraints + +- **C1 Execution boundary.** Stage execution belongs to the external afm + binary; goga observes only its own moments (composition, launch, exit + code). The product cannot open stage-level events — the extension + surface is bounded by goga-owned moments. +- **C2 Runtime boundary.** Pipeline composition and hook delivery run + inside the goga Docker container; tool packages must be installed in that + environment. The host-side launcher only translates input; no host-side + pipeline moments exist. +- **C3 Declarative extension.** Tool contributions are workflow-shaped + declarative instructions that pass through the same compilation + validation as authored ones; the product does not open raw + compiled-flow mutation. +- **C4 Catalog additivity.** The action catalog is extended additively; + published records are never rewritten — the pipeline actions must not + alter any existing action or domain record. +- **C5 Platform delivery.** Platform delivery rules hold: delivery is never + filtered by tool eligibility; tools run at the trust level of their + installation (no isolation, no sandbox); one registry per run; hooks + receive values only for parameters declared by the fixed offered names; + per-tool isolated self contexts; a broken package import is the only + fatal case. +- **C6 Authored intent.** Authored intent is inviolable: pipeline-file + content, workflow-file instructions, and the runner's explicit skips are + never overridden by tool contributions. +- **C7 Backward compatibility.** With no tool packages installed, every + existing pipeline form must behave exactly as before — output, errors, + and exit codes included; commands that reach no checkpoint never call + `register_hooks`. +- **C8 No git reads at checkpoints.** Facts delivered at a checkpoint are + built from the operation's own data; no git reads at a checkpoint. +- **C9 Documentation surface.** The contract reaches tool authors through + the repo's usage documentation: every action's moments, context members, + and failure semantics must be documented for tool package authors — the + integration task must be answerable from the docs. + +## Scope + +### In Scope + +- The three pipeline domain actions on the hooks platform: the workflow + amendment `amend_workflow` (hard — the platform's first hard action) and + the `run_created`/`run_completed` notifications (soft), with their + catalog records. +- The workflow amendment layer: delivery at composition time in the card + and run forms, the delivered facts (pipeline identity, workflow + decision, the original authored workflow post runner-skip merge, current + work identity), the full workflow-instruction vocabulary, authored-wins + per-slot precedence, tool composition in enumeration order, per-tool + staged commit, validation through the same compilation machine. +- The two run notifications with the final effective workflow, final + composition facts, provenance, the current work identity, its history + status at the event moment, and the runtime dir; `run_completed` fires + on every launch-attempt return with the actual exit code. +- Card equivalence: the card composes through the layer and displays + provenance; the run start names contributing tools. +- Disable semantics: explicit workflow disable disables the layer; a silent + auto-match miss keeps it active. +- Diagnostics: `goga hooks` inspection covers pipeline subscriptions; + registration never cached. +- Zero-impact guarantee: toolless environments behave exactly as before. +- Tool-author documentation of the new contract (usage docs: actions, + moments, context members, failure semantics). + +### Out of Scope + +- Any consumer of the new surface: bundled, built-in, or reference + `goga_tool_*` packages — including the artifacts → statuses integration + (third-party territory). +- Stage-level events during afm execution and any change to afm itself + (execution boundary). +- Raw compiled-flow mutation (the imperative post-compile surface — a + rejected alternative). +- Host-side launcher moments (the `-t/--topic` switch, the `--todo` + editor, the docker shape) and new host CLI flags (including a separate + layer switch — `--no-workflow` covers disabling). +- Changes to workflow-file syntax or parser semantics (tools reuse the + existing vocabulary; no new instruction kinds). +- Behavior of other hook domains (topics, statuses, onboarding). +- Reporting, analytics, or telemetry products built on the events. +- Onboarding-session integration for pipeline tools. + +## Success Criteria + +- **SC1** A third-party `goga_tool_*` package, with no goga code change, + can subscribe to all three pipeline actions; `goga hooks` shows its + pipeline subscriptions. +- **SC2** With such a tool installed, its composition contribution is + reflected identically in the card and in the executed run for the same + flags, and the card names the contributing tool (provenance). +- **SC3** Authored intent survives tool participation: workflow-file + instructions and the runner's explicit skips are never overridden + (authored-wins per slot — an authored skip is unbeatable); a machine + without the tool composes the same authored result. A pipeline-file + stage not protected by an authored skip may be removed by a tool — the + authored skip, not stage presence, is the protection. +- **SC4** A failing amendment hook stops the launch before the runner with + a clean error naming the tool and the action; a failing notification hook + warns naming tool, action, and reason, and the run's exit code is + unaffected. +- **SC5** `run_created` fires immediately before the runner launch; + `run_completed` fires on every launch-attempt return — zero, non-zero, + and spawn failures alike — and carries the current work's identity, its + history status at the moment, the actual exit code, and the runtime dir + path. +- **SC6** With no tool packages installed, every pipeline form behaves + exactly as before — same output, same errors, same exit codes. +- **SC7** An explicit workflow disable composes the raw authored DSL (the + layer is off); a silent auto-match miss keeps the layer active. +- **SC8** The tool-author documentation answers each integration scenario + named in the problem — artifact → status integration on run completion, + run reporting and automation, workflow personalization, on-the-fly + composition add-ons — from moments, context members, and failure + semantics alone. +- **SC9** Tool package edits apply from the next command without + reinstall. diff --git a/.goga/history/2026/add-pipeline-hooks/task.md b/.goga/history/2026/add-pipeline-hooks/task.md new file mode 100644 index 00000000..36189759 --- /dev/null +++ b/.goga/history/2026/add-pipeline-hooks/task.md @@ -0,0 +1,264 @@ +# Open the pipeline domain to tool package integrations (pipeline hooks) + +## Current State + +The hooks platform (`goga/hooks` facade over catalog / dispatch / registry / tools) +is domain-agnostic and production-ready, but the action catalog carries only the +statuses, onboarding, and topics domains — every record is soft. The pipeline +domain runs without any hooks: `goga/pipeline` coordinates discovery → workflow +resolution (`resolve_workflow`) → runner-skip merge (`apply_skip_stages`) → +compilation (`compile_flow`) → prompt materialization → launch (`run_flow`), and +the card (`describe_pipeline`) shares the same workflow-resolution rule set. An +installed `goga_tool_*` package has no way to observe pipeline moments or +contribute to pipeline operation: every pipeline integration today requires +changes inside goga itself. `docs/features/pipelines/hooks.md` is a negative +stub stating that the pipelines domain exposes no hook actions. + +The decision record (ADR, 18/09/26 — same topic directory) settles the design: +three actions (`pipeline/amend_workflow` hard — the platform's first hard +action; `pipeline/run_created` soft; `pipeline/run_completed` soft), the +authored-wins-per-slot workflow overlay, per-tool staged commit, and the event +timeline. The PRD has been re-aligned to the ADR (this topic directory) — the +documents are consistent; the task implements them. + +## Description + +Open the pipeline domain to installed `goga_tool_*` packages through three +hooks-platform actions, implementing the ADR decisions: + +1. **Action catalog** — three additive records in `goga/hooks/catalog`: + `pipeline/amend_workflow` (hard — the first hard action in the platform), + `pipeline/run_created` (soft), `pipeline/run_completed` (soft). Published + records stay untouched. Diagnostics ride the platform: `goga hooks` shows + pipeline subscriptions (catalog-driven, no command change), registration + is never cached, and every warning and error of the delivery names the + tool, the action, and the reason. +2. **Pipeline hooks zone** — a dedicated zone of the pipeline domain for the + hooks surface (precedent: `goga/topics/hooks`): the fact models (pipeline + identity with name, description, project/user source; the workflow decision + with its resolved name; the read-only original authored workflow — post + decision, post runner-skip merge, identical for every tool; the topics-shaped + work identity with its branch-only degradation; the work's history status as + maximal present statuses of both axes), the read-and-contribute context of + `amend_workflow` with per-tool staged commit, the two notification contexts, + and the checkpoint facade consumed by the pipeline flows. Exact cell boundary + and member contracts are design-stage decisions. +3. **Workflow overlay layer** — authored-wins per slot, applied after the + runner-skip merge and before compilation, in both the run form and the card + form: `prompt` appends (authored first, tool texts in enumeration order); + `memory` is a whole-block slot (authored unbeatable; otherwise + later-tool-wins); `stages` is per-field (authored fields win, tools fill + unset fields; `skip` is an ordinary field — authored skip unbeatable, unset + skip tool-fillable, including removing an unprotected pipeline-file stage); + `extend` adds entries (authored names win; among tools later wins). Tool + contributions are declarative `WorkflowDocument`-shaped instructions passing + the same compilation validation as authored ones. Provenance: the tools whose + contributions committed. +4. **Run integration** — `run_pipeline`: deliver the amendment before + compilation; emit `run_created` immediately before the runner launch (after + compilation and prompt materialization) with the pipeline identity, the + workflow decision, the final effective workflow, final composition, + provenance, work identity, history status, and runtime dir; emit + `run_completed` on every launch-attempt return — zero, non-zero, and spawn + failures (126/127) — with the same facts recomputed at the completion + moment plus the actual exit code and runtime dir path. +5. **Card integration** — `describe_pipeline` composes through the same layer + with the same precedence (card/run equivalence for the same flags) and + displays provenance; the flat list and overview forms involve no hooks; an + explicit workflow disable disables the layer; a silent auto-match miss keeps + it active. No run events fire in card form. +6. **Tool-author documentation** — fill the negative stub + `docs/features/pipelines/hooks.md` (address | error class | fires table, + context members, failure semantics) and add the pipeline-domain + registering-hooks usage for tool package authors following the established + pattern; keep mkdocs navigation and traceability in sync. +7. **Tests** — unit coverage per project conventions for every new public + surface, the overlay merge semantics (per-slot precedence, enumeration + order, per-tool commit/discard), the event timeline (pre-launch creation, + any-exit completion with code and runtime dir), card/run equivalence, and + the zero-impact guarantee. + +## Scope + +**In scope:** +- The three pipeline domain actions on the hooks platform with their catalog + records and error classes. +- The workflow amendment layer: delivery at composition time in the card and + run forms; the delivered read surface; the full workflow-instruction + vocabulary; authored-wins per-slot precedence; tool composition in + enumeration order; per-tool staged commit; validation through the same + compilation machine; provenance. +- The two run notifications with the pipeline identity, the workflow + decision, the final effective workflow, final composition, provenance, + work identity, history status, and runtime dir; `run_completed` fires on + every launch-attempt return with the actual exit code. +- Card equivalence with provenance display; disable semantics; silent-miss + behavior. +- Diagnostics: `goga hooks` covers pipeline subscriptions; registration never + cached; every warning and error names the tool, the action, and the reason. +- Tool-author documentation (docs page, registering-hooks usage, mkdocs sync). +- Tests and the zero-impact guarantee. + +**Out of scope:** +- Any consumer of the new surface: bundled, built-in, or reference + `goga_tool_*` packages — including the artifacts → statuses integration + (third-party territory). +- Stage-level events during afm execution and any change to afm itself + (execution boundary). +- Raw compiled-flow mutation (rejected alternative). +- Host-side launcher moments and new host CLI flags (including a separate + layer switch — `--no-workflow` covers disabling). +- Changes to workflow-file syntax or parser semantics (tools reuse the + existing vocabulary; no new instruction kinds). +- Behavior of other hook domains (topics, statuses, onboarding). +- Reporting, analytics, or telemetry products built on the events. +- Onboarding-session integration for pipeline tools. + +## Acceptance Criteria + +- **AC1** A third-party `goga_tool_*` package, with no goga code change, can + subscribe to all three pipeline actions; `goga hooks` shows its pipeline + subscriptions. +- **AC2** With such a tool installed, its composition contribution is + reflected identically in the card and in the executed run for the same + flags, and the card names the contributing tool (provenance). +- **AC3** Authored intent survives tool participation per slot: workflow-file + instructions and the runner's explicit skips are never overridden; an + authored skip is unbeatable; an unset skip is tool-fillable — including + removing a pipeline-file stage not protected by an authored skip (the + authored skip, not stage presence, is the protection). +- **AC4** A failing amendment tool stops the launch before the runner with a + clean error naming the tool and the action, and the tool's whole + contribution is discarded; a failing notification hook warns naming tool, + action, and reason, and the run's exit code is unaffected. +- **AC5** `run_created` fires immediately before the runner launch; + `run_completed` fires on every launch-attempt return — zero, non-zero, and + spawn failures alike — carrying the work identity, history status at the + moment, actual exit code, and runtime dir path. A missing pipeline or a + structural composition error fires nothing. +- **AC6** With no tool packages installed, every pipeline form behaves exactly + as before — same output, same errors, same exit codes. +- **AC7** An explicit workflow disable composes the raw authored DSL (the + layer is off); a silent auto-match miss keeps the layer active. +- **AC8** The tool-author documentation answers each integration scenario + named in the problem — artifact → status integration on run completion, run + reporting and automation, workflow personalization, on-the-fly composition + add-ons — from moments, context members, and failure semantics alone. +- **AC9** Tool package edits apply from the next command without reinstall. + +## Stack + +- **Frameworks:** none new — Python 3.10+ on the existing codebase; stdlib + `dataclasses` (kw_only=True), `enum`, `pathlib`, `logging` (structured, per + project conventions); in-container argparse surface unchanged except card + output gaining provenance. +- **Libraries:** PyYAML (existing, unchanged); pytest, ruff, pytest-cov + (existing test toolchain per conventions). +- **Infrastructure:** the existing hooks platform (internal — registry, + emission, per-tool staged delivery primitives, action catalog); mkdocs + (existing documentation infrastructure). + +Internal platform links consumed through Imports (all existing): +`goga/hooks` (facade: `HookRegistry`, `emit_hook_event`, `wrap_context`, +`build_hook_arguments`, `declared_actions`), `goga/pipeline/workflow` +(`WorkflowDocument` instruction models), `goga/history` +(`resolve_current_branch_name`, `resolve_topic_dir`), and +`goga/history/statuses` (`maximal_present`). + +## External Dependencies + +No new external components — the task builds entirely on the existing hooks +platform and stack; existing cooks and cell usages cover everything consumed. + +| Component | Usage file | Status | +|-----------|------------|--------| +| (none) | — | no creation or update required | + +## Risks and Constraints + +- `amend_workflow` is the platform's **first hard action**: the dispatch and + error paths must stop the command cleanly (naming the tool and the action) + while catalog additivity and all published records stay untouched. +- The per-slot overlay merge is the most intricate logic (per-field + authored-wins, skip as an ordinary field, whole-block memory, prompt append + ordering, later-tool-wins) — it must be exactly the ADR semantics and is the + priority target of unit tests. +- Card/run equivalence (R4.1, R8.2) — both forms must go through the same + layer with the same precedence; two divergent code paths are the main drift + risk. +- `run_completed` must fire on every return path, including spawn failures + (126/127) — error-path coverage is mandatory in tests. +- Zero-impact guarantee: with no tool packages installed, output, errors, and + exit codes of every pipeline form are byte-identical to today. +- No git reads at a checkpoint: every delivered fact is built from the + operation's own data (branch/topic resolution happens in the operation, not + inside hook delivery). +- Tools are mutually blind: the `amend_workflow` read surface delivers the + original authored workflow to every tool — no staged-application state. +- Execution/runtime boundaries (C1–C3): stage execution belongs to afm; the + layer runs in-container; contributions stay declarative through the same + compilation validation. +- Provenance display format is not fixed by the ADR — a design-stage decision + recorded as open. + +## Scope Estimate + +Single task, medium-large scale. The parts are tightly coupled — the overlay +layer is consumed by both the card and the run, the events carry the layer's +output, and the documentation describes the same contract — so decomposition +would not yield independently valuable deliverables. Internal decomposition +(catalog records, hooks zone, layer, run/card integration, docs, tests) is a +design/plan-stage concern. + +## Existing Architecture + +Affected cells and their link connections: + +- `goga/hooks/catalog` — three additive records (`pipeline/amend_workflow` + hard; `pipeline/run_created`, `pipeline/run_completed` soft). Data-only + change; no cell's contract changes. +- **Pipeline hooks zone** (new; precedent `goga/topics/hooks`) — owns the + fact models, the `amend_workflow` read-and-contribute context with per-tool + staged commit (onboarding precedent, per `per-tool-delivery`), the two + notification contexts, and the checkpoint facade. Links: imports from + `goga/hooks` (platform facade), `goga/pipeline/workflow` (instruction + models), `goga/history` (branch and topic-dir resolution), + `goga/history/statuses` (status computation). The exact boundary and member + contracts are design-stage decisions (ADR unresolved item). +- `goga/pipeline` — `run_pipeline` gains the amendment delivery between the + skip merge and `compile_flow`, `run_created` immediately before `run_flow`, + and `run_completed` on its return (every exit code); `describe_pipeline` + composes through the same layer and reports provenance in the card output. + Imports the pipeline hooks zone facade. +- `goga/pipeline/workflow`, `goga/pipeline/compiler` — contracts unchanged; + the layer reuses the declarative vocabulary and the same validation + machine. +- `docs/features/pipelines/hooks.md` (fill the stub), the tool-author + registering-hooks usage of the pipeline domain, `mkdocs.yml` navigation — + the nav slot already exists. + +Cross-import rule respected: the hooks zone imports from the platform and the +sibling leaf cells only (never from `goga/pipeline` itself — the parent +imports the zone, not the reverse). + +## Notes + +- Decision source: the ADR in this topic directory. Where the earlier PRD + wording differed (stage-presence protection, `run_started`/`run_finished` + timing, notification context), the ADR wins — and the PRD has already been + re-aligned to the ADR during task formulation (established-decision, + guarantee, R1–R3, scope, and success-criteria sections updated), so the + document history is consistent before this task's release. +- No code examples are included in this task (stage constraint). Exact context + member names, signatures, contribution-method contracts, the hooks-zone cell + boundary, and the provenance display format remain design-stage decisions. +- No new external dependencies; `.goga/usages/cooks/` is untouched. The + practices consumed live at cell level: `declaring-actions`, + `per-tool-delivery`, `registering-hooks` (`goga/hooks/.usages`), the + `goga/topics/hooks` precedent, and project conventions + (`.goga/usages/conventions.md`). + +--- + +Author: trifonovmixail +CreatedAt: 18/09/26 diff --git a/goga/assets/pipelines/development.yml b/goga/assets/pipelines/development.yml index b0df691f..45b980c2 100644 --- a/goga/assets/pipelines/development.yml +++ b/goga/assets/pipelines/development.yml @@ -22,6 +22,7 @@ description: "Development process" - Avoid creating single-function files. Functions/Classes should be logically grouped into modules based on high cohesion and shared context. Communication: + - All details of each phase must be fully included in the file question; use diff only as a last resort. - When approving report content gates (PRIMARY_ANALYSIS_REPORT, TYPE_MAP_REPORT, CELL_DISTRIBUTION_REPORT, TYPE_DETAIL_REPORT, CELL_ASSEMBLY_REPORT), please provide full artifacts in the format specified in a question or propose to user. skills: diff --git a/goga/hooks/catalog/CODEMANIFEST b/goga/hooks/catalog/CODEMANIFEST index a4574da2..7b4ede2a 100644 --- a/goga/hooks/catalog/CODEMANIFEST +++ b/goga/hooks/catalog/CODEMANIFEST @@ -102,6 +102,18 @@ Annotations: | record domain="topics", name="amend_todo_entry", error_class="soft": a failing hook of the action is skipped with a warning and the command continues + - The catalog carries the pipeline workflow-amendment action — the + record domain="pipeline", name="amend_workflow", error_class="hard": + the first failing hook of the action stops the command with a clean + error naming the tool — the platform's first hard action + - The catalog carries the pipeline run-creation notification action — + the record domain="pipeline", name="run_created", error_class="soft": + a failing hook of the action is skipped with a warning and the command + continues + - The catalog carries the pipeline run-completion notification action — + the record domain="pipeline", name="run_completed", error_class="soft": + a failing hook of the action is skipped with a warning and the command + continues Constraints: - Do not derive records from installed packages or imports — the diff --git a/goga/pipeline/.usages/describe-pipeline.md b/goga/pipeline/.usages/describe-pipeline.md index ec664079..3f74cc9f 100644 --- a/goga/pipeline/.usages/describe-pipeline.md +++ b/goga/pipeline/.usages/describe-pipeline.md @@ -3,8 +3,11 @@ `describe_pipeline` composes the card of a single pipeline: the authored name and description from the DSL header, plus the stage list (id and title per stage) in execution order — the composition a run of the same pipeline with -the same workflow flags would execute. Nothing is launched and nothing is -written into the project or runtime directories. +the same workflow flags would execute. The card composes through the +pipeline hooks zone — the same amendment layer with the same precedence a +run applies — and names the tools whose contributions committed into the +composition. No run events fire in card form. Nothing is launched and +nothing is written into the project or runtime directories. ## Usage @@ -49,6 +52,8 @@ Returns `PipelineCard`. - `name: str` — pipeline name from the DSL header - `description: str` — pipeline description from the DSL header - `stages: list[CardStage]` — stage rows in execution order +- `provenance: list[str]` — the tools whose contributions committed into + the composition, in enumeration order; empty when none contributed `CardStage` — `@dataclass(kw_only=True)`: @@ -59,9 +64,13 @@ Returns `PipelineCard`. `workflow` / `no_workflow` follow one rule set shared with run coordination: disabled → raw composition; explicit name → that workflow file; otherwise -basename auto-match; a missing file is a silent miss. The stage composition -is produced by the same compilation machine a run uses, so loop-expanded -copies appear as separate rows with their generated ids. +basename auto-match; a missing file is a silent miss. The card composes +through the pipeline hooks zone with the same precedence a run applies — +the same flags produce the same composition and the same provenance in +both forms, and the card names the contributing tools (`card.provenance`). +The stage composition is produced by the same compilation machine a run +uses, so loop-expanded copies appear as separate rows with their generated +ids. No run events fire in card form. ## Side effects diff --git a/goga/pipeline/.usages/pipeline-cli.md b/goga/pipeline/.usages/pipeline-cli.md index 67784611..ca312ee3 100644 --- a/goga/pipeline/.usages/pipeline-cli.md +++ b/goga/pipeline/.usages/pipeline-cli.md @@ -30,7 +30,10 @@ host-side docker launcher through the runpy entrypoint in `__main__.py`. - `--info`/`-i` (flag) — print the card instead of running: `name:` and `description:` field lines, a blank line, a `---` separator, a blank line, then one bullet block per stage in execution order — the marker line - `* :` and a `title:` field line indented by four spaces. + `* :` and a `title:` field line indented by four spaces. When the + card provenance is non-empty, one blank line and one `tools:` field line + follow the stage blocks — the contributing tools comma-separated in + provenance order; an empty provenance adds nothing (byte-identical card). `-w WORKFLOW` applies a workflow to the card composition; `--no-workflow` reports the raw DSL composition; neither flag resolves the basename auto-match. Both flags are diff --git a/goga/pipeline/.usages/registering-hooks.md b/goga/pipeline/.usages/registering-hooks.md new file mode 100644 index 00000000..f132d5b4 --- /dev/null +++ b/goga/pipeline/.usages/registering-hooks.md @@ -0,0 +1,101 @@ +# pipeline — registering hooks + +How a `goga_tool_*` package subscribes its hooks to the pipeline domain +actions. For tool package authors; no goga code changes are needed. + +The domain opens three actions. One is an amendment — a read-and-contribute +view over the workflow a run is about to execute, delivered before +compilation; it is the platform's first hard action. Two are notifications — +the read-only facts of the run, delivered immediately before the runner +launch and on every launch-attempt return. + +## The events + +| Address | Error class | Fires | +|---|---|---| +| `pipeline / amend_workflow` | hard | After the workflow resolution and the runner-skip merge, before compilation — in the run form and in the card form alike. | +| `pipeline / run_created` | soft | Immediately before the runner launch — after compilation and prompt materialization. | +| `pipeline / run_completed` | soft | On every launch-attempt return — zero, non-zero, and spawn failures (126/127) alike. | + +A failing moment fires nothing: a missing pipeline and a structural +composition error return before any checkpoint. + +## Subscribe + +```python +# inside the goga_tool_ package +def register_hooks(hooks): + hooks.subscribe("pipeline", "amend_workflow", "hardening", add_hardening) + hooks.subscribe("pipeline", "run_completed", "reporter", report_run) +``` + +- `domain` — always `"pipeline"`. +- `action` — the event name from the table above. +- `name` — the hook name, unique per tool per address. +- `hook` — the callable executed when the event fires. + +A hook receives values only for the parameters it declares by the fixed +offered names: `context` — the delivered object of the event, read +attributes and call methods freely, attribute assignment is blocked; +`self` — the isolated context of your tool. The declaration order does not +matter; names you did not declare receive nothing. + +## The amendment view + +`amend_workflow` delivers a `WorkflowAmendment` view per tool. The +reads: `pipeline` — the identity of the running pipeline; `decision` — +the workflow decision (disabled / explicit / auto-match / silent-miss, +with the resolved name); `workflow` — the original authored workflow +after the decision and the runner-skip merge, read-only and identical +for every tool (None when no workflow resolved); `work` — the current +work identity. + +```python +def add_hardening(context): + context.contribute(hardening_workflow) +``` + +- `contribute(document)` buffers one declarative `WorkflowDocument`-shaped + contribution — the same instruction vocabulary an authored + workflow-file uses (prompt, stages, extend, memory). +- Your tool's contribution commits only after every hook of your tool + returns without raising; a repeat call replaces your buffer whole. +- Authored intent wins per slot: the prompt appends (authored first, tool + texts in enumeration order), the memory block is whole (an authored + block is unbeatable), stage fields fill only what the author left + unset, extend entries add under fresh names. `skip` is an ordinary + field — an authored skip is unbeatable, an unset skip is yours to set, + including removing a pipeline-file stage the author did not protect. +- A failing hook of the amendment stops the command with a clean error + naming your tool and the action; your whole contribution is discarded. +- The merged workflow passes the same compilation validation as an + authored one — a contribution naming an unknown stage surfaces as the + compiler's structural error. + +## The run notifications + +Both notifications deliver read-only facts; a failing hook warns naming +your tool, the action, and the reason — the run's exit code is never +affected. + +- `run_created` — `RunCreated`: `pipeline`, `decision`, `workflow` (the + final effective workflow — authored instructions plus the committed + tool contributions), `composition` (the ordered stages as the card + shows them), `provenance` (the tools whose contributions committed), + `work`, `statuses` (the maximal present topic statuses at the moment), + `runtime_dir`. +- `run_completed` — `RunCompleted`: the same facts recomputed at the + completion moment, plus `exit_code` — the actual exit code of the + launch attempt. Completion is a fact, not a success claim. + +## Integration scenarios + +- **Artifact → status on completion** — subscribe to `run_completed`, + read `work` and `exit_code`, register your status on the statuses + domain keyed by your artifact. +- **Run reporting and automation** — subscribe to `run_created` and + `run_completed`, read the facts, keep state in your `self` context. +- **Workflow personalization** — subscribe to `amend_workflow`, read + `workflow`, contribute your declarative adjustments. +- **On-the-fly composition add-ons** — contribute `extend` entries with + fresh stage names; authored names always win. diff --git a/goga/pipeline/.usages/run-pipeline.md b/goga/pipeline/.usages/run-pipeline.md index 5ffe09d9..6bb856eb 100644 --- a/goga/pipeline/.usages/run-pipeline.md +++ b/goga/pipeline/.usages/run-pipeline.md @@ -1,8 +1,10 @@ # run_pipeline — in-container run coordination `run_pipeline` resolves a pipeline name to a file, resolves an optional workflow, -compiles the pipeline-file to an afm flow-file via `compile_flow`, materializes -the four agent prompt files, then launches afm via `run_flow`. +delivers the workflow amendment through the pipeline hooks zone, compiles the +pipeline-file to an afm flow-file via `compile_flow`, materializes the four +agent prompt files, emits the run-creation facts, launches afm via `run_flow`, +and emits the run-completion facts on its return. ## Signature @@ -24,6 +26,29 @@ GOGA_SKIP_STAGES= carries the CLI --skip/-s names; applied in-memory onto the resolved workflow before compilation. Unset/empty = no skip. Unknown names surface as the compiler's structural error. +## Workflow amendment + +After the skip merge and before compilation, the workflow amendment is +delivered through the pipeline hooks zone (`PipelineHooks.amend_workflow`); +`compile_flow` receives the effective workflow the delivery returns. An +explicit workflow disable turns the layer off — the raw authored DSL +composes and no amendment delivers; a silent auto-match miss keeps the +layer active onto the empty base. With no tool packages installed the +overlay is the passthrough — every run composes exactly what was passed. +The amendment is a hard action: the first failing tool stops the command +with a clean error, and its whole contribution is discarded. + +## Run events + +`run_created` fires immediately before the runner launch — after +compilation and prompt materialization. `run_completed` fires on every +launch-attempt return — zero, non-zero, and spawn failures (126/127) +alike — with the work statuses recomputed at the completion moment and +the actual exit code. Both notifications are soft: a failing hook warns +and the run's exit code is unaffected. A missing pipeline and a +structural composition error fire no events — the return happens before +the checkpoints. + ## parallel parallel (int | None) optionally caps concurrently executing stages. It is diff --git a/goga/pipeline/CODEMANIFEST b/goga/pipeline/CODEMANIFEST index 0500b30a..8f839e3c 100644 --- a/goga/pipeline/CODEMANIFEST +++ b/goga/pipeline/CODEMANIFEST @@ -31,6 +31,25 @@ Imports: - Types: - resolve_project_name From: goga/config + - Types: + - PipelineHooks + - PipelineIdentity + - WorkflowDecision + - WorkIdentity + - WorkflowOverlay + - CompositionStage + Usages: + - checkpoints + From: goga/pipeline/hooks + - Types: + - resolve_current_branch_name + - resolve_topic_dir + - resolve_topic_status + - assemble_status_scale + Usages: + - topic-paths + - topic-statuses + From: goga/history Usages: convention: .goga/usages/conventions.md @@ -115,6 +134,20 @@ Annotations: | performs no stage-name validation — unknown names surface as the compiler's structural error. + The run coordination and the card compose through the pipeline hooks zone: + the workflow amendment is delivered after the runner-skip merge and before + compilation, the run notifications fire around the runner launch, and the + card reports the contributing tools. An explicit workflow disable turns + the layer off — the raw authored DSL composes and no amendment delivers; + a silent auto-match miss keeps the layer active onto the empty base. The + branch, topic, and status facts resolve in the operation before the + delivery — the checkpoints read nothing. With no tool packages installed + the overlay is the passthrough — every form behaves exactly as before. + Use the `checkpoints` practice for the checkpoint surface of the zone. + Use the `topic-paths` practice for the topic directory resolution behind + the work identity and the `topic-statuses` practice for the status facts + of the run events. + The cell runs inside the goga Docker image when invoked through python -m goga.pipeline; the host-side launcher lives in goga/commands/pipeline (docker runtime boundary — no Python Imports). @@ -220,21 +253,31 @@ Annotations: | Authored pipeline name from the DSL header; may differ from the discovered stem. -"PipelineCard(name: str, description: str, stages: list[CardStage])": +"PipelineCard(name: str, description: str, stages: list[CardStage], provenance: list[str] = [])": location: pipeline_card.py annotations: | Describe the card of a single pipeline: the authored name and - description plus the ordered stage rows. + description, the ordered stage rows, and the tools whose contributions + shaped the composition. `name`: pipeline name from the DSL header `description`: pipeline description from the DSL header `stages`: stage rows in execution order — one per compiled stage + `provenance`: the tools whose contributions committed into the + composition, in enumeration order; empty when none + contributed Build with the standard library dataclasses module and @dataclass(kw_only=True) (per `convention`). Requirements: - Use @dataclass(kw_only=True) + - `provenance` defaults to an empty list — constructions without it + remain valid + - `provenance` defaults to an empty list via + field(default_factory=list) in the implementation; the signature + default `[]` is a DSL representation, the actual default factory is + applied at construction properties: "name -> str": | @@ -244,6 +287,9 @@ Annotations: | "stages -> list[CardStage]": | Stage rows in execution order; loop-expanded copies appear as separate rows. + "provenance -> list[str]": | + The tools whose contributions committed into the composition, in + enumeration order; empty when none contributed. "CardStage(id: str, title: str)": location: pipeline_card.py @@ -419,8 +465,9 @@ Annotations: | "describe_pipeline(name: str, project_dir: Path, user_dir: Path, workflow: str | None, no_workflow: bool) -> card: PipelineCard": location: describe_pipeline.py annotations: | - Compose the card of a single pipeline: name, description, and the - post-workflow stage composition as it would execute. + Compose the card of a single pipeline: name, description, the + post-workflow stage composition as it would execute, and the tools + that contributed to it. `name`: pipeline name without extension `project_dir`: project-level pipelines directory (absolute) @@ -428,9 +475,14 @@ Annotations: | `workflow`: optional explicit workflow name (without the .yml extension) `no_workflow`: when True, workflow application is disabled `card`: `PipelineCard` — the pipeline name and description from the DSL - header, one `CardStage` per stage in execution order + header, one `CardStage` per stage in execution order, and the + provenance of the amendment Apply `compile-flow` for the compilation contract and the documents tuple. + Apply `checkpoints` for the amendment delivery of the pipeline hooks + zone. + Apply `topic-paths` for the work identity resolution behind the + amendment facts. Apply `convention` for docstring style and intra-package imports. Algorithm: @@ -438,20 +490,39 @@ Annotations: | on no match report the missing pipeline with a readable error 2. Resolve the workflow via `resolve_workflow` with the pipeline name and the workflow flags - 3. Compile the pipeline-file via `compile_flow` into a temporary flow-file + 3. Resolve the amendment facts (the `PipelineIdentity` — the authored + header name and description read via `parse_dsl` from the + pipeline-file text; the `WorkflowDecision`; and the `WorkIdentity` — + the current branch via `resolve_current_branch_name`, the literal + "unknown" when it resolves None; the hosting topic slug and year via + `resolve_topic_dir` when its directory exists, the branch-only form + otherwise) and, unless the decision is disabled, deliver the + amendment via the `PipelineHooks` checkpoint surface with the resolved + workflow — receiving the overlay result; a disabled decision delivers + nothing and the overlay is the passthrough `WorkflowOverlay` of the + resolved workflow + 4. Compile the pipeline-file via `compile_flow` into a temporary flow-file located in a system temporary directory — outside the project - directory and outside every runtime directory — and receive the - documents tuple - 4. Order the compiled stages via `order_stages` - 5. Build the card: name and description from the parsed pipeline document + directory and outside every runtime directory — with the overlay + workflow, and receive the documents tuple + 5. Order the compiled stages via `order_stages` + 6. Build the card: name and description from the parsed pipeline document header; one `CardStage` per ordered stage — id from the `FlowStage` - id, title from the `FlowStage` name (the display label) - 6. Discard the temporary flow-file and return the card + id, title from the `FlowStage` name (the display label); the card + provenance from the overlay + 7. Discard the temporary flow-file and return the card Requirements: - The stage composition equals the composition a run of the same pipeline - with the same workflow flags would execute — the same compilation - machine produces both + with the same workflow flags would execute — the same amendment layer + with the same precedence and the same compilation machine produce + both + - The card names the contributing tools — the provenance is empty when + nothing contributed + - An explicit workflow disable turns the layer off — no delivery + happens and the raw authored DSL composes + - A silent auto-match miss keeps the layer active — the delivery runs + onto the empty base - Loop-expanded stage copies appear as separate stages - The run-only GOGA_SKIP_STAGES environment variable is not read — the CLI skip channel is a run concern; workflow-file skip directives DO @@ -463,9 +534,11 @@ Annotations: | Constraints: - Do not launch afm and do not run any stage — the card is read-only + - Do not emit run events in card form - Do not write into the project directory or any runtime directory - - Do not re-parse the pipeline-file — header data comes from the documents - tuple + - Do not re-parse the pipeline-file for the card fields — the card's + name and description come from the documents tuple; the single early + `parse_dsl` read of step 3 serves the amendment facts only - Do not reorder stages beyond `order_stages` "run_pipeline(name: str, project_dir: Path, user_dir: Path, port: int, parallel: int | None = None) -> exit_code: int": @@ -473,13 +546,16 @@ Annotations: | annotations: | Resolve a pipeline name to an absolute file path via `list_pipelines`, resolve an optional workflow via `resolve_workflow` from the environment - decision, compile the pipeline-file (optionally extended by the workflow) - into an afm flow-file at runtime via `compile_flow`, materialize the four + decision, deliver the workflow amendment through the pipeline hooks zone, + compile the pipeline-file (extended by the effective workflow) into an + afm flow-file at runtime via `compile_flow`, materialize the four agent prompt files (defaults plus inline overrides) into the runtime - prompts directory, then launch afm through `run_flow`. This is the run - coordination routine — it performs discovery, workflow resolution, path - resolution, compilation, and prompt materialization; the actual subprocess - execution lives in `run_flow`. + prompts directory, emit the run-creation facts, launch afm through + `run_flow`, and emit the run-completion facts on its return. This is the + run coordination routine — it performs discovery, workflow resolution, + path resolution, fact resolution, amendment delivery, compilation, and + prompt materialization; the actual subprocess execution lives in + `run_flow`. `name`: pipeline name without extension `project_dir`: project-level pipelines directory (same meaning as in `list_pipelines`) @@ -502,6 +578,10 @@ Annotations: | tuple. Apply `default_prompts` for resolving the packaged default prompt files. Apply `run-flow` for the subprocess launch contract. + Apply `checkpoints` for the amendment delivery and the two emissions of + the pipeline hooks zone. + Apply `topic-paths` for the work identity resolution and + `topic-statuses` for the status facts of the run events. Algorithm: 1. Discover pipelines via `list_pipelines` and find the entry whose name @@ -521,21 +601,49 @@ Annotations: | 7. Read GOGA_SKIP_STAGES from the environment (unset/empty — no skip); when non-empty split into names and apply via `apply_skip_stages` onto the resolved workflow - 8. Resolve the in-container project name via `resolve_project_name` - (None when the git origin remote is unavailable). Compile via - `compile_flow` with the resolved workflow, the in-container project - root (Path.cwd()) as root_dir, and the project name; receive the - documents tuple; structural errors propagate unchanged - 9. Materialize agent prompts atomically (validate-all, then wipe, then - write): resolve the default prompts directory per `default_prompts`; - for each overridable role (planner, executor, reviewer) require an - inline override from the documents tuple or an existing default file - (stem via `translate_role`); require the summary default; then reset - /prompts/ and write exactly four files — overrides where - present, defaults otherwise, summary always from the default - 10. Launch afm via `run_flow` with the compiled flow-file path, `port`, + 8. Resolve the amendment facts from the operation's own data: the + `PipelineIdentity` (discovered name; authored header name and + description read via `parse_dsl` from the pipeline-file text; entry + source), the `WorkflowDecision` (disabled / + explicit / auto-match / silent-miss with the resolved name), and the + `WorkIdentity` (current branch via `resolve_current_branch_name`, + the literal "unknown" when it resolves None; the hosting topic slug + and year via `resolve_topic_dir` when its + directory exists, the branch-only form otherwise) + 9. Unless the decision is disabled, deliver the amendment via the + `PipelineHooks` checkpoint surface with the authored workflow after + the skip merge — receiving the overlay result; a disabled decision + delivers nothing (the layer is off) and the overlay is the + passthrough `WorkflowOverlay` of the merged workflow + 10. Resolve the in-container project name via `resolve_project_name` + (None when the git origin remote is unavailable). Compile via + `compile_flow` with the overlay workflow (the resolved workflow when + the layer is off), the in-container project root (Path.cwd()) as + root_dir, and the project name; receive the documents tuple; + structural errors propagate unchanged + 11. Materialize agent prompts atomically (validate-all, then wipe, then + write): resolve the default prompts directory per `default_prompts`; + for each overridable role (planner, executor, reviewer) require an + inline override from the documents tuple or an existing default file + (stem via `translate_role`); require the summary default; then reset + /prompts/ and write exactly four files — overrides where + present, defaults otherwise, summary always from the default + 12. Order the compiled stages via `order_stages` and build one + `CompositionStage` per ordered stage — id from the `FlowStage` id, + title from the `FlowStage` name + 13. Resolve the work statuses — the maximal present statuses of the + hosting topic via `assemble_status_scale` and `resolve_topic_status`; + an empty list in the branch-only form + 14. Emit the run-creation facts via the checkpoint surface immediately + before the launch: the identity, the decision, the overlay, the + composition, the work identity, the statuses, and the runtime dir as + a posix string + 15. Launch afm via `run_flow` with the compiled flow-file path, `port`, and max_parallel=`parallel` - 11. Return the exit code returned by `run_flow` + 16. On every return of `run_flow` — zero, non-zero, and spawn failures + alike — recompute the work statuses at the completion moment and + emit the run-completion facts with the actual exit code + 17. Return the exit code returned by `run_flow` Requirements: - Always pass the absolute pipeline path to `compile_flow` — never the @@ -549,11 +657,27 @@ Annotations: | - GOGA_WORKFLOW_DISABLED="1" takes precedence over GOGA_WORKFLOW_NAME - Workflow resolution and parsing go through `resolve_workflow` — one rule set shared with the card - - A missing workflow-file is a silent miss, not an error + - The amendment delivery and its precedence are the same rule set the + card applies — the same flags compose the same overlay in both forms + - A missing workflow-file is a silent miss, not an error; the layer + stays active onto the empty base + - An explicit disable turns the layer off — no delivery happens and the + raw authored DSL composes - Skip merges onto any resolved workflow and applies to a workflow-less pipeline; unknown skip names surface as the compiler's structural error - - /prompts/ contains exactly four files after step 9 succeeds; + - The branch, topic, and status facts resolve in the operation before + the delivery — the checkpoints read nothing + - The "run_created" action fires immediately before the runner launch; + the "run_completed" action fires on every launch-attempt return path + — no exit path skips the completion emission + - A missing pipeline and a structural composition error fire no events + — the return happens before the checkpoints + - The runtime dir fact is the resolved AFM_DIR path as a posix string + - With no tool packages installed the overlay is the passthrough — the + compiled workflow, the prompts, and the output behave exactly as + before + - /prompts/ contains exactly four files after step 11 succeeds; validation precedes any wipe or write — atomicity guarantees no partial state on disk - Inline prompt overrides come exclusively from the documents tuple @@ -577,6 +701,9 @@ Annotations: | - Do not accept relative `project_dir` or `user_dir` - Do not mask or wrap exceptions from `compile_flow` or `parse_workflow` — structural errors propagate with their readable messages + - Do not resolve git or topic facts inside a hook delivery — the + operation owns the resolution + - Do not skip the completion emission on any launch-attempt return path - Do not write prompts inside the project directory or /workspace — always /prompts/ - Do not write inline prompt overrides into the compiled flow-file @@ -712,7 +839,11 @@ Annotations: | - The card template: a "name:" line, a "description:" line, a blank line, a "---" separator, a blank line, then per ordered stage the marker line "* :" and a "title:" line indented by four spaces; the separator - block is printed even when the card carries no stages + block is printed even when the card carries no stages; when the card + provenance is non-empty, one blank line and one "tools:" field line + follow the stage blocks — the contributing tools comma-separated in + provenance order; an empty provenance adds nothing — the output stays + byte-identical to the provenance-free card - An empty discovery is not an error: the flat list prints nothing — zero discovered pipelines, zero lines; the overview likewise prints nothing; both return 0 diff --git a/goga/pipeline/hooks/.usages/checkpoints.md b/goga/pipeline/hooks/.usages/checkpoints.md new file mode 100644 index 00000000..151c3639 --- /dev/null +++ b/goga/pipeline/hooks/.usages/checkpoints.md @@ -0,0 +1,88 @@ +# pipeline — amending workflows and emitting run checkpoints + +How the pipeline flows consume the hooks zone of the pipeline domain: +delivering the workflow amendment before compilation and emitting the two +run notifications around the runner launch. For the run coordination and +the card over the pipeline facade. + +## The checkpoint surface + +One `PipelineHooks` object serves every checkpoint of a command — the +surface shares one registry per run, so a command that reaches several +checkpoints enumerates the tool packages once. + +```python +from goga.pipeline.hooks import PipelineHooks + +hooks = PipelineHooks() +``` + +## Resolve the facts in the operation + +Every context is built from the values the caller passes — the checkpoint +reads no repository. Resolve the facts before the delivery: + +- `PipelineIdentity` — the discovered pipeline name, the authored header + name and description, and the source (`project` or `user`). +- `WorkflowDecision` — the outcome of the workflow resolution: `disabled`, + `explicit`, `auto-match`, or `silent-miss`, with the resolved workflow + name when applicable. +- `WorkIdentity` — the current branch with the topic slug and year when + the branch hosts a topic; the branch-only form otherwise. + +## Amend before compilation + +Deliver the amendment after the runner-skip merge and before +`compile_flow`; compile the effective workflow the delivery returns. + +```python +overlay = hooks.amend_workflow( + pipeline=identity, + decision=decision, + workflow=merged_workflow, # None is valid — a silent miss keeps the layer active + work=work, +) +compile_flow(overlay.workflow, ...) +``` + +- A tool contributes one declarative `WorkflowDocument`; authored intent + wins per slot — the prompt appends, the memory block is whole, stage + fields fill only what the author left unset, extend entries add. +- The amendment action is hard: the first failing tool stops the command + with a clean error naming the tool and the action; the tool's whole + contribution is discarded. +- An address without subscriptions returns the passthrough overlay — the + workflow stays what was passed, the provenance is empty. With no tool + packages installed every run composes exactly what was passed. + +## Emit around the launch + +Emit the creation immediately before the runner launch (after compilation +and prompt materialization) and the completion on every launch-attempt +return — zero, non-zero, and spawn failures alike. + +```python +hooks.emit_run_created( + pipeline=identity, decision=decision, overlay=overlay, + composition=stages, work=work, statuses=statuses, + runtime_dir=runtime_dir, +) +exit_code = run_flow(...) +statuses = resolve_topic_status(topic_dir, scale) # recompute at the moment +hooks.emit_run_completed( + pipeline=identity, decision=decision, overlay=overlay, + composition=stages, work=work, statuses=statuses, + runtime_dir=runtime_dir, exit_code=exit_code, +) +``` + +- Both notifications are fire-and-forget: a failing hook warns naming the + tool, the action, and the reason; the run's exit code is unaffected. +- `composition` carries the ordered stages as the card shows them; build + it from the compiled stages of the same compilation the run executes. + +## The card form + +The card composes through the same amendment with the same precedence and +reports `overlay.provenance` as the contributing tools. No run events fire +in card form. diff --git a/goga/pipeline/hooks/CODEMANIFEST b/goga/pipeline/hooks/CODEMANIFEST new file mode 100644 index 00000000..bec3e2ec --- /dev/null +++ b/goga/pipeline/hooks/CODEMANIFEST @@ -0,0 +1,534 @@ +Imports: + - Types: + - HookRegistry + - wrap_context + - build_hook_arguments + - emit_hook_event + - declared_actions + Usages: + - declaring-actions + - per-tool-delivery + - registering-hooks + From: goga/hooks + - Types: + - WorkflowDocument + From: goga/pipeline/workflow + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and + testing in the project + + This cell owns the hooks zone of the pipeline domain: the fact + vocabulary of the run events, the read-and-contribute amendment + context with its per-tool staged commit, the authored-wins workflow + overlay, and the checkpoint surface that delivers the amendment and + emits the two run notifications over the platform facade. One registry + per run carries every checkpoint of a command — the checkpoints never + multiply the package enumeration. Every context is built from the + operation data the caller passes — no repository reads happen here. + The amendment action is hard — the platform's first: the first + failing tool stops the command with a clean error naming the tool and + the action, and the tool's whole contribution is discarded. The two + notifications are soft — a failing hook warns naming the tool, the + action, and the reason, and the run's exit code is unaffected. Tools + are mutually blind — every amendment view reads the original authored + workflow, never a staged state. + Use the `per-tool-delivery` practice for the staged delivery loop of + the amendment checkpoint — its loop skeleton, primitives, and + tool-grouped commit apply as written. + Use the `declaring-actions` practice for the emission contract of + the notification checkpoints. + Use the `registering-hooks` practice for the hook signature and the + failure handling behind every checkpoint. + Use relative imports. + +--- + +"PipelineIdentity(name: str, display_name: str = \"\", description: str, source: str)": + location: identity.py + annotations: | + The identity vocabulary of every pipeline event — the discovered + name, the authored header facts, and the source of the + pipeline-file. + + `name`: the discovered pipeline name — the file stem without the + .yml extension + `display_name`: the authored pipeline name from the DSL header; may + differ from the discovered stem; empty when the + header names none + `description`: the pipeline description from the DSL header + `source`: the origin of the pipeline-file — exactly project or user + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - `name` is non-empty and carries no path separators and no .yml + suffix + - `source` is exactly project or user + - Pure facts — the constructing operation passes resolved values; + nothing is read here + properties: + "name -> str": | + The discovered pipeline name — the file stem without the .yml + extension. + "display_name -> str": | + The authored pipeline name from the DSL header; may differ from + the discovered stem. + "description -> str": | + The pipeline description from the DSL header. + "source -> str": | + The origin of the pipeline-file — project or user. + +"WorkflowDecision(kind: str, workflow_name: str | None)": + location: identity.py + annotations: | + The workflow decision of one composition — the outcome of the + resolution and the resolved name. + + `kind`: exactly one of disabled, explicit, auto-match, silent-miss + `workflow_name`: the resolved workflow name — present for explicit + and auto-match, None otherwise + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - `kind` is exactly one of the four fixed values + - Pure facts — the decision mirrors the resolution the operation + already made + properties: + "kind -> str": | + The outcome of the workflow resolution — disabled, explicit, + auto-match, or silent-miss. + "workflow_name -> str | None": | + The resolved workflow name, or None when no name resolved. + +"WorkIdentity(branch: str, slug: str | None = None, year: str | None = None)": + location: identity.py + annotations: | + The topics-shaped identity of the current work — the branch, with + the topic slug and year when the branch hosts a topic. + + `branch`: the current branch name as resolved by the operation + `slug`: the normalized topic slug — present when the branch hosts a + topic + `year`: the resolved year as four digits — present when the branch + hosts a topic + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The hosting decision and every resolution happen in the + constructing operation — nothing is read here + - The branch-only form — `slug` and `year` None — serves a branch + hosting no topic + properties: + "branch -> str": | + The current branch name as resolved by the operation. + "slug -> str | None": | + The normalized topic slug, or None in the branch-only form. + "year -> str | None": | + The resolved year as four digits, or None in the branch-only form. + +"CompositionStage(id: str, title: str)": + location: contexts.py + annotations: | + One row of the final composition — the stage identity and its + display title, as the card shows them. + + `id`: the stage identifier + `title`: the stage display title + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "id -> str": | + The stage identifier. + "title -> str": | + The stage display title. + +"ToolContribution(tool: str, document: WorkflowDocument)": + location: overlay.py + annotations: | + The committed contribution of one tool — the pairing of the tool + identity with its declarative document. + + `tool`: the tool identity assigned by the platform + `document`: the committed contribution — a `WorkflowDocument`-shaped + set of instructions + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "tool -> str": | + The tool identity assigned by the platform. + "document -> WorkflowDocument": | + The committed contribution of the tool. + +"WorkflowOverlay(workflow: WorkflowDocument | None, provenance: list[str])": + location: overlay.py + annotations: | + The result of the amendment layer — the effective workflow and its + provenance. + + `workflow`: the final effective workflow — None only in the + passthrough case: no authored workflow and no committed + contribution + `provenance`: the tools whose contributions committed, in + enumeration order + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - A None workflow with a non-empty provenance never occurs — a + committed contribution always yields a document + properties: + "workflow -> WorkflowDocument | None": | + The final effective workflow, or None in the passthrough case. + "provenance -> list[str]": | + The tools whose contributions committed, in enumeration order. + +"WorkflowAmendment(pipeline: PipelineIdentity, decision: WorkflowDecision, workflow: WorkflowDocument | None, work: WorkIdentity)": + location: amendments.py + annotations: | + The read-and-contribute view of one tool — the delivered facts of + the amendment checkpoint and the buffer of one tool's contribution. + + `pipeline`: the identity of the pipeline being composed + `decision`: the workflow decision of the operation + `workflow`: the original authored workflow — post decision, post + runner-skip merge, pre-layer; read-only and identical + for every tool; None when no workflow resolved + `work`: the current work identity + + Apply the `convention` practice for the data-model rules and + intra-package imports. + Use the `registering-hooks` practice for the hook signature that + receives this view. + + Requirements: + - The reads deliver the original facts — no staged-application + state exists; a tool never sees another tool's contribution + - The buffered contribution belongs to this tool alone + properties: + "pipeline -> PipelineIdentity": | + The identity of the pipeline being composed. + "decision -> WorkflowDecision": | + The workflow decision of the operation. + "workflow -> WorkflowDocument | None": | + The original authored workflow, read-only and identical for every + tool; None when no workflow resolved. + "work -> WorkIdentity": | + The current work identity. + methods: + "contribute(document: WorkflowDocument)": | + Buffer one declarative contribution of this tool. + + `document`: the complete contribution — a `WorkflowDocument`-shaped + set of instructions (prompt, stages, extend, memory) + using the same vocabulary an authored workflow-file + uses + + Requirements: + - The call buffers into the buffer of this tool alone and changes + nothing until the delivery commits it + - The replacement is whole — a later call replaces the earlier + buffered document + - A document with no prompt, no stages, no extend, and no memory + is an empty contribution — the delivery discards it with a + warning + + Constraints: + - Do not cancel, redirect, or defer the operation — a + contribution transforms the workflow layer only + +"merge_workflow_overlay(base: WorkflowDocument | None, contributions: list[ToolContribution]) -> overlay: WorkflowOverlay": + location: overlay.py + annotations: | + The authored-wins overlay merge — compose the effective workflow + from the authored base and the committed tool contributions. + + `base`: the authored workflow after the decision and the + runner-skip merge; None is the empty base — a silent + auto-match miss keeps the layer active + `contributions`: the committed contributions in enumeration order + `overlay`: the effective workflow with its provenance + + Apply the `convention` practice for docstring style and + intra-package imports. + + Algorithm: + 1. Take `base` as the authored layer — None is the empty base + 2. prompt: place the authored prompt first, then append the prompt + of every committed contribution in enumeration order; with no + authored prompt the first tool text becomes the prompt + 3. memory: keep the authored block when present — it is unbeatable; + otherwise the block of the later contributing tool wins; no + field-level merging + 4. stages: for each stage name take the authored entry as the + ground — an authored field is never overwritten; a field the + author left unset takes the value of the later contributing tool + that sets it; a stage with no authored entry is fully defined by + the tools + 5. skip is an ordinary stage field: an authored skip — from the + workflow-file or the merged runner skip — is unbeatable; an + unset skip takes a contributing skip; skip=False overrides + nothing + 6. extend: keep the authored entries; a contribution entry under an + authored name is not applied; among contributing tools the later + entry wins per name + 7. Compose the provenance from the identities of the committed + contributions in enumeration order + 8. Return the `WorkflowOverlay` — the workflow None only when the + base is None and no contribution committed + + Requirements: + - Pure — the inputs stay unmutated; the result is a new document + - The prompt concatenation joins the non-empty texts with a single + blank line between consecutive texts — the authored prompt first, + then each committed contribution text in enumeration order; no other + separators, prefixes, or suffixes are added + - Deterministic — the same inputs give the same overlay + - The result stays declarative — it passes the same compilation + validation an authored workflow passes + + Constraints: + - Do not read or write the filesystem + - Do not invent instructions absent from the inputs + - Do not mutate `base`, the contributions, or their maps + +"RunCreated(pipeline: PipelineIdentity, decision: WorkflowDecision, workflow: WorkflowDocument | None, composition: list[CompositionStage], provenance: list[str], work: WorkIdentity, statuses: list[str], runtime_dir: str)": + location: contexts.py + annotations: | + The read-only context of the run-creation notification — the facts + of the composition at the moment immediately before the runner + launch. + + `pipeline`: the identity of the running pipeline + `decision`: the workflow decision of the operation + `workflow`: the final effective workflow — the authored instructions + plus the committed tool contributions + `composition`: the ordered stages of the final composition — one row + per compiled stage, as the card shows + `provenance`: the tools whose contributions committed, in + enumeration order + `work`: the current work identity + `statuses`: the maximal present statuses of the work's topic at the + moment — both axes, built-in and tool + `runtime_dir`: the run's runtime directory as a posix string + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Read-only facts of the composed moment — a hook observes and + cannot alter + properties: + "pipeline -> PipelineIdentity": | + The identity of the running pipeline. + "decision -> WorkflowDecision": | + The workflow decision of the operation. + "workflow -> WorkflowDocument | None": | + The final effective workflow — authored instructions plus the + committed tool contributions. + "composition -> list[CompositionStage]": | + The ordered stages of the final composition, as the card shows + them. + "provenance -> list[str]": | + The tools whose contributions committed, in enumeration order. + "work -> WorkIdentity": | + The current work identity. + "statuses -> list[str]": | + The maximal present statuses of the work's topic at the moment. + "runtime_dir -> str": | + The run's runtime directory as a posix string. + +"RunCompleted(pipeline: PipelineIdentity, decision: WorkflowDecision, workflow: WorkflowDocument | None, composition: list[CompositionStage], provenance: list[str], work: WorkIdentity, statuses: list[str], runtime_dir: str, exit_code: int)": + location: contexts.py + annotations: | + The read-only context of the run-completion notification — the same + facts recomputed at the completion moment, plus the outcome of the + launch attempt. + + `pipeline`: the identity of the running pipeline + `decision`: the workflow decision of the operation + `workflow`: the final effective workflow the run executed + `composition`: the ordered stages of the executed composition + `provenance`: the tools whose contributions committed + `work`: the current work identity + `statuses`: the maximal present statuses recomputed at the + completion moment + `runtime_dir`: the run's runtime directory as a posix string + `exit_code`: the actual exit code of the launch attempt — zero, + non-zero, or a spawn failure (126/127) + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Read-only facts of the completed attempt — completion is a fact, + not a success claim + properties: + "pipeline -> PipelineIdentity": | + The identity of the running pipeline. + "decision -> WorkflowDecision": | + The workflow decision of the operation. + "workflow -> WorkflowDocument | None": | + The final effective workflow the run executed. + "composition -> list[CompositionStage]": | + The ordered stages of the executed composition. + "provenance -> list[str]": | + The tools whose contributions committed, in enumeration order. + "work -> WorkIdentity": | + The current work identity. + "statuses -> list[str]": | + The maximal present statuses recomputed at the completion moment. + "runtime_dir -> str": | + The run's runtime directory as a posix string. + "exit_code -> int": | + The actual exit code of the launch attempt. + +"PipelineHooks()": + location: events.py + annotations: | + The checkpoint surface of the pipeline domain — the amendment + delivery and the two run notifications over the platform facade. + + Apply the `convention` practice for the code style and + intra-package imports. + Use the `per-tool-delivery` practice for the staged delivery loop of + the amendment checkpoint. + Use the `declaring-actions` practice for the emission contract of + the notification checkpoints. + Use the `registering-hooks` practice for the registration contract + behind every checkpoint. + + Requirements: + - Cheap construction — no enumeration and no imports happen at + construction + - One `HookRegistry` per run carries every checkpoint of a command + — the assembly runs once per run whatever the number of + checkpoints + - Every context is built from the values the caller passes — no + repository reads happen at a checkpoint + methods: + "amend_workflow(pipeline: PipelineIdentity, decision: WorkflowDecision, workflow: WorkflowDocument | None, work: WorkIdentity) -> overlay: WorkflowOverlay": | + Deliver the workflow-amendment checkpoint and return the + effective workflow with its provenance. + + `pipeline`: the identity of the pipeline being composed + `decision`: the workflow decision of the operation + `workflow`: the authored workflow after the decision and the + runner-skip merge; None when no workflow resolved + `work`: the current work identity + `overlay`: the effective workflow and the contributing tools + + Use the `per-tool-delivery` practice for the delivery loop. + + Algorithm: + 1. Resolve the address domain="pipeline", action="amend_workflow" + against `declared_actions` + 2. Walk the subscriptions of the address per tool in enumeration + order: build the tool's `WorkflowAmendment` view over the + delivered facts — every tool reads the same original + `workflow` — wrap it via `wrap_context`, project the call + arguments via `build_hook_arguments` with the tool's own + context, and call each hook of the tool + 3. A tool whose every hook returned without raising and whose + buffer carries a non-empty contribution commits as one + `ToolContribution` + 4. A tool with a raising hook is a hard failure: a clean error + naming the tool and the action stops the command at the first + failure; the tool's contribution is discarded + 5. A tool whose buffered document is empty — no prompt, no + stages, no extend, no memory — is a content no-op: a warning + naming the tool, the action, and the reason, the contribution + discarded, the walk continues + 6. Merge the committed contributions onto `workflow` via + `merge_workflow_overlay` and return the overlay + + Requirements: + - The commit granularity is the tool — a tool's whole + contribution commits only after every hook of the tool succeeds + - An address without subscriptions returns the passthrough + overlay — the workflow passed in, an empty provenance + - The merged result passes the same compilation validation an + authored workflow passes + + Constraints: + - Do not apply any contribution outside the single merge after + the walk + - Do not skip a subscriber of the address + - Do not read repositories or the filesystem at the checkpoint + "emit_run_created(pipeline: PipelineIdentity, decision: WorkflowDecision, overlay: WorkflowOverlay, composition: list[CompositionStage], work: WorkIdentity, statuses: list[str], runtime_dir: str)": | + Emit the run-creation notification — the facts of the composition + immediately before the runner launch. + + `pipeline`: the identity of the running pipeline + `decision`: the workflow decision of the operation + `overlay`: the amendment result — the effective workflow and the + provenance + `composition`: the ordered stages of the final composition + `work`: the current work identity + `statuses`: the maximal present statuses at the moment + `runtime_dir`: the run's runtime directory as a posix string + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `RunCreated` context from the values — the effective + workflow and the provenance read from `overlay` + 2. Emit the address domain="pipeline", action="run_created" via + `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + - A failing hook is skipped with a warning under the soft error + class of the action — the launch proceeds + "emit_run_completed(pipeline: PipelineIdentity, decision: WorkflowDecision, overlay: WorkflowOverlay, composition: list[CompositionStage], work: WorkIdentity, statuses: list[str], runtime_dir: str, exit_code: int)": | + Emit the run-completion notification — the recomputed facts of + the finished launch attempt. + + `pipeline`: the identity of the running pipeline + `decision`: the workflow decision of the operation + `overlay`: the amendment result of the run + `composition`: the ordered stages of the executed composition + `work`: the current work identity + `statuses`: the maximal present statuses recomputed at the + completion moment + `runtime_dir`: the run's runtime directory as a posix string + `exit_code`: the actual exit code of the launch attempt + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `RunCompleted` context from the values + 2. Emit the address domain="pipeline", action="run_completed" + via `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + - The emission happens on every launch-attempt return path — + zero, non-zero, and spawn failures alike + - A failing hook warns under the soft error class — the exit code + of the run is never affected + +--- + +Author: Goga +CreatedAt: 18/09/26 +Description: | + Owner of the pipeline domain hooks zone — the run-event facts, the + workflow amendment with its overlay, and the checkpoint surface over + the hooks platform. From ad9353451139b4c1a83d18b2db1fef9142b11c14 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 18 Sep 2026 15:15:29 +0000 Subject: [PATCH 064/205] feat: add the three pipeline records to the hooks catalog --- .goga/history/2026/add-pipeline-hooks/plan.md | 14 +++--- goga/hooks/catalog/catalog.py | 3 ++ tests/hooks/catalog/test_catalog.py | 44 ++++++++++++++++++- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/.goga/history/2026/add-pipeline-hooks/plan.md b/.goga/history/2026/add-pipeline-hooks/plan.md index 55a4a5ca..6d9f066d 100644 --- a/.goga/history/2026/add-pipeline-hooks/plan.md +++ b/.goga/history/2026/add-pipeline-hooks/plan.md @@ -404,7 +404,7 @@ these records existing, hence the task comes first. **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: extend `tests/hooks/catalog/test_catalog.py` (the file already +- [x] **Contract tests**: extend `tests/hooks/catalog/test_catalog.py` (the file already exists with `TestCatalogContract` / logic classes) with the design scenario: ``` @@ -422,7 +422,7 @@ these records existing, hence the task comes first. Also assert the total record count is 13 and that the ten pre-existing records are unchanged (regression pin). Expected to fail at this stage — the records do not exist. -- [ ] **Code**: append to `_DECLARED_ACTIONS` in `goga/hooks/catalog/catalog.py`: +- [x] **Code**: append to `_DECLARED_ACTIONS` in `goga/hooks/catalog/catalog.py`: ```python Action(domain="pipeline", name="amend_workflow", error_class="hard"), @@ -432,16 +432,16 @@ these records existing, hence the task comes first. (list order is irrelevant — `declared_actions()` sorts — but keep the file's existing grouping style.) -- [ ] **Interface verification**: `python -m pytest tests/hooks/catalog/test_catalog.py -q` +- [x] **Interface verification**: `python -m pytest tests/hooks/catalog/test_catalog.py -q` — all pass, including the pre-existing tests. -- [ ] **Logic tests**: covered by the scenario above (presence, error classes, ordering, +- [x] **Logic tests**: covered by the scenario above (presence, error classes, ordering, count); add nothing speculative. -- [ ] **Debugging**: `python -m pytest tests/hooks/catalog -q` — fix implementation code +- [x] **Debugging**: `python -m pytest tests/hooks/catalog -q` — fix implementation code until all tests pass (do NOT fix test code). -- [ ] **Contract re-verification**: `declared_actions()` still returns every record, +- [x] **Contract re-verification**: `declared_actions()` still returns every record, complete and unfiltered, deterministic; `Action` untouched; the module docstring still matches. -- [ ] **Lint**: `python -m ruff check goga/hooks/catalog tests/hooks/catalog && python -m ruff format --check goga/hooks/catalog tests/hooks/catalog` — fix formatting if necessary. +- [x] **Lint**: `python -m ruff check goga/hooks/catalog tests/hooks/catalog && python -m ruff format --check goga/hooks/catalog tests/hooks/catalog` — fix formatting if necessary. ### Task 2: Zone package skeleton and test scaffolding (infrastructure) diff --git a/goga/hooks/catalog/catalog.py b/goga/hooks/catalog/catalog.py index acc84f71..923678fb 100644 --- a/goga/hooks/catalog/catalog.py +++ b/goga/hooks/catalog/catalog.py @@ -40,6 +40,9 @@ class Action: _DECLARED_ACTIONS: list[Action] = [ # supported data, not discovery Action(domain="onboarding", name="amend_config", error_class="soft"), Action(domain="onboarding", name="declare_session", error_class="soft"), + Action(domain="pipeline", name="amend_workflow", error_class="hard"), + Action(domain="pipeline", name="run_created", error_class="soft"), + Action(domain="pipeline", name="run_completed", error_class="soft"), Action(domain="statuses", name="register_statuses", error_class="soft"), Action(domain="topics", name="amend_creation", error_class="soft"), Action(domain="topics", name="amend_todo_entry", error_class="soft"), diff --git a/tests/hooks/catalog/test_catalog.py b/tests/hooks/catalog/test_catalog.py index 0a2af3ab..2e8430b3 100644 --- a/tests/hooks/catalog/test_catalog.py +++ b/tests/hooks/catalog/test_catalog.py @@ -102,7 +102,7 @@ def test_declared_actions_carries_the_seven_topics_records(self) -> None: checkpoint the topics zone emits resolves its address here. An address the zone emits but the catalog misses is a runtime ValueError in every flow, so the record set is pinned against - drift, together with the complete total: 3 existing + 7 topics. + drift, together with the complete total: 3 + 7 topics + 3 pipeline. """ topics = [action for action in declared_actions() if action.domain == "topics"] @@ -115,7 +115,47 @@ def test_declared_actions_carries_the_seven_topics_records(self) -> None: ("topic_switched", "soft"), ("topic_todo_entered", "soft"), ] - assert len(declared_actions()) == 10 + assert len(declared_actions()) == 13 + + def test_catalog_carries_the_three_pipeline_records(self) -> None: + """The pipeline domain block — the platform's first hard action, two soft notifications. + + ``pipeline/amend_workflow`` (hard) stops a run on hook failure; + ``pipeline/run_created`` and ``pipeline/run_completed`` (soft) only + notify. The block orders between ``onboarding`` and ``statuses`` in + the ``(domain, name)`` sort, and the ten pre-existing records are + unchanged — the catalog grows to 13 records additively. + """ + records = declared_actions() + triples = {(r.domain, r.name, r.error_class) for r in records} + + assert ("pipeline", "amend_workflow", "hard") in triples + assert ("pipeline", "run_completed", "soft") in triples + assert ("pipeline", "run_created", "soft") in triples + + pipeline = [action.name for action in records if action.domain == "pipeline"] + + assert pipeline == ["amend_workflow", "run_completed", "run_created"] + + domains = [action.domain for action in records] + + assert domains.index("onboarding") < domains.index("pipeline") < domains.index("statuses") + assert len(records) == 13 + + pre_existing = [ + ("onboarding", "amend_config", "soft"), + ("onboarding", "declare_session", "soft"), + ("statuses", "register_statuses", "soft"), + ("topics", "amend_creation", "soft"), + ("topics", "amend_todo_entry", "soft"), + ("topics", "topic_created", "soft"), + ("topics", "topic_deleted", "soft"), + ("topics", "topic_published", "soft"), + ("topics", "topic_switched", "soft"), + ("topics", "topic_todo_entered", "soft"), + ] + + assert all(triple in triples for triple in pre_existing) def test_declared_actions_is_deterministic_and_complete(self) -> None: """Same records in ``(domain, name)`` order on every call, unfiltered. From 2a7602ae57fd27b5db416a8ceabbe91ee0cbc774 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 18 Sep 2026 15:17:54 +0000 Subject: [PATCH 065/205] feat: add pipeline hooks zone package skeleton and test scaffolding --- .goga/history/2026/add-pipeline-hooks/plan.md | 12 +++++++----- goga/pipeline/hooks/.usages/checkpoints.md | 19 ++++++++++++++----- goga/pipeline/hooks/__init__.py | 14 ++++++++++++++ tests/pipeline/hooks/__init__.py | 0 tests/pipeline/hooks/conftest.py | 10 ++++++++++ 5 files changed, 45 insertions(+), 10 deletions(-) create mode 100644 goga/pipeline/hooks/__init__.py create mode 100644 tests/pipeline/hooks/__init__.py create mode 100644 tests/pipeline/hooks/conftest.py diff --git a/.goga/history/2026/add-pipeline-hooks/plan.md b/.goga/history/2026/add-pipeline-hooks/plan.md index 6d9f066d..c213c3a3 100644 --- a/.goga/history/2026/add-pipeline-hooks/plan.md +++ b/.goga/history/2026/add-pipeline-hooks/plan.md @@ -460,10 +460,10 @@ design's General Setup specifies. **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] Create `goga/pipeline/hooks/__init__.py` — package docstring naming the zone (the +- [x] Create `goga/pipeline/hooks/__init__.py` — package docstring naming the zone (the hooks zone of the pipeline domain) and an empty `__all__: list[str] = []` for now; no imports yet (the modules do not exist). Relative imports only, once they appear. -- [ ] Create `tests/pipeline/hooks/__init__.py` (empty) and `tests/pipeline/hooks/conftest.py`: +- [x] Create `tests/pipeline/hooks/__init__.py` (empty) and `tests/pipeline/hooks/conftest.py`: ```python from tests.hooks.conftest import install_tool_package, pin_package_environment # noqa: F401 @@ -472,12 +472,14 @@ design's General Setup specifies. (cross-package import precedent: `tests/test_cli.py:20`; the fixtures pin the two platform boundary points — `packages_distributions` and the `sys.modules` entry of a `goga_tool_*` package — so the platform code under test runs for real). -- [ ] Verify collection: `python -m pytest tests/pipeline/hooks --collect-only -q` — the +- [x] Verify collection: `python -m pytest tests/pipeline/hooks --collect-only -q` — the package collects cleanly (zero tests is expected at this stage). -- [ ] Verify package importability: `python -c "import goga.pipeline.hooks"` — no error +- [x] Verify package importability: `python -c "import goga.pipeline.hooks"` — no error (the partially-initialized-parent edge is safe: `goga/pipeline/__init__.py` does not import the zone yet). -- [ ] Lint: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting if necessary. +- [x] Lint: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting if necessary. + (Formatting fix applied to the pre-existing `.usages/checkpoints.md` code blocks — + ruff 0.16 formats embedded Python in Markdown; rewrap only, no content change.) ### Task 3: Zone identity models — `identity.py` (TDD coding) diff --git a/goga/pipeline/hooks/.usages/checkpoints.md b/goga/pipeline/hooks/.usages/checkpoints.md index 151c3639..cef6e7e9 100644 --- a/goga/pipeline/hooks/.usages/checkpoints.md +++ b/goga/pipeline/hooks/.usages/checkpoints.md @@ -63,16 +63,25 @@ return — zero, non-zero, and spawn failures alike. ```python hooks.emit_run_created( - pipeline=identity, decision=decision, overlay=overlay, - composition=stages, work=work, statuses=statuses, + pipeline=identity, + decision=decision, + overlay=overlay, + composition=stages, + work=work, + statuses=statuses, runtime_dir=runtime_dir, ) exit_code = run_flow(...) statuses = resolve_topic_status(topic_dir, scale) # recompute at the moment hooks.emit_run_completed( - pipeline=identity, decision=decision, overlay=overlay, - composition=stages, work=work, statuses=statuses, - runtime_dir=runtime_dir, exit_code=exit_code, + pipeline=identity, + decision=decision, + overlay=overlay, + composition=stages, + work=work, + statuses=statuses, + runtime_dir=runtime_dir, + exit_code=exit_code, ) ``` diff --git a/goga/pipeline/hooks/__init__.py b/goga/pipeline/hooks/__init__.py new file mode 100644 index 00000000..cd8b58b1 --- /dev/null +++ b/goga/pipeline/hooks/__init__.py @@ -0,0 +1,14 @@ +"""Hooks zone of the pipeline domain — the checkpoint surface of pipeline runs. + +The zone owns the fact vocabulary of the run events (identity, contexts), the +authored-wins workflow overlay, the read-and-contribute amendment view, and the +``PipelineHooks`` checkpoint surface delivering the platform's first hard +action ``pipeline/amend_workflow`` and the two soft notifications +``pipeline/run_created`` / ``pipeline/run_completed``. + +Built incrementally: each entity task adds its module's import and ``__all__`` +entry. The contract names land over the course of the zone tasks; until then +the facade is intentionally empty. +""" + +__all__: list[str] = [] diff --git a/tests/pipeline/hooks/__init__.py b/tests/pipeline/hooks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/pipeline/hooks/conftest.py b/tests/pipeline/hooks/conftest.py new file mode 100644 index 00000000..6979d372 --- /dev/null +++ b/tests/pipeline/hooks/conftest.py @@ -0,0 +1,10 @@ +"""Shared fixtures of the pipeline hooks zone tests — the platform boundary. + +Re-exports the two boundary fixtures of the hooks platform tests +(``pin_package_environment`` / ``install_tool_package``) so the zone suites pin +the same two outside-world points — the ``packages_distributions`` read and the +``sys.modules`` entry of a ``goga_tool_*`` package — with the platform code +under test running for real. +""" + +from tests.hooks.conftest import install_tool_package, pin_package_environment # noqa: F401 From e0f582768d0faaf740c76ad13647d6fd070af913 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 18 Sep 2026 15:22:37 +0000 Subject: [PATCH 066/205] feat: add pipeline hooks zone identity models with contract and logic tests --- .goga/history/2026/add-pipeline-hooks/plan.md | 16 +- goga/pipeline/hooks/__init__.py | 12 +- goga/pipeline/hooks/identity.py | 111 ++++++++++++ tests/pipeline/hooks/test_identity.py | 169 ++++++++++++++++++ 4 files changed, 297 insertions(+), 11 deletions(-) create mode 100644 goga/pipeline/hooks/identity.py create mode 100644 tests/pipeline/hooks/test_identity.py diff --git a/.goga/history/2026/add-pipeline-hooks/plan.md b/.goga/history/2026/add-pipeline-hooks/plan.md index c213c3a3..5d09efbf 100644 --- a/.goga/history/2026/add-pipeline-hooks/plan.md +++ b/.goga/history/2026/add-pipeline-hooks/plan.md @@ -502,7 +502,7 @@ rules: non-empty, no path separators, no `.yml` suffix); `WorkflowDecision` — **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: create `tests/pipeline/hooks/test_identity.py`: +- [x] **Contract tests**: create `tests/pipeline/hooks/test_identity.py`: - importability from the facade after this task: `from goga.pipeline.hooks import PipelineIdentity, WorkflowDecision, WorkIdentity` (fails now — expected); - each model is a `kw_only` dataclass (positional construction raises `TypeError`; @@ -511,14 +511,14 @@ rules: non-empty, no path separators, no `.yml` suffix); `WorkflowDecision` — `PipelineIdentity`: `name, display_name="", description, source`; `WorkflowDecision`: `kind, workflow_name`; `WorkIdentity`: `branch, slug=None, year=None`. -- [ ] **Code**: create `goga/pipeline/hooks/identity.py` with the three dataclasses and +- [x] **Code**: create `goga/pipeline/hooks/identity.py` with the three dataclasses and the `__post_init__` guards (`ValueError` on a bad `source` literal, a bad `kind` literal, and invalid `name` input — non-empty, no `/`/`\\`, no `.yml` suffix). -- [ ] **Code**: add the three names to `goga/pipeline/hooks/__init__.py` imports and +- [x] **Code**: add the three names to `goga/pipeline/hooks/__init__.py` imports and `__all__` (keep `__all__` alphabetical). -- [ ] **Interface verification**: `python -m pytest tests/pipeline/hooks/test_identity.py -q` +- [x] **Interface verification**: `python -m pytest tests/pipeline/hooks/test_identity.py -q` — all pass. -- [ ] **Logic tests** (same file): +- [x] **Logic tests** (same file): - `PipelineIdentity(source="elsewhere")` raises `ValueError`; `source="project"` and `source="user"` construct; - `PipelineIdentity(name="dir/x")` / `name="x.yml"` / `name=""` raise `ValueError`; @@ -527,11 +527,11 @@ rules: non-empty, no path separators, no `.yml` suffix); `WorkflowDecision` — `workflow_name=None` accepted; - `WorkIdentity(branch="b")` alone constructs the branch-only form (`slug is None`, `year is None`). -- [ ] **Debugging**: `python -m pytest tests/pipeline/hooks -q` — fix implementation code +- [x] **Debugging**: `python -m pytest tests/pipeline/hooks -q` — fix implementation code until all tests pass (do NOT fix test code). -- [ ] **Contract re-verification**: fields/properties match the declared API; pure facts — +- [x] **Contract re-verification**: fields/properties match the declared API; pure facts — no repository reads anywhere in the module; facade exposes the three names. -- [ ] **Lint**: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting, apply decomposition if necessary. +- [x] **Lint**: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting, apply decomposition if necessary. ### Task 4: Zone run-event contexts — `contexts.py` (TDD coding) diff --git a/goga/pipeline/hooks/__init__.py b/goga/pipeline/hooks/__init__.py index cd8b58b1..5624a00f 100644 --- a/goga/pipeline/hooks/__init__.py +++ b/goga/pipeline/hooks/__init__.py @@ -7,8 +7,14 @@ ``pipeline/run_created`` / ``pipeline/run_completed``. Built incrementally: each entity task adds its module's import and ``__all__`` -entry. The contract names land over the course of the zone tasks; until then -the facade is intentionally empty. +entry. With the identity models landed, the three identity names are +re-exported here — three of the eleven contract names. """ -__all__: list[str] = [] +from .identity import PipelineIdentity, WorkflowDecision, WorkIdentity + +__all__: list[str] = [ + "PipelineIdentity", + "WorkIdentity", + "WorkflowDecision", +] diff --git a/goga/pipeline/hooks/identity.py b/goga/pipeline/hooks/identity.py new file mode 100644 index 00000000..745f7189 --- /dev/null +++ b/goga/pipeline/hooks/identity.py @@ -0,0 +1,111 @@ +"""The identity vocabulary of the pipeline run events — pure fact carriers. + +Three dataclasses shared by every context of the zone: ``PipelineIdentity`` +(the discovered pipeline name, the authored header facts, and the source of +the pipeline-file), ``WorkflowDecision`` (the outcome of the workflow +resolution the operation already made), and ``WorkIdentity`` (the +topics-shaped identity of the current work — the branch, with the topic slug +and year when the branch hosts a topic). + +Nothing is read here — the constructing operation passes resolved values. The +two contract invariants that are data-shaped (not operation-shaped) are +guarded at construction, following the :class:`~goga.pipeline.pipeline_entry.PipelineEntry` +convention: the ``PipelineIdentity`` name rules and ``source`` literal, and +the ``WorkflowDecision`` ``kind`` literal. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +_SOURCES: tuple[str, ...] = ("project", "user") +_DECISION_KINDS: tuple[str, ...] = ("disabled", "explicit", "auto-match", "silent-miss") + + +@dataclass(kw_only=True) +class PipelineIdentity: + """The identity of the pipeline every event of a run is about. + + The discovered name (the file stem without the ``.yml`` extension), the + authored header facts, and the source of the pipeline-file. + + Args: + name: the discovered pipeline name — the file stem without the + ``.yml`` extension; must be non-empty and carry no path + separators and no ``.yml`` suffix. + display_name: the authored pipeline name from the DSL header; may + differ from the discovered stem; empty when the header names + none. + description: the pipeline description from the DSL header. + source: the origin of the pipeline-file — exactly ``project`` or + ``user``. + + Raises: + ValueError: if ``name`` is empty, carries a path separator, or ends + with ``.yml``, or if ``source`` is not ``project`` or ``user``. + """ + + name: str + display_name: str = "" + description: str + source: str + + def __post_init__(self) -> None: + """Validate the ``name`` rules and the ``source`` literal.""" + if not self.name: + raise ValueError("pipeline name must not be empty") + + if "/" in self.name or "\\" in self.name: + raise ValueError("pipeline name must not contain path separators ('/' or '\\')") + + if self.name.endswith(".yml"): + raise ValueError("pipeline name must not include the '.yml' extension") + + if self.source not in _SOURCES: + raise ValueError(f"pipeline source must be one of {_SOURCES}, got {self.source!r}") + + +@dataclass(kw_only=True) +class WorkflowDecision: + """The workflow decision of one composition — the resolution outcome. + + Mirrors the resolution the operation already made: the kind of the + outcome and the resolved workflow name. + + Args: + kind: the outcome of the workflow resolution — exactly one of + ``disabled``, ``explicit``, ``auto-match``, ``silent-miss``. + workflow_name: the resolved workflow name — present for ``explicit`` + and ``auto-match``, ``None`` otherwise. + + Raises: + ValueError: if ``kind`` is not one of the four fixed values. + """ + + kind: str + workflow_name: str | None + + def __post_init__(self) -> None: + """Validate the ``kind`` literal.""" + if self.kind not in _DECISION_KINDS: + raise ValueError(f"workflow decision kind must be one of {_DECISION_KINDS}, got {self.kind!r}") + + +@dataclass(kw_only=True) +class WorkIdentity: + """The topics-shaped identity of the current work. + + The branch as resolved by the operation, with the hosting topic's slug + and year when the branch hosts a topic. + + Args: + branch: the current branch name as resolved by the operation. + slug: the normalized topic slug, or ``None`` in the branch-only + form. + year: the resolved year as four digits, or ``None`` in the + branch-only form. + """ + + branch: str + slug: str | None = None + year: str | None = None diff --git a/tests/pipeline/hooks/test_identity.py b/tests/pipeline/hooks/test_identity.py new file mode 100644 index 00000000..8c327c01 --- /dev/null +++ b/tests/pipeline/hooks/test_identity.py @@ -0,0 +1,169 @@ +"""Contract and logic tests for the entities declared in +``goga/pipeline/hooks/CODEMANIFEST`` with ``location: identity.py``: + +- ``PipelineIdentity(name, display_name, description, source)`` — the identity + vocabulary of every pipeline event +- ``WorkflowDecision(kind, workflow_name)`` — the outcome of the workflow + resolution +- ``WorkIdentity(branch, slug, year)`` — the topics-shaped identity of the + current work + +Supported data only — no mocks, no filesystem: the models are pure fact +carriers, every resolution happens in the constructing operation. +""" + +from __future__ import annotations + +import dataclasses + +import pytest +from goga.pipeline.hooks import PipelineIdentity, WorkflowDecision, WorkIdentity + +from tests.conftest import is_kw_only_dataclass + + +def _field_defaults(cls: type) -> list[tuple[str, object]]: + """(name, default) per declared field — ``MISSING`` for required fields.""" + return [(field.name, field.default) for field in dataclasses.fields(cls)] + + +# --- Contract tests --- + + +class TestIdentityContract: + def test_entities_are_importable_from_the_zone_facade(self) -> None: + """All three models live on the zone package and its ``__all__`` is exact.""" + import goga.pipeline.hooks as zone + + assert zone.PipelineIdentity is PipelineIdentity + assert zone.WorkflowDecision is WorkflowDecision + assert zone.WorkIdentity is WorkIdentity + assert zone.__all__ == ["PipelineIdentity", "WorkIdentity", "WorkflowDecision"] + + def test_models_are_kw_only_dataclasses(self) -> None: + """Positional construction raises ``TypeError`` for every model.""" + for cls in (PipelineIdentity, WorkflowDecision, WorkIdentity): + assert dataclasses.is_dataclass(cls) + assert is_kw_only_dataclass(cls) + + with pytest.raises(TypeError): + PipelineIdentity("deploy", "", "d", "project") # type: ignore[misc] + + with pytest.raises(TypeError): + WorkflowDecision("explicit", "ci") # type: ignore[misc] + + with pytest.raises(TypeError): + WorkIdentity("feature-demo", "feature-demo", "2026") # type: ignore[misc] + + def test_pipeline_identity_carries_exactly_the_declared_fields(self) -> None: + """``name, display_name="", description, source`` — names, order, defaults.""" + assert _field_defaults(PipelineIdentity) == [ + ("name", dataclasses.MISSING), + ("display_name", ""), + ("description", dataclasses.MISSING), + ("source", dataclasses.MISSING), + ] + + def test_workflow_decision_carries_exactly_the_declared_fields(self) -> None: + """``kind, workflow_name`` — both required, no defaults.""" + assert _field_defaults(WorkflowDecision) == [ + ("kind", dataclasses.MISSING), + ("workflow_name", dataclasses.MISSING), + ] + + def test_work_identity_carries_exactly_the_declared_fields(self) -> None: + """``branch, slug=None, year=None`` — names, order, defaults.""" + assert _field_defaults(WorkIdentity) == [ + ("branch", dataclasses.MISSING), + ("slug", None), + ("year", None), + ] + + +# --- Logic tests --- + + +class TestPipelineIdentity: + def test_source_rejects_anything_but_project_or_user(self) -> None: + """The ``source`` literal is guarded — exactly project or user.""" + with pytest.raises(ValueError, match="pipeline source must be one of"): + PipelineIdentity(name="deploy", description="d", source="elsewhere") + + for source in ("project", "user"): + identity = PipelineIdentity(name="deploy", description="d", source=source) + + assert identity.source == source + + def test_name_rejects_empty_separators_and_yml_suffix(self) -> None: + """The ``name`` rules: non-empty, no ``/``/``\\``, no ``.yml`` suffix.""" + for bad_name, pattern in ( + ("", "must not be empty"), + ("dir/x", "path separators"), + ("dir\\x", "path separators"), + ("deploy.yml", "'.yml' extension"), + ): + with pytest.raises(ValueError, match=pattern): + PipelineIdentity(name=bad_name, description="d", source="project") + + def test_fields_round_trip_and_display_name_defaults_empty(self) -> None: + """Authored header facts carry verbatim; ``display_name`` defaults to ``""``.""" + default = PipelineIdentity(name="deploy", description="Ships the service", source="project") + + assert default.display_name == "" + + authored = PipelineIdentity( + name="deploy", + display_name="Deploy the service", + description="Ships the service", + source="user", + ) + + assert authored.name == "deploy" + assert authored.display_name == "Deploy the service" + assert authored.description == "Ships the service" + assert authored.source == "user" + + +class TestWorkflowDecision: + def test_kind_rejects_unknown_literals(self) -> None: + """The ``kind`` literal is guarded — exactly the four fixed values.""" + with pytest.raises(ValueError, match="workflow decision kind must be one of"): + WorkflowDecision(kind="bogus", workflow_name=None) + + def test_all_four_kinds_construct(self) -> None: + """disabled, explicit, auto-match, and silent-miss all construct.""" + decisions = [ + WorkflowDecision(kind="disabled", workflow_name=None), + WorkflowDecision(kind="explicit", workflow_name="ci"), + WorkflowDecision(kind="auto-match", workflow_name="deploy"), + WorkflowDecision(kind="silent-miss", workflow_name=None), + ] + + assert [decision.kind for decision in decisions] == [ + "disabled", + "explicit", + "auto-match", + "silent-miss", + ] + assert decisions[0].workflow_name is None + assert decisions[1].workflow_name == "ci" + assert decisions[2].workflow_name == "deploy" + assert decisions[3].workflow_name is None + + +class TestWorkIdentity: + def test_branch_only_form_leaves_slug_and_year_none(self) -> None: + """``WorkIdentity(branch="b")`` alone serves a branch hosting no topic.""" + work = WorkIdentity(branch="b") + + assert work.branch == "b" + assert work.slug is None + assert work.year is None + + def test_hosting_form_carries_slug_and_year(self) -> None: + """The topic-hosting form carries the normalized slug and the year.""" + work = WorkIdentity(branch="feature-demo", slug="feature-demo", year="2026") + + assert work.branch == "feature-demo" + assert work.slug == "feature-demo" + assert work.year == "2026" From 94bb0255bb3bef71f310fd18e1e4ed016e4fa764 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 18 Sep 2026 15:25:33 +0000 Subject: [PATCH 067/205] feat: add pipeline hooks zone run-event contexts with contract and logic tests --- .goga/history/2026/add-pipeline-hooks/plan.md | 16 +- goga/pipeline/hooks/__init__.py | 9 +- goga/pipeline/hooks/contexts.py | 100 ++++++ tests/pipeline/hooks/test_events.py | 294 ++++++++++++++++++ tests/pipeline/hooks/test_identity.py | 9 +- 5 files changed, 417 insertions(+), 11 deletions(-) create mode 100644 goga/pipeline/hooks/contexts.py create mode 100644 tests/pipeline/hooks/test_events.py diff --git a/.goga/history/2026/add-pipeline-hooks/plan.md b/.goga/history/2026/add-pipeline-hooks/plan.md index 5d09efbf..82dd3c65 100644 --- a/.goga/history/2026/add-pipeline-hooks/plan.md +++ b/.goga/history/2026/add-pipeline-hooks/plan.md @@ -552,7 +552,7 @@ created here and extended by Task 7, which owns the emission behavior). **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: create `tests/pipeline/hooks/test_events.py` (data-model +- [x] **Contract tests**: create `tests/pipeline/hooks/test_events.py` (data-model contract block; Task 7 appends the delivery/emission classes): - the three names importable from `goga.pipeline.hooks` (fails now — expected); - `kw_only` enforced (positional construction raises `TypeError`); @@ -560,21 +560,21 @@ created here and extended by Task 7, which owns the emission behavior). `CompositionStage(id, title)`; `RunCreated(pipeline, decision, workflow, composition, provenance, work, statuses, runtime_dir)`; `RunCompleted` = the same eight plus `exit_code` last. -- [ ] **Code**: create `goga/pipeline/hooks/contexts.py` with the three dataclasses +- [x] **Code**: create `goga/pipeline/hooks/contexts.py` with the three dataclasses (docstrings mirroring the CODEMANIFEST property annotations). -- [ ] **Code**: add `CompositionStage`, `RunCreated`, `RunCompleted` to the facade +- [x] **Code**: add `CompositionStage`, `RunCreated`, `RunCompleted` to the facade `__init__.py` and `__all__` (alphabetical). -- [ ] **Interface verification**: `python -m pytest tests/pipeline/hooks/test_events.py -q` +- [x] **Interface verification**: `python -m pytest tests/pipeline/hooks/test_events.py -q` — all pass. -- [ ] **Logic tests**: construction carries every field verbatim (build a +- [x] **Logic tests**: construction carries every field verbatim (build a `RunCreated`/`RunCompleted` from identity/decision/workflow fixtures and assert each attribute round-trips; `RunCompleted.exit_code` accepts 0, 3, and 127); dataclass equality of two identically-built contexts holds. -- [ ] **Debugging**: `python -m pytest tests/pipeline/hooks -q` — fix implementation code +- [x] **Debugging**: `python -m pytest tests/pipeline/hooks -q` — fix implementation code until all tests pass (do NOT fix test code). -- [ ] **Contract re-verification**: read-only facts — no methods, no behavior, no defaults +- [x] **Contract re-verification**: read-only facts — no methods, no behavior, no defaults beyond the declared signatures; facade exposes the names. -- [ ] **Lint**: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting, apply decomposition if necessary. +- [x] **Lint**: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting, apply decomposition if necessary. ### Task 5: The authored-wins overlay — `overlay.py` (TDD coding) diff --git a/goga/pipeline/hooks/__init__.py b/goga/pipeline/hooks/__init__.py index 5624a00f..2c30099a 100644 --- a/goga/pipeline/hooks/__init__.py +++ b/goga/pipeline/hooks/__init__.py @@ -7,14 +7,19 @@ ``pipeline/run_created`` / ``pipeline/run_completed``. Built incrementally: each entity task adds its module's import and ``__all__`` -entry. With the identity models landed, the three identity names are -re-exported here — three of the eleven contract names. +entry. With the identity models and the run-event contexts landed, the three +identity names and the three context names are re-exported here — six of the +eleven contract names. """ +from .contexts import CompositionStage, RunCompleted, RunCreated from .identity import PipelineIdentity, WorkflowDecision, WorkIdentity __all__: list[str] = [ + "CompositionStage", "PipelineIdentity", + "RunCompleted", + "RunCreated", "WorkIdentity", "WorkflowDecision", ] diff --git a/goga/pipeline/hooks/contexts.py b/goga/pipeline/hooks/contexts.py new file mode 100644 index 00000000..180bfb7e --- /dev/null +++ b/goga/pipeline/hooks/contexts.py @@ -0,0 +1,100 @@ +"""The run-event contexts of the pipeline domain — read-only fact bundles. + +Three dataclasses carrying the facts a hook observes at a run checkpoint: +``CompositionStage`` (one row of the final composition, as the card shows +it), ``RunCreated`` (the facts of the composition at the moment immediately +before the runner launch), and ``RunCompleted`` (the same facts recomputed +at the completion moment, plus the outcome of the launch attempt). + +Read-only facts of the composed or completed moment — a hook observes and +cannot alter. No behavior lives here: no methods, no defaults, no +repository reads; the constructing operation passes resolved values. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ..workflow import WorkflowDocument +from .identity import PipelineIdentity, WorkflowDecision, WorkIdentity + + +@dataclass(kw_only=True) +class CompositionStage: + """One row of the final composition — the stage identity and its display title. + + Args: + id: the stage identifier. + title: the stage display title. + """ + + id: str + title: str + + +@dataclass(kw_only=True) +class RunCreated: + """The read-only context of the run-creation notification. + + The facts of the composition at the moment immediately before the + runner launch — a hook observes and cannot alter. + + Args: + pipeline: the identity of the running pipeline. + decision: the workflow decision of the operation. + workflow: the final effective workflow — authored instructions + plus the committed tool contributions, or ``None`` when no + effective workflow exists. + composition: the ordered stages of the final composition — one + row per compiled stage, as the card shows them. + provenance: the tools whose contributions committed, in + enumeration order. + work: the current work identity. + statuses: the maximal present statuses of the work's topic at the + moment — both axes, built-in and tool. + runtime_dir: the run's runtime directory as a posix string. + """ + + pipeline: PipelineIdentity + decision: WorkflowDecision + workflow: WorkflowDocument | None + composition: list[CompositionStage] + provenance: list[str] + work: WorkIdentity + statuses: list[str] + runtime_dir: str + + +@dataclass(kw_only=True) +class RunCompleted: + """The read-only context of the run-completion notification. + + The same facts as :class:`RunCreated` recomputed at the completion + moment, plus the outcome of the launch attempt — completion is a + fact, not a success claim. + + Args: + pipeline: the identity of the running pipeline. + decision: the workflow decision of the operation. + workflow: the final effective workflow the run executed, or + ``None`` when no effective workflow existed. + composition: the ordered stages of the executed composition. + provenance: the tools whose contributions committed, in + enumeration order. + work: the current work identity. + statuses: the maximal present statuses of the work's topic + recomputed at the completion moment. + runtime_dir: the run's runtime directory as a posix string. + exit_code: the actual exit code of the launch attempt — zero, + non-zero, or a spawn failure (126/127). + """ + + pipeline: PipelineIdentity + decision: WorkflowDecision + workflow: WorkflowDocument | None + composition: list[CompositionStage] + provenance: list[str] + work: WorkIdentity + statuses: list[str] + runtime_dir: str + exit_code: int diff --git a/tests/pipeline/hooks/test_events.py b/tests/pipeline/hooks/test_events.py new file mode 100644 index 00000000..d060662a --- /dev/null +++ b/tests/pipeline/hooks/test_events.py @@ -0,0 +1,294 @@ +"""Contract and logic tests for the entities declared in +``goga/pipeline/hooks/CODEMANIFEST`` with ``location: contexts.py``: + +- ``CompositionStage(id, title)`` — one row of the final composition, as + the card shows it +- ``RunCreated(...)`` — the read-only context of the run-creation + notification, the facts of the composed moment +- ``RunCompleted(...)`` — the same facts recomputed at the completion + moment, plus the launch attempt's ``exit_code`` + +Supported data only — no mocks, no filesystem: the models are pure fact +carriers (a hook observes and cannot alter), every resolution happens in +the constructing operation. Task 7 appends the delivery/emission classes. +""" + +from __future__ import annotations + +import dataclasses + +import pytest +from goga.pipeline.hooks import ( + CompositionStage, + PipelineIdentity, + RunCompleted, + RunCreated, + WorkflowDecision, + WorkIdentity, +) +from goga.pipeline.workflow import WorkflowDocument + +from tests.conftest import is_kw_only_dataclass + +_CREATED_FIELDS: list[tuple[str, object]] = [ + ("pipeline", dataclasses.MISSING), + ("decision", dataclasses.MISSING), + ("workflow", dataclasses.MISSING), + ("composition", dataclasses.MISSING), + ("provenance", dataclasses.MISSING), + ("work", dataclasses.MISSING), + ("statuses", dataclasses.MISSING), + ("runtime_dir", dataclasses.MISSING), +] + + +def _field_defaults(cls: type) -> list[tuple[str, object]]: + """(name, default) per declared field — ``MISSING`` for required fields.""" + return [(field.name, field.default) for field in dataclasses.fields(cls)] + + +@pytest.fixture +def pipeline() -> PipelineIdentity: + """The identity of the running pipeline.""" + return PipelineIdentity( + name="deploy", + display_name="Deploy the service", + description="Ships the service", + source="project", + ) + + +@pytest.fixture +def decision() -> WorkflowDecision: + """The workflow decision of the operation.""" + return WorkflowDecision(kind="auto-match", workflow_name="deploy") + + +@pytest.fixture +def workflow() -> WorkflowDocument: + """The final effective workflow.""" + return WorkflowDocument(prompt="authored") + + +@pytest.fixture +def work() -> WorkIdentity: + """The current work identity — the topic-hosting form.""" + return WorkIdentity(branch="feature-demo", slug="feature-demo", year="2026") + + +# --- Contract tests --- + + +class TestContextsContract: + def test_entities_are_importable_from_the_zone_facade(self) -> None: + """All three models live on the zone package and its ``__all__`` is exact.""" + import goga.pipeline.hooks as zone + + assert zone.CompositionStage is CompositionStage + assert zone.RunCreated is RunCreated + assert zone.RunCompleted is RunCompleted + assert zone.__all__ == [ + "CompositionStage", + "PipelineIdentity", + "RunCompleted", + "RunCreated", + "WorkIdentity", + "WorkflowDecision", + ] + + def test_models_are_kw_only_dataclasses(self) -> None: + """Positional construction raises ``TypeError`` for every model.""" + for cls in (CompositionStage, RunCreated, RunCompleted): + assert dataclasses.is_dataclass(cls) + assert is_kw_only_dataclass(cls) + + with pytest.raises(TypeError): + CompositionStage("build", "Build") # type: ignore[misc] + + with pytest.raises(TypeError): + RunCreated( # type: ignore[misc] + PipelineIdentity(name="deploy", description="d", source="project"), + WorkflowDecision(kind="explicit", workflow_name="ci"), + None, + [], + [], + WorkIdentity(branch="b"), + [], + "/runtime", + ) + + with pytest.raises(TypeError): + RunCompleted( # type: ignore[misc] + PipelineIdentity(name="deploy", description="d", source="project"), + WorkflowDecision(kind="explicit", workflow_name="ci"), + None, + [], + [], + WorkIdentity(branch="b"), + [], + "/runtime", + 0, + ) + + def test_composition_stage_carries_exactly_the_declared_fields(self) -> None: + """``id, title`` — both required, no defaults.""" + assert _field_defaults(CompositionStage) == [ + ("id", dataclasses.MISSING), + ("title", dataclasses.MISSING), + ] + + def test_run_created_carries_exactly_the_declared_fields(self) -> None: + """The eight composed-moment facts — names, order, no defaults.""" + assert _field_defaults(RunCreated) == _CREATED_FIELDS + + def test_run_completed_is_run_created_plus_exit_code_last(self) -> None: + """The same eight fields plus ``exit_code`` as the ninth and last.""" + assert _field_defaults(RunCompleted) == [*_CREATED_FIELDS, ("exit_code", dataclasses.MISSING)] + + +# --- Logic tests --- + + +class TestCompositionStage: + def test_row_carries_id_and_title_verbatim(self) -> None: + """One row of the final composition as the card shows it.""" + row = CompositionStage(id="build", title="Build") + + assert row.id == "build" + assert row.title == "Build" + + +class TestRunCreated: + def test_construction_carries_every_field_verbatim(self) -> None: + """Each attribute round-trips — the facts are observed, not derived.""" + composition = [CompositionStage(id="build", title="Build")] + provenance = ["goga_tool_demo"] + statuses = ["todo"] + + context = RunCreated( + pipeline=PipelineIdentity(name="deploy", display_name="Deploy", description="d", source="user"), + decision=WorkflowDecision(kind="explicit", workflow_name="ci"), + workflow=WorkflowDocument(prompt="authored"), + composition=composition, + provenance=provenance, + work=WorkIdentity(branch="feature-demo", slug="feature-demo", year="2026"), + statuses=statuses, + runtime_dir="/runtime/afm", + ) + + assert context.pipeline.name == "deploy" + assert context.decision.kind == "explicit" + assert context.workflow is not None + assert context.workflow.prompt == "authored" + assert context.composition == composition + assert context.provenance == provenance + assert context.work.slug == "feature-demo" + assert context.statuses == statuses + assert context.runtime_dir == "/runtime/afm" + + def test_workflow_none_serves_the_silent_miss_moment(self) -> None: + """``workflow`` is ``None`` when no effective workflow exists.""" + context = RunCreated( + pipeline=PipelineIdentity(name="deploy", description="d", source="project"), + decision=WorkflowDecision(kind="silent-miss", workflow_name=None), + workflow=None, + composition=[], + provenance=[], + work=WorkIdentity(branch="b"), + statuses=[], + runtime_dir="/runtime", + ) + + assert context.workflow is None + + def test_identically_built_contexts_are_equal(self) -> None: + """Dataclass equality holds for two identically-built contexts.""" + + def build() -> RunCreated: + return RunCreated( + pipeline=PipelineIdentity(name="deploy", description="d", source="project"), + decision=WorkflowDecision(kind="auto-match", workflow_name="deploy"), + workflow=WorkflowDocument(prompt="authored"), + composition=[CompositionStage(id="build", title="Build")], + provenance=["goga_tool_demo"], + work=WorkIdentity(branch="b"), + statuses=["todo"], + runtime_dir="/runtime", + ) + + assert build() == build() + + +class TestRunCompleted: + def test_construction_carries_every_field_verbatim( + self, + pipeline: PipelineIdentity, + decision: WorkflowDecision, + workflow: WorkflowDocument, + work: WorkIdentity, + ) -> None: + """Each attribute round-trips — completion is a fact, not a success claim.""" + composition = [CompositionStage(id="build", title="Build")] + + context = RunCompleted( + pipeline=pipeline, + decision=decision, + workflow=workflow, + composition=composition, + provenance=["goga_tool_demo"], + work=work, + statuses=["done"], + runtime_dir="/runtime/afm", + exit_code=0, + ) + + assert context.pipeline is pipeline + assert context.decision is decision + assert context.workflow is workflow + assert context.composition == composition + assert context.provenance == ["goga_tool_demo"] + assert context.work is work + assert context.statuses == ["done"] + assert context.runtime_dir == "/runtime/afm" + assert context.exit_code == 0 + + @pytest.mark.parametrize("exit_code", [0, 3, 127]) + def test_exit_code_accepts_zero_nonzero_and_spawn_failure(self, exit_code: int) -> None: + """Zero, non-zero, and a spawn failure 126/127 are all plain facts.""" + context = RunCompleted( + pipeline=PipelineIdentity(name="deploy", description="d", source="project"), + decision=WorkflowDecision(kind="silent-miss", workflow_name=None), + workflow=None, + composition=[], + provenance=[], + work=WorkIdentity(branch="b"), + statuses=[], + runtime_dir="/runtime", + exit_code=exit_code, + ) + + assert context.exit_code == exit_code + + def test_identically_built_contexts_are_equal( + self, + pipeline: PipelineIdentity, + decision: WorkflowDecision, + workflow: WorkflowDocument, + work: WorkIdentity, + ) -> None: + """Dataclass equality holds for two identically-built contexts.""" + + def build() -> RunCompleted: + return RunCompleted( + pipeline=pipeline, + decision=decision, + workflow=workflow, + composition=[CompositionStage(id="build", title="Build")], + provenance=["goga_tool_demo"], + work=work, + statuses=["done"], + runtime_dir="/runtime", + exit_code=3, + ) + + assert build() == build() diff --git a/tests/pipeline/hooks/test_identity.py b/tests/pipeline/hooks/test_identity.py index 8c327c01..99c0e32b 100644 --- a/tests/pipeline/hooks/test_identity.py +++ b/tests/pipeline/hooks/test_identity.py @@ -38,7 +38,14 @@ def test_entities_are_importable_from_the_zone_facade(self) -> None: assert zone.PipelineIdentity is PipelineIdentity assert zone.WorkflowDecision is WorkflowDecision assert zone.WorkIdentity is WorkIdentity - assert zone.__all__ == ["PipelineIdentity", "WorkIdentity", "WorkflowDecision"] + assert zone.__all__ == [ + "CompositionStage", + "PipelineIdentity", + "RunCompleted", + "RunCreated", + "WorkIdentity", + "WorkflowDecision", + ] def test_models_are_kw_only_dataclasses(self) -> None: """Positional construction raises ``TypeError`` for every model.""" From e50a693772d6186f296affe9dee98c4939366d71 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 18 Sep 2026 15:29:55 +0000 Subject: [PATCH 068/205] feat: add pipeline hooks zone authored-wins overlay with contract and logic tests --- .goga/history/2026/add-pipeline-hooks/plan.md | 16 +- goga/pipeline/hooks/__init__.py | 10 +- goga/pipeline/hooks/overlay.py | 297 ++++++++++++++++++ tests/pipeline/hooks/test_events.py | 6 + tests/pipeline/hooks/test_identity.py | 6 + tests/pipeline/hooks/test_overlay.py | 275 ++++++++++++++++ 6 files changed, 599 insertions(+), 11 deletions(-) create mode 100644 goga/pipeline/hooks/overlay.py create mode 100644 tests/pipeline/hooks/test_overlay.py diff --git a/.goga/history/2026/add-pipeline-hooks/plan.md b/.goga/history/2026/add-pipeline-hooks/plan.md index 82dd3c65..e8db92ee 100644 --- a/.goga/history/2026/add-pipeline-hooks/plan.md +++ b/.goga/history/2026/add-pipeline-hooks/plan.md @@ -644,21 +644,21 @@ contribution committed (unreachable past the empty short-circuit). **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: create `tests/pipeline/hooks/test_overlay.py`: +- [x] **Contract tests**: create `tests/pipeline/hooks/test_overlay.py`: - `ToolContribution`, `WorkflowOverlay`, `merge_workflow_overlay` importable from `goga.pipeline.hooks` (fails now — expected); - both models `kw_only` with the declared fields; - `merge_workflow_overlay` signature: parameters `base`, `contributions`, return `WorkflowOverlay` (inspect.signature). -- [ ] **Code**: create `goga/pipeline/hooks/overlay.py` — the two dataclasses and the +- [x] **Code**: create `goga/pipeline/hooks/overlay.py` — the two dataclasses and the merge implementing the algorithm above exactly (blank-line-joined prompt, whole-block memory, per-field stage merge with the SET table, authored-names-win extend, enumeration-order provenance, empty short-circuit returning the passed object). -- [ ] **Code**: add `ToolContribution`, `WorkflowOverlay`, `merge_workflow_overlay` to the +- [x] **Code**: add `ToolContribution`, `WorkflowOverlay`, `merge_workflow_overlay` to the facade `__init__.py` and `__all__` (alphabetical). -- [ ] **Interface verification**: `python -m pytest tests/pipeline/hooks/test_overlay.py -q` +- [x] **Interface verification**: `python -m pytest tests/pipeline/hooks/test_overlay.py -q` — contract tests pass. -- [ ] **Logic tests** (same file — the design's scenarios, verbatim): +- [x] **Logic tests** (same file — the design's scenarios, verbatim): ``` test_merge_prompt_concatenates_authored_first_then_tools @@ -771,13 +771,13 @@ contribution committed (unreachable past the empty short-circuit). merge(None, []).workflow is None ``` -- [ ] **Debugging**: `python -m pytest tests/pipeline/hooks -q` — fix implementation code +- [x] **Debugging**: `python -m pytest tests/pipeline/hooks -q` — fix implementation code until all tests pass (do NOT fix test code). -- [ ] **Contract re-verification**: purity (no input mutated anywhere — the assertions +- [x] **Contract re-verification**: purity (no input mutated anywhere — the assertions pin it), determinism, declarative result shape (`WorkflowDocument` with `stages` as `dict[str, WorkflowStage]`, `extend` as `dict[str, WorkflowExtendStage]`); facade exposes the three names. -- [ ] **Lint**: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting, apply decomposition if necessary. +- [x] **Lint**: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting, apply decomposition if necessary. ### Task 6: The amendment view — `amendments.py` (TDD coding) diff --git a/goga/pipeline/hooks/__init__.py b/goga/pipeline/hooks/__init__.py index 2c30099a..18577615 100644 --- a/goga/pipeline/hooks/__init__.py +++ b/goga/pipeline/hooks/__init__.py @@ -7,19 +7,23 @@ ``pipeline/run_created`` / ``pipeline/run_completed``. Built incrementally: each entity task adds its module's import and ``__all__`` -entry. With the identity models and the run-event contexts landed, the three -identity names and the three context names are re-exported here — six of the -eleven contract names. +entry. With the identity models, the run-event contexts, and the authored-wins +overlay landed, the three identity names, the three context names, and the +three overlay names are re-exported here — nine of the eleven contract names. """ from .contexts import CompositionStage, RunCompleted, RunCreated from .identity import PipelineIdentity, WorkflowDecision, WorkIdentity +from .overlay import ToolContribution, WorkflowOverlay, merge_workflow_overlay __all__: list[str] = [ "CompositionStage", "PipelineIdentity", "RunCompleted", "RunCreated", + "ToolContribution", "WorkIdentity", "WorkflowDecision", + "WorkflowOverlay", + "merge_workflow_overlay", ] diff --git a/goga/pipeline/hooks/overlay.py b/goga/pipeline/hooks/overlay.py new file mode 100644 index 00000000..060427c2 --- /dev/null +++ b/goga/pipeline/hooks/overlay.py @@ -0,0 +1,297 @@ +"""The authored-wins workflow overlay — composing the effective workflow. + +Two data models and one Routine make up the overlay layer of the zone: +``ToolContribution`` (one tool's committed contribution), ``WorkflowOverlay`` +(the composed effective workflow plus the committed-tool provenance), and +``merge_workflow_overlay`` (the pure authored-wins composition of the two). +Authored intent wins per slot — a tool never overrides what the project +author wrote; it fills what the author left unset and adds what the author +never named. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ..workflow import WorkflowDocument, WorkflowExtendStage, WorkflowMemory, WorkflowStage + +_STAGE_FIELDS: tuple[str, ...] = ( + "agent", + "prompt", + "loop", + "skills", + "skip", + "approve", + "manual", + "notes", + "reflect", + "memory", +) +"""The ``WorkflowStage`` field set in declaration order.""" + +_STAGE_DEFAULTS: dict[str, object] = dict.fromkeys(_STAGE_FIELDS) +_STAGE_DEFAULTS["skip"] = False +"""The unset stage shape — every field ``None`` except ``skip=False``.""" + + +@dataclass(kw_only=True) +class ToolContribution: + """One tool's committed contribution to the effective workflow. + + Args: + tool: the platform-assigned identity of the contributing tool. + document: the tool's declarative contribution — instructions only, + carried verbatim into the merge. + """ + + tool: str + document: WorkflowDocument + + +@dataclass(kw_only=True) +class WorkflowOverlay: + """The composed effective workflow and its committed-tool provenance. + + Args: + workflow: the effective workflow — the authored document with the + committed contributions applied — or ``None`` only in the + passthrough case (no authored workflow and nothing committed). + provenance: the tools whose contributions committed, in enumeration + order. + """ + + workflow: WorkflowDocument | None + provenance: list[str] + + +def _is_set(field: str, value: object) -> bool: + """Report whether a stage field carries authored intent (the SET table). + + Every field is set when its value is not ``None`` — including the + three-state ``manual``, where both ``True`` (force) and ``False`` + (explicit cancel) are intent. ``skip`` is the exception: ``False`` is the + model default and means "not skipped", so only a positive ``skip=True`` + counts as set. + + Args: + field: the ``WorkflowStage`` field name. + value: the field value to classify. + + Returns: + Whether the value represents authored intent. + """ + if field == "skip": + return value is True + + return value is not None + + +def _merge_stage( + base: WorkflowDocument | None, + name: str, + contributions: list[ToolContribution], +) -> WorkflowStage: + """Merge one stage name over the authored entry and every contribution. + + The authored entry's set fields never yield; unset fields take the later + contributing tool's value; a name with no authored entry starts from the + unset shape and is fully tool-defined. A fresh :class:`WorkflowStage` is + built regardless — the inputs are never mutated. + + Args: + base: the authored workflow, or ``None`` when no workflow resolved. + name: the stage name to merge. + contributions: the committed contributions, in enumeration order. + + Returns: + The merged stage for ``name``. + """ + authored = base.stages.get(name) if base is not None else None + + if authored is not None: + values: dict[str, object] = {field: getattr(authored, field) for field in _STAGE_FIELDS} + else: + values = dict(_STAGE_DEFAULTS) + + authored_set = {field: _is_set(field, values[field]) for field in _STAGE_FIELDS} + + for contribution in contributions: + tool_stage = contribution.document.stages.get(name) + + if tool_stage is None: + continue + + for field in _STAGE_FIELDS: + if _is_set(field, getattr(tool_stage, field)) and not authored_set[field]: + values[field] = getattr(tool_stage, field) + + return WorkflowStage(**values) # type: ignore[arg-type] + + +def _merge_prompt(base: WorkflowDocument | None, contributions: list[ToolContribution]) -> str | None: + """Join the non-empty prompt texts with a single blank line. + + Authored first, then the contributions in enumeration order; every empty + text is dropped. ``None`` when no text survives. + + Args: + base: the authored workflow, or ``None`` when no workflow resolved. + contributions: the committed contributions, in enumeration order. + + Returns: + The merged top-level prompt, or ``None``. + """ + texts: list[str] = [] + + if base is not None and base.prompt: + texts.append(base.prompt) + + for contribution in contributions: + if contribution.document.prompt: + texts.append(contribution.document.prompt) + + return "\n\n".join(texts) or None + + +def _merge_memory(base: WorkflowDocument | None, contributions: list[ToolContribution]) -> WorkflowMemory | None: + """Resolve the whole-block memory — authored unbeatable, else later tool. + + No field-level merging: the authored block wins outright when present; + otherwise the LATER contributing tool's block wins (last assignment). + + Args: + base: the authored workflow, or ``None`` when no workflow resolved. + contributions: the committed contributions, in enumeration order. + + Returns: + The effective memory configuration, or ``None``. + """ + memory = base.memory if base is not None else None + + if memory is None: + for contribution in contributions: + if contribution.document.memory is not None: + memory = contribution.document.memory + + return memory + + +def _merge_stages(base: WorkflowDocument | None, contributions: list[ToolContribution]) -> dict[str, WorkflowStage]: + """Merge every stage name — authored names first, then fresh names. + + The name order is deterministic: the authored names in their map order, + then the fresh names in first-appearance order across the contributions. + + Args: + base: the authored workflow, or ``None`` when no workflow resolved. + contributions: the committed contributions, in enumeration order. + + Returns: + The merged stages map. + """ + names: list[str] = list(base.stages) if base is not None else [] + + for contribution in contributions: + for name in contribution.document.stages: + if name not in names: + names.append(name) + + return {name: _merge_stage(base, name, contributions) for name in names} + + +def _merge_extend( + base: WorkflowDocument | None, + contributions: list[ToolContribution], +) -> dict[str, WorkflowExtendStage]: + """Merge the extend maps — authored names win, among tools later wins. + + The authored entries are kept verbatim; a contribution entry under an + authored name is dropped; a fresh name takes the later contributing + tool's entry. + + Args: + base: the authored workflow, or ``None`` when no workflow resolved. + contributions: the committed contributions, in enumeration order. + + Returns: + The merged extend map. + """ + extend: dict[str, WorkflowExtendStage] = dict(base.extend) if base is not None else {} + + for contribution in contributions: + for name, entry in contribution.document.extend.items(): + if base is not None and name in base.extend: + continue + + extend[name] = entry + + return extend + + +def merge_workflow_overlay( + base: WorkflowDocument | None, + contributions: list[ToolContribution], +) -> WorkflowOverlay: + """Compose the effective workflow from the authored base and the contributions. + + The authored-wins merge, per slot: + + - Empty ``contributions`` is the passthrough — the passed workflow + object itself returns with empty provenance (the no-tool-packages + guarantee: zero rebuild, byte-identical behavior). + - ``prompt`` joins the non-empty texts with a single blank line — + authored first, then the contributions in enumeration order. + - ``memory`` is whole-block: the authored block when present is + unbeatable; otherwise the LATER contributing tool's block wins. No + field-level merging. + - ``stages`` merge per name (authored names first, then fresh names + in appearance order): authored-set fields never yield, unset fields + take the later contributing tool's value, and a name with no + authored entry is fully tool-defined. + - ``extend`` keeps the authored entries; a contribution entry under + an authored name is dropped; among tools the later entry wins per + name. + - ``provenance`` is the contributing tools in enumeration order. + + The merge is pure — every merged stage and the merged document are new + instances (field values by reference, the repo's shallow-copy + convention); ``base``, the contributions, and their maps are never + mutated. No filesystem access, fully deterministic, and the result stays + declarative: it compiles through the unchanged ``compile_flow``. + + Requirements: + - Do not mutate ``base``, the contributions, or their maps. + - Do not raise — invalid shapes cannot occur (committed + contributions are non-empty by construction; structural validation + of the merged result belongs to ``compile_flow``). + - ``workflow`` is ``None`` only when ``base`` is ``None`` and no + contribution committed — a ``None`` workflow with a non-empty + provenance never occurs. + + Args: + base: the authored workflow post decision and post runner-skip merge, + or ``None`` when no workflow resolved. + contributions: the committed tool contributions, in enumeration + order. + + Returns: + The :class:`WorkflowOverlay` — the effective workflow and the + committed-tool provenance. + """ + # Step 1 — empty contributions: the passthrough. The passed workflow + # object itself, zero rebuild, empty provenance. + if not contributions: + return WorkflowOverlay(workflow=base, provenance=[]) + + # Steps 2-5 — the authored-wins merge per slot: prompt, memory, stages, + # extend. Each helper is pure; the composed document is a new instance. + # Step 6 — provenance: the contributing tools in enumeration order. + return WorkflowOverlay( + workflow=WorkflowDocument( + prompt=_merge_prompt(base, contributions), + stages=_merge_stages(base, contributions), + extend=_merge_extend(base, contributions), + memory=_merge_memory(base, contributions), + ), + provenance=[contribution.tool for contribution in contributions], + ) diff --git a/tests/pipeline/hooks/test_events.py b/tests/pipeline/hooks/test_events.py index d060662a..0c44a9bf 100644 --- a/tests/pipeline/hooks/test_events.py +++ b/tests/pipeline/hooks/test_events.py @@ -87,13 +87,19 @@ def test_entities_are_importable_from_the_zone_facade(self) -> None: assert zone.CompositionStage is CompositionStage assert zone.RunCreated is RunCreated assert zone.RunCompleted is RunCompleted + # The facade grows incrementally — the overlay task added its three + # names after these six; Task 7 completes the surface to the eleven + # contract names. assert zone.__all__ == [ "CompositionStage", "PipelineIdentity", "RunCompleted", "RunCreated", + "ToolContribution", "WorkIdentity", "WorkflowDecision", + "WorkflowOverlay", + "merge_workflow_overlay", ] def test_models_are_kw_only_dataclasses(self) -> None: diff --git a/tests/pipeline/hooks/test_identity.py b/tests/pipeline/hooks/test_identity.py index 99c0e32b..6c7a8534 100644 --- a/tests/pipeline/hooks/test_identity.py +++ b/tests/pipeline/hooks/test_identity.py @@ -38,13 +38,19 @@ def test_entities_are_importable_from_the_zone_facade(self) -> None: assert zone.PipelineIdentity is PipelineIdentity assert zone.WorkflowDecision is WorkflowDecision assert zone.WorkIdentity is WorkIdentity + # The facade grows incrementally — the overlay task added its three + # names after these six; Task 7 completes the surface to the eleven + # contract names. assert zone.__all__ == [ "CompositionStage", "PipelineIdentity", "RunCompleted", "RunCreated", + "ToolContribution", "WorkIdentity", "WorkflowDecision", + "WorkflowOverlay", + "merge_workflow_overlay", ] def test_models_are_kw_only_dataclasses(self) -> None: diff --git a/tests/pipeline/hooks/test_overlay.py b/tests/pipeline/hooks/test_overlay.py new file mode 100644 index 00000000..2a747bea --- /dev/null +++ b/tests/pipeline/hooks/test_overlay.py @@ -0,0 +1,275 @@ +"""Contract and logic tests for the entities declared in +``goga/pipeline/hooks/CODEMANIFEST`` with ``location: overlay.py``: + +- ``ToolContribution(tool, document)`` — one tool's committed contribution +- ``WorkflowOverlay(workflow, provenance)`` — the composed effective workflow +- ``merge_workflow_overlay(base, contributions)`` — the authored-wins merge + +Supported data and pure functions only — no mocks, no filesystem: the merge is +pure (inputs never mutated, new instances returned), deterministic, and stays +declarative (the result compiles through the unchanged ``compile_flow``). +""" + +from __future__ import annotations + +import dataclasses +import inspect + +import pytest +from goga.pipeline.hooks import ToolContribution, WorkflowOverlay, merge_workflow_overlay +from goga.pipeline.workflow import ( + WorkflowDocument, + WorkflowExtendStage, + WorkflowMemory, + WorkflowStage, +) + +from tests.conftest import is_kw_only_dataclass + + +def _field_defaults(cls: type) -> list[tuple[str, object]]: + """(name, default) per declared field — ``MISSING`` for required fields.""" + return [(field.name, field.default) for field in dataclasses.fields(cls)] + + +# --- Contract tests --- + + +class TestOverlayContract: + def test_entities_are_importable_from_the_zone_facade(self) -> None: + """All three names live on the zone package and its ``__all__``.""" + import goga.pipeline.hooks as zone + + assert zone.ToolContribution is ToolContribution + assert zone.WorkflowOverlay is WorkflowOverlay + assert zone.merge_workflow_overlay is merge_workflow_overlay + for name in ("ToolContribution", "WorkflowOverlay", "merge_workflow_overlay"): + assert name in zone.__all__ + + def test_models_are_kw_only_dataclasses(self) -> None: + """Positional construction raises ``TypeError`` for both models.""" + for cls in (ToolContribution, WorkflowOverlay): + assert dataclasses.is_dataclass(cls) + assert is_kw_only_dataclass(cls) + + with pytest.raises(TypeError): + ToolContribution("t1", WorkflowDocument()) # type: ignore[misc] + + with pytest.raises(TypeError): + WorkflowOverlay(None, []) # type: ignore[misc] + + def test_models_carry_exactly_the_declared_fields(self) -> None: + """``tool, document`` and ``workflow, provenance`` — no defaults.""" + assert _field_defaults(ToolContribution) == [ + ("tool", dataclasses.MISSING), + ("document", dataclasses.MISSING), + ] + assert _field_defaults(WorkflowOverlay) == [ + ("workflow", dataclasses.MISSING), + ("provenance", dataclasses.MISSING), + ] + + def test_merge_signature_matches_the_contract(self) -> None: + """Parameters ``base, contributions``; return annotation ``WorkflowOverlay``.""" + signature = inspect.signature(merge_workflow_overlay) + + assert list(signature.parameters) == ["base", "contributions"] + # With `from __future__ import annotations` the annotation is a string; + # without it, it is the evaluated class. Accept either form. + assert signature.return_annotation in ("WorkflowOverlay", WorkflowOverlay) + + +# --- Logic tests (the design's scenarios, verbatim) --- + + +class TestMergeWorkflowOverlay: + def test_merge_prompt_concatenates_authored_first_then_tools(self) -> None: + """Non-empty texts join with a single blank line, authored first, tools in order.""" + base = WorkflowDocument(prompt="authored") + contributions = [ + ToolContribution(tool="t1", document=WorkflowDocument(prompt="one")), + ToolContribution(tool="t2", document=WorkflowDocument(prompt="two")), + ] + + overlay = merge_workflow_overlay(base, contributions) + + assert overlay.workflow is not None + assert overlay.workflow.prompt == "authored\n\none\n\ntwo" + assert overlay.provenance == ["t1", "t2"] + + def test_merge_stage_fields_fill_only_unset_later_tool_wins(self) -> None: + """Authored-set fields block every tool; unset fields take the later tool's value.""" + base = WorkflowDocument(stages={"build": WorkflowStage(agent="author-agent", loop=2)}) + contributions = [ + ToolContribution( + tool="t1", + document=WorkflowDocument(stages={"build": WorkflowStage(agent="t1-agent", skills=["s1"])}), + ), + ToolContribution( + tool="t2", + document=WorkflowDocument(stages={"build": WorkflowStage(agent="t2-agent", loop=5)}), + ), + ] + + overlay = merge_workflow_overlay(base, contributions) + + assert overlay.workflow is not None + assert overlay.workflow.stages["build"].agent == "author-agent" + assert overlay.workflow.stages["build"].loop == 2 + assert overlay.workflow.stages["build"].skills == ["s1"] + assert base.stages["build"].skills is None # purity — input untouched + + def test_merge_skip_false_overrides_nothing_authored_skip_unbeatable(self) -> None: + """Only a positive skip is authored intent; a fresh name is fully tool-defined.""" + base = WorkflowDocument(stages={"build": WorkflowStage(skip=True)}) + contributions = [ + ToolContribution( + tool="t1", + document=WorkflowDocument( + stages={"build": WorkflowStage(skip=False), "deploy": WorkflowStage(skip=True)}, + ), + ), + ] + + overlay = merge_workflow_overlay(base, contributions) + + assert overlay.workflow is not None + assert overlay.workflow.stages["build"].skip is True + assert overlay.workflow.stages["deploy"].skip is True + + def test_merge_memory_authored_block_unbeatable_and_later_tool_wins(self) -> None: + """Memory is whole-block: authored kept; else the later tool's block.""" + case_a = merge_workflow_overlay( + WorkflowDocument(memory=WorkflowMemory(max_rules=5)), + [ToolContribution(tool="t1", document=WorkflowDocument(memory=WorkflowMemory(max_rules=99)))], + ) + case_b = merge_workflow_overlay( + WorkflowDocument(prompt="authored"), + [ + ToolContribution(tool="t1", document=WorkflowDocument(memory=WorkflowMemory(max_rules=7))), + ToolContribution(tool="t2", document=WorkflowDocument(memory=WorkflowMemory(max_rules=9))), + ], + ) + + assert case_a.workflow is not None + assert case_a.workflow.memory is not None + assert case_a.workflow.memory.max_rules == 5 + assert case_b.workflow is not None + assert case_b.workflow.memory is not None + assert case_b.workflow.memory.max_rules == 9 + + def test_merge_extend_authored_names_win_and_later_tool_wins(self) -> None: + """A contribution under an authored name is dropped; fresh names: later tool wins.""" + base = WorkflowDocument(extend={"audit": WorkflowExtendStage(after=["build"], body={"title": "Audit"})}) + contributions = [ + ToolContribution( + tool="t1", + document=WorkflowDocument( + extend={ + "audit": WorkflowExtendStage(before=["build"], body={"title": "X"}), + "notify": WorkflowExtendStage(after=["deploy"], body={"title": "N1"}), + }, + ), + ), + ToolContribution( + tool="t2", + document=WorkflowDocument( + extend={"notify": WorkflowExtendStage(after=["audit"], body={"title": "N2"})}, + ), + ), + ] + + overlay = merge_workflow_overlay(base, contributions) + + assert overlay.workflow is not None + assert overlay.workflow.extend["audit"].after == ["build"] + assert overlay.workflow.extend["notify"].after == ["audit"] + assert len(overlay.workflow.extend) == 2 + assert base.extend["audit"].after == ["build"] # purity — input untouched + + def test_merge_empty_base_tools_build_the_document(self) -> None: + """A None base with committed contributions produces a real document.""" + contributions = [ + ToolContribution( + tool="t1", + document=WorkflowDocument(prompt="one", stages={"build": WorkflowStage(agent="a")}), + ), + ToolContribution(tool="t2", document=WorkflowDocument(prompt="two")), + ] + + overlay = merge_workflow_overlay(None, contributions) + + assert overlay.workflow is not None + assert overlay.workflow.prompt == "one\n\ntwo" + assert overlay.workflow.stages["build"].agent == "a" + assert overlay.provenance == ["t1", "t2"] + + def test_merge_passthrough_short_circuit_returns_base_object(self) -> None: + """Empty contributions return the passed workflow object itself — no rebuild.""" + base = WorkflowDocument(prompt="x") + + overlay = merge_workflow_overlay(base, []) + + assert overlay.workflow is base + assert overlay.provenance == [] + assert merge_workflow_overlay(None, []).workflow is None + + def test_merge_builds_new_instances_and_never_mutates_inputs(self) -> None: + """Purity — the result is new objects; base and contributions are untouched.""" + base_stage = WorkflowStage(agent="author-agent") + base = WorkflowDocument(prompt="authored", stages={"build": base_stage}) + contribution_stage = WorkflowStage(agent="t1-agent") + contribution = ToolContribution( + tool="t1", + document=WorkflowDocument(prompt="one", stages={"build": contribution_stage}), + ) + + overlay = merge_workflow_overlay(base, [contribution]) + + assert overlay.workflow is not None + assert overlay.workflow is not base + assert overlay.workflow.stages["build"] is not base_stage + assert overlay.workflow.stages["build"] is not contribution_stage + assert base.prompt == "authored" + assert base.stages == {"build": base_stage} + assert base_stage.agent == "author-agent" + assert contribution.document.prompt == "one" + assert contribution.document.stages == {"build": contribution_stage} + + def test_merge_authored_names_order_first_then_fresh_names_in_order(self) -> None: + """Deterministic stage order — authored names first, fresh names in appearance order.""" + base = WorkflowDocument(stages={"build": WorkflowStage(agent="author")}) + contributions = [ + ToolContribution(tool="t1", document=WorkflowDocument(stages={"scan": WorkflowStage(loop=1)})), + ToolContribution( + tool="t2", + document=WorkflowDocument(stages={"audit": WorkflowStage(loop=2), "scan": WorkflowStage(loop=3)}), + ), + ] + + overlay = merge_workflow_overlay(base, contributions) + + assert overlay.workflow is not None + assert list(overlay.workflow.stages) == ["build", "scan", "audit"] + assert overlay.workflow.stages["scan"].loop == 3 # later tool wins on the fresh name + assert overlay.workflow.stages["audit"].loop == 2 + + def test_merge_empty_prompt_texts_are_dropped(self) -> None: + """An empty-string prompt contributes nothing — the join drops empties.""" + overlay = merge_workflow_overlay( + WorkflowDocument(prompt="authored"), + [ToolContribution(tool="t1", document=WorkflowDocument(prompt=""))], + ) + + assert overlay.workflow is not None + assert overlay.workflow.prompt == "authored" + + def test_merge_prompt_none_when_no_text_survives(self) -> None: + """No authored prompt and no tool prompt — the merged prompt is ``None``.""" + overlay = merge_workflow_overlay( + None, + [ToolContribution(tool="t1", document=WorkflowDocument(stages={"build": WorkflowStage(agent="a")}))], + ) + + assert overlay.workflow is not None + assert overlay.workflow.prompt is None From 25e57fb5f4b9dd8f9b72a495cf40a299393473e0 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 18 Sep 2026 15:33:18 +0000 Subject: [PATCH 069/205] feat: add pipeline hooks zone amendment view with contract and logic tests --- .goga/history/2026/add-pipeline-hooks/plan.md | 16 +- goga/pipeline/hooks/__init__.py | 9 +- goga/pipeline/hooks/amendments.py | 58 ++++ tests/pipeline/hooks/test_amendments.py | 247 ++++++++++++++++++ tests/pipeline/hooks/test_events.py | 5 +- tests/pipeline/hooks/test_identity.py | 5 +- 6 files changed, 325 insertions(+), 15 deletions(-) create mode 100644 goga/pipeline/hooks/amendments.py create mode 100644 tests/pipeline/hooks/test_amendments.py diff --git a/.goga/history/2026/add-pipeline-hooks/plan.md b/.goga/history/2026/add-pipeline-hooks/plan.md index e8db92ee..777c55b7 100644 --- a/.goga/history/2026/add-pipeline-hooks/plan.md +++ b/.goga/history/2026/add-pipeline-hooks/plan.md @@ -808,26 +808,26 @@ view). **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: create `tests/pipeline/hooks/test_amendments.py`: +- [x] **Contract tests**: create `tests/pipeline/hooks/test_amendments.py`: - `WorkflowAmendment` importable from `goga.pipeline.hooks` (fails now — expected); - `kw_only`, fields exactly `pipeline, decision, workflow, work`; - `contribute` is a public method with signature `(document)`; - `_contribution` is `init=False`, default `None`, excluded from `repr`. -- [ ] **Code**: create `goga/pipeline/hooks/amendments.py` (imports: +- [x] **Code**: create `goga/pipeline/hooks/amendments.py` (imports: `from .identity import PipelineIdentity, WorkflowDecision, WorkIdentity`; `from ..workflow import WorkflowDocument`). -- [ ] **Code**: add `WorkflowAmendment` to the facade `__init__.py` and `__all__`. -- [ ] **Interface verification**: `python -m pytest tests/pipeline/hooks/test_amendments.py -q` +- [x] **Code**: add `WorkflowAmendment` to the facade `__init__.py` and `__all__`. +- [x] **Interface verification**: `python -m pytest tests/pipeline/hooks/test_amendments.py -q` — all pass. -- [ ] **Logic tests**: `contribute(WorkflowDocument(prompt="a"))` sets the buffer; +- [x] **Logic tests**: `contribute(WorkflowDocument(prompt="a"))` sets the buffer; a second `contribute(WorkflowDocument(prompt="b"))` replaces it whole (`_contribution.prompt == "b"`); a fresh view starts with `_contribution is None`; `contribute` returns `None`. -- [ ] **Debugging**: `python -m pytest tests/pipeline/hooks -q` — fix implementation code +- [x] **Debugging**: `python -m pytest tests/pipeline/hooks -q` — fix implementation code until all tests pass (do NOT fix test code). -- [ ] **Contract re-verification**: no staged-application state (the four fields are the +- [x] **Contract re-verification**: no staged-application state (the four fields are the constructor facts, unchanged by `contribute`); the buffer belongs to this view alone. -- [ ] **Lint**: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting, apply decomposition if necessary. +- [x] **Lint**: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting, apply decomposition if necessary. ### Task 7: The checkpoint surface — `events.py` + facade completion (TDD coding) diff --git a/goga/pipeline/hooks/__init__.py b/goga/pipeline/hooks/__init__.py index 18577615..b071863c 100644 --- a/goga/pipeline/hooks/__init__.py +++ b/goga/pipeline/hooks/__init__.py @@ -7,11 +7,13 @@ ``pipeline/run_created`` / ``pipeline/run_completed``. Built incrementally: each entity task adds its module's import and ``__all__`` -entry. With the identity models, the run-event contexts, and the authored-wins -overlay landed, the three identity names, the three context names, and the -three overlay names are re-exported here — nine of the eleven contract names. +entry. With the identity models, the run-event contexts, the authored-wins +overlay, and the amendment view landed, the three identity names, the three +context names, the three overlay names, and the amendment view are +re-exported here — ten of the eleven contract names. """ +from .amendments import WorkflowAmendment from .contexts import CompositionStage, RunCompleted, RunCreated from .identity import PipelineIdentity, WorkflowDecision, WorkIdentity from .overlay import ToolContribution, WorkflowOverlay, merge_workflow_overlay @@ -23,6 +25,7 @@ "RunCreated", "ToolContribution", "WorkIdentity", + "WorkflowAmendment", "WorkflowDecision", "WorkflowOverlay", "merge_workflow_overlay", diff --git a/goga/pipeline/hooks/amendments.py b/goga/pipeline/hooks/amendments.py new file mode 100644 index 00000000..6f82bbf2 --- /dev/null +++ b/goga/pipeline/hooks/amendments.py @@ -0,0 +1,58 @@ +"""The amendment view of the pipeline domain — the read-and-contribute view. + +``WorkflowAmendment`` is the context one tool receives at the hard +``pipeline/amend_workflow`` checkpoint: the delivered facts of the +composition (the pipeline identity, the workflow decision, the original +authored workflow, and the current work identity) plus the buffer of that +one tool's contribution. The view is read-and-contribute — reads deliver +the original facts (no staged-application state exists, a tool never sees +another tool's contribution), and :meth:`contribute` is the only write +channel, buffering one declarative document until the delivery commits it. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from ..workflow import WorkflowDocument +from .identity import PipelineIdentity, WorkflowDecision, WorkIdentity + + +@dataclass(kw_only=True) +class WorkflowAmendment: + """The read-and-contribute view of one tool at the amendment checkpoint. + + Args: + pipeline: the identity of the pipeline being composed. + decision: the workflow decision of the operation. + workflow: the original authored workflow — post decision, post + runner-skip merge, pre-layer; read-only and identical for + every tool; ``None`` when no workflow resolved. + work: the current work identity. + """ + + pipeline: PipelineIdentity + decision: WorkflowDecision + workflow: WorkflowDocument | None + work: WorkIdentity + + _contribution: WorkflowDocument | None = field(init=False, default=None, repr=False) + + def contribute(self, document: WorkflowDocument) -> None: + """Buffer one declarative contribution of this tool. + + The replacement is whole — a later call replaces the earlier + buffered document. No validation lives here: a bad document + surfaces at the consumer, and an empty document (no prompt, no + stages, no extend, no memory) is discarded by the delivery with a + warning. The call changes nothing until the delivery commits it — + it does not cancel, redirect, or defer the operation. + + Args: + document: the complete contribution — a + :class:`~goga.pipeline.workflow.WorkflowDocument`-shaped + set of instructions (prompt, stages, extend, memory) using + the same vocabulary an authored workflow-file uses. + """ + + self._contribution = document diff --git a/tests/pipeline/hooks/test_amendments.py b/tests/pipeline/hooks/test_amendments.py new file mode 100644 index 00000000..d9f429bb --- /dev/null +++ b/tests/pipeline/hooks/test_amendments.py @@ -0,0 +1,247 @@ +"""Contract and logic tests for the entity declared in +``goga/pipeline/hooks/CODEMANIFEST`` with ``location: amendments.py``: + +- ``WorkflowAmendment(pipeline, decision, workflow, work)`` — the + read-and-contribute view of one tool: the delivered facts of the + amendment checkpoint plus the buffer of one tool's contribution + +Supported data only — no mocks, no filesystem: the view carries the +constructor facts verbatim and buffers the one contribution of this tool +alone. Task 7 delivers the view through the platform proxy. +""" + +from __future__ import annotations + +import dataclasses +import inspect + +import pytest +from goga.pipeline.hooks import ( + PipelineIdentity, + WorkflowAmendment, + WorkflowDecision, + WorkIdentity, +) +from goga.pipeline.workflow import WorkflowDocument + +from tests.conftest import is_kw_only_dataclass + + +def _field_defaults(cls: type) -> list[tuple[str, object]]: + """(name, default) per declared field — ``MISSING`` for required fields.""" + return [(field.name, field.default) for field in dataclasses.fields(cls)] + + +def _field(cls: type, name: str) -> dataclasses.Field: + """The declared field metadata of ``name``.""" + for field in dataclasses.fields(cls): + if field.name == name: + return field + + raise AssertionError(f"{cls.__name__} carries no field {name!r}") + + +@pytest.fixture +def pipeline() -> PipelineIdentity: + """The identity of the pipeline being composed.""" + return PipelineIdentity( + name="deploy", + display_name="Deploy the service", + description="Ships the service", + source="project", + ) + + +@pytest.fixture +def decision() -> WorkflowDecision: + """The workflow decision of the operation.""" + return WorkflowDecision(kind="auto-match", workflow_name="deploy") + + +@pytest.fixture +def workflow() -> WorkflowDocument: + """The original authored workflow — pre-layer, identical for every tool.""" + return WorkflowDocument(prompt="authored") + + +@pytest.fixture +def work() -> WorkIdentity: + """The current work identity.""" + return WorkIdentity(branch="feature-demo", slug="feature-demo", year="2026") + + +@pytest.fixture +def view( + pipeline: PipelineIdentity, + decision: WorkflowDecision, + workflow: WorkflowDocument, + work: WorkIdentity, +) -> WorkflowAmendment: + """The read-and-contribute view of one tool.""" + return WorkflowAmendment( + pipeline=pipeline, + decision=decision, + workflow=workflow, + work=work, + ) + + +# --- Contract tests --- + + +class TestAmendmentContract: + def test_entity_is_importable_from_the_zone_facade(self) -> None: + """The view lives on the zone package and its ``__all__`` is exact.""" + import goga.pipeline.hooks as zone + + assert zone.WorkflowAmendment is WorkflowAmendment + # The facade grows incrementally — the amendments task added the + # tenth name; Task 7 completes the surface to the eleven contract + # names. + assert zone.__all__ == [ + "CompositionStage", + "PipelineIdentity", + "RunCompleted", + "RunCreated", + "ToolContribution", + "WorkIdentity", + "WorkflowAmendment", + "WorkflowDecision", + "WorkflowOverlay", + "merge_workflow_overlay", + ] + + def test_model_is_a_kw_only_dataclass(self) -> None: + """Positional construction raises ``TypeError``.""" + assert dataclasses.is_dataclass(WorkflowAmendment) + assert is_kw_only_dataclass(WorkflowAmendment) + + with pytest.raises(TypeError): + WorkflowAmendment( # type: ignore[misc] + PipelineIdentity(name="deploy", description="d", source="project"), + WorkflowDecision(kind="explicit", workflow_name="ci"), + None, + WorkIdentity(branch="b"), + ) + + def test_view_carries_exactly_the_declared_fields(self) -> None: + """``pipeline, decision, workflow, work`` — names, order, no defaults.""" + init_defaults = [ + (name, default) for name, default in _field_defaults(WorkflowAmendment) if name != "_contribution" + ] + + assert init_defaults == [ + ("pipeline", dataclasses.MISSING), + ("decision", dataclasses.MISSING), + ("workflow", dataclasses.MISSING), + ("work", dataclasses.MISSING), + ] + assert [field.name for field in dataclasses.fields(WorkflowAmendment)][:-1] == [ + "pipeline", + "decision", + "workflow", + "work", + ] + + def test_contribution_buffer_is_private_init_false_default_none(self) -> None: + """``_contribution`` — not constructor surface, not repr, starts ``None``.""" + buffer = _field(WorkflowAmendment, "_contribution") + + assert buffer.init is False + assert buffer.default is None + assert buffer.repr is False + + def test_contribute_is_a_public_method_taking_document(self) -> None: + """``contribute(document)`` — one parameter, no return value promised.""" + assert callable(WorkflowAmendment.contribute) + assert not WorkflowAmendment.contribute.__name__.startswith("_") + + signature = inspect.signature(WorkflowAmendment.contribute) + + assert list(signature.parameters) == ["self", "document"] + + +# --- Logic tests --- + + +class TestContribute: + def test_fresh_view_starts_with_an_empty_buffer( + self, + view: WorkflowAmendment, + ) -> None: + """A view that has not contributed yet carries no buffered document.""" + assert view._contribution is None + + def test_contribute_sets_the_buffer(self, view: WorkflowAmendment) -> None: + """The buffered document is the exact object this tool contributed.""" + document = WorkflowDocument(prompt="a") + + view.contribute(document) + + assert view._contribution is document + assert view._contribution.prompt == "a" + + def test_second_contribute_replaces_the_buffer_whole(self, view: WorkflowAmendment) -> None: + """Whole replacement — a later call replaces the earlier document.""" + view.contribute(WorkflowDocument(prompt="a")) + replacement = WorkflowDocument(prompt="b") + + view.contribute(replacement) + + assert view._contribution is replacement + assert view._contribution.prompt == "b" + + def test_contribute_returns_none(self, view: WorkflowAmendment) -> None: + """The call buffers silently — no value returns.""" + assert view.contribute(WorkflowDocument(prompt="a")) is None + + +class TestViewFacts: + def test_constructor_facts_round_trip_verbatim( + self, + view: WorkflowAmendment, + pipeline: PipelineIdentity, + decision: WorkflowDecision, + workflow: WorkflowDocument, + work: WorkIdentity, + ) -> None: + """The reads deliver the original facts by reference.""" + assert view.pipeline is pipeline + assert view.decision is decision + assert view.workflow is workflow + assert view.work is work + + def test_workflow_none_serves_the_unresolved_moment(self) -> None: + """``workflow`` is ``None`` when no workflow resolved.""" + view = WorkflowAmendment( + pipeline=PipelineIdentity(name="deploy", description="d", source="project"), + decision=WorkflowDecision(kind="silent-miss", workflow_name=None), + workflow=None, + work=WorkIdentity(branch="b"), + ) + + assert view.workflow is None + + def test_contribute_changes_no_constructor_fact(self, view: WorkflowAmendment) -> None: + """No staged-application state — the four facts stay the constructor's.""" + facts_before = (view.pipeline, view.decision, view.workflow, view.work) + + view.contribute(WorkflowDocument(prompt="a")) + + assert (view.pipeline, view.decision, view.workflow, view.work) == facts_before + + def test_buffer_belongs_to_this_view_alone( + self, + pipeline: PipelineIdentity, + decision: WorkflowDecision, + workflow: WorkflowDocument, + work: WorkIdentity, + ) -> None: + """Two identically-built views never share a buffer — per-tool state.""" + first = WorkflowAmendment(pipeline=pipeline, decision=decision, workflow=workflow, work=work) + second = WorkflowAmendment(pipeline=pipeline, decision=decision, workflow=workflow, work=work) + + first.contribute(WorkflowDocument(prompt="mine")) + + assert first._contribution is not None + assert second._contribution is None diff --git a/tests/pipeline/hooks/test_events.py b/tests/pipeline/hooks/test_events.py index 0c44a9bf..f2f1fa88 100644 --- a/tests/pipeline/hooks/test_events.py +++ b/tests/pipeline/hooks/test_events.py @@ -88,8 +88,8 @@ def test_entities_are_importable_from_the_zone_facade(self) -> None: assert zone.RunCreated is RunCreated assert zone.RunCompleted is RunCompleted # The facade grows incrementally — the overlay task added its three - # names after these six; Task 7 completes the surface to the eleven - # contract names. + # names after these six, the amendments task the tenth; Task 7 + # completes the surface to the eleven contract names. assert zone.__all__ == [ "CompositionStage", "PipelineIdentity", @@ -97,6 +97,7 @@ def test_entities_are_importable_from_the_zone_facade(self) -> None: "RunCreated", "ToolContribution", "WorkIdentity", + "WorkflowAmendment", "WorkflowDecision", "WorkflowOverlay", "merge_workflow_overlay", diff --git a/tests/pipeline/hooks/test_identity.py b/tests/pipeline/hooks/test_identity.py index 6c7a8534..d359cce8 100644 --- a/tests/pipeline/hooks/test_identity.py +++ b/tests/pipeline/hooks/test_identity.py @@ -39,8 +39,8 @@ def test_entities_are_importable_from_the_zone_facade(self) -> None: assert zone.WorkflowDecision is WorkflowDecision assert zone.WorkIdentity is WorkIdentity # The facade grows incrementally — the overlay task added its three - # names after these six; Task 7 completes the surface to the eleven - # contract names. + # names after these six, the amendments task the tenth; Task 7 + # completes the surface to the eleven contract names. assert zone.__all__ == [ "CompositionStage", "PipelineIdentity", @@ -48,6 +48,7 @@ def test_entities_are_importable_from_the_zone_facade(self) -> None: "RunCreated", "ToolContribution", "WorkIdentity", + "WorkflowAmendment", "WorkflowDecision", "WorkflowOverlay", "merge_workflow_overlay", From 1bdb40ff4b74f7c80c2288193f220ca4a5abd3c5 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 18 Sep 2026 18:37:37 +0300 Subject: [PATCH 070/205] fix: permissions for afm home --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index 38e5cd1d..0cb5cbc0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -57,6 +57,8 @@ ENV GOGA_DOCKER=1 ENV RALPHEX_DOCKER=1 ENV AFM_IN_DOCKER=1 +RUN install -d -o goga -g goga -m 0755 / home/goga/.afm + USER goga WORKDIR /workspace From 4645b52a6fbd35598056d7f72a216399d49a42a6 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 18 Sep 2026 15:41:14 +0000 Subject: [PATCH 071/205] feat: add pipeline hooks checkpoint surface with contract and logic tests --- .goga/history/2026/add-pipeline-hooks/plan.md | 16 +- goga/pipeline/hooks/__init__.py | 11 +- goga/pipeline/hooks/events.py | 275 ++++++++++++++++ tests/pipeline/hooks/test_amendments.py | 5 +- tests/pipeline/hooks/test_events.py | 296 ++++++++++++++++-- tests/pipeline/hooks/test_identity.py | 6 +- 6 files changed, 572 insertions(+), 37 deletions(-) create mode 100644 goga/pipeline/hooks/events.py diff --git a/.goga/history/2026/add-pipeline-hooks/plan.md b/.goga/history/2026/add-pipeline-hooks/plan.md index 777c55b7..42c25cb3 100644 --- a/.goga/history/2026/add-pipeline-hooks/plan.md +++ b/.goga/history/2026/add-pipeline-hooks/plan.md @@ -908,7 +908,7 @@ completes the facade to exactly the 11 contract names. **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: extend `tests/pipeline/hooks/test_events.py`: +- [x] **Contract tests**: extend `tests/pipeline/hooks/test_events.py`: - `PipelineHooks` importable from the facade; methods `amend_workflow`, `emit_run_created`, `emit_run_completed` exist with the declared signatures (`inspect.signature`, `self` excluded); @@ -928,12 +928,12 @@ completes the facade to exactly the 11 contract names. each name importable from the package root ``` -- [ ] **Code**: create `goga/pipeline/hooks/events.py` implementing the algorithms above +- [x] **Code**: create `goga/pipeline/hooks/events.py` implementing the algorithms above (`logger = logging.getLogger(__name__)`; `mock`-free; relative imports only). -- [ ] **Code**: complete the facade `__init__.py` to the 11 names. -- [ ] **Interface verification**: `python -m pytest tests/pipeline/hooks/test_events.py -q` +- [x] **Code**: complete the facade `__init__.py` to the 11 names. +- [x] **Interface verification**: `python -m pytest tests/pipeline/hooks/test_events.py -q` — all pass. -- [ ] **Logic tests** (same file; tool-package simulation via +- [x] **Logic tests** (same file; tool-package simulation via `pin_package_environment({"goga_tool_demo": ["demo-dist"]})` + `install_tool_package("goga_tool_demo", register_hooks=...)` — the platform code under test runs for real; design scenarios verbatim): @@ -1001,13 +1001,13 @@ completes the facade to exactly the 11 contract names. caplog has exactly one discard warning (names tool #1) ``` -- [ ] **Debugging**: `python -m pytest tests/pipeline/hooks -q` — fix implementation code +- [x] **Debugging**: `python -m pytest tests/pipeline/hooks -q` — fix implementation code until all tests pass (do NOT fix test code). -- [ ] **Contract re-verification**: commit granularity is the tool; an address without +- [x] **Contract re-verification**: commit granularity is the tool; an address without subscriptions returns the passthrough overlay (the passed workflow, empty provenance); no repository/filesystem reads at any checkpoint; one registry per instance across amendment + emissions; the facade is exactly the 11 names. -- [ ] **Lint**: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting, apply decomposition if necessary. +- [x] **Lint**: `python -m ruff check goga/pipeline/hooks tests/pipeline/hooks && python -m ruff format --check goga/pipeline/hooks tests/pipeline/hooks` — fix formatting, apply decomposition if necessary. ### Task 8: `PipelineCard.provenance` (TDD coding) diff --git a/goga/pipeline/hooks/__init__.py b/goga/pipeline/hooks/__init__.py index b071863c..039c6795 100644 --- a/goga/pipeline/hooks/__init__.py +++ b/goga/pipeline/hooks/__init__.py @@ -6,20 +6,21 @@ action ``pipeline/amend_workflow`` and the two soft notifications ``pipeline/run_created`` / ``pipeline/run_completed``. -Built incrementally: each entity task adds its module's import and ``__all__`` -entry. With the identity models, the run-event contexts, the authored-wins -overlay, and the amendment view landed, the three identity names, the three -context names, the three overlay names, and the amendment view are -re-exported here — ten of the eleven contract names. +Built incrementally: each entity task added its module's import and +``__all__`` entry. With the identity models, the run-event contexts, the +authored-wins overlay, the amendment view, and the checkpoint surface landed, +the eleven contract names of the zone are re-exported here. """ from .amendments import WorkflowAmendment from .contexts import CompositionStage, RunCompleted, RunCreated +from .events import PipelineHooks from .identity import PipelineIdentity, WorkflowDecision, WorkIdentity from .overlay import ToolContribution, WorkflowOverlay, merge_workflow_overlay __all__: list[str] = [ "CompositionStage", + "PipelineHooks", "PipelineIdentity", "RunCompleted", "RunCreated", diff --git a/goga/pipeline/hooks/events.py b/goga/pipeline/hooks/events.py new file mode 100644 index 00000000..a2f02731 --- /dev/null +++ b/goga/pipeline/hooks/events.py @@ -0,0 +1,275 @@ +"""The checkpoint surface of the pipeline domain — the events cell of the zone. + +The entity declared in the cell CODEMANIFEST with ``location: events.py``: +``PipelineHooks`` — the amendment delivery and the two run notifications of +the pipeline flows over the platform facade. Construction is cheap and every +context is built from the values the caller passes; one lazily-built run +registry carries every checkpoint of a command, so the package enumeration +happens once per run whatever the number of checkpoints. The amendment is +the platform's first hard action — the first failing tool stops the command +and its whole contribution is discarded — while the two notifications are +soft fire-and-forget emissions: a failing hook warns inside the platform +and never affects the run. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from ...hooks import ( + HookRegistry, + build_hook_arguments, + declared_actions, + emit_hook_event, + wrap_context, +) +from .amendments import WorkflowAmendment +from .contexts import CompositionStage, RunCompleted, RunCreated +from .identity import PipelineIdentity, WorkflowDecision, WorkIdentity +from .overlay import ToolContribution, WorkflowOverlay, merge_workflow_overlay + +if TYPE_CHECKING: # the workflow document is a context fact the zone models carry + from ..workflow import WorkflowDocument + +logger = logging.getLogger(__name__) + + +class PipelineHooks: + """The checkpoint surface of the pipeline domain. + + Owns the single run registry shared by the amendment delivery and the + two run notifications, and drives the delivery per tool with staged + commit over the public primitives of the hooks platform. Tools are + mutually blind — every amendment view reads the same original authored + workflow, never a staged state; a tool's contribution commits only + after every hook of the tool succeeded. + + Requirements: + - Cheap construction — no enumeration and no imports happen at + construction + - One ``HookRegistry`` per run carries every checkpoint of a + command — the assembly runs once per run whatever the number of + checkpoints + - Every context is built from the values the caller passes — no + repository reads happen at a checkpoint + """ + + def __init__(self) -> None: + """Create the checkpoint surface of one command. + + Nothing is enumerated and nothing is imported: the run registry + builds lazily on the first checkpoint that needs it. + """ + self._registry: HookRegistry | None = None + + def _ensure_registry(self) -> HookRegistry: + """Build the run registry once — the shared state of every checkpoint. + + Returns: + The assembled registry of the run — built on the first call and + reused by every checkpoint; never rebuilt on the same surface. + + Raises: + ImportError: A tool package exists but its facade fails to + import — the single fatal case; the message names the + package. + """ + if self._registry is None: + registry = HookRegistry() + registry.build_once() + self._registry = registry + + return self._registry + + def amend_workflow( + self, + pipeline: PipelineIdentity, + decision: WorkflowDecision, + workflow: WorkflowDocument | None, + work: WorkIdentity, + ) -> WorkflowOverlay: + """Deliver the workflow-amendment checkpoint and return the overlay. + + Algorithm: + 1. Resolve the address ``pipeline.amend_workflow`` against + ``declared_actions`` — an unknown address is a clean error + of the emitting side + 2. Walk the subscriptions of the address per tool in + enumeration order: build the tool's ``WorkflowAmendment`` + view over the delivered facts — every tool reads the same + original ``workflow`` — wrap it via ``wrap_context``, + project the call arguments via ``build_hook_arguments`` + with the tool's own context, and call each hook of the tool + 3. A tool whose every hook returned without raising and whose + buffer carries a non-empty contribution commits as one + ``ToolContribution`` + 4. A tool with a raising hook is a hard failure: a clean error + naming the hook, the tool, and the action stops the command + at the first failure; the tool's whole contribution is + discarded together with its view + 5. A tool whose buffered document is empty — no prompt, no + stages, no extend, no memory — is a content no-op: a + warning naming the tool, the contribution discarded, the + walk continues + 6. Merge the committed contributions onto ``workflow`` via + ``merge_workflow_overlay`` and return the overlay — an + address without subscriptions returns the passthrough + overlay + + Args: + pipeline: The identity of the pipeline being composed. + decision: The workflow decision of the operation. + workflow: The authored workflow after the decision and the + runner-skip merge; ``None`` when no workflow resolved. + work: The current work identity. + + Returns: + The :class:`~goga.pipeline.hooks.WorkflowOverlay` — the + effective workflow and the contributing tools. + + Raises: + ValueError: The address is not declared, or a hook of the + hard action failed — the message names the hook, the + tool, and the reason. + """ + registry = self._ensure_registry() + + record = next( + (entry for entry in declared_actions() if entry.domain == "pipeline" and entry.name == "amend_workflow"), + None, + ) + if record is None: + raise ValueError("unknown hook action: pipeline.amend_workflow") + + groups: dict[str, list] = {} + for subscription in registry.subscriptions_for("pipeline", "amend_workflow"): + groups.setdefault(subscription.tool, []).append(subscription) + + contributions: list[ToolContribution] = [] + + for tool, subscriptions in groups.items(): + # A fresh view per tool — fresh facts, fresh buffer; the view + # dies with the tool when a hook fails, taking the buffer with it. + amendment = WorkflowAmendment(pipeline=pipeline, decision=decision, workflow=workflow, work=work) + proxy = wrap_context(amendment) + + for subscription in subscriptions: + try: + subscription.hook(**build_hook_arguments(subscription.hook, proxy, registry.self_context(tool))) + except Exception as reason: + # Hard: stop at the first failure. The message copies the + # platform's format — hook name, tool, address, reason. + raise ValueError( + f"hook {subscription.name} of tool {tool} failed on pipeline.amend_workflow: {reason}" + ) from reason + + if amendment._contribution is None: + continue # never contributed — silent + + document = amendment._contribution + if document.prompt is None and not document.stages and not document.extend and document.memory is None: + logger.warning( + "tool %s contributed an empty document to pipeline.amend_workflow: discarded", + tool, + ) + continue + + contributions.append(ToolContribution(tool=tool, document=document)) + + return merge_workflow_overlay(workflow, contributions) + + # The parameter list is fixed by the cell contract — the CODEMANIFEST + # declares every fact the context carries. + def emit_run_created( # noqa: PLR0913, PLR0917 + self, + pipeline: PipelineIdentity, + decision: WorkflowDecision, + overlay: WorkflowOverlay, + composition: list[CompositionStage], + work: WorkIdentity, + statuses: list[str], + runtime_dir: str, + ) -> None: + """Emit the run-creation notification — the facts before the launch. + + Fire-and-forget: nothing is collected and no value returns. A + failing hook is skipped with a warning under the soft error class + of the action — the launch proceeds. + + Args: + pipeline: The identity of the running pipeline. + decision: The workflow decision of the operation. + overlay: The amendment result — the effective workflow and + the provenance. + composition: The ordered stages of the final composition. + work: The current work identity. + statuses: The maximal present statuses at the moment. + runtime_dir: The run's runtime directory as a posix string. + """ + context = RunCreated( + pipeline=pipeline, + decision=decision, + workflow=overlay.workflow, + composition=composition, + provenance=overlay.provenance, + work=work, + statuses=statuses, + runtime_dir=runtime_dir, + ) + + emit_hook_event( + self._ensure_registry(), + "pipeline", + "run_created", + context_for=lambda _tool: context, + ) + + def emit_run_completed( # noqa: PLR0913, PLR0917 + self, + pipeline: PipelineIdentity, + decision: WorkflowDecision, + overlay: WorkflowOverlay, + composition: list[CompositionStage], + work: WorkIdentity, + statuses: list[str], + runtime_dir: str, + exit_code: int, + ) -> None: + """Emit the run-completion notification — the finished attempt's facts. + + Fire-and-forget: nothing is collected and no value returns. The + emission happens on every launch-attempt return path — zero, + non-zero, and spawn failures alike — and a failing hook warns + under the soft error class: the exit code of the run is never + affected. + + Args: + pipeline: The identity of the running pipeline. + decision: The workflow decision of the operation. + overlay: The amendment result of the run. + composition: The ordered stages of the executed composition. + work: The current work identity. + statuses: The maximal present statuses recomputed at the + completion moment. + runtime_dir: The run's runtime directory as a posix string. + exit_code: The actual exit code of the launch attempt. + """ + context = RunCompleted( + pipeline=pipeline, + decision=decision, + workflow=overlay.workflow, + composition=composition, + provenance=overlay.provenance, + work=work, + statuses=statuses, + runtime_dir=runtime_dir, + exit_code=exit_code, + ) + + emit_hook_event( + self._ensure_registry(), + "pipeline", + "run_completed", + context_for=lambda _tool: context, + ) diff --git a/tests/pipeline/hooks/test_amendments.py b/tests/pipeline/hooks/test_amendments.py index d9f429bb..55c2d5ec 100644 --- a/tests/pipeline/hooks/test_amendments.py +++ b/tests/pipeline/hooks/test_amendments.py @@ -95,11 +95,12 @@ def test_entity_is_importable_from_the_zone_facade(self) -> None: import goga.pipeline.hooks as zone assert zone.WorkflowAmendment is WorkflowAmendment - # The facade grows incrementally — the amendments task added the - # tenth name; Task 7 completes the surface to the eleven contract + # The facade grew incrementally through the zone tasks; the + # checkpoint-surface task completed it to the eleven contract # names. assert zone.__all__ == [ "CompositionStage", + "PipelineHooks", "PipelineIdentity", "RunCompleted", "RunCreated", diff --git a/tests/pipeline/hooks/test_events.py b/tests/pipeline/hooks/test_events.py index f2f1fa88..b9f3e6f1 100644 --- a/tests/pipeline/hooks/test_events.py +++ b/tests/pipeline/hooks/test_events.py @@ -1,5 +1,6 @@ """Contract and logic tests for the entities declared in -``goga/pipeline/hooks/CODEMANIFEST`` with ``location: contexts.py``: +``goga/pipeline/hooks/CODEMANIFEST`` with ``location: contexts.py`` and +``location: events.py``: - ``CompositionStage(id, title)`` — one row of the final composition, as the card shows it @@ -7,23 +8,30 @@ notification, the facts of the composed moment - ``RunCompleted(...)`` — the same facts recomputed at the completion moment, plus the launch attempt's ``exit_code`` +- ``PipelineHooks()`` — the checkpoint surface delivering the hard + amendment and emitting the two soft notifications -Supported data only — no mocks, no filesystem: the models are pure fact -carriers (a hook observes and cannot alter), every resolution happens in -the constructing operation. Task 7 appends the delivery/emission classes. +The context models are supported data only. The checkpoint surface runs +for real over the platform boundary fixtures of ``tests/hooks/conftest.py`` +(re-exported by the zone test package) — the registry, the registrars, and +the delivery execute the actual platform code. """ from __future__ import annotations import dataclasses +import inspect +import logging import pytest from goga.pipeline.hooks import ( CompositionStage, + PipelineHooks, PipelineIdentity, RunCompleted, RunCreated, WorkflowDecision, + WorkflowOverlay, WorkIdentity, ) from goga.pipeline.workflow import WorkflowDocument @@ -41,6 +49,21 @@ ("runtime_dir", dataclasses.MISSING), ] +_ZONE_ALL: list[str] = [ + "CompositionStage", + "PipelineHooks", + "PipelineIdentity", + "RunCompleted", + "RunCreated", + "ToolContribution", + "WorkIdentity", + "WorkflowAmendment", + "WorkflowDecision", + "WorkflowOverlay", + "merge_workflow_overlay", +] +"""The completed zone facade — exactly the eleven contract names.""" + def _field_defaults(cls: type) -> list[tuple[str, object]]: """(name, default) per declared field — ``MISSING`` for required fields.""" @@ -87,21 +110,10 @@ def test_entities_are_importable_from_the_zone_facade(self) -> None: assert zone.CompositionStage is CompositionStage assert zone.RunCreated is RunCreated assert zone.RunCompleted is RunCompleted - # The facade grows incrementally — the overlay task added its three - # names after these six, the amendments task the tenth; Task 7 - # completes the surface to the eleven contract names. - assert zone.__all__ == [ - "CompositionStage", - "PipelineIdentity", - "RunCompleted", - "RunCreated", - "ToolContribution", - "WorkIdentity", - "WorkflowAmendment", - "WorkflowDecision", - "WorkflowOverlay", - "merge_workflow_overlay", - ] + # The facade grew incrementally through the zone tasks; Task 7 + # (the checkpoint surface) completed it to the eleven contract + # names. + assert zone.__all__ == _ZONE_ALL def test_models_are_kw_only_dataclasses(self) -> None: """Positional construction raises ``TypeError`` for every model.""" @@ -299,3 +311,249 @@ def build() -> RunCompleted: ) assert build() == build() + + +# --- Task 7: the checkpoint surface — contract tests --- + + +class TestCheckpointContract: + def test_zone_facade_exports_exactly_the_contract(self) -> None: + """The facade IS the contract surface — the eleven names, importable.""" + import goga.pipeline.hooks as zone + + assert zone.PipelineHooks is PipelineHooks + assert sorted(zone.__all__) == sorted(_ZONE_ALL) + assert zone.__all__ == _ZONE_ALL + + for name in zone.__all__: + assert getattr(zone, name, None) is not None, name + + def test_surface_carries_the_declared_method_signatures(self) -> None: + """Every checkpoint takes exactly the declared parameters.""" + assert list(inspect.signature(PipelineHooks.amend_workflow).parameters) == [ + "self", + "pipeline", + "decision", + "workflow", + "work", + ] + assert list(inspect.signature(PipelineHooks.emit_run_created).parameters) == [ + "self", + "pipeline", + "decision", + "overlay", + "composition", + "work", + "statuses", + "runtime_dir", + ] + assert list(inspect.signature(PipelineHooks.emit_run_completed).parameters) == [ + "self", + "pipeline", + "decision", + "overlay", + "composition", + "work", + "statuses", + "runtime_dir", + "exit_code", + ] + + def test_construction_enumerates_nothing(self, pin_package_environment) -> None: + """Cheap construction — the package environment stays unread.""" + boundary = pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + PipelineHooks() + + assert boundary.call_count == 0 + + +# --- Task 7: the checkpoint surface — logic tests (real platform) --- + + +def _facts() -> tuple[PipelineIdentity, WorkflowDecision, WorkIdentity]: + """The amendment facts of a project deploy pipeline on a plain branch.""" + return ( + PipelineIdentity(name="deploy", description="Ships the service", source="project"), + WorkflowDecision(kind="auto-match", workflow_name="deploy"), + WorkIdentity(branch="b"), + ) + + +class TestAmendWorkflowDelivery: + def test_amend_workflow_commits_per_tool_and_merges( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """Two tools commit one contribution each; both read the same original.""" + boundary = pin_package_environment( + { + "goga_tool_demo": ["demo-dist"], + "goga_tool_second": ["second-dist"], + } + ) + + def register_demo(hooks: object) -> None: + def hardening(self: object, context: object) -> None: + self.first_seen_workflow_id = id(context.workflow) + self.seen_pipeline_name = context.pipeline.name + self.seen_decision_kind = context.decision.kind + context.contribute(WorkflowDocument(prompt="harden")) + + hooks.subscribe("pipeline", "amend_workflow", "hardening", hardening) # type: ignore[attr-defined] + + def register_second(hooks: object) -> None: + def softening(self: object, context: object) -> None: + context.contribute(WorkflowDocument(prompt="second")) + self.second_seen_workflow_id = id(context.workflow) + self.second_seen_prompt = context.workflow.prompt + + hooks.subscribe("pipeline", "amend_workflow", "softening", softening) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register_demo) + install_tool_package("goga_tool_second", register_hooks=register_second) + + pipeline, decision, work = _facts() + authored = WorkflowDocument(prompt="authored") + surface = PipelineHooks() + + overlay = surface.amend_workflow( + pipeline=pipeline, + decision=decision, + workflow=authored, + work=work, + ) + + assert boundary.call_count == 1 # the real enumeration, one build + assert overlay.workflow is not None + assert overlay.workflow.prompt == "authored\n\nharden\n\nsecond" + # The platform derives the identities from the package names: + # goga_tool_demo -> demo, goga_tool_second -> second. + assert overlay.provenance == ["demo", "second"] + + # The facts each tool recorded in its own self context: the reads + # delivered the amendment view, not the staged merge. + demo_context = surface._registry.self_context("demo") + second_context = surface._registry.self_context("second") + + assert demo_context.seen_pipeline_name == "deploy" + assert demo_context.seen_decision_kind == "auto-match" + assert demo_context.first_seen_workflow_id == second_context.second_seen_workflow_id + assert second_context.second_seen_prompt == "authored" + + def test_amend_workflow_registry_built_once_across_checkpoints( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """The amendment and the completion share one registry build.""" + boundary = pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + def register_both(hooks: object) -> None: + def hardening(self: object, context: object) -> None: + return None + + def notify(self: object, context: object) -> None: + return None + + hooks.subscribe("pipeline", "amend_workflow", "hardening", hardening) # type: ignore[attr-defined] + hooks.subscribe("pipeline", "run_completed", "notify", notify) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register_both) + + pipeline, decision, work = _facts() + hooks = PipelineHooks() + + hooks.amend_workflow(pipeline=pipeline, decision=decision, workflow=None, work=work) + hooks.emit_run_completed( + pipeline=pipeline, + decision=decision, + overlay=WorkflowOverlay(workflow=None, provenance=[]), + composition=[], + work=work, + statuses=[], + runtime_dir="/runtime", + exit_code=0, + ) + + assert boundary.call_count == 1 + + def test_amend_workflow_hard_failure_stops_command_and_discards( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """The first failing tool stops the walk — later tools never run.""" + pin_package_environment({"goga_tool_demo": ["demo-dist"], "goga_tool_second": ["second-dist"]}) + witnesses: list[str] = [] + + def register_boom(hooks: object) -> None: + def hardening(self: object, context: object) -> None: + context.contribute(WorkflowDocument(prompt="x")) + raise RuntimeError("boom") + + hooks.subscribe("pipeline", "amend_workflow", "hardening", hardening) # type: ignore[attr-defined] + + def register_witness(hooks: object) -> None: + def softening(self: object, context: object) -> None: + witnesses.append("second-called") + + hooks.subscribe("pipeline", "amend_workflow", "softening", softening) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register_boom) + install_tool_package("goga_tool_second", register_hooks=register_witness) + + pipeline, decision, work = _facts() + + with pytest.raises(ValueError, match=r"pipeline\.amend_workflow: boom"): + PipelineHooks().amend_workflow( + pipeline=pipeline, + decision=decision, + workflow=WorkflowDocument(prompt="authored"), + work=work, + ) + + assert witnesses == [] # tool #2 never called; no overlay returned + + def test_empty_contribution_discarded_with_warning_silent_tool_ok( + self, + pin_package_environment, + install_tool_package, + caplog: pytest.LogCaptureFixture, + ) -> None: + """An empty buffer is a discard with one warning; no buffer is silent.""" + pin_package_environment({"goga_tool_demo": ["demo-dist"], "goga_tool_second": ["second-dist"]}) + + def register_empty(hooks: object) -> None: + def empty(self: object, context: object) -> None: + context.contribute(WorkflowDocument()) + + hooks.subscribe("pipeline", "amend_workflow", "empty", empty) # type: ignore[attr-defined] + + def register_silent(hooks: object) -> None: + def silent(self: object, context: object) -> None: + return None + + hooks.subscribe("pipeline", "amend_workflow", "silent", silent) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register_empty) + install_tool_package("goga_tool_second", register_hooks=register_silent) + + pipeline, decision, work = _facts() + base = WorkflowDocument(prompt="a") + + with caplog.at_level(logging.WARNING, logger="goga.pipeline.hooks.events"): + overlay = PipelineHooks().amend_workflow( + pipeline=pipeline, + decision=decision, + workflow=base, + work=work, + ) + + assert overlay.workflow is base # the passthrough — nothing committed + assert overlay.provenance == [] + + discards = [record.getMessage() for record in caplog.records if "discarded" in record.getMessage()] + assert len(discards) == 1 + assert "demo" in discards[0] diff --git a/tests/pipeline/hooks/test_identity.py b/tests/pipeline/hooks/test_identity.py index d359cce8..04f52e13 100644 --- a/tests/pipeline/hooks/test_identity.py +++ b/tests/pipeline/hooks/test_identity.py @@ -38,11 +38,11 @@ def test_entities_are_importable_from_the_zone_facade(self) -> None: assert zone.PipelineIdentity is PipelineIdentity assert zone.WorkflowDecision is WorkflowDecision assert zone.WorkIdentity is WorkIdentity - # The facade grows incrementally — the overlay task added its three - # names after these six, the amendments task the tenth; Task 7 - # completes the surface to the eleven contract names. + # The facade grew incrementally through the zone tasks; the + # checkpoint-surface task completed it to the eleven contract names. assert zone.__all__ == [ "CompositionStage", + "PipelineHooks", "PipelineIdentity", "RunCompleted", "RunCreated", From dc0e11fd00ade5a076d0b17aef79bc06f2c610d6 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 18 Sep 2026 15:43:03 +0000 Subject: [PATCH 072/205] feat: add PipelineCard provenance field with contract and logic tests --- .goga/history/2026/add-pipeline-hooks/plan.md | 14 +++--- goga/pipeline/pipeline_card.py | 5 ++- tests/pipeline/test_pipeline_card.py | 43 ++++++++++++++++--- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/.goga/history/2026/add-pipeline-hooks/plan.md b/.goga/history/2026/add-pipeline-hooks/plan.md index 42c25cb3..7edff762 100644 --- a/.goga/history/2026/add-pipeline-hooks/plan.md +++ b/.goga/history/2026/add-pipeline-hooks/plan.md @@ -1025,14 +1025,14 @@ compile unchanged (the default); two cards never share the list (factory per ins **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: extend `tests/pipeline/test_pipeline_card.py`: +- [x] **Contract tests**: extend `tests/pipeline/test_pipeline_card.py`: - the `PipelineCard` field set is now exactly `name, description, stages, provenance`; - construction without `provenance` remains valid (existing tests already pin this — they must stay green unchanged: the regression proof of the additive default). -- [ ] **Code**: add the field + docstring line to `goga/pipeline/pipeline_card.py`. -- [ ] **Interface verification**: `python -m pytest tests/pipeline/test_pipeline_card.py -q` +- [x] **Code**: add the field + docstring line to `goga/pipeline/pipeline_card.py`. +- [x] **Interface verification**: `python -m pytest tests/pipeline/test_pipeline_card.py -q` — all pass. -- [ ] **Logic tests** (design scenario, verbatim): +- [x] **Logic tests** (design scenario, verbatim): ``` test_pipeline_card_provenance_default_factory_isolated @@ -1043,11 +1043,11 @@ compile unchanged (the default); two cards never share the list (factory per ins Assertions: card_b.provenance == [] ``` -- [ ] **Debugging**: `python -m pytest tests/pipeline/test_pipeline_card.py -q` — fix +- [x] **Debugging**: `python -m pytest tests/pipeline/test_pipeline_card.py -q` — fix implementation code until all tests pass (do NOT fix test code). -- [ ] **Contract re-verification**: `PipelineCard` remains a `kw_only` dataclass; the +- [x] **Contract re-verification**: `PipelineCard` remains a `kw_only` dataclass; the field order ends with `provenance`; the facade `goga.pipeline.PipelineCard` unchanged. -- [ ] **Lint**: `python -m ruff check goga/pipeline/pipeline_card.py tests/pipeline/test_pipeline_card.py && python -m ruff format --check goga/pipeline/pipeline_card.py tests/pipeline/test_pipeline_card.py` — fix formatting if necessary. +- [x] **Lint**: `python -m ruff check goga/pipeline/pipeline_card.py tests/pipeline/test_pipeline_card.py && python -m ruff format --check goga/pipeline/pipeline_card.py tests/pipeline/test_pipeline_card.py` — fix formatting if necessary. ### Task 9: The card form through the amendment layer — `describe_pipeline.py` (TDD coding) diff --git a/goga/pipeline/pipeline_card.py b/goga/pipeline/pipeline_card.py index f2cacad2..65fdf1df 100644 --- a/goga/pipeline/pipeline_card.py +++ b/goga/pipeline/pipeline_card.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field @dataclass(kw_only=True) @@ -35,8 +35,11 @@ class PipelineCard: name: author-facing pipeline name from the DSL header. description: author-facing pipeline description from the DSL header. stages: stage rows in execution order; may be empty. + provenance: tools whose contributions committed into the composition, + in enumeration order; empty when none contributed. """ name: str description: str stages: list[CardStage] + provenance: list[str] = field(default_factory=list) diff --git a/tests/pipeline/test_pipeline_card.py b/tests/pipeline/test_pipeline_card.py index 39f0fa87..ccc2530e 100644 --- a/tests/pipeline/test_pipeline_card.py +++ b/tests/pipeline/test_pipeline_card.py @@ -9,10 +9,14 @@ an empty ``stages`` list is a legitimate value. The ``stages`` order is part of the contract: it is the execution order produced by :func:`~goga.pipeline.order_stages.order_stages`, and nobody re-sorts it -afterwards. +afterwards. The card also carries ``provenance`` — the tools whose +contributions committed into the composition, in enumeration order — with a +per-instance default (``default_factory=list``): omitting it stays valid and +two cards never share the default list. Contract tests pin the surface (fields, required construction). Logic tests -cover dataclass equality of the stage rows and the preserved order. +cover dataclass equality of the stage rows, the preserved order, and the +isolated provenance default. """ from __future__ import annotations @@ -60,10 +64,20 @@ def test_pipeline_card_constructs_with_keyword_arguments(self) -> None: assert card.stages == [CardStage(id="build", title="Build")] def test_pipeline_card_exposes_declared_field_names(self) -> None: - """The dataclass declares exactly name, description, and stages.""" - fields = {f.name for f in PipelineCard.__dataclass_fields__.values()} + """The dataclass declares exactly name, description, stages, and provenance.""" + fields = [f.name for f in PipelineCard.__dataclass_fields__.values()] - assert fields == {"name", "description", "stages"} + assert fields == ["name", "description", "stages", "provenance"] + + def test_pipeline_card_constructs_without_provenance(self) -> None: + """provenance has a per-instance default: omitting it stays valid.""" + card = PipelineCard( + name="Deploy", + description="Deploy the service", + stages=[CardStage(id="build", title="Build")], + ) + + assert card.provenance == [] class TestPipelineCardLogic: @@ -90,3 +104,22 @@ def test_pipeline_card_preserves_stage_order_as_given(self) -> None: card = PipelineCard(name="Deploy", description="Deploy the service", stages=stages) assert [stage.id for stage in card.stages] == ["test", "build"] + + def test_pipeline_card_provenance_default_factory_isolated(self) -> None: + """Two default constructions carry independent provenance lists.""" + card_a = PipelineCard(name="a", description="d", stages=[]) + card_b = PipelineCard(name="a", description="d", stages=[]) + card_a.provenance.append("x") + + assert card_b.provenance == [] + + def test_pipeline_card_carries_given_provenance_verbatim(self) -> None: + """An explicit provenance list round-trips in the given order.""" + card = PipelineCard( + name="Deploy", + description="Deploy the service", + stages=[], + provenance=["t1", "t2"], + ) + + assert card.provenance == ["t1", "t2"] From fb579a9d26225b50abdbb3946361d0599aebccff Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 18 Sep 2026 15:55:15 +0000 Subject: [PATCH 073/205] feat: compose the pipeline card through the hooks amendment layer --- .goga/history/2026/add-pipeline-hooks/plan.md | 19 ++- goga/pipeline/describe_pipeline.py | 129 ++++++++++++++---- tests/pipeline/conftest.py | 2 + tests/pipeline/test_describe_pipeline.py | 111 ++++++++++++++- 4 files changed, 227 insertions(+), 34 deletions(-) diff --git a/.goga/history/2026/add-pipeline-hooks/plan.md b/.goga/history/2026/add-pipeline-hooks/plan.md index 7edff762..7c85e29c 100644 --- a/.goga/history/2026/add-pipeline-hooks/plan.md +++ b/.goga/history/2026/add-pipeline-hooks/plan.md @@ -1104,18 +1104,18 @@ resolve_topic_dir`). **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: extend `tests/pipeline/test_describe_pipeline.py` — the +- [x] **Contract tests**: extend `tests/pipeline/test_describe_pipeline.py` — the signature is unchanged (existing tests pin it); add the new-surface pin: the returned card carries `provenance == []` on the no-tools path (deterministic via `pin_package_environment({})` — the registry builds empty, the overlay is the passthrough). -- [ ] **Code**: rewire `goga/pipeline/describe_pipeline.py` to the 7 steps above +- [x] **Code**: rewire `goga/pipeline/describe_pipeline.py` to the 7 steps above (hooks instance scoped to the call; `logger.debug` may add provenance/composition — additive, debug level). -- [ ] **Interface verification**: `python -m pytest tests/pipeline/test_describe_pipeline.py -q` +- [x] **Interface verification**: `python -m pytest tests/pipeline/test_describe_pipeline.py -q` — all pass, including the pre-existing tests (they pin the no-tools composition and must stay green). -- [ ] **Logic tests** (design scenarios, verbatim): +- [x] **Logic tests** (design scenarios, verbatim): ``` test_describe_pipeline_reports_provenance_through_same_layer @@ -1154,13 +1154,18 @@ resolve_topic_dir`). no exception ``` -- [ ] **Debugging**: `python -m pytest tests/pipeline/test_describe_pipeline.py tests/pipeline -q` + (Both scenarios live in `tests/pipeline/test_describe_pipeline.py` as + `TestDescribePipelineAmendmentLayer`, running the real platform over the + `tests/hooks/conftest.py` boundary fixtures re-exported by + `tests/pipeline/conftest.py`; the disabled scenario's workflow adds a skip + directive so "raw composition" is falsifiable.) +- [x] **Debugging**: `python -m pytest tests/pipeline/test_describe_pipeline.py tests/pipeline -q` — fix implementation code until all tests pass (do NOT fix test code). -- [ ] **Contract re-verification**: the stage composition equals the composition a run of +- [x] **Contract re-verification**: the stage composition equals the composition a run of the same pipeline with the same workflow flags would execute; the silent auto-match miss keeps the layer active onto the empty base; `GOGA_SKIP_STAGES` never read; the temp flow-file lives outside the project and runtime directories and is removed. -- [ ] **Lint**: `python -m ruff check goga/pipeline/describe_pipeline.py tests/pipeline/test_describe_pipeline.py && python -m ruff format --check goga/pipeline/describe_pipeline.py tests/pipeline/test_describe_pipeline.py` — fix formatting, apply decomposition if necessary. +- [x] **Lint**: `python -m ruff check goga/pipeline/describe_pipeline.py tests/pipeline/test_describe_pipeline.py && python -m ruff format --check goga/pipeline/describe_pipeline.py tests/pipeline/test_describe_pipeline.py` — fix formatting, apply decomposition if necessary. ### Task 10: The run form through the amendment layer — `run_pipeline.py` (TDD coding) diff --git a/goga/pipeline/describe_pipeline.py b/goga/pipeline/describe_pipeline.py index 82a5856c..8581947b 100644 --- a/goga/pipeline/describe_pipeline.py +++ b/goga/pipeline/describe_pipeline.py @@ -3,13 +3,19 @@ Composes the informational card of ONE pipeline: the author-facing name/description from the DSL header plus the post-workflow stage composition in execution order. The card deliberately shares the run path's machinery — -the same :func:`~goga.pipeline.resolve_workflow.resolve_workflow` rule set and -the same :func:`~goga.pipeline.compiler.compile_flow` compiler — so the -composition the card reports is structurally the composition the run executes. -Workflow ``skip`` directives therefore apply (they are compiler directives), -loop copies appear as separate ``NAME-1..N`` rows, while the run-only -``GOGA_SKIP_STAGES`` environment variable is NOT read — the card answers -"what is this pipeline?", not "what would this particular run skip?". +the same :func:`~goga.pipeline.resolve_workflow.resolve_workflow` rule set, +the same amendment layer of the pipeline hooks zone, and the same +:func:`~goga.pipeline.compiler.compile_flow` compiler — so the composition +the card reports is structurally the composition the run executes: the +workflow the amendment layer returns is the workflow compiled here, and the +tools the card's ``provenance`` lists are exactly the tools a run with the +same workflow flags would compose through. Workflow ``skip`` directives +therefore apply (they are compiler directives), loop copies appear as +separate ``NAME-1..N`` rows, while the run-only ``GOGA_SKIP_STAGES`` +environment variable is NOT read — the card answers "what is this +pipeline?", not "what would this particular run skip?". No run events fire +in card form: the amendment is delivered (unless the workflow decision is +disabled) but neither notification is emitted. The compiled flow-file is written to a throwaway temp directory (never the project tree or a runtime directory) and removed once the card is composed. @@ -21,7 +27,9 @@ import tempfile from pathlib import Path -from .compiler import compile_flow +from ..history import resolve_current_branch_name, resolve_topic_dir +from .compiler import compile_flow, parse_dsl +from .hooks import PipelineHooks, PipelineIdentity, WorkflowDecision, WorkflowOverlay, WorkIdentity from .list_pipelines import list_pipelines from .order_stages import order_stages from .pipeline_card import CardStage, PipelineCard @@ -44,17 +52,26 @@ def describe_pipeline( source wins on conflicts); an unknown name raises ``RuntimeError``. The optional workflow is resolved through the shared rule set (:func:`resolve_workflow` — ``no_workflow`` > explicit ``workflow`` > - basename auto-match, silent miss), the pipeline is compiled through the - real :func:`compile_flow` machine into a temp flow-file (no ``root_dir`` / - ``project_name`` — they only affect discarded top-level keys, never the - stages), and the compiled stages are ordered by - :func:`~goga.pipeline.order_stages.order_stages` into execution order. - ``name``/``description`` are the author-facing header values (they may - differ from the discovered file stem). + basename auto-match, silent miss). The amendment facts are then resolved — + the :class:`~goga.pipeline.hooks.PipelineIdentity` from one early + ``parse_dsl`` header read, the + :class:`~goga.pipeline.hooks.WorkflowDecision` from the flags and the + resolution outcome, and the :class:`~goga.pipeline.hooks.WorkIdentity` + from the current git branch and its hosting topic directory — and the + amendment is delivered through :class:`~goga.pipeline.hooks.PipelineHooks` + unless the decision is disabled (a disabled decision delivers nothing and + composes over the passthrough overlay). The pipeline is compiled through + the real :func:`compile_flow` machine into a temp flow-file (no + ``root_dir`` / ``project_name`` — they only affect discarded top-level + keys, never the stages) with the overlay workflow, and the compiled + stages are ordered by :func:`~goga.pipeline.order_stages.order_stages` + into execution order. ``name``/``description`` are the author-facing + header values (they may differ from the discovered file stem) and come + from the documents tuple — never a re-parse of the pipeline-file. Nothing is executed: no afm invocation, no stage run, no prompt - materialization. The temp flow-file is the only write and is removed on - routine exit. + materialization, no run events. The temp flow-file is the only write and + is removed on routine exit. Args: name: pipeline name without extension (e.g. ``"deploy"``) — the @@ -69,7 +86,9 @@ def describe_pipeline( name). Returns: - The composed :class:`~goga.pipeline.pipeline_card.PipelineCard`. + The composed :class:`~goga.pipeline.pipeline_card.PipelineCard` — its + ``provenance`` carries the tools whose contributions committed into + the composition, in enumeration order. Raises: RuntimeError: If no discovered pipeline carries ``name`` (message @@ -78,11 +97,17 @@ def describe_pipeline( workflow-file, propagated unchanged from ``parse_workflow`` via ``resolve_workflow``. StructuralError: On a structural defect in the pipeline DSL, - propagated unchanged from ``compile_flow``. + propagated unchanged from ``parse_dsl`` / ``compile_flow``. yaml.YAMLError: If the pipeline-file is not valid YAML, propagated - unchanged from ``compile_flow``. + unchanged from ``parse_dsl`` / ``compile_flow``. OSError: If the pipeline-file cannot be read or the temp flow-file cannot be written, propagated unchanged. + ValueError: If a hook of the hard ``pipeline.amend_workflow`` action + failed during the delivery — the message names the hook, the + tool, and the action; propagated unchanged from the checkpoint + surface. + ImportError: If a tool package exists but its facade fails to import + — the fatal registry-build error, propagated unchanged. """ # Step 1 — locate the pipeline by name (project source wins on conflicts). entries = list_pipelines(project_dir, user_dir) @@ -98,26 +123,78 @@ def describe_pipeline( # same resolver the run path uses, so card composition == run composition). workflow_doc = resolve_workflow(name, workflow, no_workflow) - # Step 3 — compile through the real machine into a throwaway temp dir. + # Step 3 — resolve the amendment facts and deliver the amendment. One + # early ``parse_dsl`` read serves the identity facts; the card fields of + # step 6 come from the documents tuple, never a re-parse. + header, _, _ = parse_dsl(pipeline_path.read_text()) + identity = PipelineIdentity( + name=match.name, + display_name=header.name, + description=header.description, + source=match.source.value, + ) + + # The kind-derivation matrix — the outcome of the resolution the rule set + # performed but does not report: disabled wins, a resolved document under + # an explicit name is "explicit", under no name "auto-match", and no + # document (explicit-missing / auto-miss / containment escape) is a + # silent miss. + if no_workflow: + decision = WorkflowDecision(kind="disabled", workflow_name=None) + elif workflow_doc is None: + decision = WorkflowDecision(kind="silent-miss", workflow_name=None) + elif workflow not in (None, ""): + decision = WorkflowDecision(kind="explicit", workflow_name=workflow) + else: + decision = WorkflowDecision(kind="auto-match", workflow_name=name) + + branch = resolve_current_branch_name() or "unknown" + try: + topic_dir = resolve_topic_dir(branch) + except ValueError: + topic_dir = None # a fully unsluggable branch hosts no topic + + if topic_dir is not None and topic_dir.is_dir(): + work = WorkIdentity(branch=branch, slug=topic_dir.name, year=topic_dir.parent.name) + else: + work = WorkIdentity(branch=branch) + + hooks = PipelineHooks() + if decision.kind != "disabled": + overlay = hooks.amend_workflow(pipeline=identity, decision=decision, workflow=workflow_doc, work=work) + else: + # Disabled delivers nothing — the passthrough overlay of the resolved + # workflow (None under a disabled decision); no registry is built. + overlay = WorkflowOverlay(workflow=workflow_doc, provenance=[]) + + # Step 4 — compile through the real machine into a throwaway temp dir, + # with the effective workflow the amendment layer returned. # ``root_dir``/``project_name`` are not passed: they only shape top-level # output keys, never the stages the card reports. with tempfile.TemporaryDirectory(prefix="goga-pipeline-card-") as tmp: flow_path = Path(tmp) / "flow.yml" - pipeline_doc, flow_doc = compile_flow(pipeline_path, flow_path, workflow=workflow_doc) + pipeline_doc, flow_doc = compile_flow(pipeline_path, flow_path, workflow=overlay.workflow) - # Step 4 — order the compiled stages into execution order (loop copies are + # Step 5 — order the compiled stages into execution order (loop copies are # separate rows already; skip removal happened in the compiler). ordered = order_stages(flow_doc.stages) logger.debug( "pipeline card composed", - extra={"pipeline": name, "stages": len(ordered), "workflow_applied": workflow_doc is not None}, + extra={ + "pipeline": name, + "stages": len(ordered), + "workflow_applied": overlay.workflow is not None, + "provenance": overlay.provenance, + }, ) - # Step 5 — the card: author-facing header values + stage rows. The header - # comes from the documents tuple, never a re-parse of the pipeline-file. + # Steps 6-7 — the card: author-facing header values from the documents + # tuple, one row per ordered stage, the contributing tools of the overlay. + # The temp flow-file dies with its directory above. return PipelineCard( name=pipeline_doc.header.name, description=pipeline_doc.header.description, stages=[CardStage(id=stage.id, title=stage.name) for stage in ordered], + provenance=overlay.provenance, ) diff --git a/tests/pipeline/conftest.py b/tests/pipeline/conftest.py index 1a4ede46..3b3c1e1c 100644 --- a/tests/pipeline/conftest.py +++ b/tests/pipeline/conftest.py @@ -6,6 +6,8 @@ import pytest +from tests.hooks.conftest import install_tool_package, pin_package_environment # noqa: F401 + @pytest.fixture def isolated_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: diff --git a/tests/pipeline/test_describe_pipeline.py b/tests/pipeline/test_describe_pipeline.py index a36f6371..e64bba3e 100644 --- a/tests/pipeline/test_describe_pipeline.py +++ b/tests/pipeline/test_describe_pipeline.py @@ -13,6 +13,13 @@ project or a runtime directory) and removed once the card is composed. An unknown pipeline name raises ``RuntimeError`` with a readable message. +The card composes through the same amendment layer a run composes through: +the amendment facts are resolved in the routine and delivered over the +checkpoint surface (unless the decision is disabled), the card reports the +committed tools as ``provenance``, and no run events fire. The tool-package +scenarios run the platform code for real over the boundary fixtures of +``tests/hooks/conftest.py``. + Fixtures mirror the design's General Setup: ``deploy.yml`` (STAGES format, build→test) and ``hardening.yml`` (``stages.test.skip: true`` + ``extend.audit``). @@ -31,7 +38,7 @@ from goga.pipeline.describe_pipeline import describe_pipeline from goga.pipeline.order_stages import order_stages from goga.pipeline.pipeline_card import CardStage, PipelineCard -from goga.pipeline.workflow import parse_workflow +from goga.pipeline.workflow import WorkflowDocument, parse_workflow # The package __init__ re-exports ``describe_pipeline`` (the function), which # shadows the ``describe_pipeline`` submodule name in attribute access — @@ -62,6 +69,12 @@ # chained copies (``build-1``/``build-2``), each a separate card row. _LOOP_YML = "stages:\n build:\n loop: 2\n" +# The auto-match workflow of the amendment scenarios — an authored prompt the +# tool layer appends to. The skip directive exists only so the disabled +# scenario can prove the workflow did NOT leak into the composition (it would +# remove ``test`` if it did). +_AUTHORED_YML = "prompt: authored\nstages:\n test:\n skip: true\n" + # A user-source pipeline-file — distinct header values prove the card was # composed from the user dir, not an identically named project file. _USER_DEPLOY_YML = """\ @@ -111,6 +124,26 @@ def test_describe_pipeline_signature(self) -> None: assert hints["no_workflow"] is bool assert hints["return"] is PipelineCard + def test_describe_pipeline_card_carries_empty_provenance_without_tools( + self, + tmp_path: Path, + isolated_cwd: Path, + pin_package_environment, + ) -> None: + """The no-tools path composes through the amendment layer as the passthrough. + + With the package environment pinned empty the registry builds empty, + the delivery commits nothing, and the overlay is the passthrough — + the returned card's ``provenance`` is the deterministic empty list. + """ + pin_package_environment({}) + project_dir = tmp_path / "project_pipelines" + _write_pipeline(project_dir, "deploy", _DEPLOY_YML) + + card = describe_pipeline("deploy", project_dir, tmp_path / "user_pipelines", None, False) + + assert card.provenance == [] + class TestDescribePipelineLogic: def test_describe_pipeline_returns_card_from_header_and_stages(self, tmp_path: Path, isolated_cwd: Path) -> None: @@ -242,3 +275,79 @@ def test_describe_pipeline_ignores_skip_env( card = describe_pipeline("deploy", project_dir, tmp_path / "user_pipelines", None, False) assert [stage.id for stage in card.stages] == ["build", "test"] + + +class TestDescribePipelineAmendmentLayer: + """The card composes through the same amendment a run composes through.""" + + def test_describe_pipeline_reports_provenance_through_same_layer( + self, + tmp_path: Path, + isolated_cwd: Path, + pin_package_environment, + install_tool_package, + ) -> None: + """A committed tool contribution lands on the card as provenance. + + The workflow auto-matches (``deploy.yml`` with the authored prompt); + the tool contributes a prompt-only document over it; the merged + overlay workflow is what ``compile_flow`` receives. The real compiler + runs into the temp dir — no mocks beyond the tool environment. + """ + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + def register(hooks: object) -> None: + def hardening(self: object, context: object) -> None: + context.contribute(WorkflowDocument(prompt="tool-text")) + + hooks.subscribe("pipeline", "amend_workflow", "hardening", hardening) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register) + + project_dir = tmp_path / "project_pipelines" + _write_pipeline(project_dir, "deploy", _DEPLOY_YML) + _write_workflow(isolated_cwd, "deploy", _AUTHORED_YML) + + card = describe_pipeline("deploy", project_dir, tmp_path / "user_pipelines", None, False) + + # The platform derives the tool identity from the package name: + # goga_tool_demo -> demo. + assert card.provenance == ["demo"] + assert card.name == "Deploy" + + def test_describe_pipeline_disabled_reports_raw_composition( + self, + tmp_path: Path, + isolated_cwd: Path, + pin_package_environment, + install_tool_package, + ) -> None: + """A disabled decision delivers nothing — the raw composition, no tool layer. + + The amend hook fails loudly when called, so a clean return proves the + delivery never ran. The auto-match workflow exists (with a skip that + would remove ``test`` if it leaked in) — ``no_workflow`` must win over + it, and the card is the same composition a no-workflow-file compile + produces. + """ + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + def register(hooks: object) -> None: + def hardening(self: object, context: object) -> None: + raise AssertionError("amend hook must not run for a disabled decision") + + hooks.subscribe("pipeline", "amend_workflow", "hardening", hardening) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register) + + project_dir = tmp_path / "project_pipelines" + _write_pipeline(project_dir, "deploy", _DEPLOY_YML) + _write_workflow(isolated_cwd, "deploy", _AUTHORED_YML) + + card = describe_pipeline("deploy", project_dir, tmp_path / "user_pipelines", None, True) + + assert card.provenance == [] + assert [(stage.id, stage.title) for stage in card.stages] == [ + ("build", "Build"), + ("test", "Test"), + ] From 3fb5a3cdfaf719d6de7ce24441122244d83060cb Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 18 Sep 2026 16:18:21 +0000 Subject: [PATCH 074/205] feat: wire the run form through the pipeline hooks amendment layer --- .goga/history/2026/add-pipeline-hooks/plan.md | 14 +- goga/pipeline/run_pipeline.py | 352 +++++++--- .../config/test_resolve_project_name_flows.py | 9 +- tests/integration/test_parallel_flow.py | 9 +- tests/integration/test_pipeline_cli.py | 22 +- .../test_integration_materialization.py | 9 +- tests/pipeline/test_run_pipeline.py | 9 +- tests/pipeline/test_run_pipeline_contract.py | 9 +- tests/pipeline/test_run_pipeline_hooks.py | 615 ++++++++++++++++++ tests/pipeline/test_run_pipeline_workflow.py | 21 +- 10 files changed, 958 insertions(+), 111 deletions(-) create mode 100644 tests/pipeline/test_run_pipeline_hooks.py diff --git a/.goga/history/2026/add-pipeline-hooks/plan.md b/.goga/history/2026/add-pipeline-hooks/plan.md index 7c85e29c..8d20ea00 100644 --- a/.goga/history/2026/add-pipeline-hooks/plan.md +++ b/.goga/history/2026/add-pipeline-hooks/plan.md @@ -1256,19 +1256,19 @@ registry, delivery, and emissions run for real. **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: create `tests/pipeline/test_run_pipeline_hooks.py` — the +- [x] **Contract tests**: create `tests/pipeline/test_run_pipeline_hooks.py` — the signature is unchanged (existing `tests/pipeline/test_run_pipeline.py` contract tests pin it; they must stay green); pin the new import wiring: the module now imports `PipelineHooks` & co. from `.hooks` and the four history names from `..history` (attribute presence on the module). -- [ ] **Code**: rewire `goga/pipeline/run_pipeline.py` to the 17 steps (insert the fact +- [x] **Code**: rewire `goga/pipeline/run_pipeline.py` to the 17 steps (insert the fact resolution after the skip merge, the delivery before `resolve_project_name`/compile, the composition/statuses/creation before `run_flow`, the recomputed-statuses completion after it; update the docstring's step numbering and the Raises section with the hard `ValueError`/`ImportError` channels). -- [ ] **Interface verification**: `python -m pytest tests/pipeline/test_run_pipeline_hooks.py tests/pipeline/test_run_pipeline.py tests/pipeline/test_run_pipeline_workflow.py -q` +- [x] **Interface verification**: `python -m pytest tests/pipeline/test_run_pipeline_hooks.py tests/pipeline/test_run_pipeline.py tests/pipeline/test_run_pipeline_workflow.py -q` — all pass (the pre-existing suites are the no-tools regression proof). -- [ ] **Logic tests** (design scenarios, verbatim): +- [x] **Logic tests** (design scenarios, verbatim): ``` test_run_pipeline_full_event_sequence_around_launch @@ -1411,16 +1411,16 @@ registry, delivery, and emissions run for real. caplog contains a warning naming the tool, "run_completed", "boom" ``` -- [ ] **Debugging**: `python -m pytest tests/pipeline -q` — fix implementation code until +- [x] **Debugging**: `python -m pytest tests/pipeline -q` — fix implementation code until all tests pass (do NOT fix test code). -- [ ] **Contract re-verification**: every requirement of the 17-step contract — absolute +- [x] **Contract re-verification**: every requirement of the 17-step contract — absolute paths to `compile_flow`/`run_flow`; `port`/`parallel` forwarding unchanged; env reads exactly `AFM_DIR`, `GOGA_WORKFLOW_DISABLED`, `GOGA_WORKFLOW_NAME`, `GOGA_SKIP_STAGES`; DISABLED precedence; skip merge before delivery; delivery before compile; creation after prompt materialization and before launch; completion on every return path; the runtime dir fact as posix string; with no tool packages the passthrough — output exactly as before. -- [ ] **Lint**: `python -m ruff check goga/pipeline/run_pipeline.py tests/pipeline/test_run_pipeline_hooks.py && python -m ruff format --check goga/pipeline/run_pipeline.py tests/pipeline/test_run_pipeline_hooks.py` — fix formatting, apply decomposition if necessary. +- [x] **Lint**: `python -m ruff check goga/pipeline/run_pipeline.py tests/pipeline/test_run_pipeline_hooks.py && python -m ruff format --check goga/pipeline/run_pipeline.py tests/pipeline/test_run_pipeline_hooks.py` — fix formatting, apply decomposition if necessary. ### Task 11: CLI card `tools:` line and clean hard-error rendering — `cli.py` (TDD coding) diff --git a/goga/pipeline/run_pipeline.py b/goga/pipeline/run_pipeline.py index dd0dc5f4..fdf52c75 100644 --- a/goga/pipeline/run_pipeline.py +++ b/goga/pipeline/run_pipeline.py @@ -1,18 +1,40 @@ from __future__ import annotations +import logging import os import shutil import sys from pathlib import Path +from typing import TYPE_CHECKING from ..afm import run_flow from ..config import resolve_project_name +from ..history import ( + assemble_status_scale, + resolve_current_branch_name, + resolve_topic_dir, + resolve_topic_status, +) from .apply_skip_stages import apply_skip_stages -from .compiler import compile_flow, translate_role +from .compiler import PipelineRoles, compile_flow, parse_dsl, translate_role +from .hooks import ( + CompositionStage, + PipelineHooks, + PipelineIdentity, + WorkflowDecision, + WorkflowOverlay, + WorkIdentity, +) from .list_pipelines import list_pipelines -from .pipeline_entry import PipelineSource +from .order_stages import order_stages +from .pipeline_entry import PipelineEntry, PipelineSource from .resolve_workflow import resolve_workflow +if TYPE_CHECKING: # the workflow document is a fact the amendment facts carry + from .workflow import WorkflowDocument + +logger = logging.getLogger(__name__) + # The three overridable pipeline roles. Each role resolves to its afm prompt-file # stem via the single source of truth ``translate_role`` (planner→planning, # executor→implementation, reviewer→review). ``summary`` is NOT a role — it is a @@ -30,6 +52,140 @@ def _resolve_defaults_dir() -> Path: return Path(__file__).resolve().parent.parent / "assets" / "afm" / "prompts" +def _resolve_amendment_facts( + match: PipelineEntry, + pipeline_path: Path, + no_workflow: bool, + workflow_name: str | None, + workflow: WorkflowDocument | None, +) -> tuple[PipelineIdentity, WorkflowDecision, WorkIdentity, Path | None]: + """Resolve the amendment facts of step 8 from the operation's own data. + + The checkpoints read no repository — every fact resolves here: the + :class:`~goga.pipeline.hooks.PipelineIdentity` from one early + ``parse_dsl`` header read, the + :class:`~goga.pipeline.hooks.WorkflowDecision` from the kind-derivation + matrix (disabled wins; a resolved document under an explicit name is + ``explicit``, under no name ``auto-match``; no document is a silent + miss), and the :class:`~goga.pipeline.hooks.WorkflowIdentity` from the + current branch and its hosting topic directory. + + Args: + match: The discovered pipeline entry — the name and the source. + pipeline_path: The absolute pipeline-file path (parsed once here). + no_workflow: The disabled flag of the environment decision. + workflow_name: The explicit workflow name of the environment + decision, or ``None``. + workflow: The resolved workflow after the runner-skip merge. + + Returns: + The identity, the decision, the work identity, and the hosting + topic directory (``None`` in the branch-only form). + """ + header, _, _ = parse_dsl(pipeline_path.read_text()) + identity = PipelineIdentity( + name=match.name, + display_name=header.name, + description=header.description, + source=match.source.value, + ) + + if no_workflow: + decision = WorkflowDecision(kind="disabled", workflow_name=None) + elif workflow is None: + decision = WorkflowDecision(kind="silent-miss", workflow_name=None) + elif workflow_name not in (None, ""): + decision = WorkflowDecision(kind="explicit", workflow_name=workflow_name) + else: + decision = WorkflowDecision(kind="auto-match", workflow_name=match.name) + + # The branch (the literal "unknown" when git resolves none) with the + # hosting topic's slug and year when the branch hosts a topic. A fully + # unsluggable branch raises ``ValueError`` out of the topic composer — + # guarded to the branch-only form. + branch = resolve_current_branch_name() or "unknown" + try: + topic_dir = resolve_topic_dir(branch) + except ValueError: + topic_dir = None # a fully unsluggable branch hosts no topic + + if topic_dir is not None and topic_dir.is_dir(): + work = WorkIdentity(branch=branch, slug=topic_dir.name, year=topic_dir.parent.name) + else: + work = WorkIdentity(branch=branch) + topic_dir = None + + return identity, decision, work, topic_dir + + +def _materialize_prompts(afm_dir: Path, roles: PipelineRoles | None) -> None: + """Materialize the four agent prompt files of step 11 into ``/prompts/``. + + Validate-first: every overridable role (``planner``/``executor``/ + ``reviewer``) is checked — an inline override from the documents tuple + header or an existing package default at its ``translate_role`` stem — + and the ``summary`` package default is checked BEFORE the prompts + directory is wiped, so a missing default with no override raises before + any file is written and the directory is left untouched (atomicity). + The wipe + recreate then makes re-runs idempotent regardless of prior + directory state; ``summary`` is a separate, always-default channel — + never overridden, always copied from the default. + + Args: + afm_dir: The resolved runtime directory — the prompts land at + ``/prompts/``. + roles: The inline role overrides from the documents tuple header, or + ``None`` when the header carries no ``roles`` block. + + Raises: + RuntimeError: When a role's package default is missing with no inline + override (message ``": default prompt missing from package + and no inline override supplied"``), or when the ``summary`` + package default is missing (message ``"summary: default prompt + missing from package"``) — both raised before the wipe. + """ + defaults_dir = _resolve_defaults_dir() + + # 11b — validate-all before wipe (atomicity). + for role in _ROLES: + stem = translate_role(role) + override = getattr(roles, role) if roles is not None else None + if override is None and not (defaults_dir / f"{stem}.md").exists(): + raise RuntimeError(f"{stem}: default prompt missing from package and no inline override supplied") + if not (defaults_dir / "summary.md").exists(): + raise RuntimeError("summary: default prompt missing from package") + + # 11c — wipe + recreate so re-runs are idempotent regardless of prior state. + prompts_dir = afm_dir / "prompts" + if prompts_dir.exists(): + shutil.rmtree(prompts_dir) + prompts_dir.mkdir(parents=True, exist_ok=False) + + # 11d — write per role: an override replaces the file at its stem; otherwise + # copy the package default. ``summary`` is always copied from the default — + # it has no inline override channel. + for role in _ROLES: + stem = translate_role(role) + override = getattr(roles, role) if roles is not None else None + target = prompts_dir / f"{stem}.md" + if override is not None: + target.write_text(override) + else: + shutil.copy(defaults_dir / f"{stem}.md", target) + shutil.copy(defaults_dir / "summary.md", prompts_dir / "summary.md") + + # 11e — exactly four prompt files materialized (planning/implementation/review + # from ``_ROLES`` via ``translate_role`` plus the literal summary). A real + # guard, not an ``assert``: the count must hold in optimized runs + # (``python -O``) too, and a divergence here (concurrent writer, FS oddity) + # is a RuntimeError rather than a bare AssertionError surfacing at the CLI. + expected_stems = [translate_role(role) for role in _ROLES] + ["summary"] + materialized = sorted(prompts_dir.iterdir()) + expected = sorted(prompts_dir / f"{stem}.md" for stem in expected_stems) + if materialized != expected: + raise RuntimeError(f"prompt materialization incomplete: expected {expected}, got {materialized}") + + def run_pipeline(name: str, project_dir: Path, user_dir: Path, port: int, parallel: int | None = None) -> int: """Resolve, compile, and run a goga pipeline by name via the external ``afm`` binary. @@ -37,37 +193,33 @@ def run_pipeline(name: str, project_dir: Path, user_dir: Path, port: int, parall pipeline file path from the matching entry's source directory, resolves an optional workflow via :func:`~goga.pipeline.resolve_workflow.resolve_workflow` from the environment decision (``GOGA_WORKFLOW_DISABLED`` > - ``GOGA_WORKFLOW_NAME`` > basename fallback), compiles the goga DSL - pipeline-file into an afm flow-file via + ``GOGA_WORKFLOW_NAME`` > basename fallback), merges the runner skip + directives, resolves the amendment facts (the pipeline identity from one + early ``parse_dsl`` header read, the workflow decision, and the work + identity from the current branch and its hosting topic), delivers the + workflow amendment through the pipeline hooks zone (unless the decision is + disabled), compiles the goga DSL pipeline-file into an afm flow-file via :func:`compile_flow` at the path ``/flow.yml`` (forwarding the - parsed workflow when one resolved), materializes the four agent prompt files - into ``/prompts/`` (step 8), then launches ``afm`` via - :func:`goga.afm.run_flow` with the compiled flow-file path (not the DSL - path), the caller-allocated ``port``, and an optional concurrency cap. The - ``afm`` binary's exit code is propagated; a missing pipeline returns a - non-zero code without invoking the compiler or the binary. + overlay workflow the amendment layer returned), materializes the four agent + prompt files into ``/prompts/``, emits the run-creation facts, + then launches ``afm`` via :func:`goga.afm.run_flow` with the compiled + flow-file path (not the DSL path), the caller-allocated ``port``, and an + optional concurrency cap. On every return of ``run_flow`` the work + statuses are recomputed at the completion moment, the run-completion facts + are emitted with the actual exit code, and the exit code is returned. ``parallel`` is forwarded as ``run_flow(..., max_parallel=parallel)`` so a non-``None`` cap materializes as ``afm run --max-parallel `` (the host-side ``-p/--parallel`` option threads through to it). It is - compilation-orthogonal: steps 1-8 do not consume it, and ``None`` (the - default) reaches ``run_flow`` as ``max_parallel=None`` so the flag is + compilation-orthogonal: no step before the launch consumes it, and ``None`` + (the default) reaches ``run_flow`` as ``max_parallel=None`` so the flag is omitted (backward compatible). - Step 8 materializes the four afm prompt files - (``planning``, ``implementation``, ``review``, ``summary``) into - ``/prompts/``. The first three correspond to the overridable roles - (``planner``/``executor``/``reviewer``), each resolved to its afm stem via - :func:`translate_role`; for each role, an inline override from the - pipeline-file ``roles`` header replaces the file wholesale at its stem, - otherwise the package default is copied. ``summary`` is a separate, - always-default channel — it is never overridden and is always copied from the - package default. The step is validate-first: every role is checked (override - present or package default exists at its stem) AND the summary default is - checked BEFORE the prompts directory is wiped, so a missing default with no - override raises before any file is written and the directory is left - untouched (atomicity). The wipe + recreate makes re-runs idempotent - regardless of prior directory state. + Step 11 (via :func:`_materialize_prompts`) materializes the four afm + prompt files (``planning``, ``implementation``, ``review``, ``summary``) + into ``/prompts/`` — validate-first (atomicity), wipe + + recreate (idempotent), overrides replacing the file wholesale at their + stem, ``summary`` always from the package default. Args: name: pipeline name without extension (e.g. ``"deploy"``). @@ -90,7 +242,7 @@ def run_pipeline(name: str, project_dir: Path, user_dir: Path, port: int, parall Raises: RuntimeError: When the ``AFM_DIR`` environment variable is unset or empty - (message ``"AFM_DIR not set"``), or when step 8 finds a missing + (message ``"AFM_DIR not set"``), or when step 11 finds a missing package default for an overridable role's stem with no inline override (message ``": default prompt missing from package and no inline override supplied"``), or when the ``summary`` package default is @@ -101,28 +253,40 @@ def run_pipeline(name: str, project_dir: Path, user_dir: Path, port: int, parall ``GOGA_WORKFLOW_DISABLED`` is not ``"1"`` and the resolved workflow-file exists but is malformed. StructuralError: On a structural defect in the pipeline DSL, propagated - unchanged from :func:`compile_flow`. + unchanged from :func:`parse_dsl` (step 8, the fact-resolution + header read) or :func:`compile_flow`. + ValueError: When a hook of the hard ``pipeline.amend_workflow`` action + failed during the amendment delivery (step 9, before any compile, + write, or launch) — the message names the hook, the tool, and the + action; the tool's whole contribution is discarded. + ImportError: When a tool package exists but its facade fails to import + — the fatal registry-build error of the hooks platform. FileNotFoundError / PermissionError: Propagated unchanged from :func:`compile_flow` when ``pipeline_path`` is unreadable or the ``flow_path`` parent directory does not exist. yaml.YAMLError: Propagated unchanged from :func:`parse_dsl` when the pipeline-file is not valid YAML. """ + # Steps 1-3 — discover the entry and build the absolute pipeline path. entries = list_pipelines(project_dir, user_dir) match = next((entry for entry in entries if entry.name == name), None) if match is None: + # Step 2 — the missing-pipeline report; the return happens before any + # checkpoint, so no events fire. print(f"Error: pipeline '{name}' is missing", file=sys.stderr) return 1 source_dir = project_dir if match.source == PipelineSource.PROJECT else user_dir pipeline_path = (source_dir / f"{match.name}.yml").resolve() + # Steps 4-5 — the runtime directory and the output flow path inside it. afm_env = os.environ.get("AFM_DIR") if not afm_env: raise RuntimeError("AFM_DIR not set") afm_dir = Path(afm_env).resolve() flow_path = afm_dir / "flow.yml" + runtime_dir = afm_dir.as_posix() # Step 6: resolve an optional workflow via ``resolve_workflow`` from the # environment decision (GOGA_WORKFLOW_DISABLED > GOGA_WORKFLOW_NAME > basename @@ -131,12 +295,12 @@ def run_pipeline(name: str, project_dir: Path, user_dir: Path, port: int, parall # flags, so what the card shows is what the run executes. DISABLED priority # is enforced twice: in the input (the name is nulled when disabled) and as # step 1 of the rule set. Structural workflow errors propagate from - # parse_workflow unchanged. + # parse_workflow unchanged — before the delivery, so no events fire. no_workflow = os.environ.get("GOGA_WORKFLOW_DISABLED") == "1" workflow_name = None if no_workflow else os.environ.get("GOGA_WORKFLOW_NAME") workflow = resolve_workflow(name, workflow_name, no_workflow) - # Step 6e: merge CLI skip directives (the comma-split ``GOGA_SKIP_STAGES`` + # Step 7: merge CLI skip directives (the comma-split ``GOGA_SKIP_STAGES`` # container env var) onto the resolved workflow without mutating it. An empty # or unset var is a no-op (``apply_skip_stages`` returns the input unchanged); # otherwise the merged document carries ``WorkflowStage(skip=True)`` entries @@ -147,6 +311,24 @@ def run_pipeline(name: str, project_dir: Path, user_dir: Path, port: int, parall skip_stages = [s for s in raw.split(",") if s] workflow = apply_skip_stages(workflow, skip_stages) + # Step 8: the amendment facts (identity, decision, work) and the hosting + # topic directory — resolved in the operation, read by no checkpoint. + identity, decision, work, topic_dir = _resolve_amendment_facts( + match, pipeline_path, no_workflow, workflow_name, workflow + ) + + # Step 9: deliver the amendment with the authored workflow after the skip + # merge — receiving the overlay result. A disabled decision delivers + # nothing (the layer is off) and the overlay is the passthrough of the + # merged workflow. The delivery is hard: the first failing tool stops the + # run here with a clean ``ValueError``, before any compile, write, or + # launch — and no events fire. + hooks = PipelineHooks() + if decision.kind != "disabled": + overlay = hooks.amend_workflow(pipeline=identity, decision=decision, workflow=workflow, work=work) + else: + overlay = WorkflowOverlay(workflow=workflow, provenance=[]) + # The in-container project root is the single source of truth for the afm # ``root_dir`` directive emitted into the compiled flow-file. ``Path.cwd()`` # resolves to ``/workspace`` inside the goga container (the host-side @@ -156,62 +338,76 @@ def run_pipeline(name: str, project_dir: Path, user_dir: Path, port: int, parall # transformer with no environment-variable reads. root_dir = str(Path.cwd().resolve()) - # Step 7: derive the in-container project name from the git origin remote URL + # Step 10: derive the in-container project name from the git origin remote URL # for the ``[]`` description prefix (Part 2). OUTPUT-only context, # mirroring ``root_dir`` — derived here from the environment, never read from # config, and ``resolve_project_name`` never raises so it cannot abort the run. + # Compilation runs with the overlay workflow — with no tool packages the + # overlay is the passthrough, so the compiled workflow is exactly what was + # resolved. Structural errors propagate unchanged (no events). project_name = resolve_project_name() - pipeline_doc, _flow_doc = compile_flow( - pipeline_path, flow_path, workflow=workflow, root_dir=root_dir, project_name=project_name + pipeline_doc, flow_doc = compile_flow( + pipeline_path, flow_path, workflow=overlay.workflow, root_dir=root_dir, project_name=project_name ) - # Step 8: materialize the four agent prompt files into /prompts/. - defaults_dir = _resolve_defaults_dir() - roles = pipeline_doc.header.roles - - # 8b — validate-all before wipe (atomicity): each overridable role needs an - # inline override (from ``header.roles``) or an existing package default at - # its ``translate_role`` stem; ``summary`` always needs its package default - # (it is a separate, non-overridable channel). A missing default with no - # override raises BEFORE any file is written, so a failed run leaves prompts/ - # as-is. - for role in _ROLES: - stem = translate_role(role) - override = getattr(roles, role) if roles is not None else None - if override is None and not (defaults_dir / f"{stem}.md").exists(): - raise RuntimeError(f"{stem}: default prompt missing from package and no inline override supplied") - if not (defaults_dir / "summary.md").exists(): - raise RuntimeError("summary: default prompt missing from package") + # Step 11: materialize the four agent prompt files into /prompts/. + _materialize_prompts(afm_dir, pipeline_doc.header.roles) - # 8c — wipe + recreate so re-runs are idempotent regardless of prior state. - prompts_dir = afm_dir / "prompts" - if prompts_dir.exists(): - shutil.rmtree(prompts_dir) - prompts_dir.mkdir(parents=True, exist_ok=False) + # Step 12: the composition — one row per ordered compiled stage, as the card + # shows them, built from the same compilation the run executes. + composition = [CompositionStage(id=stage.id, title=stage.name) for stage in order_stages(flow_doc.stages)] - # 8d — write per role: an override replaces the file at its stem; otherwise - # copy the package default. ``summary`` is always copied from the default — - # it has no inline override channel. - for role in _ROLES: - stem = translate_role(role) - override = getattr(roles, role) if roles is not None else None - target = prompts_dir / f"{stem}.md" - if override is not None: - target.write_text(override) - else: - shutil.copy(defaults_dir / f"{stem}.md", target) - shutil.copy(defaults_dir / "summary.md", prompts_dir / "summary.md") + # Step 13: the work statuses of the hosting topic — the scale assembles once + # per run and both status reads share it. The branch-only form stays empty + # and never assembles the scale. + scale = None + statuses: list[str] = [] + if topic_dir is not None: + scale = assemble_status_scale() + statuses = resolve_topic_status(topic_dir, scale) - # 8e — exactly four prompt files materialized (planning/implementation/review - # from ``_ROLES`` via ``translate_role`` plus the literal summary). A real - # guard, not an ``assert``: the count must hold in optimized runs - # (``python -O``) too, and a divergence here (concurrent writer, FS oddity) - # is a RuntimeError rather than a bare AssertionError surfacing at the CLI. - expected_stems = [translate_role(role) for role in _ROLES] + ["summary"] - materialized = sorted(prompts_dir.iterdir()) - expected = sorted(prompts_dir / f"{stem}.md" for stem in expected_stems) - if materialized != expected: - raise RuntimeError(f"prompt materialization incomplete: expected {expected}, got {materialized}") + logger.debug( + "pipeline run composed", + extra={ + "pipeline": name, + "stages": [stage.id for stage in composition], + "workflow_applied": overlay.workflow is not None, + "provenance": overlay.provenance, + "statuses": statuses, + }, + ) + + # Step 14: the run-creation facts immediately before the launch — soft, so a + # failing hook warns and the launch proceeds. + hooks.emit_run_created( + pipeline=identity, + decision=decision, + overlay=overlay, + composition=composition, + work=work, + statuses=statuses, + runtime_dir=runtime_dir, + ) - return run_flow(flow_path, port, max_parallel=parallel) + # Step 15: launch. Spawn failures are return codes (126/127), not raises. + exit_code = run_flow(flow_path, port, max_parallel=parallel) + + # Steps 16-17: on every return of run_flow — zero, non-zero, and spawn + # failures alike — recompute the statuses at the completion moment (one + # scale, two reads) and emit the completion facts with the actual exit + # code; a failing hook warns and never affects the code. + if topic_dir is not None: + statuses = resolve_topic_status(topic_dir, scale) + + hooks.emit_run_completed( + pipeline=identity, + decision=decision, + overlay=overlay, + composition=composition, + work=work, + statuses=statuses, + runtime_dir=runtime_dir, + exit_code=exit_code, + ) + return exit_code diff --git a/tests/config/test_resolve_project_name_flows.py b/tests/config/test_resolve_project_name_flows.py index 5285be6e..a159ea10 100644 --- a/tests/config/test_resolve_project_name_flows.py +++ b/tests/config/test_resolve_project_name_flows.py @@ -71,9 +71,14 @@ def _fake_documents(project_name: str | None) -> tuple[PipelineDocument, FlowDoc def _write_pipeline(directory: Path, name: str = "deploy") -> None: - """Create an empty pipeline file so name resolution matches it.""" + """Create a minimal valid pipeline file so name resolution matches it. + + The fact-resolution step of ``run_pipeline`` parses the file via + ``parse_dsl`` (the header read), so the fixture text must be valid DSL — + string name/description in the header and a ``---`` body separator. + """ directory.mkdir(parents=True, exist_ok=True) - (directory / f"{name}.yml").write_text("pipeline") + (directory / f"{name}.yml").write_text("name: Deploy\ndescription: d\n---\n\nbuild:\n title: Build\n") class TestFlowC1PipelinePrefix: diff --git a/tests/integration/test_parallel_flow.py b/tests/integration/test_parallel_flow.py index 3b01ab17..05393d94 100644 --- a/tests/integration/test_parallel_flow.py +++ b/tests/integration/test_parallel_flow.py @@ -171,11 +171,16 @@ class TestParallelContainerCliToRunFlow: @staticmethod def _write_project(tmp_path: Path, name: str = "deploy") -> Path: - """Create a project CWD carrying a ``.yml`` pipeline file; return the CWD.""" + """Create a project CWD carrying a ``.yml`` pipeline file; return the CWD. + + The fact-resolution step parses the file via ``parse_dsl`` (the header + read), so the fixture text must be valid DSL — string name/description + in the header and a ``---`` body separator. + """ project_tmp = tmp_path / "project" project_pipelines = project_tmp / ".goga" / "pipelines" project_pipelines.mkdir(parents=True) - (project_pipelines / f"{name}.yml").write_text("pipeline") + (project_pipelines / f"{name}.yml").write_text("name: Deploy\ndescription: d\n---\n\nbuild:\n title: Build\n") return project_tmp @staticmethod diff --git a/tests/integration/test_pipeline_cli.py b/tests/integration/test_pipeline_cli.py index 4c9c8652..929525f5 100644 --- a/tests/integration/test_pipeline_cli.py +++ b/tests/integration/test_pipeline_cli.py @@ -67,6 +67,12 @@ # run_pipeline function; resolve it so compile_flow can be patched there. _run_pipeline_module = sys.modules["goga.pipeline.run_pipeline"] +# The minimal valid pipeline-file for the run-path tests — the +# fact-resolution step parses the file via ``parse_dsl`` (the header read), +# so the fixture text must be valid DSL (string name/description in the +# header, ``---`` body separator). +_MINIMAL_YML = "name: Deploy\ndescription: d\n---\n\nbuild:\n title: Build\n" + def _make_config() -> ProjectConfig: """Build a minimal ProjectConfig satisfying the new schema (top-level image, pipeline block).""" @@ -108,7 +114,7 @@ def test_run_invokes_afm_run_with_port_and_path(self, tmp_path: Path, monkeypatc project_tmp = tmp_path / "project" project_pipelines = project_tmp / ".goga" / "pipelines" project_pipelines.mkdir(parents=True) - (project_pipelines / "deploy.yml").write_text("pipeline") + (project_pipelines / "deploy.yml").write_text(_MINIMAL_YML) user_tmp = tmp_path / "user" @@ -159,7 +165,7 @@ def test_run_propagates_nonzero_afm_exit_code(self, tmp_path: Path, monkeypatch) project_tmp = tmp_path / "project" project_pipelines = project_tmp / ".goga" / "pipelines" project_pipelines.mkdir(parents=True) - (project_pipelines / "deploy.yml").write_text("pipeline") + (project_pipelines / "deploy.yml").write_text(_MINIMAL_YML) monkeypatch.setattr(Path, "cwd", lambda: project_tmp) monkeypatch.setattr(Path, "home", lambda: tmp_path / "user") @@ -183,7 +189,7 @@ def test_run_propagates_127_when_afm_missing(self, tmp_path: Path, monkeypatch) project_tmp = tmp_path / "project" project_pipelines = project_tmp / ".goga" / "pipelines" project_pipelines.mkdir(parents=True) - (project_pipelines / "deploy.yml").write_text("pipeline") + (project_pipelines / "deploy.yml").write_text(_MINIMAL_YML) monkeypatch.setattr(Path, "cwd", lambda: project_tmp) monkeypatch.setattr(Path, "home", lambda: tmp_path / "user") @@ -202,12 +208,12 @@ def test_run_resolves_project_source_on_name_conflict(self, tmp_path: Path, monk project_tmp = tmp_path / "project" project_pipelines = project_tmp / ".goga" / "pipelines" project_pipelines.mkdir(parents=True) - (project_pipelines / "shared.yml").write_text("project-shared") + (project_pipelines / "shared.yml").write_text(_MINIMAL_YML) user_tmp = tmp_path / "user" user_pipelines = user_tmp / ".goga" / "pipelines" user_pipelines.mkdir(parents=True) - (user_pipelines / "shared.yml").write_text("user-shared") + (user_pipelines / "shared.yml").write_text(_MINIMAL_YML) monkeypatch.setattr(Path, "cwd", lambda: project_tmp) monkeypatch.setattr(Path, "home", lambda: user_tmp) @@ -237,7 +243,7 @@ def test_list_prints_bullet_entries(self, tmp_path: Path, monkeypatch, capsys) - project_tmp = tmp_path / "project" project_pipelines = project_tmp / ".goga" / "pipelines" project_pipelines.mkdir(parents=True) - (project_pipelines / "deploy.yml").write_text("pipeline") + (project_pipelines / "deploy.yml").write_text(_MINIMAL_YML) user_tmp = tmp_path / "user" user_pipelines = user_tmp / ".goga" / "pipelines" @@ -259,12 +265,12 @@ def test_list_project_wins_on_name_conflict(self, tmp_path: Path, monkeypatch, c project_tmp = tmp_path / "project" project_pipelines = project_tmp / ".goga" / "pipelines" project_pipelines.mkdir(parents=True) - (project_pipelines / "shared.yml").write_text("project-shared") + (project_pipelines / "shared.yml").write_text(_MINIMAL_YML) user_tmp = tmp_path / "user" user_pipelines = user_tmp / ".goga" / "pipelines" user_pipelines.mkdir(parents=True) - (user_pipelines / "shared.yml").write_text("user-shared") + (user_pipelines / "shared.yml").write_text(_MINIMAL_YML) monkeypatch.setattr(Path, "cwd", lambda: project_tmp) monkeypatch.setattr(Path, "home", lambda: user_tmp) diff --git a/tests/pipeline/test_integration_materialization.py b/tests/pipeline/test_integration_materialization.py index 4d29244c..0a7d6971 100644 --- a/tests/pipeline/test_integration_materialization.py +++ b/tests/pipeline/test_integration_materialization.py @@ -78,9 +78,14 @@ def afm_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: def _write_pipeline(directory: Path, name: str = "deploy") -> None: - """Create an empty pipeline file so name resolution matches it.""" + """Create a minimal valid pipeline file so name resolution matches it. + + The fact-resolution step parses the file via ``parse_dsl`` (the header + read), so the fixture text must be valid DSL — string name/description + in the header and a ``---`` body separator. + """ directory.mkdir(parents=True, exist_ok=True) - (directory / f"{name}.yml").write_text("pipeline") + (directory / f"{name}.yml").write_text("name: Deploy\ndescription: d\n---\n\nbuild:\n title: Build\n") def _write_defaults(defaults_dir: Path, stems: tuple[str, ...] = _PROMPT_STEMS) -> None: diff --git a/tests/pipeline/test_run_pipeline.py b/tests/pipeline/test_run_pipeline.py index a27e0edb..3ee7d935 100644 --- a/tests/pipeline/test_run_pipeline.py +++ b/tests/pipeline/test_run_pipeline.py @@ -47,9 +47,14 @@ def afm_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: def _write_pipeline(directory: Path, name: str = "deploy") -> None: - """Create an empty pipeline file so name resolution matches it.""" + """Create a minimal valid pipeline file so name resolution matches it. + + The fact-resolution step parses the file via ``parse_dsl`` (the header + read), so the fixture text must be valid DSL — string name/description + in the header and a ``---`` body separator. + """ directory.mkdir(parents=True, exist_ok=True) - (directory / f"{name}.yml").write_text("pipeline") + (directory / f"{name}.yml").write_text("name: Deploy\ndescription: d\n---\n\nbuild:\n title: Build\n") def _fake_documents( diff --git a/tests/pipeline/test_run_pipeline_contract.py b/tests/pipeline/test_run_pipeline_contract.py index 7568c745..701601f6 100644 --- a/tests/pipeline/test_run_pipeline_contract.py +++ b/tests/pipeline/test_run_pipeline_contract.py @@ -38,9 +38,14 @@ def _fake_documents() -> tuple[PipelineDocument, FlowDocument]: def _write_pipeline(directory: Path, name: str = "deploy") -> None: - """Create an empty pipeline file so name resolution matches it.""" + """Create a minimal valid pipeline file so name resolution matches it. + + The fact-resolution step parses the file via ``parse_dsl`` (the header + read), so the fixture text must be valid DSL — string name/description + in the header and a ``---`` body separator. + """ directory.mkdir(parents=True, exist_ok=True) - (directory / f"{name}.yml").write_text("pipeline") + (directory / f"{name}.yml").write_text("name: Deploy\ndescription: d\n---\n\nbuild:\n title: Build\n") class TestRunPipelineWorkflowContract: diff --git a/tests/pipeline/test_run_pipeline_hooks.py b/tests/pipeline/test_run_pipeline_hooks.py new file mode 100644 index 00000000..fbb8c712 --- /dev/null +++ b/tests/pipeline/test_run_pipeline_hooks.py @@ -0,0 +1,615 @@ +"""Operation-level tests for the ``run_pipeline`` Routine through the hooks zone. + +The run form composes through the pipeline hooks zone: the amendment facts +resolve in the routine (the identity from one ``parse_dsl`` header read, the +decision from the kind-derivation matrix, the work identity from the branch +and its hosting topic directory), the amendment is delivered between the +skip merge and the compilation, the composition and the statuses build from +the same compilation the run executes, and the two notifications fire around +the runner launch — the creation immediately before it, the completion on +every launch-attempt return path with the statuses recomputed at the moment. + +The scenarios run the real platform over the boundary fixtures of +``tests/hooks/conftest.py`` (re-exported by ``tests/pipeline/conftest.py``): +the registry, the delivery, and the emissions execute the actual platform +code, with only the module boundaries mocked — ``compile_flow`` / +``run_flow`` / ``resolve_current_branch_name`` on the run module (tmp dirs +are not repos) — per the design's General Setup. History trees are real +``.goga/history///`` structures built under ``current_year()`` +(never a hardcoded year literal), so the suite survives a year boundary. +""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path +from typing import Any +from unittest import mock + +import pytest +from goga.history import current_year +from goga.pipeline import run_pipeline +from goga.pipeline.compiler import ( + BodyFormat, + FlowDocument, + FlowStage, + PhasesBody, + PipelineDocument, + PipelineHeader, +) +from goga.pipeline.hooks import ( + CompositionStage, + PipelineHooks, + PipelineIdentity, + WorkflowDecision, + WorkflowOverlay, + WorkIdentity, +) +from goga.pipeline.workflow import WorkflowSyntaxError + +# goga.pipeline.run_pipeline is shadowed in the package __init__ by the +# run_pipeline function, so a string-based mock.patch path walking through it +# fails on Python 3.10. Resolve the real module via sys.modules and patch its +# attributes directly. Per [[feedback_mock_patch_module_shadowing]]. +_run_pipeline_module = sys.modules["goga.pipeline.run_pipeline"] + +# The minimal real pipeline file of every scenario — a valid header (the +# step-8 fact resolution parses it via parse_dsl) and one authored stage. +_PIPELINE_YML = """\ +name: Deploy +description: Deploy pipeline +--- + +build: + title: Build +""" + + +@pytest.fixture +def afm_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Point AFM_DIR at a tmp dir and return the resolved path. + + Mirrored from ``tests/pipeline/test_run_pipeline.py`` — flow_path inside + run_pipeline is ``afm_dir / "flow.yml"`` and ``runtime_dir`` is its posix + string, so returning the resolved value lets the event-fact assertions + compare against exactly what run_pipeline builds. + """ + directory = (tmp_path / ".afm").resolve() + monkeypatch.setenv("AFM_DIR", str(directory)) + return directory + + +def _write_pipeline(project_dir: Path, name: str = "deploy") -> Path: + """Write the minimal deploy pipeline file and return its path.""" + project_dir.mkdir(parents=True, exist_ok=True) + path = project_dir / f"{name}.yml" + path.write_text(_PIPELINE_YML) + return path + + +def _documents() -> tuple[PipelineDocument, FlowDocument]: + """The documents tuple the mocked ``compile_flow`` returns. + + One compiled ``build`` stage so the composition fact is non-trivial: the + run derives ``CompositionStage(id="build", title="Build")`` from it via + ``order_stages``. + """ + pipeline_doc = PipelineDocument( + header=PipelineHeader(name="Deploy", description="Deploy pipeline"), + format=BodyFormat.PHASES, + body=PhasesBody(steps=[]), + ) + flow_doc = FlowDocument( + name="Deploy", + description="Deploy pipeline", + stages=[FlowStage(id="build", name="Build", depends_on=None, fields={})], + ) + return (pipeline_doc, flow_doc) + + +def _write_topic(cwd: Path, slug: str) -> Path: + """Build a real topic tree ``.goga/history///`` with todo.md.""" + topic_dir = cwd / ".goga" / "history" / current_year() / slug + topic_dir.mkdir(parents=True, exist_ok=True) + (topic_dir / "todo.md").write_text("todo\n") + return topic_dir + + +def _isolate_workflow_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Clear the three workflow/skip env inputs for a deterministic resolution.""" + monkeypatch.delenv("GOGA_WORKFLOW_DISABLED", raising=False) + monkeypatch.delenv("GOGA_WORKFLOW_NAME", raising=False) + monkeypatch.delenv("GOGA_SKIP_STAGES", raising=False) + + +def _install_events_tool( + pin_package_environment, + install_tool_package, + recorded: dict[str, Any], + events: list[str], + amend: Any | None = None, +) -> None: + """Pin the package environment and install one tool recording both run events. + + The tool subscribes ``run_created`` and ``run_completed`` and stores the + received read-only contexts in ``recorded`` (the facts a hook observes), + appending to ``events`` so orderings are falsifiable. ``amend`` optionally + adds an ``amend_workflow`` subscription (e.g. a loud canary or a + contribution). + """ + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + def register(hooks: object) -> None: + def on_created(self: object, context: object) -> None: + events.append("created") + recorded["created"] = context + + def on_completed(self: object, context: object) -> None: + events.append("completed") + recorded["completed"] = context + + hooks.subscribe("pipeline", "run_created", "notify", on_created) # type: ignore[attr-defined] + hooks.subscribe("pipeline", "run_completed", "notify", on_completed) # type: ignore[attr-defined] + if amend is not None: + hooks.subscribe("pipeline", "amend_workflow", "amend", amend) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register) + + +# --- Contract tests — the new import wiring --- + + +class TestRunPipelineHooksWiringContract: + def test_module_imports_the_zone_checkpoint_surface(self) -> None: + """The run module imports PipelineHooks & co. from the ``.hooks`` zone.""" + assert _run_pipeline_module.PipelineHooks is PipelineHooks + assert _run_pipeline_module.PipelineIdentity is PipelineIdentity + assert _run_pipeline_module.WorkflowDecision is WorkflowDecision + assert _run_pipeline_module.WorkflowOverlay is WorkflowOverlay + assert _run_pipeline_module.WorkIdentity is WorkIdentity + assert _run_pipeline_module.CompositionStage is CompositionStage + + def test_module_imports_the_history_fact_resolvers(self) -> None: + """The run module imports the four history names from ``..history``.""" + from goga.history import ( + assemble_status_scale, + resolve_current_branch_name, + resolve_topic_dir, + resolve_topic_status, + ) + + assert _run_pipeline_module.assemble_status_scale is assemble_status_scale + assert _run_pipeline_module.resolve_current_branch_name is resolve_current_branch_name + assert _run_pipeline_module.resolve_topic_dir is resolve_topic_dir + assert _run_pipeline_module.resolve_topic_status is resolve_topic_status + + +# --- Logic tests — the design scenarios over the real platform --- + + +class TestRunPipelineEventSequence: + # The fixture lists are the scenarios' real dependencies (tmp layout + the + # platform boundary factories) — the design's General Setup fixes them. + def test_run_pipeline_full_event_sequence_around_launch( # noqa: PLR0913, PLR0917 + self, + tmp_path: Path, + isolated_cwd: Path, + afm_dir: Path, + monkeypatch: pytest.MonkeyPatch, + pin_package_environment, + install_tool_package, + ) -> None: + """Facts resolve, the layer composes, and both events bracket the launch. + + No workflow file exists (silent-miss — the layer stays active onto the + empty base); the tool subscribes only the two notifications, so the + amendment is the passthrough and ``compile_flow`` receives ``None``. + The run returns run_flow's exit code; the creation fires before the + launch with the composition and statuses of the compiled moment. + """ + recorded: dict[str, Any] = {} + events: list[str] = [] + _install_events_tool(pin_package_environment, install_tool_package, recorded, events) + _isolate_workflow_env(monkeypatch) + monkeypatch.setattr(_run_pipeline_module, "resolve_current_branch_name", lambda: "feature-demo") + + project_dir = tmp_path / "project_pipelines" + _write_pipeline(project_dir) + _write_topic(isolated_cwd, "feature-demo") + + def _run(*args: object, **kwargs: object) -> int: + events.append("run") + return 3 + + with ( + mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_documents()) as mock_compile, + mock.patch.object(_run_pipeline_module, "run_flow", side_effect=_run), + ): + result = run_pipeline("deploy", project_dir, tmp_path / "user_pipelines", 50321) + + assert result == 3 + created = recorded["created"] + assert created.pipeline.name == "deploy" + assert created.decision.kind == "silent-miss" + assert created.composition == [CompositionStage(id="build", title="Build")] + assert created.statuses == ["todo"] + assert created.runtime_dir == afm_dir.as_posix() + assert created.work == WorkIdentity(branch="feature-demo", slug="feature-demo", year=current_year()) + assert recorded["completed"].exit_code == 3 + # The silent-miss amendment was the passthrough onto the empty base. + assert created.workflow is None + assert mock_compile.call_args.kwargs["workflow"] is None + # created recorded before run_flow called, completed after. + assert events == ["created", "run", "completed"] + + def test_spawn_failure_still_emits_completion_with_code( # noqa: PLR0913, PLR0917 + self, + tmp_path: Path, + isolated_cwd: Path, + afm_dir: Path, + monkeypatch: pytest.MonkeyPatch, + pin_package_environment, + install_tool_package, + ) -> None: + """A 127 spawn failure is a return code — the completion still fires with it.""" + recorded: dict[str, Any] = {} + events: list[str] = [] + _install_events_tool(pin_package_environment, install_tool_package, recorded, events) + _isolate_workflow_env(monkeypatch) + monkeypatch.setattr(_run_pipeline_module, "resolve_current_branch_name", lambda: "feature-demo") + + project_dir = tmp_path / "project_pipelines" + _write_pipeline(project_dir) + _write_topic(isolated_cwd, "feature-demo") + + with ( + mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_documents()), + mock.patch.object(_run_pipeline_module, "run_flow", return_value=127), + ): + result = run_pipeline("deploy", project_dir, tmp_path / "user_pipelines", 50321) + + assert result == 127 + assert recorded["completed"].exit_code == 127 + assert events == ["created", "completed"] # completed recorded after created + + def test_missing_pipeline_and_structural_error_fire_no_events( # noqa: PLR0913, PLR0917 + self, + tmp_path: Path, + isolated_cwd: Path, + afm_dir: Path, + monkeypatch: pytest.MonkeyPatch, + pin_package_environment, + install_tool_package, + ) -> None: + """Both pre-checkpoint failure paths return/raise before any event fires.""" + recorded: dict[str, Any] = {} + events: list[str] = [] + _install_events_tool(pin_package_environment, install_tool_package, recorded, events) + _isolate_workflow_env(monkeypatch) + monkeypatch.setattr(_run_pipeline_module, "resolve_current_branch_name", lambda: "feature-demo") + + project_dir = tmp_path / "project_pipelines" + _write_pipeline(project_dir) + + with ( + mock.patch.object(_run_pipeline_module, "compile_flow") as mock_compile, + mock.patch.object(_run_pipeline_module, "run_flow") as mock_run_flow, + ): + result = run_pipeline("nope", project_dir, tmp_path / "user_pipelines", 50321) + + assert result == 1 # return 1 at discovery — checkpoints unreached + assert events == [] + assert recorded == {} + mock_compile.assert_not_called() + mock_run_flow.assert_not_called() + + # Case B — a malformed resolved workflow-file: WorkflowSyntaxError + # propagates from resolve_workflow, before the delivery. + workflows_dir = isolated_cwd / ".goga" / "workflows" + workflows_dir.mkdir(parents=True) + (workflows_dir / "custom.yml").write_text("bogus_key: value\n") + monkeypatch.setenv("GOGA_WORKFLOW_NAME", "custom") + + with ( + mock.patch.object(_run_pipeline_module, "compile_flow") as mock_compile, + mock.patch.object(_run_pipeline_module, "run_flow") as mock_run_flow, + pytest.raises(WorkflowSyntaxError, match="unknown key in workflow"), + ): + run_pipeline("deploy", project_dir, tmp_path / "user_pipelines", 50321) + + assert events == [] + mock_compile.assert_not_called() + mock_run_flow.assert_not_called() + + +class TestRunPipelineWorkIdentity: + def test_work_identity_unknown_branch_and_empty_slug_guard( # noqa: PLR0913, PLR0917 + self, + tmp_path: Path, + isolated_cwd: Path, + afm_dir: Path, + monkeypatch: pytest.MonkeyPatch, + pin_package_environment, + install_tool_package, + ) -> None: + """A None branch reads "unknown"; an unsluggable branch is guarded branch-only. + + Case A — detached HEAD / missing git: the literal "unknown" branch + hosts no topic directory, so the branch-only form. Case B — a fully + non-ASCII branch (every character drops, the slug is empty) makes + resolve_topic_dir raise ValueError; the guard absorbs it and the form + stays branch-only. Neither case is an error — events still fire. + """ + recorded: dict[str, Any] = {} + events: list[str] = [] + _install_events_tool(pin_package_environment, install_tool_package, recorded, events) + _isolate_workflow_env(monkeypatch) + + project_dir = tmp_path / "project_pipelines" + _write_pipeline(project_dir) + + monkeypatch.setattr(_run_pipeline_module, "resolve_current_branch_name", lambda: None) + with ( + mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_documents()), + mock.patch.object(_run_pipeline_module, "run_flow", return_value=0), + ): + run_pipeline("deploy", project_dir, tmp_path / "user_pipelines", 50321) + + work = recorded["created"].work + assert (work.branch, work.slug, work.year) == ("unknown", None, None) + + monkeypatch.setattr(_run_pipeline_module, "resolve_current_branch_name", lambda: "Ветка") + with ( + mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_documents()), + mock.patch.object(_run_pipeline_module, "run_flow", return_value=0), + ): + run_pipeline("deploy", project_dir, tmp_path / "user_pipelines", 50321) + + work = recorded["created"].work + assert work.branch == "Ветка" + assert work.slug is None + assert work.year is None + # No exception; events still fired on both runs. + assert events == ["created", "completed", "created", "completed"] + + +class TestRunPipelineDecisionMatrix: + def test_workflow_decision_kind_derivation_matrix( # noqa: PLR0913, PLR0917 + self, + tmp_path: Path, + isolated_cwd: Path, + afm_dir: Path, + monkeypatch: pytest.MonkeyPatch, + pin_package_environment, + install_tool_package, + ) -> None: + """Every env configuration derives its (kind, workflow_name) exactly. + + disabled wins; a resolved document under an explicit name is + "explicit"; under no name "auto-match"; a missing document (explicit + or auto miss) is a silent miss. Observed through the recorded + creation facts of each run. + """ + recorded: dict[str, Any] = {} + events: list[str] = [] + _install_events_tool(pin_package_environment, install_tool_package, recorded, events) + monkeypatch.setattr(_run_pipeline_module, "resolve_current_branch_name", lambda: "feature-demo") + + project_dir = tmp_path / "project_pipelines" + _write_pipeline(project_dir) + workflows_dir = isolated_cwd / ".goga" / "workflows" + workflows_dir.mkdir(parents=True) + (workflows_dir / "ci.yml").write_text("prompt: ci\n") + + def _run_once() -> None: + recorded.clear() + with ( + mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_documents()), + mock.patch.object(_run_pipeline_module, "run_flow", return_value=0), + ): + run_pipeline("deploy", project_dir, tmp_path / "user_pipelines", 50321) + + def _assert_decision(label: str, expected: tuple[str, str | None]) -> None: + decision = recorded["created"].decision + assert (decision.kind, decision.workflow_name) == expected, label + + # Auto-miss first — the basename file does not exist yet. + _isolate_workflow_env(monkeypatch) + _run_once() + _assert_decision("auto-miss", ("silent-miss", None)) + + # Auto-match hit — the basename file now exists. + (workflows_dir / "deploy.yml").write_text("prompt: authored\n") + _isolate_workflow_env(monkeypatch) + _run_once() + _assert_decision("auto-match hit", ("auto-match", "deploy")) + + # Explicit name that resolves — and one that does not. + monkeypatch.setenv("GOGA_WORKFLOW_NAME", "ci") + _run_once() + _assert_decision("explicit hit", ("explicit", "ci")) + + monkeypatch.setenv("GOGA_WORKFLOW_NAME", "ghost") + _run_once() + _assert_decision("explicit miss", ("silent-miss", None)) + + # Disabled wins over everything. + monkeypatch.setenv("GOGA_WORKFLOW_NAME", "ignored") + monkeypatch.setenv("GOGA_WORKFLOW_DISABLED", "1") + _run_once() + _assert_decision("disabled", ("disabled", None)) + + +class TestRunPipelineAmendmentAndStatuses: + def test_disabled_decision_skips_delivery_compiles_raw_and_still_emits( # noqa: PLR0913, PLR0917 + self, + tmp_path: Path, + isolated_cwd: Path, + afm_dir: Path, + monkeypatch: pytest.MonkeyPatch, + pin_package_environment, + install_tool_package, + ) -> None: + """A disabled decision delivers nothing, compiles raw, still emits both events. + + The tool's amendment hook fails loudly when called, so a clean return + proves the delivery never ran. The auto-match workflow exists — the + disable must win — and the overlay is the passthrough of the empty + base, so ``compile_flow`` receives ``workflow=None``. + """ + recorded: dict[str, Any] = {} + events: list[str] = [] + + def canary(self: object, context: object) -> None: + raise AssertionError("amend hook must not run for a disabled decision") + + _install_events_tool(pin_package_environment, install_tool_package, recorded, events, amend=canary) + + monkeypatch.setenv("GOGA_WORKFLOW_DISABLED", "1") + monkeypatch.delenv("GOGA_WORKFLOW_NAME", raising=False) + monkeypatch.delenv("GOGA_SKIP_STAGES", raising=False) + monkeypatch.setattr(_run_pipeline_module, "resolve_current_branch_name", lambda: "feature-demo") + + project_dir = tmp_path / "project_pipelines" + _write_pipeline(project_dir) + workflows_dir = isolated_cwd / ".goga" / "workflows" + workflows_dir.mkdir(parents=True) + (workflows_dir / "deploy.yml").write_text("prompt: authored\n") + + with ( + mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_documents()) as mock_compile, + mock.patch.object(_run_pipeline_module, "run_flow", return_value=0), + ): + result = run_pipeline("deploy", project_dir, tmp_path / "user_pipelines", 50321) + + assert result == 0 + assert recorded["created"].decision.kind == "disabled" + assert recorded["created"].provenance == [] + assert mock_compile.call_args.kwargs["workflow"] is None + assert events == ["created", "completed"] # no "amend" entry — both events fired + + def test_statuses_recomputed_at_completion_and_branch_only_stays_empty( # noqa: PLR0913, PLR0917 + self, + tmp_path: Path, + isolated_cwd: Path, + afm_dir: Path, + monkeypatch: pytest.MonkeyPatch, + pin_package_environment, + install_tool_package, + ) -> None: + """Hosting: statuses recompute at the completion moment; branch-only: none, no scale. + + The hosting run starts at ``todo``; the fake run writes + ``completed/plan.md``, which outranks ``todo.md`` in the scale, so the + completion emission reports ``done`` — the maximal-present recompute + at the moment. The branch-only run (a branch hosting no topic) keeps + ``[]`` at both moments and never assembles the scale: the enumeration + boundary reads exactly once — the pipeline registry build alone. + """ + recorded: dict[str, Any] = {} + events: list[str] = [] + _install_events_tool(pin_package_environment, install_tool_package, recorded, events) + _isolate_workflow_env(monkeypatch) + monkeypatch.setattr(_run_pipeline_module, "resolve_current_branch_name", lambda: "feature-demo") + + project_dir = tmp_path / "project_pipelines" + _write_pipeline(project_dir) + topic_dir = _write_topic(isolated_cwd, "feature-demo") + + def _run_writes_done(*args: object, **kwargs: object) -> int: + events.append("run") + completed_dir = topic_dir / "completed" + completed_dir.mkdir() + (completed_dir / "plan.md").write_text("plan\n") + return 0 + + with ( + mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_documents()), + mock.patch.object(_run_pipeline_module, "run_flow", side_effect=_run_writes_done), + ): + result = run_pipeline("deploy", project_dir, tmp_path / "user_pipelines", 50321) + + assert result == 0 + assert recorded["created"].statuses == ["todo"] + assert recorded["completed"].statuses == ["done"] + + # Branch-only — a fresh boundary pin counts from zero; the branch + # hosts no topic directory, so no scale assembly ever runs. + branch_recorded: dict[str, Any] = {} + branch_events: list[str] = [] + boundary = pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + def register_branch(hooks: object) -> None: + def on_created(self: object, context: object) -> None: + branch_events.append("created") + branch_recorded["created"] = context + + def on_completed(self: object, context: object) -> None: + branch_events.append("completed") + branch_recorded["completed"] = context + + hooks.subscribe("pipeline", "run_created", "notify", on_created) # type: ignore[attr-defined] + hooks.subscribe("pipeline", "run_completed", "notify", on_completed) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register_branch) + monkeypatch.setattr(_run_pipeline_module, "resolve_current_branch_name", lambda: "solo-branch") + + with ( + mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_documents()), + mock.patch.object(_run_pipeline_module, "run_flow", return_value=0), + ): + result = run_pipeline("deploy", project_dir, tmp_path / "user_pipelines", 50321) + + assert result == 0 + assert branch_recorded["created"].statuses == [] + assert branch_recorded["completed"].statuses == [] + assert branch_events == ["created", "completed"] + assert boundary.call_count == 1 # only the pipeline registry build + + def test_emit_soft_failure_warns_and_never_affects_exit_code( # noqa: PLR0913, PLR0917 + self, + tmp_path: Path, + isolated_cwd: Path, + afm_dir: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + pin_package_environment, + install_tool_package, + ) -> None: + """A failing run_completed hook warns; the run's exit code is unaffected.""" + recorded: dict[str, Any] = {} + events: list[str] = [] + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + def register(hooks: object) -> None: + def on_created(self: object, context: object) -> None: + events.append("created") + recorded["created"] = context + + def on_completed(self: object, context: object) -> None: + events.append("completed") + raise RuntimeError("boom") + + hooks.subscribe("pipeline", "run_created", "notify", on_created) # type: ignore[attr-defined] + hooks.subscribe("pipeline", "run_completed", "notify", on_completed) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register) + _isolate_workflow_env(monkeypatch) + monkeypatch.setattr(_run_pipeline_module, "resolve_current_branch_name", lambda: "feature-demo") + + project_dir = tmp_path / "project_pipelines" + _write_pipeline(project_dir) + + with ( + mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_documents()), + mock.patch.object(_run_pipeline_module, "run_flow", return_value=0), + caplog.at_level(logging.WARNING), + ): + result = run_pipeline("deploy", project_dir, tmp_path / "user_pipelines", 50321) + + assert result == 0 + assert events == ["created", "completed"] # the launch happened, the emission ran + assert "demo" in caplog.text + assert "run_completed" in caplog.text + assert "boom" in caplog.text diff --git a/tests/pipeline/test_run_pipeline_workflow.py b/tests/pipeline/test_run_pipeline_workflow.py index b626484d..3a59fe73 100644 --- a/tests/pipeline/test_run_pipeline_workflow.py +++ b/tests/pipeline/test_run_pipeline_workflow.py @@ -28,6 +28,11 @@ # workflow-focused tests. _PROMPT_STEMS = ("planning", "implementation", "review", "summary") +# The minimal valid pipeline-file — the fact-resolution step parses the file +# via ``parse_dsl`` (the header read), so the fixture text must be valid DSL +# (string name/description in the header, ``---`` body separator). +_MINIMAL_YML = "name: Deploy\ndescription: d\n---\n\nbuild:\n title: Build\n" + def _fake_documents() -> tuple[PipelineDocument, FlowDocument]: """Build the documents tuple ``compile_flow`` returns, for mock wiring. @@ -85,7 +90,7 @@ def test_run_pipeline_with_workflow_env_name( project_dir = tmp_path / ".goga" / "pipelines" project_dir.mkdir(parents=True) - (project_dir / "deploy.yml").write_text("pipeline") + (project_dir / "deploy.yml").write_text(_MINIMAL_YML) with ( mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_fake_documents()) as mock_compile, @@ -114,7 +119,7 @@ def test_run_pipeline_workflow_disabled_env_overrides_name( project_dir = tmp_path / ".goga" / "pipelines" project_dir.mkdir(parents=True) - (project_dir / "deploy.yml").write_text("pipeline") + (project_dir / "deploy.yml").write_text(_MINIMAL_YML) with ( mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_fake_documents()) as mock_compile, @@ -136,7 +141,7 @@ def test_run_pipeline_basename_fallback_silent_miss( # No .goga/workflows/ dir at all — the basename fallback (deploy.yml) misses. project_dir = tmp_path / ".goga" / "pipelines" project_dir.mkdir(parents=True) - (project_dir / "deploy.yml").write_text("pipeline") + (project_dir / "deploy.yml").write_text(_MINIMAL_YML) with ( mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_fake_documents()) as mock_compile, @@ -162,7 +167,7 @@ def test_run_pipeline_basename_fallback_hit( project_dir = tmp_path / ".goga" / "pipelines" project_dir.mkdir(parents=True) - (project_dir / "deploy.yml").write_text("pipeline") + (project_dir / "deploy.yml").write_text(_MINIMAL_YML) with ( mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_fake_documents()) as mock_compile, @@ -195,7 +200,7 @@ def test_run_pipeline_propagates_workflow_syntax_error( project_dir = tmp_path / ".goga" / "pipelines" project_dir.mkdir(parents=True) - (project_dir / "deploy.yml").write_text("pipeline") + (project_dir / "deploy.yml").write_text(_MINIMAL_YML) with ( mock.patch.object(_run_pipeline_module, "compile_flow") as mock_compile, @@ -225,7 +230,7 @@ def test_run_pipeline_workflow_name_missing_file_silent_miss( (tmp_path / ".goga" / "workflows").mkdir(parents=True) project_dir = tmp_path / ".goga" / "pipelines" project_dir.mkdir(parents=True) - (project_dir / "deploy.yml").write_text("pipeline") + (project_dir / "deploy.yml").write_text(_MINIMAL_YML) with ( mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_fake_documents()) as mock_compile, @@ -252,7 +257,7 @@ def test_run_pipeline_workflow_name_path_traversal_silent_miss( project_dir = tmp_path / ".goga" / "pipelines" project_dir.mkdir(parents=True) - (project_dir / "deploy.yml").write_text("pipeline") + (project_dir / "deploy.yml").write_text(_MINIMAL_YML) with ( mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_fake_documents()) as mock_compile, @@ -293,7 +298,7 @@ def test_run_pipeline_env_disabled_takes_precedence_over_name( project_dir = tmp_path / ".goga" / "pipelines" project_dir.mkdir(parents=True) - (project_dir / "deploy.yml").write_text("pipeline") + (project_dir / "deploy.yml").write_text(_MINIMAL_YML) with ( mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_fake_documents()) as mock_compile, From fc39ef5c1cf501b079082acee700b3628bb8f5c1 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 18 Sep 2026 16:23:42 +0000 Subject: [PATCH 075/205] feat: render the card tools line and clean hard-amendment errors in the pipeline CLI --- .goga/history/2026/add-pipeline-hooks/plan.md | 14 +- goga/pipeline/cli.py | 38 ++- tests/pipeline/test_pipeline_cli.py | 257 ++++++++++++++++++ 3 files changed, 299 insertions(+), 10 deletions(-) diff --git a/.goga/history/2026/add-pipeline-hooks/plan.md b/.goga/history/2026/add-pipeline-hooks/plan.md index 8d20ea00..6bb74e72 100644 --- a/.goga/history/2026/add-pipeline-hooks/plan.md +++ b/.goga/history/2026/add-pipeline-hooks/plan.md @@ -1451,16 +1451,16 @@ untouched. **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] **Contract tests**: extend `tests/pipeline/test_pipeline_cli.py` — the card template +- [x] **Contract tests**: extend `tests/pipeline/test_pipeline_cli.py` — the card template requirement: "when the card provenance is non-empty, one blank line and one `tools:` field line follow the stage blocks — the contributing tools comma-separated in provenance order; an empty provenance adds nothing — the output stays byte-identical to the provenance-free card" (fails now — expected). -- [ ] **Code**: add the `tools:` block to `_run_card`; add `ValueError` and `ImportError` +- [x] **Code**: add the `tools:` block to `_run_card`; add `ValueError` and `ImportError` to the `_run_card` and `_run_execution` caught tuples. -- [ ] **Interface verification**: `python -m pytest tests/pipeline/test_pipeline_cli.py -q` +- [x] **Interface verification**: `python -m pytest tests/pipeline/test_pipeline_cli.py -q` — all pass, including every pre-existing template test (byte-identity regression). -- [ ] **Logic tests** (design scenarios, verbatim): +- [x] **Logic tests** (design scenarios, verbatim): ``` test_cli_card_renders_tools_line_and_stays_byte_identical_without_it @@ -1490,12 +1490,12 @@ untouched. exit code != 0 ``` -- [ ] **Debugging**: `python -m pytest tests/pipeline/test_pipeline_cli.py tests/pipeline -q` +- [x] **Debugging**: `python -m pytest tests/pipeline/test_pipeline_cli.py tests/pipeline -q` — fix implementation code until all tests pass (do NOT fix test code). -- [ ] **Contract re-verification**: every template requirement (flat list, overview, +- [x] **Contract re-verification**: every template requirement (flat list, overview, card, tools line); no traceback for any operation failure; `--port`/`--parallel` behavior untouched. -- [ ] **Lint**: `python -m ruff check goga/pipeline/cli.py tests/pipeline/test_pipeline_cli.py && python -m ruff format --check goga/pipeline/cli.py tests/pipeline/test_pipeline_cli.py` — fix formatting if necessary. +- [x] **Lint**: `python -m ruff check goga/pipeline/cli.py tests/pipeline/test_pipeline_cli.py && python -m ruff format --check goga/pipeline/cli.py tests/pipeline/test_pipeline_cli.py` — fix formatting if necessary. ### Task 12: Integration verification of the wired flows (integration tests) diff --git a/goga/pipeline/cli.py b/goga/pipeline/cli.py index e25bb0f6..eec68163 100644 --- a/goga/pipeline/cli.py +++ b/goga/pipeline/cli.py @@ -123,10 +123,19 @@ def _run_overview(project_dir: Path, user_dir: Path) -> int: def _run_card(args: argparse.Namespace, project_dir: Path, user_dir: Path) -> int: - """Operation (c): the card — name/description fields, a `---` separator, stage bullets.""" + """Operation (c): the card — name/description fields, a `---` separator, stage bullets, `tools:` line.""" try: card = describe_pipeline(args.name, project_dir, user_dir, workflow=args.workflow, no_workflow=args.no_workflow) - except (StructuralError, WorkflowSyntaxError, RuntimeError, yaml.YAMLError, OSError, UnicodeDecodeError) as exc: + except ( + StructuralError, + WorkflowSyntaxError, + RuntimeError, + yaml.YAMLError, + OSError, + UnicodeDecodeError, + ValueError, + ImportError, + ) as exc: print(f"Error: {exc}", file=sys.stderr) return 1 @@ -140,6 +149,14 @@ def _run_card(args: argparse.Namespace, project_dir: Path, user_dir: Path) -> in print(f"* {stage.id}:") print(f" title: {stage.title}") + # The contributing tools, comma-separated in provenance (enumeration) + # order. One blank line + one field line whenever provenance is non-empty; + # an empty provenance adds nothing, so the output stays byte-identical to + # the provenance-free card. + if card.provenance: + print() + print(f"tools: {', '.join(card.provenance)}") + return 0 @@ -165,6 +182,18 @@ def _run_execution(args: argparse.Namespace, project_dir: Path, user_dir: Path) except UnicodeDecodeError as exc: print(f"Error: pipeline '{args.name}' is not valid UTF-8: {exc}", file=sys.stderr) return 1 + except ValueError as exc: + # The hard pipeline.amend_workflow failure — the run stopped before + # any compile or launch. Caught after the specific ValueError + # subclasses above so they keep their tailored messages. + print(f"Error: pipeline '{args.name}' was not amended: {exc}", file=sys.stderr) + return 1 + except ImportError as exc: + # The fatal registry-build error of the hooks platform (a broken tool + # package facade) — cf. the (ValueError, ImportError) precedent in + # goga/commands/history/history.py. + print(f"Error: {exc}", file=sys.stderr) + return 1 def pipeline_cli(argv: list[str]) -> int: @@ -185,7 +214,10 @@ def pipeline_cli(argv: list[str]) -> int: on a run without ``--info``); ``1`` when an info operation raises :class:`StructuralError`, :class:`WorkflowSyntaxError`, :class:`RuntimeError`, :class:`yaml.YAMLError`, - :class:`OSError`, or :class:`UnicodeDecodeError` — these are + :class:`OSError`, :class:`UnicodeDecodeError`, + :class:`ValueError` (the hard ``pipeline.amend_workflow`` + failure), or :class:`ImportError` (the fatal hooks-registry + build) — these are caught here and reported as a clean stderr message rather than propagated as a traceback; ``1`` likewise when :func:`run_pipeline` raises one of its handled failures; diff --git a/tests/pipeline/test_pipeline_cli.py b/tests/pipeline/test_pipeline_cli.py index 3b2aa442..242d586f 100644 --- a/tests/pipeline/test_pipeline_cli.py +++ b/tests/pipeline/test_pipeline_cli.py @@ -17,6 +17,11 @@ # [[feedback_mock_patch_module_shadowing]]. _cli_module = sys.modules["goga.pipeline.cli"] +# The same shadowing applies to the run module the hard-amendment scenario +# mocks at its module boundaries (``compile_flow`` / ``run_flow`` / +# ``resolve_current_branch_name``) — cf. tests/pipeline/test_run_pipeline_hooks.py. +_run_pipeline_module = sys.modules["goga.pipeline.run_pipeline"] + # General Setup fixtures (STAGES DSL file + workflow files). _DEPLOY_YML = """\ name: Deploy @@ -681,3 +686,255 @@ def test_pipeline_cli_run_non_utf8_pipeline_renders_clean_error( captured = capsys.readouterr() assert captured.err.startswith("Error:") assert "Traceback" not in captured.err + + +@pytest.fixture +def afm_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Point AFM_DIR at a tmp dir and return the resolved path. + + Mirrored from ``tests/pipeline/test_run_pipeline_hooks.py`` — the + hard-amendment scenario stops the run before any compile or launch, but + the runtime-dir resolution (step 4) must still pass. + """ + directory = (tmp_path / ".afm").resolve() + monkeypatch.setenv("AFM_DIR", str(directory)) + return directory + + +def _isolate_workflow_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Clear the three workflow/skip env inputs for a deterministic resolution.""" + monkeypatch.delenv("GOGA_WORKFLOW_DISABLED", raising=False) + monkeypatch.delenv("GOGA_WORKFLOW_NAME", raising=False) + monkeypatch.delenv("GOGA_SKIP_STAGES", raising=False) + + +class TestPipelineCliCardToolsContract: + def test_card_provenance_renders_blank_line_and_tools_field( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Non-empty provenance: one blank line and one `tools:` field line follow the stage blocks.""" + monkeypatch.setattr(Path, "cwd", lambda: tmp_path) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + + card = PipelineCard( + name="Deploy", + description="Deploy the service", + stages=[CardStage(id="build", title="Build")], + provenance=["t1", "t2"], + ) + with mock.patch.object(_cli_module, "describe_pipeline", return_value=card): + exit_code = pipeline_cli(["run", "deploy", "--info"]) + + assert exit_code == 0 + captured = capsys.readouterr() + assert captured.out.endswith("* build:\n title: Build\n\ntools: t1, t2\n") + + def test_card_provenance_comma_separated_in_provenance_order( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """The contributing tools are comma-separated in provenance (enumeration) order.""" + monkeypatch.setattr(Path, "cwd", lambda: tmp_path) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + + card = PipelineCard( + name="Deploy", + description="Deploy the service", + stages=[], + provenance=["later-tool", "earlier-tool", "third-tool"], + ) + with mock.patch.object(_cli_module, "describe_pipeline", return_value=card): + exit_code = pipeline_cli(["run", "deploy", "--info"]) + + assert exit_code == 0 + captured = capsys.readouterr() + assert captured.out.endswith("\ntools: later-tool, earlier-tool, third-tool\n") + + def test_card_empty_provenance_adds_nothing_byte_identical( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Default provenance: no `tools:` anywhere — the output stays byte-identical to before.""" + monkeypatch.setattr(Path, "cwd", lambda: tmp_path) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + + card = PipelineCard( + name="Deploy", description="Deploy the service", stages=[CardStage(id="build", title="Build")] + ) + with mock.patch.object(_cli_module, "describe_pipeline", return_value=card): + exit_code = pipeline_cli(["run", "deploy", "--info"]) + + assert exit_code == 0 + captured = capsys.readouterr() + assert "tools:" not in captured.out + assert captured.out == ("name: Deploy\ndescription: Deploy the service\n\n---\n\n* build:\n title: Build\n") + + def test_card_zero_stages_tools_follows_separator_blank_line( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """With zero stages the tools block follows the separator's blank line — deterministic.""" + monkeypatch.setattr(Path, "cwd", lambda: tmp_path) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + + card = PipelineCard(name="Deploy", description="Deploy the service", stages=[], provenance=["t1"]) + with mock.patch.object(_cli_module, "describe_pipeline", return_value=card): + exit_code = pipeline_cli(["run", "deploy", "--info"]) + + assert exit_code == 0 + captured = capsys.readouterr() + assert captured.out == "name: Deploy\ndescription: Deploy the service\n\n---\n\n\ntools: t1\n" + + def test_card_renders_value_error_and_import_error_cleanly( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """The hard amendment (ValueError) and the registry build (ImportError) render cleanly on the card path.""" + monkeypatch.setattr(Path, "cwd", lambda: tmp_path) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + + failures = [ + ValueError("hook amend of tool demo failed on pipeline.amend_workflow: boom"), + ImportError("cannot import facade of goga_tool_broken"), + ] + for failure in failures: + with mock.patch.object(_cli_module, "describe_pipeline", side_effect=failure): + exit_code = pipeline_cli(["run", "deploy", "--info"]) + + assert exit_code == 1 + captured = capsys.readouterr() + assert captured.err.startswith("Error:") + assert str(failure) in captured.err + assert "Traceback" not in captured.err + assert captured.out == "" + + def test_execution_renders_value_error_and_import_error_cleanly( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """The hard amendment (ValueError) and the registry build (ImportError) render cleanly on the run path.""" + monkeypatch.setattr(Path, "cwd", lambda: tmp_path) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + + failures = [ + ValueError("hook amend of tool demo failed on pipeline.amend_workflow: boom"), + ImportError("cannot import facade of goga_tool_broken"), + ] + for failure in failures: + with mock.patch.object(_cli_module, "run_pipeline", side_effect=failure): + exit_code = pipeline_cli(["run", "deploy", "--port", "50321"]) + + assert exit_code == 1 + captured = capsys.readouterr() + assert captured.err.startswith("Error:") + assert str(failure) in captured.err + assert "Traceback" not in captured.err + assert captured.out == "" + + +class TestPipelineCliHooksRendering: + def test_cli_card_renders_tools_line_and_stays_byte_identical_without_it( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Design scenario: the tools line renders with provenance; without it the bytes are unchanged.""" + monkeypatch.setattr(Path, "cwd", lambda: tmp_path) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + + card_with = PipelineCard( + name="Deploy", + description="Deploy the service", + stages=[CardStage(id="build", title="Build")], + provenance=["t1", "t2"], + ) + card_without = PipelineCard( + name="Deploy", description="Deploy the service", stages=[CardStage(id="build", title="Build")] + ) + + with mock.patch.object(_cli_module, "describe_pipeline", return_value=card_with): + pipeline_cli(["run", "deploy", "--info"]) + out_with = capsys.readouterr().out + + with mock.patch.object(_cli_module, "describe_pipeline", return_value=card_without): + pipeline_cli(["run", "deploy", "--info"]) + out_without = capsys.readouterr().out + + assert out_with.endswith("\ntools: t1, t2\n") + assert "tools:" not in out_without + assert out_without == ("name: Deploy\ndescription: Deploy the service\n\n---\n\n* build:\n title: Build\n") + + def test_run_pipeline_hard_amendment_renders_clean_error_no_launch( # noqa: PLR0913, PLR0917 + self, + tmp_path: Path, + afm_dir: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + pin_package_environment, + install_tool_package, + ) -> None: + """Design scenario: a hard amendment failure renders cleanly and stops everything. + + The tool's amend_workflow hook raises; the zone wraps the failure in + its ValueError, which the CLI renders as a clean stderr message (no + traceback). The stop happens before any compile, prompt write, or + launch — run_flow is never called, the prompts directory is never + created, and no run event fires. + """ + events: list[str] = [] + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + def register(hooks: object) -> None: + def on_amend(self: object, context: object) -> None: + events.append("amend") + raise RuntimeError("boom") + + def on_created(self: object, context: object) -> None: + events.append("created") + + def on_completed(self: object, context: object) -> None: + events.append("completed") + + hooks.subscribe("pipeline", "amend_workflow", "amend", on_amend) # type: ignore[attr-defined] + hooks.subscribe("pipeline", "run_created", "notify", on_created) # type: ignore[attr-defined] + hooks.subscribe("pipeline", "run_completed", "notify", on_completed) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register) + + _isolate_workflow_env(monkeypatch) + monkeypatch.setattr(_run_pipeline_module, "resolve_current_branch_name", lambda: "feature-demo") + + project_root = tmp_path / "project" + _write_pipeline(project_root, "deploy", _DEPLOY_YML) + monkeypatch.setattr(Path, "cwd", lambda: project_root) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + + with ( + mock.patch.object(_run_pipeline_module, "compile_flow") as mock_compile, + mock.patch.object(_run_pipeline_module, "run_flow") as mock_run_flow, + ): + exit_code = pipeline_cli(["run", "deploy", "--port", "50321"]) + + assert exit_code != 0 + captured = capsys.readouterr() + assert "pipeline.amend_workflow" in captured.err + assert "boom" in captured.err + assert "Traceback" not in captured.err + mock_compile.assert_not_called() + mock_run_flow.assert_not_called() + assert not (afm_dir / "prompts").exists() + assert events == ["amend"] From 7632af0e823533ee0bc7ad5443ad1b48ecf52545 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 18 Sep 2026 16:27:46 +0000 Subject: [PATCH 076/205] feat: verify the wired pipeline hooks flows end to end --- .goga/history/2026/add-pipeline-hooks/plan.md | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/.goga/history/2026/add-pipeline-hooks/plan.md b/.goga/history/2026/add-pipeline-hooks/plan.md index 6bb74e72..b51f4819 100644 --- a/.goga/history/2026/add-pipeline-hooks/plan.md +++ b/.goga/history/2026/add-pipeline-hooks/plan.md @@ -1510,18 +1510,27 @@ General Setup). This task verifies the composed whole and guards the read-only s **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] Run the full suite: `python -m pytest` — every test green (the pre-feature suites - are the byte-identical/no-tools regression proof; any failure is a defect, not a - fixture problem). -- [ ] Verify the zone facade: `python -c "import goga.pipeline.hooks as h; assert sorted(h.__all__) == ['CompositionStage', 'PipelineHooks', 'PipelineIdentity', 'RunCompleted', 'RunCreated', 'ToolContribution', 'WorkIdentity', 'WorkflowAmendment', 'WorkflowDecision', 'WorkflowOverlay', 'merge_workflow_overlay']"` - — the facade IS the contract surface. -- [ ] Verify the cell graph: `goga lint` — 78 cells, 0 errors (contracts and cells stay +- [x] Run the full suite: `python -m pytest` — 5686 passed, 9 failed. All 9 failures + (docker runner/integration, onboarding integration, `python -m` subprocess entrypoint, + `--version` enumeration pin) reproduce identically on the pre-feature base commit + `751c2b9` in a scratch worktree — sandbox environment limitations (docker binary absent, + exit 127; subprocess env loses `yaml`; no installed package metadata for the version + gate), pre-existing and not feature defects (environment-verification items, not + automatable here). The targeted plan suites: 147 passed. +- [x] Verify the zone facade: `python -c "import goga.pipeline.hooks as h; assert sorted(h.__all__) == ['CompositionStage', 'PipelineHooks', 'PipelineIdentity', 'RunCompleted', 'RunCreated', 'ToolContribution', 'WorkIdentity', 'WorkflowAmendment', 'WorkflowDecision', 'WorkflowOverlay', 'merge_workflow_overlay']"` + — passes; the facade IS the contract surface (11 names). +- [x] Verify the cell graph: `goga lint` — 78 cells, 0 errors (contracts and cells stay consistent). -- [ ] Verify the untouched surfaces: `git status`/`git diff` — `goga/pipeline/workflow`, - the `goga/hooks` platform modules (dispatch/registry/tools), the compiler, and every - `CODEMANIFEST` show no implementation changes (the three manifests carry only the - apply-stage contract edits already in the working tree). -- [ ] Lint the whole: `python -m ruff check goga tests && python -m ruff format --check goga tests`. +- [x] Verify the untouched surfaces: `git status` clean; zero diff on + `goga/pipeline/workflow`, the `goga/hooks` platform modules (dispatch/registry/tools), + and the compiler; the three `CODEMANIFEST`s (plus the packaged + `goga/assets/pipelines/development.yml` prompt asset) were touched only by the + apply-stage commit `d76b44e` — no implementation task modified a manifest. +- [x] Lint the whole: `python -m ruff check goga tests` — all checks passed; + `python -m ruff format --check goga tests` — 15 files flagged, all pre-existing on the + base commit under the same ruff 0.16.8 (Markdown `.usages` embedded-Python drift plus + onboarding/topics/commands test files); none on this feature's surface, and every file + this feature added or touched is format-clean (757 formatted on branch vs 742 on base). --- @@ -1537,16 +1546,16 @@ General Setup). This task verifies the composed whole and guards the read-only s ## Completion Criteria -- [ ] Every contract entity is implemented in the correct `location` (11 zone entities across `identity.py`, `contexts.py`, `overlay.py`, `amendments.py`, `events.py`; catalog records in `catalog.py`; consumer edits in `pipeline_card.py`, `describe_pipeline.py`, `run_pipeline.py`, `cli.py`) -- [ ] Every contract entity is accessible from the facade (`goga.pipeline.hooks.__all__` — exactly the 11 names) -- [ ] Properties and methods match the declared API (signatures, defaults, `kw_only`) -- [ ] Descriptions are reflected in behavior (authored-wins merge, hard/soft error classes, mutually-blind tools, one registry per run, emissions around the launch, kind-derivation matrix) -- [ ] Contract dependencies are met (platform facade imports, `WorkflowDocument` from `..workflow`, history facade imports in the operations) -- [ ] Re-exports are accessible from the facade (none declared — vacuously true) -- [ ] Every coding task followed the TDD workflow (contract tests → code → verification → logic tests → debugging → re-verification → lint) -- [ ] Contract tests and logic tests cover facade, API, and behavior within each coding task -- [ ] Integration tests exist where cross-entity scenarios require them (Tasks 7-11 scenario suites over the real platform + Task 12 composed verification) -- [ ] No package boundary was expanded (no new cells beyond the contract-created zone; `goga/pipeline/workflow`, `goga/hooks` platform modules, and the compiler untouched) -- [ ] `CODEMANIFEST` files were not modified (contract is read-only) -- [ ] All validation commands pass -- [ ] Every Usages entry is mentioned in at least one task (`convention` — all tasks; `per-tool-delivery`, `declaring-actions`, `registering-hooks` — Tasks 6-7; `checkpoints` — Tasks 9-10; `topic-paths` — Tasks 9-10; `topic-statuses` — Task 10; `argparse`/`cli_entrypoint` — Task 11; `default_prompts`/`compile-flow`/`parse-dsl`/`run-flow` — Tasks 9-10) +- [x] Every contract entity is implemented in the correct `location` (11 zone entities across `identity.py`, `contexts.py`, `overlay.py`, `amendments.py`, `events.py`; catalog records in `catalog.py`; consumer edits in `pipeline_card.py`, `describe_pipeline.py`, `run_pipeline.py`, `cli.py`) +- [x] Every contract entity is accessible from the facade (`goga.pipeline.hooks.__all__` — exactly the 11 names) +- [x] Properties and methods match the declared API (signatures, defaults, `kw_only`) +- [x] Descriptions are reflected in behavior (authored-wins merge, hard/soft error classes, mutually-blind tools, one registry per run, emissions around the launch, kind-derivation matrix) +- [x] Contract dependencies are met (platform facade imports, `WorkflowDocument` from `..workflow`, history facade imports in the operations) +- [x] Re-exports are accessible from the facade (none declared — vacuously true) +- [x] Every coding task followed the TDD workflow (contract tests → code → verification → logic tests → debugging → re-verification → lint) +- [x] Contract tests and logic tests cover facade, API, and behavior within each coding task +- [x] Integration tests exist where cross-entity scenarios require them (Tasks 7-11 scenario suites over the real platform + Task 12 composed verification) +- [x] No package boundary was expanded (no new cells beyond the contract-created zone; `goga/pipeline/workflow`, `goga/hooks` platform modules, and the compiler untouched) +- [x] `CODEMANIFEST` files were not modified (contract is read-only) +- [x] All validation commands pass (targeted suites 147 passed; `goga lint` 78/0; facade check OK; `ruff check` clean — the full-suite and format-check exceptions are the pre-existing base-commit environment items documented in Task 12) +- [x] Every Usages entry is mentioned in at least one task (`convention` — all tasks; `per-tool-delivery`, `declaring-actions`, `registering-hooks` — Tasks 6-7; `checkpoints` — Tasks 9-10; `topic-paths` — Tasks 9-10; `topic-statuses` — Task 10; `argparse`/`cli_entrypoint` — Task 11; `default_prompts`/`compile-flow`/`parse-dsl`/`run-flow` — Tasks 9-10) From 8d722683cdd6fde05f5f29a8a6e22bf675754bd9 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 18 Sep 2026 16:58:58 +0000 Subject: [PATCH 077/205] fix: address code review findings --- .goga/tools/mkdocs/traceability.yml | 5 + Dockerfile | 4 +- README.md | 4 +- docs/features/hooks/api.md | 2 +- docs/features/hooks/hooks.md | 4 +- docs/features/hooks/index.md | 2 +- docs/features/pipelines/api.md | 8 +- docs/features/pipelines/cli.md | 21 ++- docs/features/pipelines/hooks.md | 74 ++++++++- docs/features/tools/hooks.md | 2 +- goga/hooks/.usages/per-tool-delivery.md | 6 +- goga/onboarding/.usages/onboarding-usage.md | 15 +- goga/onboarding/.usages/registering-hooks.md | 15 +- goga/onboarding/generator/generator.py | 7 +- .../.usages/session-participation.md | 2 +- .../questions/.usages/question-records.md | 11 +- goga/onboarding/survey/.usages/survey-run.md | 10 +- goga/pipeline/hooks/events.py | 15 +- goga/pipeline/run_pipeline.py | 16 +- goga/topics/.usages/registering-hooks.md | 3 +- goga/topics/hooks/.usages/checkpoints.md | 21 +-- goga/version/.usages/minor-line.md | 4 +- tests/commands/pipeline/test_file_roots.py | 37 +++-- .../pipeline/test_run_pipeline_container.py | 4 +- tests/onboarding/generator/test_generator.py | 4 +- tests/onboarding/survey/test_questionnaire.py | 6 +- tests/pipeline/hooks/test_events.py | 124 +++++++++++++++ tests/pipeline/hooks/test_overlay.py | 34 ++++ tests/pipeline/test_describe_pipeline.py | 108 ++++++++++++- tests/pipeline/test_pipeline_cli.py | 13 ++ tests/pipeline/test_run_pipeline_hooks.py | 148 +++++++++++++++++- tests/topics/test_creation.py | 6 +- 32 files changed, 643 insertions(+), 92 deletions(-) diff --git a/.goga/tools/mkdocs/traceability.yml b/.goga/tools/mkdocs/traceability.yml index 9f490649..f3d37e6d 100644 --- a/.goga/tools/mkdocs/traceability.yml +++ b/.goga/tools/mkdocs/traceability.yml @@ -102,11 +102,13 @@ docs/features/pipelines/configuration.md: - goga/commands/pipeline docs/features/pipelines/hooks.md: - goga/pipeline + - goga/pipeline/hooks - goga/hooks docs/features/pipelines/api.md: - goga/pipeline - goga/pipeline/workflow - goga/pipeline/compiler + - goga/pipeline/hooks docs/features/pipelines/pipeline-file.md: - goga/pipeline - goga/pipeline/compiler @@ -153,6 +155,7 @@ docs/features/tools/hooks.md: - goga/hooks - goga/commands/install - goga/topics/hooks + - goga/pipeline/hooks docs/features/tools/api.md: - goga/commands/tool @@ -272,6 +275,7 @@ docs/features/hooks/index.md: - goga/commands/hooks - goga/onboarding - goga/topics/hooks + - goga/pipeline/hooks docs/features/hooks/cli.md: - goga/commands/hooks - goga/hooks @@ -285,6 +289,7 @@ docs/features/hooks/hooks.md: - goga/hooks/tools - goga/onboarding - goga/topics/hooks + - goga/pipeline/hooks docs/features/hooks/api.md: - goga/hooks - goga/hooks/catalog diff --git a/Dockerfile b/Dockerfile index 0cb5cbc0..45faa08e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,7 @@ RUN apt-get update && \ COPY --from=ralphex-source /srv/ralphex /srv/ralphex COPY --from=afm-source /usr/local/bin/afm /srv/afm -RUN npm install -g @anthropic-ai/claude-code@2.1.209 @openai/codex@0.144.4 opencode-ai@1.17.13 @qwen-code/qwen-code@0.21.1 +RUN npm install -g @anthropic-ai/claude-code@2.1.209 @openai/codex@0.155.0 opencode-ai@1.17.13 @qwen-code/qwen-code@0.21.1 RUN curl https://cursor.com/install -fsS | bash RUN chmod +x /srv/ralphex /srv/afm @@ -57,7 +57,7 @@ ENV GOGA_DOCKER=1 ENV RALPHEX_DOCKER=1 ENV AFM_IN_DOCKER=1 -RUN install -d -o goga -g goga -m 0755 / home/goga/.afm +RUN install -d -o goga -g goga -m 0755 /home/goga/.afm USER goga diff --git a/README.md b/README.md index d52a1635..b5c4de05 100644 --- a/README.md +++ b/README.md @@ -443,7 +443,7 @@ A valid tool **must**: A tool **may** additionally expose an `install(user: str | None = None)` callable in its facade package: `goga install` calls it after a successful pip, passing the initiating user (`SUDO_USER` when goga itself runs under sudo, else the current OS user) only when the parameter is declared keyword-capable. A missing or non-callable `install` is skipped quietly. -A tool **may** also expose a `register_hooks(hooks)` callable to extend goga domains with its own hooks — today, the topic status scale, the onboarding session (`declare_session`/`amend_config`, reached via `goga init -t `), and the seven topic-lifecycle checkpoints of `topics` (two content amendments and five notifications; see [Topics — Hooks](https://qarium.github.io/goga/features/topics/hooks/)). goga calls it when a command first reaches a hook checkpoint of the run, or when you inspect the registry with `goga hooks`; commands that use no hooks never call it: +A tool **may** also expose a `register_hooks(hooks)` callable to extend goga domains with its own hooks — today, the topic status scale, the onboarding session (`declare_session`/`amend_config`, reached via `goga init -t `), the seven topic-lifecycle checkpoints of `topics` (two content amendments and five notifications; see [Topics — Hooks](https://qarium.github.io/goga/features/topics/hooks/)), and the three pipeline checkpoints of `pipeline` (the workflow amendment `amend_workflow` and the two run notifications `run_created`/`run_completed`; see [Pipelines — Hooks](https://qarium.github.io/goga/features/pipelines/hooks/)). goga calls it when a command first reaches a hook checkpoint of the run, or when you inspect the registry with `goga hooks`; commands that use no hooks never call it: ```python def register_hooks(hooks): @@ -454,7 +454,7 @@ def register_published(context): context.register("published", "mkdocs/published.md", after="planned") ``` -The hook receives the delivered status registry through `context` — read and call freely, attribute assignment is blocked. The name is shown qualified as `.` (here `mkdocs.published`); the tool identity is the package name with the `goga_tool_` prefix dropped and underscores turned into hyphens, so `goga_tool_hello_world` registers `hello-world.*`. The filepath is relative to the topic directory (nested paths allowed), and `before=`/`after=` anchor the entry to an existing scale entry — at least one anchor is required, both define a range. Built-in entries are immutable. A bad registration — an unknown anchor, an invalid range, or a crashed hook — is skipped with a warning on stderr and never aborts the command; only a package that fails to import is fatal. Run [`goga hooks`](https://qarium.github.io/goga/features/hooks/cli/) to inspect what is registered. The removed `register_topic_statuses(statuses)` callback is no longer called — a package still carrying it loses its statuses silently after the update. +The hook receives the delivered status registry through `context` — read and call freely, attribute assignment is blocked. The name is shown qualified as `.` (here `mkdocs.published`); the tool identity is the package name with the `goga_tool_` prefix dropped and underscores turned into hyphens, so `goga_tool_hello_world` registers `hello-world.*`. The filepath is relative to the topic directory (nested paths allowed), and `before=`/`after=` anchor the entry to an existing scale entry — at least one anchor is required, both define a range. Built-in entries are immutable. A bad registration — an unknown anchor, an invalid range, or a crashed hook — is skipped with a warning on stderr and never aborts the command; only a package that fails to import is fatal. That skip-with-a-warning rule covers the **soft** actions; `pipeline/amend_workflow` is the platform's first **hard** action — a hook of it that raises (or contributes a malformed document) aborts `goga pipeline` before any launch with a clean error naming the hook, the tool, and the action. Run [`goga hooks`](https://qarium.github.io/goga/features/hooks/cli/) to inspect what is registered. The removed `register_topic_statuses(statuses)` callback is no longer called — a package still carrying it loses its statuses silently after the update. After publication, install into any project: diff --git a/docs/features/hooks/api.md b/docs/features/hooks/api.md index 465398df..265ae7f9 100644 --- a/docs/features/hooks/api.md +++ b/docs/features/hooks/api.md @@ -21,7 +21,7 @@ from goga.hooks import ( | `wrap_context(...)`, `build_hook_arguments(...)` | `goga.hooks.dispatch` | The delivery primitives for domains that drive per-tool delivery themselves (staged contributions) | | `enumerate_tool_packages()` | `goga.hooks.tools` | The installed `goga_tool_*` package enumeration | -The delivery primitives serve the staged per-tool delivery pattern — a domain commits a tool's contribution only after all its hooks succeed (the onboarding session is the in-tree consumer). +The delivery primitives serve the staged per-tool delivery pattern — a domain commits a tool's contribution only after all its hooks succeed (the onboarding session, the topics amendments, and the pipeline workflow amendment are the in-tree consumers — the pipeline one being the hard variant: a failing hook raises instead of being discarded). ## The action catalog diff --git a/docs/features/hooks/hooks.md b/docs/features/hooks/hooks.md index 060cf0ee..8562732e 100644 --- a/docs/features/hooks/hooks.md +++ b/docs/features/hooks/hooks.md @@ -16,7 +16,7 @@ def register_published(context): `hooks.subscribe(domain, action, name, hook)` registers one hook: -- `domain` + `action` — the action address: the semantic owner domain and the action name within it (`"statuses"` / `"register_statuses"` is the topic-status action — see [History — Hooks](../history/hooks.md); `"onboarding"` / `"declare_session"` and `"onboarding"` / `"amend_config"` are the onboarding-session actions a tool is invited into via `goga init -t ` — see [Init — Hooks](../init/hooks.md); the seven `"topics"` addresses — `amend_creation`, `amend_todo_entry`, `topic_created`, `topic_published`, `topic_switched`, `topic_todo_entered`, `topic_deleted`, all soft — are the topic-lifecycle checkpoints: two amendments before the content is fixed and five notifications after their moments, see [Topics — Hooks](../topics/hooks.md)). +- `domain` + `action` — the action address: the semantic owner domain and the action name within it (`"statuses"` / `"register_statuses"` is the topic-status action — see [History — Hooks](../history/hooks.md); `"onboarding"` / `"declare_session"` and `"onboarding"` / `"amend_config"` are the onboarding-session actions a tool is invited into via `goga init -t ` — see [Init — Hooks](../init/hooks.md); the seven `"topics"` addresses — `amend_creation`, `amend_todo_entry`, `topic_created`, `topic_published`, `topic_switched`, `topic_todo_entered`, `topic_deleted`, all soft — are the topic-lifecycle checkpoints: two amendments before the content is fixed and five notifications after their moments, see [Topics — Hooks](../topics/hooks.md); the three `"pipeline"` addresses — `amend_workflow` (**hard**), `run_created`, `run_completed` (soft) — are the pipeline checkpoints: the workflow amendment before compilation in both the run and the card form, and the two notifications around a run's launch, see [Pipelines — Hooks](../pipelines/hooks.md)). - `name` — the hook name, unique per tool per address; registrations appear in the [`goga hooks`](cli.md) tree under their tool line. - `hook` — the callable executed when the action fires. @@ -33,7 +33,7 @@ The declaration order does not matter; names you did not declare receive nothing ## Error classes and diagnostics -Each action in the catalog fixes how a failing hook is treated. The topic-status, the onboarding, and the topics actions are **soft**: a failing hook is skipped with a stderr warning naming the tool, the action, and the reason, and the command continues. A **hard** action stops the command at the first failing hook with a clean error — the class is chosen by the owner domain when it declares the action. +Each action in the catalog fixes how a failing hook is treated. The topic-status, the onboarding, the topics, and the two pipeline notification actions are **soft**: a failing hook is skipped with a stderr warning naming the tool, the action, and the reason, and the command continues. A **hard** action stops the command at the first failing hook with a clean error — `pipeline/amend_workflow` is the existing hard action (see [Pipelines — Hooks](../pipelines/hooks.md)); the class is chosen by the owner domain when it declares the action. At registration: a wrong address, an empty name, or a repeated name on the same address is refused with a stderr warning naming the tool, the action, and the reason — the registration is skipped, the rest apply. A crashing callback is a warning; the registrations made before the crash survive. A broken package import is the only fatal case: a clean error naming the package. diff --git a/docs/features/hooks/index.md b/docs/features/hooks/index.md index ec9a284d..1083b562 100644 --- a/docs/features/hooks/index.md +++ b/docs/features/hooks/index.md @@ -8,7 +8,7 @@ The hooks domain is the mechanism behind every domain extension: a domain declar - **Tool packages extend domains with no goga code changes** — a package registers its hooks at run time; registration is never cached, so package edits apply from the next run without reinstall. - **Inspection** — `goga hooks` assembles the registry once and prints it as a tree: tool, domain, action — the fact of registration, including every refused registration with its reason. -The declared actions today: the status-scale registration of the [History](../history/hooks.md) domain, the two onboarding actions of the [Init](../init/hooks.md) domain (`onboarding/declare_session`, `onboarding/amend_config`, both soft — a tool reaches them via `goga init -t `), and the seven lifecycle actions of the [Topics](../topics/hooks.md) domain (`topics/amend_creation`, `topics/amend_todo_entry`, `topics/topic_created`, `topics/topic_published`, `topics/topic_switched`, `topics/topic_todo_entered`, `topics/topic_deleted`, all soft — two amendments before the content is fixed, five notifications after their moments). The authoring side — how a tool package writes its `register_hooks` callback — is the [registration contract](hooks.md). +The declared actions today: the status-scale registration of the [History](../history/hooks.md) domain, the two onboarding actions of the [Init](../init/hooks.md) domain (`onboarding/declare_session`, `onboarding/amend_config`, both soft — a tool reaches them via `goga init -t `), the seven lifecycle actions of the [Topics](../topics/hooks.md) domain (`topics/amend_creation`, `topics/amend_todo_entry`, `topics/topic_created`, `topics/topic_published`, `topics/topic_switched`, `topics/topic_todo_entered`, `topics/topic_deleted`, all soft — two amendments before the content is fixed, five notifications after their moments), and the three actions of the [Pipelines](../pipelines/hooks.md) domain (`pipeline/amend_workflow` — the platform's first **hard** action, the workflow amendment delivered before compilation in both the run and the card form — plus the soft `pipeline/run_created` / `pipeline/run_completed` around a run's launch). The authoring side — how a tool package writes its `register_hooks` callback — is the [registration contract](hooks.md). ## Model diff --git a/docs/features/pipelines/api.md b/docs/features/pipelines/api.md index c2481e23..3598fe94 100644 --- a/docs/features/pipelines/api.md +++ b/docs/features/pipelines/api.md @@ -1,6 +1,6 @@ # Pipelines — API -The facade of the domain package **`goga.pipeline`** — discovery and run coordination of goga pipeline files. The DSL parsing and flow compilation live in the nested cells `goga.pipeline.workflow` and `goga.pipeline.compiler`; this facade carries the discovery, description, and run surfaces. +The facade of the domain package **`goga.pipeline`** — discovery and run coordination of goga pipeline files. The DSL parsing and flow compilation live in the nested cells `goga.pipeline.workflow` and `goga.pipeline.compiler`, and the hooks zone of the domain (the workflow amendment and the run notifications) in `goga.pipeline.hooks`; this facade carries the discovery, description, and run surfaces. The signatures below are the CODEMANIFEST contract of the cell. @@ -18,11 +18,11 @@ describe_pipeline(name: str, project_dir: Path, user_dir: Path, ```python PipelineEntry(name: str, source: PipelineSource) PipelineSummary(name: str, source: PipelineSource, description: str, display_name: str = "") -PipelineCard(name: str, description: str, stages: list[CardStage]) +PipelineCard(name: str, description: str, stages: list[CardStage], provenance: list[str] = []) CardStage(id: str, title: str) ``` -The discovery and description result types. `PipelineSource` distinguishes the project and user origins. +The discovery and description result types. `PipelineSource` distinguishes the project and user origins. `PipelineCard.provenance` carries the tools whose workflow contributions committed into the composition, in enumeration order — empty when none contributed (see [Hooks](hooks.md)). ## Workflow resolution and stage ordering @@ -43,7 +43,7 @@ run_pipeline(name: str, project_dir: Path, user_dir: Path, port: int, pipeline_cli(argv: list[str]) -> int ``` -`run_pipeline` is the in-container execution: compile the pipeline-file, materialize the agent prompts, and execute the pipeline — the container exit code is returned. `parallel` caps the number of stages executed concurrently (`None` — unbounded). `pipeline_cli` is the in-container argparse entry point behind `goga pipeline` (the host-side launcher is the [Install/CLI layer](cli.md)). +`run_pipeline` is the in-container execution: deliver the `pipeline.amend_workflow` checkpoint over the resolved workflow (see [Hooks](hooks.md)), compile the pipeline-file with the merged overlay workflow, materialize the agent prompts, emit the `run_created` notification, execute the pipeline, and emit `run_completed` with the actual exit code on every launch-attempt return path — the container exit code is returned. `parallel` caps the number of stages executed concurrently (`None` — unbounded). `pipeline_cli` is the in-container argparse entry point behind `goga pipeline` (the host-side launcher is the [Install/CLI layer](cli.md)). ## Example diff --git a/docs/features/pipelines/cli.md b/docs/features/pipelines/cli.md index b2afabea..a8bbc0e1 100644 --- a/docs/features/pipelines/cli.md +++ b/docs/features/pipelines/cli.md @@ -23,7 +23,7 @@ The command is a single Click command (not a group). Form validation happens on |---|---|---| | Flat list | `goga pipeline --list` | Prints one `* {name}[ (project)]` bullet per pipeline. Project pipelines are annotated with `(project)`; user pipelines are printed bare. | | Overview | `goga pipeline --list --info` | One bullet block per pipeline: `* {name}[ (project)]` followed by indented `name:` and `description:` fields (the authored header values). | -| Card | `goga pipeline --info` | Prints `name:` and `description:` fields, a `---` separator, then one `* {stage-id}:` bullet with an indented `title:` field per stage **in execution order** (workflow `skip`/`extend`/`loop` applied; loop copies appear as separate `NAME-1..N` rows). Nothing runs. | +| Card | `goga pipeline --info` | Prints `name:` and `description:` fields, a `---` separator, then one `* {stage-id}:` bullet with an indented `title:` field per stage **in execution order** (workflow `skip`/`extend`/`loop` applied; loop copies appear as separate `NAME-1..N` rows). When installed tools contributed to the composition (see [Hooks](hooks.md)), one blank line and a `tools: ` field line follow the stage bullets — the contributing tools comma-separated in provenance order; with no contributing tools the card is unchanged. Nothing runs. | | Run | `goga pipeline ` | Executes the pipeline (see [Run Mode](#run-mode-goga-pipeline-name)). | | Error | `goga pipeline` (bare) | Exits 1: `Missing pipeline name. Use "goga pipeline --list" …`. `--list` plus a name is also rejected (mutually exclusive). | @@ -56,6 +56,21 @@ description: Deploy the service title: Test ``` +When tools contributed through `pipeline/amend_workflow`, the card ends with their line: + +``` +$ goga pipeline deploy --info +name: Deploy +description: Deploy the service + +--- + +* build: + title: Build + +tools: hardener, notifier +``` + The card and the run share the same workflow rule set and the same compiler, so the stages the card lists are structurally the stages a run executes (see [Workflow files](#workflow-files)). ## Run Mode (`goga pipeline `) @@ -273,14 +288,14 @@ Container side, run form: | Code | Meaning | |------|--------------------------------------------------------------------------| | `0` | The pipeline ran successfully | -| `1` | The pipeline was not found, or a handled compile/malformed-file failure rendered as a clean `Error: ...` stderr message | +| `1` | The pipeline was not found; a handled compile/malformed-file failure rendered as a clean `Error: ...` stderr message; a hard `pipeline/amend_workflow` hook failure — the run stops before any compile or launch with `Error: pipeline '' was not amended: hook of tool failed on pipeline.amend_workflow: ` (see [Hooks](hooks.md)); or a broken tool package import during hooks-registry assembly (a clean `Error: ...` naming the package) | | `2` | In-container argparse error (missing `NAME`, non-integer `--port`, missing `--port` without `--info`) | | `126`| The pipeline engine was present inside the image but could not be invoked (e.g. not executable) | | `127`| The pipeline engine is missing inside the container image | | `130`| Interrupted by SIGINT (`128 + 2`) | | `143`| Interrupted by SIGTERM (`128 + 15`) | -Container side, info forms: `0` on success; `1` for a damaged pipeline-file (unreadable, non-YAML, structurally invalid, or not UTF-8) rendered as `Error: ...` on stderr; `2` for an in-container argparse error. +Container side, info forms: `0` on success; `1` for a damaged pipeline-file (unreadable, non-YAML, structurally invalid, or not UTF-8), a hard `pipeline/amend_workflow` hook failure, or a broken tool package import during hooks-registry assembly — each rendered as `Error: ...` on stderr (see [Hooks](hooks.md)); `2` for an in-container argparse error. On SIGTERM/SIGINT during run mode the running container is killed and the process exits with `128 + signum`. diff --git a/docs/features/pipelines/hooks.md b/docs/features/pipelines/hooks.md index 639980b8..fd0336ac 100644 --- a/docs/features/pipelines/hooks.md +++ b/docs/features/pipelines/hooks.md @@ -1,5 +1,75 @@ # Pipelines — Hooks -The pipelines domain exposes **no hook actions** for tool packages today. +The pipelines domain exposes **three hook actions** for tool packages — the checkpoints of the two pipeline forms. One is the platform's first **hard** action: the workflow amendment `pipeline/amend_workflow`, delivered by both the run and the card form before compilation. Two are **soft** notifications bracketing a run's launch. With no tool packages installed the amendment layer is the passthrough — every form behaves exactly as before (byte-identical output, unchanged exit codes). -A tool package reaches the pipeline surface not through hooks but through its own artifacts: its skills merge into pipeline stages via the workflow `skills:` mechanism, and its pipeline-files install namespaced as `:.yml` and run as `goga pipeline :` (see [Tools](../tools/index.md)). The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). +## The actions + +| Address | Error class | Fires | +|---|---|---| +| `pipeline / amend_workflow` | **hard** | After the workflow resolution and the runner-skip merge, before compilation — in both `goga pipeline ` (run) and `goga pipeline --info` (card). Not delivered when the workflow decision is disabled (`--no-workflow`); a silent auto-match miss keeps it active onto the empty base. | +| `pipeline / run_created` | soft | After the four agent prompts materialize, immediately before the launch (run form only). | +| `pipeline / run_completed` | soft | On every launch-attempt return path of the run form — success, non-zero, and spawn failures (126/127) alike. | + +A tool subscribes inside its `register_hooks` callback: + +```python +# inside the goga_tool_ package +from goga.pipeline.workflow import WorkflowDocument + + +def register_hooks(hooks): + hooks.subscribe("pipeline", "amend_workflow", "harden", harden_workflow) + hooks.subscribe("pipeline", "run_completed", "record", record_completion) + + +def harden_workflow(context): + context.contribute(WorkflowDocument(prompt="Prefer the pinned toolchain.")) + + +def record_completion(context): + ... # read-only facts of the finished attempt +``` + +A failing moment fires nothing: a run that stops before the amendment (a missing pipeline, a malformed workflow-file, a structural compile error) reaches no checkpoint, and no completion fires when the launch attempt itself raises. + +## The amendment view (`amend_workflow`) + +Each tool receives a **fresh `WorkflowAmendment` view** — the read-and-contribute surface of one tool: + +- `pipeline` — the discovered identity: `name` (the file stem), `display_name`/`description` (the authored header values), `source` (`project` or `user`). +- `decision` — the workflow decision: `kind` exactly one of `disabled`, `explicit`, `auto-match`, `silent-miss`, plus the `workflow_name` when one applied. +- `workflow` — the original authored workflow (post decision, post runner-skip merge, pre-layer), **read-only and identical for every tool**. +- `work` — the current work identity: `branch` (the literal `unknown` when git resolves none), and the hosting topic's `slug`/`year` when the branch hosts one. +- `contribute(document)` — buffer one declarative `WorkflowDocument` contribution. + +The amendment contract: + +- **Whole replacement** — a later `contribute` call replaces the earlier buffered document whole; the buffer belongs to this tool alone. +- **Mutually blind tools** — every tool reads the same original workflow through its own view; no tool ever sees another tool's contribution. +- **Commit per tool** — a tool's buffer commits as one contribution only after every hook of the tool returned without raising. +- **Hard failure** — the first failing hook stops the command at the first failure with a clean error naming the hook, the tool, and the action — before any compile, write, or launch: `Error: pipeline '' was not amended: hook of tool failed on pipeline.amend_workflow: `. A buffer the walk cannot process (a value of an out-of-contract type) fails its hook the same way; a broken tool-package import is the other fatal case. An empty document (no prompt, no stages, no extend, no memory) is discarded with a warning naming the tool. +- **Content only** — an amendment shapes the effective workflow; it cannot cancel, redirect, or defer the run. + +The committed contributions merge onto the authored workflow **authored-wins, per slot**: + +- `prompt` — the non-empty texts joined with a single blank line, authored first, then the tools in enumeration order. +- stage fields — an authored-set field never yields; an unset field takes the later contributing tool's value; a stage the author never named is fully tool-defined. `manual` is three-state (`True` and `False` are both authored intent); `skip: false` overrides nothing. +- `extend` — authored names win; among tools the later entry wins per name. +- `memory` — whole-block: the authored block is unbeatable; otherwise the later tool's block wins. + +The merged workflow is what `compile_flow` receives in both forms. The card reports the committed tools as its `tools:` line; the run events carry them as `provenance`. + +## The notification contexts + +Each notification delivers **the same read-only context instance** to every subscribed tool — a hook observes and cannot alter: + +- `run_created` — `RunCreated`: `pipeline`, `decision`, `workflow` (the effective merged workflow), `composition` (the ordered stages of the final composition), `provenance` (the committed tools), `work`, `statuses` (the hosting topic's statuses at the moment; empty in the branch-only form), `runtime_dir` (the run's runtime directory as a posix string). +- `run_completed` — `RunCompleted`: the same facts recomputed at the completion moment, plus `exit_code` — the actual exit code of the launch attempt, zero, non-zero, or a spawn-failure 126/127. + +Both are fire-and-forget: a failing hook is skipped with a warning naming the tool, the action, and the reason; the launch proceeds and the run's exit code is never affected. + +## No-tools guarantee + +With no tool packages installed the registry builds empty and the overlay is the passthrough — the run and the card behave exactly as before this layer existed: byte-identical CLI output and unchanged exit codes. + +The platform mechanism behind the actions (enumeration, the registry, delivery, inspection with `goga hooks`) is the [Hooks](../hooks/index.md) domain; the registration contract for tool authors is covered in [Hooks — The registration contract](../hooks/hooks.md); the flows that fire the checkpoints are covered in [CLI](cli.md) and the facade in [API](api.md). diff --git a/docs/features/tools/hooks.md b/docs/features/tools/hooks.md index ba697343..68b1f629 100644 --- a/docs/features/tools/hooks.md +++ b/docs/features/tools/hooks.md @@ -15,4 +15,4 @@ def register_hooks(hooks): hooks.subscribe("statuses", "register_statuses", "published", register_published) ``` -The full registration contract — the hook signature (`context` / `self`), the error classes, the diagnostics — is the [Hooks domain](../hooks/hooks.md); the declared actions are listed per domain (today: [History — Hooks](../history/hooks.md), and the seven lifecycle actions of [Topics — Hooks](../topics/hooks.md) — `topics/amend_creation`, `topics/amend_todo_entry`, `topics/topic_created`, `topics/topic_published`, `topics/topic_switched`, `topics/topic_todo_entered`, `topics/topic_deleted`, all soft). The `main` entry point and its optional AST injection are covered in [CLI](cli.md#optional-injections). +The full registration contract — the hook signature (`context` / `self`), the error classes, the diagnostics — is the [Hooks domain](../hooks/hooks.md); the declared actions are listed per domain (today: [History — Hooks](../history/hooks.md), the seven lifecycle actions of [Topics — Hooks](../topics/hooks.md) — `topics/amend_creation`, `topics/amend_todo_entry`, `topics/topic_created`, `topics/topic_published`, `topics/topic_switched`, `topics/topic_todo_entered`, `topics/topic_deleted`, all soft — and the three actions of [Pipelines — Hooks](../pipelines/hooks.md) — `pipeline/amend_workflow` (**hard**), `pipeline/run_created`, `pipeline/run_completed` (soft)). The `main` entry point and its optional AST injection are covered in [CLI](cli.md#optional-injections). diff --git a/goga/hooks/.usages/per-tool-delivery.md b/goga/hooks/.usages/per-tool-delivery.md index 1d00184d..75016edb 100644 --- a/goga/hooks/.usages/per-tool-delivery.md +++ b/goga/hooks/.usages/per-tool-delivery.md @@ -38,15 +38,15 @@ for sub in registry.subscriptions_for("", ""): groups.setdefault(sub.tool, []).append(sub) for tool, subs in groups.items(): - proxy = wrap_context(build_the_context_for(tool)) # your per-tool view + proxy = wrap_context(build_the_context_for(tool)) # your per-tool view try: for sub in subs: sub.hook(**build_hook_arguments(sub.hook, proxy, registry.self_context(tool))) except Exception as reason: logger.warning("tool skipped", extra={"tool": tool, "action": "", "reason": reason}) - discard(tool) # the tool's whole contribution + discard(tool) # the tool's whole contribution continue - commit(tool) # only after every hook of the tool succeeded + commit(tool) # only after every hook of the tool succeeded ``` ## Rules the pattern keeps diff --git a/goga/onboarding/.usages/onboarding-usage.md b/goga/onboarding/.usages/onboarding-usage.md index 2f68ae46..108180a8 100644 --- a/goga/onboarding/.usages/onboarding-usage.md +++ b/goga/onboarding/.usages/onboarding-usage.md @@ -13,9 +13,18 @@ Import all types directly from `goga.onboarding`: ```python from goga.onboarding import ( - CreatedFile, FileGenerator, InitLogic, Question, QuestionGroup, - Questionnaire, SessionAnswers, SessionPlan, ToolParticipation, - apply_skips, assemble_session_plan, core_questions, + CreatedFile, + FileGenerator, + InitLogic, + Question, + QuestionGroup, + Questionnaire, + SessionAnswers, + SessionPlan, + ToolParticipation, + apply_skips, + assemble_session_plan, + core_questions, ) ``` diff --git a/goga/onboarding/.usages/registering-hooks.md b/goga/onboarding/.usages/registering-hooks.md index eab19e4a..05c34866 100644 --- a/goga/onboarding/.usages/registering-hooks.md +++ b/goga/onboarding/.usages/registering-hooks.md @@ -27,14 +27,19 @@ itself after the core questions, under a heading with the tool's name. ```python from goga.onboarding import Question, QuestionGroup + def declare_session(context): if not context.invited: - return # contract rule: return immediately + return # contract rule: return immediately context.declare(Question(id="token", kind="input", prompt="Service token")) - context.declare(QuestionGroup(id="reporting", prompt="Reporting", - children=[Question(id="enabled", kind="confirm", - prompt="Enable reporting?", default=False)])) - context.skip("docker_image.base_image") # unprefixed — core tree or own block + context.declare( + QuestionGroup( + id="reporting", + prompt="Reporting", + children=[Question(id="enabled", kind="confirm", prompt="Enable reporting?", default=False)], + ) + ) + context.skip("docker_image.base_image") # unprefixed — core tree or own block ``` - `context.invited` — False means the session did not invite this tool: diff --git a/goga/onboarding/generator/generator.py b/goga/onboarding/generator/generator.py index 3133e8b8..82f53d9b 100644 --- a/goga/onboarding/generator/generator.py +++ b/goga/onboarding/generator/generator.py @@ -190,12 +190,7 @@ def _contained_file_name(file: str) -> bool: segments — a name the write path can join under the tool's own directory without escaping it. """ - return ( - isinstance(file, str) - and bool(file) - and not Path(file).is_absolute() - and ".." not in Path(file).parts - ) + return isinstance(file, str) and bool(file) and not Path(file).is_absolute() and ".." not in Path(file).parts def _write_tool_configs(contributions: list[ToolContribution]) -> list[CreatedFile]: diff --git a/goga/onboarding/participation/.usages/session-participation.md b/goga/onboarding/participation/.usages/session-participation.md index 02854dc6..0163c3cd 100644 --- a/goga/onboarding/participation/.usages/session-participation.md +++ b/goga/onboarding/participation/.usages/session-participation.md @@ -33,7 +33,7 @@ audience: the session orchestrator. from goga.onboarding import SessionAnswers, ToolParticipation participation = ToolParticipation(invited=["my-tool", "viewer"]) -declarations = participation.collect_declarations() # moment one — before the survey +declarations = participation.collect_declarations() # moment one — before the survey # ... assemble the plan, run the survey into answers ... contributions = participation.collect_contributions(answers) # moment two — after ``` diff --git a/goga/onboarding/questions/.usages/question-records.md b/goga/onboarding/questions/.usages/question-records.md index 43145d68..c4cd8aab 100644 --- a/goga/onboarding/questions/.usages/question-records.md +++ b/goga/onboarding/questions/.usages/question-records.md @@ -31,19 +31,18 @@ records. ```python from goga.onboarding import Question, QuestionGroup -language = Question(id="language", kind="choice", prompt="Project language", - choices=["python", "golang"], default="python") +language = Question( + id="language", kind="choice", prompt="Project language", choices=["python", "golang"], default="python" +) image = Question(id="image", kind="input", prompt="Image name") setup = Question(id="setup", kind="confirm", prompt="Configure the tool?", default=False) -env = Question(id="env", kind="pairs", prompt="Environment variables", - keys=["API_URL", "TOKEN"]) +env = Question(id="env", kind="pairs", prompt="Environment variables", keys=["API_URL", "TOKEN"]) ``` ### Declare a group ```python -block = QuestionGroup(id="reporting", prompt="Reporting settings", - children=[setup, env]) +block = QuestionGroup(id="reporting", prompt="Reporting settings", children=[setup, env]) ``` A group carries one nesting level with simple children; its answer is a diff --git a/goga/onboarding/survey/.usages/survey-run.md b/goga/onboarding/survey/.usages/survey-run.md index 3864b0bd..a8528b4b 100644 --- a/goga/onboarding/survey/.usages/survey-run.md +++ b/goga/onboarding/survey/.usages/survey-run.md @@ -35,12 +35,16 @@ a plan and collects answers into the answer space. ```python from goga.onboarding import ( - SessionAnswers, Questionnaire, apply_skips, assemble_session_plan, core_questions, + SessionAnswers, + Questionnaire, + apply_skips, + assemble_session_plan, + core_questions, ) core = core_questions(image_tag="1.3", project_name="my-app", convention_exists=False) -plan = assemble_session_plan(core, declarations) # declarations: from tool participation -plan = apply_skips(plan, skips) # skips: (tool, raw path) pairs +plan = assemble_session_plan(core, declarations) # declarations: from tool participation +plan = apply_skips(plan, skips) # skips: (tool, raw path) pairs answers = SessionAnswers() Questionnaire().run(plan, answers) ``` diff --git a/goga/pipeline/hooks/events.py b/goga/pipeline/hooks/events.py index a2f02731..5246b500 100644 --- a/goga/pipeline/hooks/events.py +++ b/goga/pipeline/hooks/events.py @@ -107,7 +107,9 @@ def amend_workflow( 4. A tool with a raising hook is a hard failure: a clean error naming the hook, the tool, and the action stops the command at the first failure; the tool's whole contribution is - discarded together with its view + discarded together with its view. A buffer the walk cannot + process — a value of an out-of-contract type — fails the + hook that wrote it under the same hard error 5. A tool whose buffered document is empty — no prompt, no stages, no extend, no memory — is a content no-op: a warning naming the tool, the contribution discarded, the @@ -157,6 +159,17 @@ def amend_workflow( for subscription in subscriptions: try: subscription.hook(**build_hook_arguments(subscription.hook, proxy, registry.self_context(tool))) + + # Inside the intercept on purpose (the topics-zone + # precedent): the buffer is hook content, so a buffer the + # walk cannot process — a value of an out-of-contract + # type — fails the hook that wrote it, under the same + # hard error as a raising hook, never a raw traceback + # out of the delivery. + document = amendment._contribution + if document is not None: + for fact in ("prompt", "stages", "extend", "memory"): + getattr(document, fact) except Exception as reason: # Hard: stop at the first failure. The message copies the # platform's format — hook name, tool, address, reason. diff --git a/goga/pipeline/run_pipeline.py b/goga/pipeline/run_pipeline.py index fdf52c75..8a93612f 100644 --- a/goga/pipeline/run_pipeline.py +++ b/goga/pipeline/run_pipeline.py @@ -67,7 +67,7 @@ def _resolve_amendment_facts( :class:`~goga.pipeline.hooks.WorkflowDecision` from the kind-derivation matrix (disabled wins; a resolved document under an explicit name is ``explicit``, under no name ``auto-match``; no document is a silent - miss), and the :class:`~goga.pipeline.hooks.WorkflowIdentity` from the + miss), and the :class:`~goga.pipeline.hooks.WorkIdentity` from the current branch and its hosting topic directory. Args: @@ -76,7 +76,10 @@ def _resolve_amendment_facts( no_workflow: The disabled flag of the environment decision. workflow_name: The explicit workflow name of the environment decision, or ``None``. - workflow: The resolved workflow after the runner-skip merge. + workflow: The workflow the resolution returned — before the + runner-skip merge. The decision mirrors the resolution, not the + skip merge: a skip-only document synthesized over a missing + workflow is not a resolution. Returns: The identity, the decision, the work identity, and the hosting @@ -298,7 +301,7 @@ def run_pipeline(name: str, project_dir: Path, user_dir: Path, port: int, parall # parse_workflow unchanged — before the delivery, so no events fire. no_workflow = os.environ.get("GOGA_WORKFLOW_DISABLED") == "1" workflow_name = None if no_workflow else os.environ.get("GOGA_WORKFLOW_NAME") - workflow = resolve_workflow(name, workflow_name, no_workflow) + resolved = resolve_workflow(name, workflow_name, no_workflow) # Step 7: merge CLI skip directives (the comma-split ``GOGA_SKIP_STAGES`` # container env var) onto the resolved workflow without mutating it. An empty @@ -309,12 +312,15 @@ def run_pipeline(name: str, project_dir: Path, user_dir: Path, port: int, parall # an unknown name surfaces as a ``StructuralError`` there, not here. raw = os.environ.get("GOGA_SKIP_STAGES", "") skip_stages = [s for s in raw.split(",") if s] - workflow = apply_skip_stages(workflow, skip_stages) + workflow = apply_skip_stages(resolved, skip_stages) # Step 8: the amendment facts (identity, decision, work) and the hosting # topic directory — resolved in the operation, read by no checkpoint. + # The decision derives from the pre-merge resolution outcome: a skip-only + # document synthesized over a missing workflow is not a resolution, so + # the miss still reports as a miss. identity, decision, work, topic_dir = _resolve_amendment_facts( - match, pipeline_path, no_workflow, workflow_name, workflow + match, pipeline_path, no_workflow, workflow_name, resolved ) # Step 9: deliver the amendment with the authored workflow after the skip diff --git a/goga/topics/.usages/registering-hooks.md b/goga/topics/.usages/registering-hooks.md index daec1d75..c28084d0 100644 --- a/goga/topics/.usages/registering-hooks.md +++ b/goga/topics/.usages/registering-hooks.md @@ -49,8 +49,7 @@ hook invocations of a run, freely mutable. The declaration order does not matter; names you did not declare receive nothing. ```python -def record_created(context): - ... # read-only facts of the completed creation +def record_created(context): ... # read-only facts of the completed creation def stamp_message(context): diff --git a/goga/topics/hooks/.usages/checkpoints.md b/goga/topics/hooks/.usages/checkpoints.md index 79bdbcdb..1337c650 100644 --- a/goga/topics/hooks/.usages/checkpoints.md +++ b/goga/topics/hooks/.usages/checkpoints.md @@ -34,8 +34,8 @@ draft = hooks.amend_creation( identity, checked_out=False, published=False, - commit_message=draft_message, # None on paths that build no commit - todo=draft_todo, # None when none resolved + commit_message=draft_message, # None on paths that build no commit + todo=draft_todo, # None when none resolved ) final_message = draft.commit_message final_todo = draft.todo @@ -64,14 +64,17 @@ Emit each notification after its moment fully succeeds, with the final facts — the amended content is the reported content. ```python -hooks.emit_created(identity, checked_out=False, published=False, - todo=final_todo, commit_message=final_message, - commit_hash=planted_hash) -hooks.emit_published(identity, commit_message=final_message, - commit_hash=planted_hash, todo=final_todo) +hooks.emit_created( + identity, + checked_out=False, + published=False, + todo=final_todo, + commit_message=final_message, + commit_hash=planted_hash, +) +hooks.emit_published(identity, commit_message=final_message, commit_hash=planted_hash, todo=final_todo) hooks.emit_switched(identity, outcome="created-from-remote") -hooks.emit_deleted(identity, local_branch=branch, origin_twin=twin, - directory_removed=True) +hooks.emit_deleted(identity, local_branch=branch, origin_twin=twin, directory_removed=True) ``` - Every `emit_*` is fire-and-forget: a failing hook warns under the diff --git a/goga/version/.usages/minor-line.md b/goga/version/.usages/minor-line.md index 59ecc6ee..ebc89c20 100644 --- a/goga/version/.usages/minor-line.md +++ b/goga/version/.usages/minor-line.md @@ -26,8 +26,8 @@ Read once, derive, format at the consumer: ```python from goga.version import host_goga_version, minor_version -version = host_goga_version() # may raise when metadata is unreadable — handle at the caller -tag = minor_version(version) # "1.3.2" -> "1.3" +version = host_goga_version() # may raise when metadata is unreadable — handle at the caller +tag = minor_version(version) # "1.3.2" -> "1.3" image_hint = f"qarium/goga-python-3.12:{tag}" ``` diff --git a/tests/commands/pipeline/test_file_roots.py b/tests/commands/pipeline/test_file_roots.py index 6961975e..ba29dc51 100644 --- a/tests/commands/pipeline/test_file_roots.py +++ b/tests/commands/pipeline/test_file_roots.py @@ -146,9 +146,12 @@ def test_collect_file_roots_read_only_mode(self, tmp_path: Path) -> None: roots = collect_file_roots( [ - "-v", f"{tmp_path}/ro:/mnt/ro:ro", - "-v", f"{tmp_path}/rw:/mnt/rw:rw", - "-v", f"{tmp_path}/roz:/mnt/roz:ro,z", + "-v", + f"{tmp_path}/ro:/mnt/ro:ro", + "-v", + f"{tmp_path}/rw:/mnt/rw:rw", + "-v", + f"{tmp_path}/roz:/mnt/roz:ro,z", ], ) @@ -182,19 +185,21 @@ def test_collect_file_roots_empty_tokens(self) -> None: ) ] - def test_collect_file_roots_skips_named_volume_file_missing( - self, tmp_path: Path - ) -> None: + def test_collect_file_roots_skips_named_volume_file_missing(self, tmp_path: Path) -> None: """Named volumes, file mounts, missing paths, and unrelated flags never become roots.""" (tmp_path / "file.txt").write_text("x") roots = collect_file_roots( [ - "-v", "mydata:/mnt/named", - "-v", f"{tmp_path}/file.txt:/mnt/file", - "-v", f"{tmp_path}/missing:/mnt/missing", + "-v", + "mydata:/mnt/named", + "-v", + f"{tmp_path}/file.txt:/mnt/file", + "-v", + f"{tmp_path}/missing:/mnt/missing", "--network=host", - "-e", "X=Y", + "-e", + "X=Y", ] ) @@ -255,8 +260,10 @@ def test_collect_file_roots_root_id_collides_with_a_root_subpath_mount(self, tmp roots = collect_file_roots( [ - "-v", f"{tmp_path}/all:/", - "-v", f"{tmp_path}/home:/root", + "-v", + f"{tmp_path}/all:/", + "-v", + f"{tmp_path}/home:/root", ] ) @@ -269,8 +276,10 @@ def test_collect_file_roots_id_unique_on_sanitization_collision(self, tmp_path: roots = collect_file_roots( [ - "-v", f"{tmp_path}/goga/data:/home/goga/data", - "-v", f"{tmp_path}/goga-data:/home/goga-data", + "-v", + f"{tmp_path}/goga/data:/home/goga/data", + "-v", + f"{tmp_path}/goga-data:/home/goga-data", ] ) diff --git a/tests/commands/pipeline/test_run_pipeline_container.py b/tests/commands/pipeline/test_run_pipeline_container.py index 0aba8b81..81317d43 100644 --- a/tests/commands/pipeline/test_run_pipeline_container.py +++ b/tests/commands/pipeline/test_run_pipeline_container.py @@ -548,9 +548,7 @@ def capture(env: dict[str, str], extra_env: tuple[str, ...] = ()) -> Path: assert len(launcher_idxs) == 1 assert override_idx > max(launcher_idxs) - def test_home_and_pipeline_env_keys_do_not_override_composed_roots( - self, tmp_path: Path, monkeypatch - ) -> None: + def test_home_and_pipeline_env_keys_do_not_override_composed_roots(self, tmp_path: Path, monkeypatch) -> None: """AFM_DOCKER_FILE_ROOTS keys in home.env / config.pipeline.env lose to the composed value. The roots layer is written after the {**home_env, **git, **pipeline_env} diff --git a/tests/onboarding/generator/test_generator.py b/tests/onboarding/generator/test_generator.py index cc82ea81..c8db4aa2 100644 --- a/tests/onboarding/generator/test_generator.py +++ b/tests/onboarding/generator/test_generator.py @@ -316,9 +316,7 @@ def test_unserializable_payload_dropped_with_warning(self, caplog: pytest.LogCap assert [f.path for f in files] == [".goga/config.yml", ".goga/tools/my-tool/service.yml"] assert not Path(".goga/tools/my-tool/bad.yml").exists() - assert any( - "my-tool" in record.message and "bad.yml" in record.message for record in caplog.records - ) + assert any("my-tool" in record.message and "bad.yml" in record.message for record in caplog.records) def test_unwritable_target_dropped_with_warning(self, caplog: pytest.LogCaptureFixture) -> None: """A tool directory path occupied by a regular file fails softly — nothing crashes.""" diff --git a/tests/onboarding/survey/test_questionnaire.py b/tests/onboarding/survey/test_questionnaire.py index 5463204b..ab251b21 100644 --- a/tests/onboarding/survey/test_questionnaire.py +++ b/tests/onboarding/survey/test_questionnaire.py @@ -349,7 +349,7 @@ def test_duplicate_usage_name_is_skipped_with_a_note(self) -> None: "conventions": ".goga/usages/conventions.md", "custom": ".goga/usages/custom.md", } - assert 'already exists, skipping.' in result.output + assert "already exists, skipping." in result.output def test_agent_gates_collect_env_with_suggested_keys(self) -> None: """Accepting an agent gate records agent + env; suggested keys render first (ported).""" @@ -599,9 +599,7 @@ def test_usages_inputs_the_config_loader_rejects_re_ask(self) -> None: assert result.exit_code == 0 assert result.output.count("Error:") == 5 assert answers.snapshot()["usages"] == { - "goga-hooks": { - "goga-lint": {"git": "https://github.com/qarium/goga-lint", "root": "docs"} - } + "goga-hooks": {"goga-lint": {"git": "https://github.com/qarium/goga-lint", "root": "docs"}} } def test_whitespace_only_usages_ref_and_root_read_as_absent(self) -> None: diff --git a/tests/pipeline/hooks/test_events.py b/tests/pipeline/hooks/test_events.py index b9f3e6f1..924414cc 100644 --- a/tests/pipeline/hooks/test_events.py +++ b/tests/pipeline/hooks/test_events.py @@ -557,3 +557,127 @@ def silent(self: object, context: object) -> None: discards = [record.getMessage() for record in caplog.records if "discarded" in record.getMessage()] assert len(discards) == 1 assert "demo" in discards[0] + + def test_amend_workflow_out_of_contract_buffer_fails_that_hook_hard( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """A buffered value of an out-of-contract type fails its hook — no traceback. + + The buffer is hook content (the topics-zone precedent): a document + the walk cannot process fails the hook that wrote it under the same + hard ``ValueError`` as a raising hook — the walk stops before the + merge, and later tools never run. + """ + pin_package_environment({"goga_tool_demo": ["demo-dist"], "goga_tool_second": ["second-dist"]}) + witnesses: list[str] = [] + + def register_garbage(hooks: object) -> None: + def hardening(self: object, context: object) -> None: + context.contribute({"prompt": "not a WorkflowDocument"}) + + hooks.subscribe("pipeline", "amend_workflow", "hardening", hardening) # type: ignore[attr-defined] + + def register_witness(hooks: object) -> None: + def softening(self: object, context: object) -> None: + witnesses.append("second-called") + + hooks.subscribe("pipeline", "amend_workflow", "softening", softening) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register_garbage) + install_tool_package("goga_tool_second", register_hooks=register_witness) + + pipeline, decision, work = _facts() + + with pytest.raises( + ValueError, + match=r"pipeline\.amend_workflow: 'dict' object has no attribute 'prompt'", + ): + PipelineHooks().amend_workflow( + pipeline=pipeline, + decision=decision, + workflow=WorkflowDocument(prompt="authored"), + work=work, + ) + + assert witnesses == [] # the walk stopped at the failing tool — no merge ran + + def test_amend_workflow_two_hooks_of_one_tool_share_one_view_and_buffer( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """Two hooks of one tool share the view; the LAST contribute wins for the tool. + + The commit granularity is the tool: both hooks read the same original + workflow through one view, the second hook's ``contribute`` replaces + the first's whole document, and exactly one contribution commits. + """ + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + def register(hooks: object) -> None: + def first(self: object, context: object) -> None: + self.first_seen_workflow_id = id(context.workflow) + context.contribute(WorkflowDocument(prompt="first")) + + def second(self: object, context: object) -> None: + self.second_seen_workflow_id = id(context.workflow) + context.contribute(WorkflowDocument(prompt="second")) + + hooks.subscribe("pipeline", "amend_workflow", "first", first) # type: ignore[attr-defined] + hooks.subscribe("pipeline", "amend_workflow", "second", second) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register) + + pipeline, decision, work = _facts() + surface = PipelineHooks() + + overlay = surface.amend_workflow( + pipeline=pipeline, + decision=decision, + workflow=WorkflowDocument(prompt="authored"), + work=work, + ) + + # One view shared by both hooks — the same original workflow object. + tool_context = surface._registry.self_context("demo") + assert tool_context.first_seen_workflow_id == tool_context.second_seen_workflow_id + + # The tool commits once, with the second (later) buffer alone. + assert overlay.workflow is not None + assert overlay.workflow.prompt == "authored\n\nsecond" + assert overlay.provenance == ["demo"] + + def test_blank_prompt_contribution_commits_and_merges_to_nothing( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """The delivery gate is structural (None); the merge gate is textual. + + A ``prompt=""`` document passes the delivery's emptiness check (the + prompt is present, just blank), so the tool commits and lands in the + provenance — while the merge drops the blank text. Emptiness is + ``None``-shaped at the delivery; blankness is content the merge + discards. + """ + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + def register(hooks: object) -> None: + def blank(self: object, context: object) -> None: + context.contribute(WorkflowDocument(prompt="")) + + hooks.subscribe("pipeline", "amend_workflow", "blank", blank) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register) + + pipeline, decision, work = _facts() + base = WorkflowDocument(prompt="authored") + + overlay = PipelineHooks().amend_workflow(pipeline=pipeline, decision=decision, workflow=base, work=work) + + assert overlay.provenance == ["demo"] # committed — the document is not None-shaped empty + assert overlay.workflow is not None + assert overlay.workflow is not base # a real merge ran + assert overlay.workflow.prompt == "authored" # the blank text merged to nothing diff --git a/tests/pipeline/hooks/test_overlay.py b/tests/pipeline/hooks/test_overlay.py index 2a747bea..f5495287 100644 --- a/tests/pipeline/hooks/test_overlay.py +++ b/tests/pipeline/hooks/test_overlay.py @@ -254,6 +254,40 @@ def test_merge_authored_names_order_first_then_fresh_names_in_order(self) -> Non assert overlay.workflow.stages["scan"].loop == 3 # later tool wins on the fresh name assert overlay.workflow.stages["audit"].loop == 2 + def test_merge_manual_three_state_both_states_are_authored_intent(self) -> None: + """Authored ``manual=True`` and ``manual=False`` both block tools; unset takes the tool's value. + + ``manual`` is three-state — True (force) and False (explicit cancel) + are BOTH set — so a tool cannot flip an authored decision in either + direction, but it fills an authored absence. + """ + base = WorkflowDocument( + stages={ + "force": WorkflowStage(manual=True), + "cancel": WorkflowStage(manual=False), + "open": WorkflowStage(agent="author-agent"), + } + ) + contributions = [ + ToolContribution( + tool="t1", + document=WorkflowDocument( + stages={ + "force": WorkflowStage(manual=False), + "cancel": WorkflowStage(manual=True), + "open": WorkflowStage(manual=True), + } + ), + ), + ] + + overlay = merge_workflow_overlay(base, contributions) + + assert overlay.workflow is not None + assert overlay.workflow.stages["force"].manual is True # authored True unbeatable + assert overlay.workflow.stages["cancel"].manual is False # authored False unbeatable + assert overlay.workflow.stages["open"].manual is True # unset — the tool fills + def test_merge_empty_prompt_texts_are_dropped(self) -> None: """An empty-string prompt contributes nothing — the join drops empties.""" overlay = merge_workflow_overlay( diff --git a/tests/pipeline/test_describe_pipeline.py b/tests/pipeline/test_describe_pipeline.py index e64bba3e..1a69dc64 100644 --- a/tests/pipeline/test_describe_pipeline.py +++ b/tests/pipeline/test_describe_pipeline.py @@ -30,15 +30,17 @@ import inspect import sys from pathlib import Path -from typing import get_type_hints +from typing import Any, get_type_hints from unittest import mock import pytest +from goga.history import current_year from goga.pipeline.compiler import compile_flow from goga.pipeline.describe_pipeline import describe_pipeline +from goga.pipeline.hooks import WorkIdentity from goga.pipeline.order_stages import order_stages from goga.pipeline.pipeline_card import CardStage, PipelineCard -from goga.pipeline.workflow import WorkflowDocument, parse_workflow +from goga.pipeline.workflow import WorkflowDocument, WorkflowStage, parse_workflow # The package __init__ re-exports ``describe_pipeline`` (the function), which # shadows the ``describe_pipeline`` submodule name in attribute access — @@ -104,6 +106,18 @@ def _write_workflow(cwd: Path, name: str, text: str) -> Path: return path +@pytest.fixture(autouse=True) +def _empty_package_environment(pin_package_environment) -> None: + """Pin the package environment empty for every test of this module. + + The card path builds the real registry through the amendment layer, so + an unpinned environment would make the composed card depend on the + machine's installed ``goga_tool_*`` packages. Tests that install a tool + pin their own environment on top — the later pin wins. + """ + pin_package_environment({}) + + class TestDescribePipelineContract: def test_describe_pipeline_is_importable_from_module(self) -> None: """The routine lives at its declared location ``goga.pipeline.describe_pipeline``.""" @@ -351,3 +365,93 @@ def hardening(self: object, context: object) -> None: ("build", "Build"), ("test", "Test"), ] + + def test_describe_pipeline_committed_contribution_changes_the_composition( + self, + tmp_path: Path, + isolated_cwd: Path, + pin_package_environment, + install_tool_package, + ) -> None: + """A tool contribution that changes the stages is visible in the card. + + A skip directive contributed onto the empty base (a silent-miss + auto-match) removes ``test`` — falsifiably different from the raw + two-stage composition, proving the card compiled the merged overlay + workflow, not the pre-amendment one. + """ + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + def register(hooks: object) -> None: + def hardening(self: object, context: object) -> None: + context.contribute(WorkflowDocument(stages={"test": WorkflowStage(skip=True)})) + + hooks.subscribe("pipeline", "amend_workflow", "hardening", hardening) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register) + + project_dir = tmp_path / "project_pipelines" + _write_pipeline(project_dir, "deploy", _DEPLOY_YML) + + card = describe_pipeline("deploy", project_dir, tmp_path / "user_pipelines", None, False) + + assert [stage.id for stage in card.stages] == ["build"] # test removed by the tool's skip + assert card.provenance == ["demo"] + + def test_describe_pipeline_delivers_the_run_path_fact_set( + self, + tmp_path: Path, + isolated_cwd: Path, + monkeypatch: pytest.MonkeyPatch, + pin_package_environment, + install_tool_package, + ) -> None: + """The card delivers the same decision and work facts the run form delivers. + + Observed through a recording tool: the decision follows the flags and + the resolution outcome (explicit hit / auto miss), and the work + identity follows the branch — the hosting form on a topic branch, the + literal ``unknown`` branch-only form when git resolves none, and the + guarded branch-only form on a fully unsluggable branch. + """ + recorded: dict[str, Any] = {} + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + def register(hooks: object) -> None: + def recorder(self: object, context: object) -> None: + recorded["facts"] = context + + hooks.subscribe("pipeline", "amend_workflow", "recorder", recorder) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register) + + project_dir = tmp_path / "project_pipelines" + _write_pipeline(project_dir, "deploy", _DEPLOY_YML) + workflows_dir = isolated_cwd / ".goga" / "workflows" + workflows_dir.mkdir(parents=True) + (workflows_dir / "ci.yml").write_text("prompt: ci\n") + + topic_dir = isolated_cwd / ".goga" / "history" / current_year() / "feature-demo" + topic_dir.mkdir(parents=True) + + # Explicit hit on a topic-hosting branch — the hosting work form. + monkeypatch.setattr(_describe_pipeline_module, "resolve_current_branch_name", lambda: "feature-demo") + describe_pipeline("deploy", project_dir, tmp_path / "user_pipelines", "ci", False) + facts = recorded["facts"] + assert (facts.decision.kind, facts.decision.workflow_name) == ("explicit", "ci") + assert facts.pipeline.name == "deploy" + assert facts.pipeline.display_name == "Deploy" + assert facts.pipeline.source == "project" + assert facts.work == WorkIdentity(branch="feature-demo", slug="feature-demo", year=current_year()) + + # Auto-miss on an unsluggable branch — the guarded branch-only form. + monkeypatch.setattr(_describe_pipeline_module, "resolve_current_branch_name", lambda: "Ветка") + describe_pipeline("deploy", project_dir, tmp_path / "user_pipelines", None, False) + facts = recorded["facts"] + assert (facts.decision.kind, facts.decision.workflow_name) == ("silent-miss", None) + assert facts.work == WorkIdentity(branch="Ветка") + + # No branch at all — the literal "unknown" branch-only form. + monkeypatch.setattr(_describe_pipeline_module, "resolve_current_branch_name", lambda: None) + describe_pipeline("deploy", project_dir, tmp_path / "user_pipelines", None, False) + assert recorded["facts"].work == WorkIdentity(branch="unknown") diff --git a/tests/pipeline/test_pipeline_cli.py b/tests/pipeline/test_pipeline_cli.py index 242d586f..8d93d5e0 100644 --- a/tests/pipeline/test_pipeline_cli.py +++ b/tests/pipeline/test_pipeline_cli.py @@ -46,6 +46,19 @@ def _write_pipeline(cwd: Path, name: str, text: str) -> Path: return path +@pytest.fixture(autouse=True) +def _empty_package_environment(pin_package_environment) -> None: + """Pin the package environment empty for every test of this module. + + The info forms run the real ``describe_pipeline`` — the card path builds + the real registry through the amendment layer, so an unpinned environment + would make the byte-exact card output depend on the machine's installed + ``goga_tool_*`` packages. Tests that install a tool pin their own + environment on top — the later pin wins. + """ + pin_package_environment({}) + + class TestPipelineCliContract: def test_pipeline_cli_importable_from_facade(self) -> None: """pipeline_cli is importable from the goga.pipeline facade.""" diff --git a/tests/pipeline/test_run_pipeline_hooks.py b/tests/pipeline/test_run_pipeline_hooks.py index fbb8c712..74422a9f 100644 --- a/tests/pipeline/test_run_pipeline_hooks.py +++ b/tests/pipeline/test_run_pipeline_hooks.py @@ -46,7 +46,7 @@ WorkflowOverlay, WorkIdentity, ) -from goga.pipeline.workflow import WorkflowSyntaxError +from goga.pipeline.workflow import WorkflowDocument, WorkflowStage, WorkflowSyntaxError # goga.pipeline.run_pipeline is shadowed in the package __init__ by the # run_pipeline function, so a string-based mock.patch path walking through it @@ -440,6 +440,49 @@ def _assert_decision(label: str, expected: tuple[str, str | None]) -> None: _run_once() _assert_decision("disabled", ("disabled", None)) + def test_silent_miss_kind_survives_runner_skip_merge( # noqa: PLR0913, PLR0917 + self, + tmp_path: Path, + isolated_cwd: Path, + afm_dir: Path, + monkeypatch: pytest.MonkeyPatch, + pin_package_environment, + install_tool_package, + ) -> None: + """A miss reports as a miss even when ``GOGA_SKIP_STAGES`` synthesizes a document. + + The decision mirrors the resolution, not the skip merge: an explicit + name that resolves nothing stays ``silent-miss`` while the skip-only + merged document still reaches the amendment and the compiler. + """ + recorded: dict[str, Any] = {} + events: list[str] = [] + _install_events_tool(pin_package_environment, install_tool_package, recorded, events) + monkeypatch.setattr(_run_pipeline_module, "resolve_current_branch_name", lambda: "feature-demo") + + project_dir = tmp_path / "project_pipelines" + _write_pipeline(project_dir) + + monkeypatch.delenv("GOGA_WORKFLOW_DISABLED", raising=False) + monkeypatch.setenv("GOGA_WORKFLOW_NAME", "ghost") # resolves nothing + monkeypatch.setenv("GOGA_SKIP_STAGES", "build") + + with ( + mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_documents()) as mock_compile, + mock.patch.object(_run_pipeline_module, "run_flow", return_value=0), + ): + result = run_pipeline("deploy", project_dir, tmp_path / "user_pipelines", 50321) + + decision = recorded["created"].decision + assert (decision.kind, decision.workflow_name) == ("silent-miss", None) + + # The skip merge still applied — the synthesized document reached the + # compiler through the passthrough overlay. + compiled_workflow = mock_compile.call_args.kwargs["workflow"] + assert compiled_workflow is not None + assert compiled_workflow.stages["build"].skip is True + assert result == 0 + class TestRunPipelineAmendmentAndStatuses: def test_disabled_decision_skips_delivery_compiles_raw_and_still_emits( # noqa: PLR0913, PLR0917 @@ -489,6 +532,52 @@ def canary(self: object, context: object) -> None: assert mock_compile.call_args.kwargs["workflow"] is None assert events == ["created", "completed"] # no "amend" entry — both events fired + def test_committed_contribution_is_the_workflow_compiled( # noqa: PLR0913, PLR0917 + self, + tmp_path: Path, + isolated_cwd: Path, + afm_dir: Path, + monkeypatch: pytest.MonkeyPatch, + pin_package_environment, + install_tool_package, + ) -> None: + """The overlay workflow — not the pre-amendment one — reaches ``compile_flow``. + + A silent-miss run (no workflow file) whose tool contributes a + document: the compiled workflow is the merged overlay (the tool's + prompt and stage directive), and the creation context carries the + same effective document with the tool in its provenance. + """ + recorded: dict[str, Any] = {} + events: list[str] = [] + + def contribute(self: object, context: object) -> None: + context.contribute(WorkflowDocument(prompt="tool-text", stages={"test": WorkflowStage(skip=True)})) + + _install_events_tool(pin_package_environment, install_tool_package, recorded, events, amend=contribute) + _isolate_workflow_env(monkeypatch) + monkeypatch.setattr(_run_pipeline_module, "resolve_current_branch_name", lambda: "feature-demo") + + project_dir = tmp_path / "project_pipelines" + _write_pipeline(project_dir) + + with ( + mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_documents()) as mock_compile, + mock.patch.object(_run_pipeline_module, "run_flow", return_value=0), + ): + result = run_pipeline("deploy", project_dir, tmp_path / "user_pipelines", 50321) + + compiled_workflow = mock_compile.call_args.kwargs["workflow"] + assert compiled_workflow is not None # not the pre-amendment None + assert compiled_workflow.prompt == "tool-text" + assert compiled_workflow.stages["test"].skip is True + + created = recorded["created"] + assert created.workflow is not None + assert created.workflow.prompt == "tool-text" # the context carries the same effective document + assert created.provenance == ["demo"] + assert result == 0 + def test_statuses_recomputed_at_completion_and_branch_only_stays_empty( # noqa: PLR0913, PLR0917 self, tmp_path: Path, @@ -613,3 +702,60 @@ def on_completed(self: object, context: object) -> None: assert "demo" in caplog.text assert "run_completed" in caplog.text assert "boom" in caplog.text + + def test_run_created_soft_failure_warns_and_launch_proceeds( # noqa: PLR0913, PLR0917 + self, + tmp_path: Path, + isolated_cwd: Path, + afm_dir: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + pin_package_environment, + install_tool_package, + ) -> None: + """A failing ``run_created`` hook warns; the launch still happens. + + The creation emission is fire-and-forget exactly like the completion: + a raising hook warns inside the platform and the run continues to the + launch with its exit code unaffected. + """ + recorded: dict[str, Any] = {} + events: list[str] = [] + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + def register(hooks: object) -> None: + def on_created(self: object, context: object) -> None: + events.append("created") + raise RuntimeError("boom") + + def on_completed(self: object, context: object) -> None: + events.append("completed") + recorded["completed"] = context + + hooks.subscribe("pipeline", "run_created", "notify", on_created) # type: ignore[attr-defined] + hooks.subscribe("pipeline", "run_completed", "notify", on_completed) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register) + _isolate_workflow_env(monkeypatch) + monkeypatch.setattr(_run_pipeline_module, "resolve_current_branch_name", lambda: "feature-demo") + + project_dir = tmp_path / "project_pipelines" + _write_pipeline(project_dir) + + def _run(*args: object, **kwargs: object) -> int: + events.append("run") + return 7 + + with ( + mock.patch.object(_run_pipeline_module, "compile_flow", return_value=_documents()), + mock.patch.object(_run_pipeline_module, "run_flow", side_effect=_run), + caplog.at_level(logging.WARNING), + ): + result = run_pipeline("deploy", project_dir, tmp_path / "user_pipelines", 50321) + + assert result == 7 + # The launch happened after the failing emission; the completion fired. + assert events == ["created", "run", "completed"] + assert "demo" in caplog.text + assert "run_created" in caplog.text + assert "boom" in caplog.text diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index 3064a55e..21317e22 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -899,7 +899,11 @@ def test_create_topic_with_todo_value(self, tmp_path: Path, monkeypatch: pytest. assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" wired.plant.assert_called_once_with( - "Feature/Foo_Bar", "Payment retry", "c0ffee", "feature-foo-bar", "2026", + "Feature/Foo_Bar", + "Payment retry", + "c0ffee", + "feature-foo-bar", + "2026", "goga: create topic feature-foo-bar", ) wired.checkout.assert_not_called() From c716655973d7234f23058eb0d2f50f6638335151 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 18 Sep 2026 17:28:24 +0000 Subject: [PATCH 078/205] chore: complete the add-pipeline-hooks plan and accept the result --- .goga/history/2026/add-pipeline-hooks/{ => completed}/plan.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .goga/history/2026/add-pipeline-hooks/{ => completed}/plan.md (100%) diff --git a/.goga/history/2026/add-pipeline-hooks/plan.md b/.goga/history/2026/add-pipeline-hooks/completed/plan.md similarity index 100% rename from .goga/history/2026/add-pipeline-hooks/plan.md rename to .goga/history/2026/add-pipeline-hooks/completed/plan.md From 87a44e1ed340059faa0aecc235d31881c50a4c8a Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Sat, 19 Sep 2026 02:57:03 +0300 Subject: [PATCH 079/205] docs: name the hard amend_workflow failure in the automation exit-code summary --- docs/features/pipelines/automation.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/features/pipelines/automation.md b/docs/features/pipelines/automation.md index 5c13f224..08340c08 100644 --- a/docs/features/pipelines/automation.md +++ b/docs/features/pipelines/automation.md @@ -76,10 +76,11 @@ image lifecycle is managed externally (see The container's exit code is propagated unchanged, which makes the run directly usable as a CI step: `0` — the pipeline ran successfully; `1` — a -form or configuration error, or a handled compile failure; `126`/`127` — -the pipeline engine inside the image is not executable / missing; `130`/ -`143` — interrupted by SIGINT/SIGTERM. See -[Exit codes](cli.md#exit-codes) for the full table. +form or configuration error, a handled compile failure, or a failing hard +`pipeline/amend_workflow` hook — the run stops before any compile or +launch (see [Hooks](hooks.md)); `126`/`127` — the pipeline engine inside +the image is not executable / missing; `130`/`143` — interrupted by +SIGINT/SIGTERM. See [Exit codes](cli.md#exit-codes) for the full table. ## CI skeletons From a48b374964834a12c383c4536abae66237851034 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 19:42:38 +0000 Subject: [PATCH 080/205] chore: bump AFM_VERSION to 1.1.13 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 45faa08e..a219dbad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG AFM_VERSION=1.1.8 +ARG AFM_VERSION=1.1.13 ARG RALPHEX_VERSION=1.6 ARG PYTHON_VERSION=3.12 ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 From b81720b09ed8bd986269e058964a46904a78b952 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 19:42:41 +0000 Subject: [PATCH 081/205] docs: add the add-hooks-to-build discovery and planning history --- .goga/history/2026/add-hooks-to-build/adr.md | 138 ++ .goga/history/2026/add-hooks-to-build/arch.md | 1789 +++++++++++++++++ .../history/2026/add-hooks-to-build/design.md | 1155 +++++++++++ .goga/history/2026/add-hooks-to-build/plan.md | 1555 ++++++++++++++ .goga/history/2026/add-hooks-to-build/prd.md | 459 +++++ .goga/history/2026/add-hooks-to-build/task.md | 307 +++ 6 files changed, 5403 insertions(+) create mode 100644 .goga/history/2026/add-hooks-to-build/adr.md create mode 100644 .goga/history/2026/add-hooks-to-build/arch.md create mode 100644 .goga/history/2026/add-hooks-to-build/design.md create mode 100644 .goga/history/2026/add-hooks-to-build/plan.md create mode 100644 .goga/history/2026/add-hooks-to-build/prd.md create mode 100644 .goga/history/2026/add-hooks-to-build/task.md diff --git a/.goga/history/2026/add-hooks-to-build/adr.md b/.goga/history/2026/add-hooks-to-build/adr.md new file mode 100644 index 00000000..fc3d3881 --- /dev/null +++ b/.goga/history/2026/add-hooks-to-build/adr.md @@ -0,0 +1,138 @@ +--- +status: accepted +--- + +# Open the build domain over a stable two-pass cycle with a verdict-collecting gate + +The build domain joins the hooks platform with five additive catalog actions — +the hard `validate_build` gate plus the soft notifications `build_started`, +`pass_started`, `pass_completed`, `build_completed` — delivered by a per-domain +hooks zone fully symmetric with the open domains (pipeline, topics), and the run +model is restructured into a stable cycle (always a tasks pass then a review +pass as two separate ralphex invocations) so the moment set has a uniform +contract to describe. This ADR records the technical decisions of the discovery +interview (rounds q1–q8); the PRD at `prd.md` has been aligned to them. + +## Decision + +### Stable cycle and the two-part settings model + +- Every non-skipped run is exactly two ralphex invocations: the tasks pass + (`--tasks-only`, the root agent's wrapper, the root env as the tasks-pass env + layer) then the review pass (`--review`, the review agent's wrapper, the + review env layer, review-scoped knobs). The combined full pass is removed; a + failed tasks pass never launches the review pass; the run's exit code is the + last executed pass's; plan relocation happens only on success. +- The task env leaves the host-side container env-file and becomes the + tasks-pass env layer — the review pass never receives it (secret-safe, never + printed). +- Settings divide into two stage-bound parts with **no universal category** + (supersedes the PRD line "universal build options apply to every pass"): + the `build` section root carries the tasks-pass settings (`agent`, `env`, + `max_iterations`, `session_timeout`, `idle_timeout`, `wait`); the + `build.review` key carries the review-pass settings (`agent`, `env`, + `roles`, `base_ref`, `strategy`, `finalize`, `additional`, plus the session + knobs). Every unset review value inherits the root value; unset at both + levels → omit. `additional.agent` inherits `review.agent`. +- Review strategy: `build.review.strategy` is `full` (internal agents + + external review), `medium` (internal only — the default, reflecting goga's + current effective behavior; goga explicitly disables external review), or + `short` (external only — the review pass runs ralphex `-e` under the + additional agent's wrapper). `skip` remains the tri-state all-or-nothing + kill switch (CLI > config), orthogonal to strategy. +- External review block `build.review.additional`: `agent` (threads to + ralphex's external-review surface — `external_review_tool` `codex|custom`, + `custom_review_script`), `patience` (`--review-patience`, 0 = disabled), + `max_iterations` (`--max-external-iterations`; 0 = ralphex auto: + `max(3, max_iterations/5)`). No `enabled` key — strategy encodes on/off. + Replaces the `codex_review` setting. +- `build.review.finalize` is a user-authored string prompt for the final + review step (finalize is a ralphex review agent, `finalize.txt`). When set, + goga materializes the ralphex files for the step and enables it + (`finalize_enabled = true`) during the defaults sync; when unset the step + stays at ralphex's default (off). Replaces `skip_finalize` (bool mirror); + the `--skip-finalize` CLI flag is removed with no replacement (a prompt is + config material). +- The worktree setting is removed outright from CLI and config. A stale + `worktree` key in an existing config is not special-cased — the config + loader extracts known fields only and ignores unknown keys (verified against + the loader contract). The host-side two-pass × worktree guard is removed. +- `skip_manifest_check` stays a CLI-only pre-check toggle, outside both parts. +- CLI surface: no new flags. `--worktree` and `--skip-finalize` die; + `--review-patience` remains and addresses `additional.patience`; the + remaining existing flags address the part(s) where their knob lives. + +### Hooks surface + +- Five additive catalog records under domain `build`; no existing record + changes. A per-domain hooks zone owned by the build domain consumes the + `goga/hooks` facade; one `HookRegistry` per run is shared by all five + checkpoints. +- The gate is a **staged per-tool walk over the facade primitives, run to + completion**: every subscribed tool's validation hooks run (no early stop); + each hook either approves silently or vetoes with a reason; a crashing hook + counts as that tool's veto with the crash reason. All vetoes merge into one + clean error listing every violation (tool, hook, reason); exit code 1; no + pass launches; the plan is not relocated; no started/pass/completed events + fire. The gate modifies nothing. This is a deliberate, domain-local + deviation from the platform's hard-action semantics ("stop at the first + failure") — verdict collection requires running every tool. +- The four notifications use the standard fire-and-forget platform emission + (soft: a failing hook warns naming tool, action, reason). +- Contexts (semantic vocabulary; shapes are contract territory): a uniform + envelope (`plan`, `work` — branch + topic when hosted, branch-only + otherwise, `"unknown"` branch fallback — and `dry_run`) plus moment facts. + The gate and `build_started` carry both resolved parts (env presence as + names only). The pass contexts carry the stage, executor, pass option facts + (including strategy/additional/finalize facts on the review pass), and the + actual exit code. `build_completed` carries the final exit code, the + executed stage sequence, the relocation outcome, and the work's **current** + history statuses at the completion moment — recomputed after the relocation + attempt (moved or not; branch-only form delivers an empty list). Contexts + carry the full `finalize` prompt text when configured. All contexts are + read-only; env values are never delivered anywhere. +- Facts resolve in the operation before delivery from the operation's own data + and the history store; no git reads happen at a checkpoint moment. + +## Considered options + +- **Gate via plain `emit_hook_event` (hard)** — rejected: platform hard + semantics stop at the first failure, violating full verdict collection. +- **Changing the platform hard class to collect-all** — rejected: touches the + pipeline domain's contract; out of scope. The staged per-tool walk precedent + (`per-tool-delivery`) already exists for domains that need per-tool + outcomes. +- **Keeping a universal-options category** — rejected by the user: with + always-two executors, every setting belongs to one of the two parts; + inheritance preserves the set-once ergonomics. +- **`skip_finalize` as a bool (negative mirror or positive tri-state)** — + rejected after learning finalize is a ralphex review agent (`finalize.txt`) + that can carry a user prompt: the prompt form lets the author write the + final review instructions, with goga preparing the files. +- **An `enabled` key inside `additional`** — rejected: redundant once + `strategy` (full | medium | short) encodes external review on/off. +- **Nesting review under `review_executor` (keeping today's block names)** — + rejected in favor of the flattened form (root = task settings, `review` = + review settings): nesting makes the inheritance-from-root mechanism obvious + to the configuring user. +- **Two ADRs** (cycle restructure vs domain opening) — rejected: one decision + system; the moment set is defined over the stable cycle. + +## Consequences + +- Breaking changes shipped without compatibility paths in the major-version + window (PRD C7): the combined full pass, the `task_executor` / + `review_executor` block names, the `worktree` flag and key, the + `--skip-finalize` flag, and the `codex_review` setting all disappear. +- The PRD's "universal build options apply to every pass" sentence and the + out-of-scope line "review_executor keeps its shape" are superseded by this + ADR; the PRD has been edited to match. +- The default strategy `medium` means goga explicitly disables external + review unless `full`/`short` is configured — deliberate (reflects the + current effective state), recorded here so it is not read as an accident. +- Open questions deferred to the architecture stage: cell boundaries and + contract shapes for the hooks zone (context types, signatures, the gate + walk's composition over the facade); the exact threading of + `additional.agent` onto ralphex's external-review surface + (`external_review_tool` / `custom_review_script`); the file form of the + finalize materialization. diff --git a/.goga/history/2026/add-hooks-to-build/arch.md b/.goga/history/2026/add-hooks-to-build/arch.md new file mode 100644 index 00000000..2e14d860 --- /dev/null +++ b/.goga/history/2026/add-hooks-to-build/arch.md @@ -0,0 +1,1789 @@ +# Architecture plan: add-hooks-to-build + +Open the build domain over a stable two-pass cycle with a verdict-collecting +gate. Normative inputs: `task.md` + `adr.md` (accepted) + `prd.md` in this +topic directory. Design provenance: the brainstorm pipeline reports (intake, +context, primary analysis, type map, type detail, cell distribution, +contracts, cell assembly) — all user-approved. + +## Topic + +- Short name: **add-hooks-to-build** +- Plan path: `.goga/history/2026/add-hooks-to-build/arch.md` + +## Implementation Order + +Leaves → root (a cell is assembled only after its providers): + +1. **`goga/config/project`** (modify) — no Imports (leaf); the settings model + everything else consumes. +2. **`goga/config`** (modify, mechanical) — depends on `goga/config/project` + (embeddings); re-export list tracks the reshaped names. +3. **`goga/ralphex`** (modify) — no Imports (leaf); options table extension. +4. **`goga/hooks/catalog`** (modify, additive) — no Imports (leaf); five + `build` records. +5. **`goga/build/hooks`** (create) — depends on `goga/hooks` (facade, + unchanged); the new zone consumes only the platform facade. +6. **`goga/build`** (modify) — depends on `goga/config`, `goga/agents`, + `goga/docker`, `goga/ralphex`, `goga/build/hooks`, `goga/history`; the + core restructure + checkpoint integration. +7. **`goga/commands/build`** (modify) — depends on `goga/config`, + `goga/build`, `goga/agents`, `goga/runtime`, `goga/docker`; the host CLI + surface. +8. **`.goga/config.yml`** (dogfooding migration) — after the config cells: + this repository's own build section migrates to the two-part form in the + same change (the old block names become unknown keys and would silently + disable the build section). + +## Artifacts + +Full DSL for created cells; add/change/delete diffs with the full new or +changed type blocks for modified cells. `unchanged` marks types kept as-is. + +### 1. `goga/config/project/CODEMANIFEST` (modify) + +**Delete types:** `TaskExecutorConfig`, `ReviewExecutorConfig`. + +**Header (Usages/Annotations):** keep `convention` and the inline `yaml` +practice; extend the global Annotations with the two-part stance (see the +assembled DSL below). + +```yaml +Usages: + convention: .goga/usages/conventions.md + yaml: | + Use yaml.safe_load() to parse .goga/config.yml. + Requires the PyYAML library. + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + All data model classes in this cell are immutable dataclasses + (frozen=True, kw_only=True), per `convention`. Use the standard library + dataclasses module — NOT pydantic (pydantic is treated as tech debt in this + project). + + Use the `yaml` practice for parsing .goga/config.yml via yaml.safe_load(). + + The cell enforces structural validation only; semantic validation is + deferred to the owning consumers. + + The build section is two-part: the `build` root carries the tasks-pass + settings source (agent, env, max_iterations, session_timeout, idle_timeout, + wait, prompts_dir, agents_dir, proxy, hosts); the `build.review` key carries + the review-pass settings source (skip, agent, env, roles, base_ref, + strategy, finalize, additional, and the session knobs). The loader extracts + known fields only — unknown keys, including a stale `worktree`, are silently + ignored; the retired keys `worktree`, `skip_finalize`, `codex_review` and + the retired block names `task_executor` / `review_executor` are not + extracted. Inheritance (review from root, additional.agent from + review.agent) belongs to the consuming cell — values are exposed verbatim + with no default merging. +``` + +**Body — full new/changed type blocks:** + +```yaml +"load_project_config() -> config: ProjectConfig": + location: loader.py + annotations: | + Parses .goga/config.yml from the project root and returns a ProjectConfig instance. + + Algorithm: + 1. Locate .goga/config.yml in the project root + 2. Parse the YAML via the `yaml` practice + 3. Validate the parsed document is a mapping; raise FileNotFoundError when + the file is absent/empty, ValueError when it is not a mapping + 4. Extract and validate the top-level fields as today (lang, image, + dockerfile) + 5. Extract the pipeline block as today (unchanged semantics) + 6. Extract the build block. Absent → build None; present but not a + mapping → ValueError. From the mapping extract the tasks-pass root + fields: agent (OPTIONAL — absent/YAML-null/empty/whitespace → None; a + non-string value → ValueError), env (optional string mapping, default + empty dict), max_iterations (OPTIONAL int — absent/YAML-null → None; a + non-int value including a YAML boolean → ValueError), session_timeout, + idle_timeout, wait (OPTIONAL strings — the agent emptiness pattern), + prompts_dir and agents_dir (OPTIONAL strings), proxy (OPTIONAL string), + hosts (optional string mapping, default empty dict). Unknown keys of + the mapping are ignored — the loader extracts known fields only + 7. Extract the optional review sub-mapping of build: absent/YAML-null → + review None; present but not a mapping → ValueError. Fields: skip + (absent/null → None; non-bool → ValueError); agent (the agent + pattern); env (the env pattern); roles (absent/null → None; non-list + or a non-string element → ValueError; an empty list passes verbatim); + base_ref (the agent pattern); strategy (absent/null/empty/whitespace → + None; non-string → ValueError — structural typing only, the + full|medium|short whitelist belongs to the consumer); finalize + (absent/null/empty/whitespace → None; non-string → ValueError — the + user-authored final review prompt, stored verbatim); additional + (absent/null → None; present but not a mapping → ValueError; inside: + agent — the agent pattern; patience — absent/null → None, non-int + including a YAML boolean → ValueError; max_iterations — the patience + pattern); the session knobs (the root pattern). Construct a + `ReviewConfig` from the resolved fields and an `AdditionalReviewConfig` + from the additional mapping, and pass them into `BuildConfig` + 8. Extract the codemanifest, lint, topics, commands, tools, and usages + blocks exactly as today (unchanged) + 9. Construct and return the `ProjectConfig` from all assembled parts + + Requirements: + - The two-part build model: root tasks-pass fields plus the optional + review sub-mapping; values verbatim, no default merge — inheritance is + the consumer's + - The loader extracts known fields only: a stale worktree key or any + unknown key is silently ignored — not an error, not stored + - strategy and finalize are structural string checks — the whitelist and + the prompt semantics belong to the consumer + - All existing requirements of the unchanged sections stand as today + + Constraints: + - Do not validate review semantics (roles whitelist, env-requires-agent, + base_ref resolvability, strategy whitelist, patience range) — consumer + territory + - Do not default-merge root values into review — the consumer applies + inheritance + - Do not rename or alias the retired keys — they simply do not exist in + the model + +"ProjectConfig(lang: str, image: str | None, dockerfile: str | None, build: BuildConfig | None, pipeline: PipelineConfig | None, commands: dict, codemanifest: CodemanifestConfig | None, tools: dict[str, str] | None, usages: dict[str, dict[str, DepConfig]] | None = None, lint: LintConfig | None = None, topics: TopicsConfig | None = None)": + location: config.py + annotations: | + Root project configuration object. Constructed by load_project_config. + (Signature unchanged; the `build` field now holds the reshaped two-part + `BuildConfig`.) + properties: + "build -> BuildConfig | None": | + Build configuration from .goga/config.yml in the two-part form: the + root tasks-pass settings and the optional `review` part. Instance of + `BuildConfig`, or None when the build section is absent. Consumers that + need it (goga/commands/build) guard the None case and raise + ClickException before any field access. + # all other properties unchanged from the current manifest + +"BuildConfig(agent: str | None, env: dict[str, str], max_iterations: int | None, session_timeout: str | None, idle_timeout: str | None, wait: str | None, prompts_dir: str | None, agents_dir: str | None, proxy: str | None, hosts: dict[str, str], review: ReviewConfig | None)": + location: config.py + annotations: | + Build execution configuration in the two-part form. Constructed by + load_project_config from the build section of .goga/config.yml. The root + fields are the tasks-pass settings source; the review part is the + review-pass settings source. Inheritance from root into review belongs to + the consumer. + + `agent`: tasks-pass executor agent name — None when unset + `env`: tasks-pass environment layer — the review pass never receives it + `max_iterations`: maximum task iterations + `session_timeout`, `idle_timeout`, `wait`: session knobs (Go duration strings) + `prompts_dir`, `agents_dir`: custom ralphex source directories + `proxy`: optional HTTP/HTTPS proxy URL + `hosts`: optional host→IP mapping for docker run --add-host + `review`: the review-pass settings part, or None when absent + + Requirements: + - All fields may be None; env and hosts default to empty dicts + - Values stored verbatim, no inheritance applied here + properties: + "agent -> str | None": | + Tasks-pass executor agent name matching the wrapper convention; None + when unset. Resolution into a wrapper path belongs to the consumer. + "env -> dict[str, str]": | + Tasks-pass environment layer ({str: str}), verbatim. Applied as the + env layer of the tasks pass only — the review pass never receives it. + Empty dict when absent. + "max_iterations -> int | None": | + Maximum number of task iterations of the tasks pass. None when unset. + "session_timeout -> str | None": | + Session timeout duration, Go duration format. None when unset. + "idle_timeout -> str | None": | + Session idle timeout duration, Go duration format. None when unset. + "wait -> str | None": | + Rate-limit retry wait duration, Go duration format. None when unset. + "prompts_dir -> str | None": | + Custom ralphex prompt directory path. None when unset. + "agents_dir -> str | None": | + Custom ralphex agent directory path. None when unset. + "proxy -> str | None": | + Optional HTTP/HTTPS proxy URL for the build container. + "hosts -> dict[str, str]": | + Optional host→IP mapping for docker run --add-host flags. Empty dict + when absent. + "review -> ReviewConfig | None": | + The review-pass settings part. Instance of `ReviewConfig`, or None when + the build.review key is absent. Structural validity is owned by + `load_project_config`; semantics and inheritance belong to the consumer. + +"ReviewConfig(skip: bool | None, agent: str | None, env: dict[str, str], roles: list[str] | None, base_ref: str | None, strategy: str | None, finalize: str | None, additional: AdditionalReviewConfig | None, session_timeout: str | None, idle_timeout: str | None, wait: str | None)": + location: config.py + annotations: | + The `build.review` settings source — the review-pass part of the two-part + build model. Constructed by load_project_config. Every field is stored + verbatim; an unset field is None (or an empty dict for env) and means + "inherit from the root" to the consumer. + + Requirements: + - Immutable frozen dataclass (frozen=True, kw_only=True), per `convention` + - No normalization of emptiness, no whitelist; env resolves + absent/YAML-null/{} to an empty dict + + Constraints: + - Do not validate role names, agent names, strategy values, or env + applicability at this level — structural typing only + properties: + "skip -> bool | None": | + Tri-state source for skipping the review pass. None when the field is + absent. + "agent -> str | None": | + Review-pass executor name matching the wrapper convention. None when + unset — the consumer inherits the root agent. + "env -> dict[str, str]": | + Review-pass environment layer, verbatim. Empty dict when absent — the + consumer inherits nothing for env (the root env is the tasks-pass + layer and is never inherited). + "roles -> list[str] | None": | + Declared reviewer composition, verbatim. None when unset; an empty list + stays an empty list (the full default set is the consumer-side meaning). + "base_ref -> str | None": | + Review diff base — a branch name or a commit hash, verbatim. None when + unset (normalized by `load_project_config`). + "strategy -> str | None": | + Review strategy source — full, medium, or short; None when unset (the + consumer applies the default medium). Structural typing only. + "finalize -> str | None": | + The user-authored final review prompt, verbatim. None when unset — the + finalize step stays at the ralphex default (off). + "additional -> AdditionalReviewConfig | None": | + The external-review block source. Instance of `AdditionalReviewConfig`, + or None when absent. + "session_timeout -> str | None": | + Review-pass session timeout; None inherits the root value. + "idle_timeout -> str | None": | + Review-pass idle timeout; None inherits the root value. + "wait -> str | None": | + Review-pass rate-limit wait; None inherits the root value. + +"AdditionalReviewConfig(agent: str | None, patience: int | None, max_iterations: int | None)": + location: config.py + annotations: | + The `build.review.additional` external-review block source. + + `agent`: external review agent name — None when unset (the consumer + inherits review.agent) + `patience`: external-review stop threshold (0 = disabled) + `max_iterations`: external review iteration cap (0 = ralphex auto) + + Requirements: + - Immutable frozen dataclass (frozen=True, kw_only=True), per `convention` + - Values stored verbatim; 0 is a meaningful value, not an unset marker + + Constraints: + - Do not validate agent names or ranges at this level — structural typing + only + properties: + "agent -> str | None": | + External review agent name matching the wrapper convention. None when + unset — the consumer inherits review.agent. + "patience -> int | None": | + External-review stop threshold — stop after N consecutive unchanged + rounds; 0 = disabled. None when unset. + "max_iterations -> int | None": | + External review iteration cap; 0 = ralphex auto (max(3, + max_iterations/5)). None when unset. +``` + +**Unchanged types:** `PipelineConfig`, `CodemanifestConfig`, `LintConfig`, +`TopicsConfig`, `DepConfig`. + +**Footer:** Author: Goga; CreatedAt: 24/07/26 (original); Description updated +to the two-part form. + +### 2. `goga/config/CODEMANIFEST` (modify, mechanical) + +Imports from `goga/config/project`: **remove** `TaskExecutorConfig`, +`ReviewExecutorConfig`; **add** `ReviewConfig`, `AdditionalReviewConfig`; +keep the rest. Embeddings updated symmetrically: + +```yaml +->ProjectConfig: {} +->load_project_config: {} +->BuildConfig: {} +->ReviewConfig: {} +->AdditionalReviewConfig: {} +->PipelineConfig: {} +->CodemanifestConfig: {} +->DepConfig: {} +->LintConfig: {} +->HomeConfig: {} +->DockerArgsConfig: {} +->load_home_config: {} +->resolve_project_name: {} +->TopicsConfig: {} +``` + +Global Annotations: the re-export clause mentions the two-part build form. +Footer unchanged (Author: Goga, CreatedAt: 31/08/26). + +### 3. `goga/ralphex/CODEMANIFEST` (modify) + +Header and footer unchanged. `run_ralphex` — options table and requirements +updated (full block): + +```yaml +"run_ralphex(plan: str, options: dict[str, str | int | bool], dry_run: bool, env: dict[str, str] | None = None) -> exit_code: int": + location: run_ralphex.py + annotations: | + Launch the external `ralphex` binary to execute the given build plan with the resolved + ralphex options. goga-side entry point to ralphex; performs no config generation, + option resolution, or wrapper resolution — those live in goga/build. + + `plan`: path to the plan file (markdown), resolved by the caller (goga/build). Passed + to ralphex as the positional argument. + `options`: resolved ralphex options — the caller (goga/build) has already applied + precedence and stage binding. Keys are ralphex option names; each key maps + to exactly one ralphex CLI flag: + - tasks_only (bool) → --tasks-only (bare flag) + - review (bool) → --review (bare flag) + - external_only (bool) → -e (bare flag) + - session_timeout (str) → --session-timeout (value flag) + - idle_timeout (str) → --idle-timeout (value flag) + - wait (str) → --wait (value flag) + - max_iterations (int) → --max-iterations (value flag) + - review_patience (int) → --review-patience (value flag) + - max_external_iterations (int) → --max-external-iterations (value flag) + - base_ref (str) → --base-ref (value flag) + `dry_run`: when True, print the assembled ralphex command to sys.stderr and return 0 + without launching. + `env`: optional environment layer applied on top of the inherited process + environment for the ralphex subprocess only. Never logged, never printed. + `exit_code`: 0 on success, 1 when ralphex is missing from PATH or the launch is + rejected before the exec, otherwise ralphex's own exit code + + Algorithm: + 1. Receive `plan`, `options`, `dry_run`, and `env` from the caller + 2. Invoke ralphex via the `ralphex` practice with `plan` as the positional argument, + --config-dir .ralphex/, and the flags mapped from `options` + 3. On `dry_run`: print the assembled command to sys.stderr and return 0 — never print + the `env` layer values + 4. Verify `ralphex` is on PATH; when absent — return 1 + 5. Execute ralphex via subprocess with the composed environment and propagate its + exit code; a pre-exec rejection surfaces as a clean one-line message and exit + code 1 — never a traceback, never the `env` contents + + Requirements: + - Map `options` to ralphex CLI flags per the table: a bool key that is True emits a + bare flag (False or absent → omit); a scalar key emits -- and is + omitted when the value is None or an empty string — EXCEPT the zero-valued + external flags: `review_patience` 0 and `max_external_iterations` 0 are meaningful + (disabled / ralphex auto) and ARE passed as 0 + - The pass-mode bare flags (tasks_only, review, external_only) are mutually + exclusive per invocation — the caller guarantees it; this launcher does not check + - Apply the `ralphex` practice's exit-code rules verbatim + + Constraints: + - Do not generate .ralphex/config, resolve options, or resolve wrappers — caller's + responsibility + - Do not check flag exclusivity — the caller owns pass composition + - Do not log or otherwise expose the `env` layer contents +``` + +Removed from the table: `worktree`, `skip_finalize` (retired surface). + +### 4. `goga/hooks/catalog/CODEMANIFEST` (modify, additive) + +Header and footer unchanged. `declared_actions` Requirements **gain** (after +the existing records; no existing record changes): + +```yaml + - The catalog carries the build validation-gate action — the record + domain="build", name="validate_build", error_class="hard": a veto + stops the build before any pass with one merged error; the domain's + delivery walk runs every subscribed tool's hooks to completion — a + deliberate domain-local deviation from the stop-at-first-failure hard + semantics, recorded by the build domain's zone + - The catalog carries the build start-notification action — the record + domain="build", name="build_started", error_class="soft": a failing + hook of the action is skipped with a warning and the run continues + - The catalog carries the build pass-start notification action — the + record domain="build", name="pass_started", error_class="soft": a + failing hook of the action is skipped with a warning and the pass + launches + - The catalog carries the build pass-completion notification action — + the record domain="build", name="pass_completed", error_class="soft": + a failing hook of the action is skipped with a warning; the completion + fact is already delivered + - The catalog carries the build completion notification action — the + record domain="build", name="build_completed", error_class="soft": a + failing hook of the action is skipped with a warning; the run's exit + code is unaffected +``` + +### 5. `goga/build/hooks/CODEMANIFEST` (create — full file) + +```yaml +Imports: + - Types: + - HookRegistry + - wrap_context + - build_hook_arguments + - emit_hook_event + - declared_actions + Usages: + - declaring-actions + - per-tool-delivery + - registering-hooks + From: goga/hooks + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and + testing in the project + + This cell owns the hooks zone of the build domain: the fact vocabulary of + the run events, the read-only contexts of the five moments, the + verdict-collecting gate view, and the checkpoint surface that delivers the + gate and emits the four notifications over the platform facade. One + registry per run carries every checkpoint of a command — the checkpoints + never multiply the package enumeration. Every context is built from the + operation data the caller passes — no repository reads and no git access + happen here, at any checkpoint moment. Env values are never present in any + fact: presence is delivered as names only. The gate is the domain's hard + action with a deliberate domain-local deviation: the staged per-tool walk + runs to completion — every subscribed tool's validation hooks run, no + early stop between tools — and collects the vetoes, following the + `per-tool-delivery` precedent instead of the platform's + stop-at-first-failure hard semantics; verdict collection requires every + tool's outcome. The four notifications are soft — a failing hook warns + naming the tool, the action, and the reason, and the run's outcome is + unaffected. + Use the `per-tool-delivery` practice for the staged walk of the gate — its + loop skeleton, primitives, and per-tool grouping apply as written with one + refinement: the walk never stops early and collects vetoes instead of + committing contributions; a tool with no veto and no crash approves + silently. + Use the `declaring-actions` practice for the emission contract of the + notification checkpoints. + Use the `registering-hooks` practice for the hook signature and the failure + handling behind every checkpoint. + Use relative imports. + +--- + +"WorkIdentity(branch: str, slug: str | None = None, year: str | None = None)": + location: facts.py + annotations: | + The identity of the current work — the branch, with the topic slug and + year when the branch hosts a topic. + + `branch`: the current branch name as resolved by the operation ("unknown" + when resolution failed) + `slug`: the normalized topic slug — present when the branch hosts a topic + `year`: the resolved year as four digits — present when the branch hosts + a topic + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The hosting decision and every resolution happen in the constructing + operation — nothing is read here + - The branch-only form — `slug` and `year` None — serves a branch hosting + no topic + properties: + "branch -> str": | + The current branch name as resolved by the operation; "unknown" when + the operation could not resolve it. + "slug -> str | None": | + The normalized topic slug, or None in the branch-only form. + "year -> str | None": | + The resolved year as four digits, or None in the branch-only form. + +"BuildMoment(plan: str, work: WorkIdentity, dry_run: bool)": + location: facts.py + annotations: | + The uniform envelope of every build context — the plan under execution, + the work identity, and the rehearsal fact. + + `plan`: the plan file path of the run + `work`: the current work identity + `dry_run`: True when the run rehearses without launching anything + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "plan -> str": | + The plan file path of the run. + "work -> WorkIdentity": | + The current work identity. + "dry_run -> bool": | + True when the run rehearses the cycle without launching passes. + +"StageFacts(stage: str, agent: str | None, env: list[str], max_iterations: int | None, session_timeout: str | None, idle_timeout: str | None, wait: str | None, roles: list[str] | None, base_ref: str | None, strategy: str | None, finalize: str | None, additional: AdditionalFacts | None)": + location: facts.py + annotations: | + The resolved facts of one stage part of the run — the delivered + projection of the operation's resolved settings for that stage. + + `stage`: exactly tasks or review + `agent`: the executor agent name of the stage + `env`: the env presence of the stage layer as NAMES — values never + appear anywhere + `max_iterations`, `session_timeout`, `idle_timeout`, `wait`: the resolved + pass knobs of the stage + `roles`: the declared reviewer composition — review stage only + `base_ref`: the review diff base — review stage only + `strategy`: the resolved review strategy — review stage only + `finalize`: the full finalize prompt text when configured — review stage + only, None otherwise + `additional`: the external-review facts — review stage only + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Pure facts — the constructing operation passes resolved values with + inheritance already applied; nothing is read or derived here + - The review members are None on the tasks part + - `env` carries names only — an empty list means no env layer + properties: + "stage -> str": | + The stage identity — exactly tasks or review. + "agent -> str | None": | + The executor agent name of the stage. + "env -> list[str]": | + The env presence of the stage layer as names; never values. + "max_iterations -> int | None": | + The resolved iteration cap of the stage. + "session_timeout -> str | None": | + The resolved session timeout of the stage. + "idle_timeout -> str | None": | + The resolved idle timeout of the stage. + "wait -> str | None": | + The resolved rate-limit wait of the stage. + "roles -> list[str] | None": | + The declared reviewer composition of the review stage; None on the + tasks part. + "base_ref -> str | None": | + The review diff base of the review stage; None on the tasks part. + "strategy -> str | None": | + The resolved review strategy (full, medium, short); None on the tasks + part. + "finalize -> str | None": | + The full finalize prompt text when configured; None when unset or on + the tasks part. + "additional -> AdditionalFacts | None": | + The external-review facts of the review stage; None on the tasks part. + +"AdditionalFacts(agent: str | None, patience: int | None, max_iterations: int | None)": + location: facts.py + annotations: | + The delivered mirror of the external-review block — the documented facts + of `build.review.additional` for tool authors. + + `agent`: the external review agent name (after inheritance) + `patience`: the external-review stop threshold (0 = disabled) + `max_iterations`: the external review iteration cap (0 = ralphex auto) + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "agent -> str | None": | + The external review agent name, or None when unset. + "patience -> int | None": | + The external-review stop threshold; 0 = disabled; None when unset. + "max_iterations -> int | None": | + The external review iteration cap; 0 = ralphex auto; None when unset. + +"RelocationOutcome(moved: bool, destination: str | None)": + location: facts.py + annotations: | + The outcome of the plan relocation attempt. + + `moved`: True when the plan file was relocated + `destination`: the relocation destination path when moved, None otherwise + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "moved -> bool": | + True when the plan was relocated into the completed directory. + "destination -> str | None": | + The relocation destination when moved, None when not moved. + +"Violation(tool: str, hook: str, reason: str)": + location: facts.py + annotations: | + One collected veto of the gate walk. + + `tool`: the tool identity assigned by the platform + `hook`: the hook name that vetoed (or crashed) + `reason`: the veto reason — a hook-authored message or the crash reason; + never a raw traceback + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "tool -> str": | + The tool identity of the vetoing tool. + "hook -> str": | + The hook name that vetoed or crashed. + "reason -> str": | + The veto reason — authored or crash-derived; never a raw traceback. + +"GateVerdict(violations: list[Violation])": + location: facts.py + annotations: | + The collected verdict of the gate walk — every veto of every subscribed + tool, in enumeration order. + + `violations`: the collected violations; an empty list means approved + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The verdict is data only — acting on it (the merged error, the exit + code) belongs to the operation + properties: + "violations -> list[Violation]": | + The collected violations in enumeration order. + "approved -> bool": | + True when no violation was collected — the run may proceed. + +"BuildValidation(moment: BuildMoment, tasks: StageFacts, review: StageFacts, skip: bool)": + location: contexts.py + annotations: | + The gate's delivered view of one tool — the read-only facts of the run + about to start, plus the veto buffer of this tool alone. + + `moment`: the uniform envelope + `tasks`: the resolved facts of the tasks stage + `review`: the resolved facts of the review stage (always present, + including a skipped review — the facts describe the resolved + settings, not the execution) + `skip`: the resolved review skip state + + Apply the `convention` practice for the data-model rules and + intra-package imports. + Use the `registering-hooks` practice for the hook signature that + receives this view. + + Requirements: + - The reads deliver the resolved facts — read-only; a hook observes and + cannot alter anything + - The veto buffer belongs to this tool alone + properties: + "moment -> BuildMoment": | + The uniform envelope of the run. + "tasks -> StageFacts": | + The resolved facts of the tasks stage. + "review -> StageFacts": | + The resolved facts of the review stage. + "skip -> bool": | + The resolved review skip state of the run. + methods: + "veto(reason: str)": | + Buffer this tool's veto of the run. + + `reason`: the human-readable violation reason + + Requirements: + - The call buffers into the buffer of this tool alone and changes + nothing until the walk collects it + - The replacement is whole — a later call replaces the earlier reason + - The view records no hook identity — the walk attributes the veto + to a hook by observing the buffer change around each call + - An empty or whitespace-only reason is stored as given — the merged + error renders it verbatim + + Constraints: + - Do not cancel, redirect, or defer the operation — a veto stops the + run through the collected verdict only + +"BuildStarted(moment: BuildMoment, tasks: StageFacts, review: StageFacts, skip: bool)": + location: contexts.py + annotations: | + The read-only context of the start notification — the same resolved + facts the gate saw, delivered immediately before the first pass launch. + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Read-only facts of the starting run — a hook observes and cannot alter + properties: + "moment -> BuildMoment": | + The uniform envelope of the run. + "tasks -> StageFacts": | + The resolved facts of the tasks stage. + "review -> StageFacts": | + The resolved facts of the review stage. + "skip -> bool": | + The resolved review skip state of the run. + +"PassStarted(moment: BuildMoment, facts: StageFacts)": + location: contexts.py + annotations: | + The read-only context of the pass-start notification — the facts of the + pass about to launch. + + `facts`: the stage facts of the launching pass + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Read-only facts of the launching pass + properties: + "moment -> BuildMoment": | + The uniform envelope of the run. + "facts -> StageFacts": | + The stage facts of the pass about to launch. + +"PassCompleted(moment: BuildMoment, facts: StageFacts, exit_code: int)": + location: contexts.py + annotations: | + The read-only context of the pass-completion notification — the facts of + the finished pass plus its actual exit code. Completion is a fact, not a + success claim. + + `facts`: the stage facts of the finished pass + `exit_code`: the actual exit code of the pass — zero, non-zero, or a + spawn-failure code + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "moment -> BuildMoment": | + The uniform envelope of the run. + "facts -> StageFacts": | + The stage facts of the finished pass. + "exit_code -> int": | + The actual exit code of the pass return. + +"BuildCompleted(moment: BuildMoment, exit_code: int, stages: list[str], relocation: RelocationOutcome, statuses: list[str])": + location: contexts.py + annotations: | + The read-only context of the completion notification — the outcome of + the started run at the completion moment. + + `exit_code`: the final exit code of the run — the last executed pass's + code + `stages`: the executed stage sequence in execution order (a skipped + review is absent) + `relocation`: the outcome of the plan relocation attempt + `statuses`: the work's current history statuses at the completion moment, + recomputed after the relocation attempt; an empty list in + the branch-only form + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Read-only facts of the completed run — the artifact → history-status + integration builds from these facts alone + properties: + "moment -> BuildMoment": | + The uniform envelope of the run. + "exit_code -> int": | + The final exit code of the run. + "stages -> list[str]": | + The executed stage sequence in execution order. + "relocation -> RelocationOutcome": | + The outcome of the plan relocation attempt. + "statuses -> list[str]": | + The work's current history statuses at the completion moment; empty in + the branch-only form. + +"BuildHooks()": + location: events.py + annotations: | + The checkpoint surface of the build domain — the verdict-collecting gate + delivery and the four notification emissions over the platform facade. + + Apply the `convention` practice for the code style and intra-package + imports. + Use the `per-tool-delivery` practice for the staged walk of the gate — + with the recorded refinement: the walk runs to completion and collects + vetoes; no early stop, no contribution commit. + Use the `declaring-actions` practice for the emission contract of the + notification checkpoints. + Use the `registering-hooks` practice for the registration contract behind + every checkpoint. + + Requirements: + - Cheap construction — no enumeration and no imports happen at + construction + - One `HookRegistry` per run carries every checkpoint of a command — + the assembly runs once per run whatever the number of checkpoints + - Every context is built from the values the caller passes — no + repository reads happen at a checkpoint + methods: + "validate_build(moment: BuildMoment, tasks: StageFacts, review: StageFacts, skip: bool) -> verdict: GateVerdict": | + Deliver the validation gate and return the collected verdict. + + `moment`: the uniform envelope + `tasks`: the resolved facts of the tasks stage + `review`: the resolved facts of the review stage + `skip`: the resolved review skip state + `verdict`: the collected verdict — `approved` when no tool vetoed + + Use the `per-tool-delivery` practice for the walk (with the recorded + refinement). + + Algorithm: + 1. Resolve the address domain="build", action="validate_build" + against `declared_actions` + 2. Walk the subscriptions of the address per tool in enumeration + order: build the tool's `BuildValidation` view over the delivered + facts, wrap it via `wrap_context`, project the call arguments via + `build_hook_arguments` with the tool's own self context, and call + each hook of the tool; snapshot the view's veto buffer before + each hook call — a buffer change during a call attributes the + veto to that hook's subscription name (a later veto replaces the + earlier attribution, mirroring the whole-replacement rule) + 3. A tool whose every hook returned without raising and whose view + carries no buffered veto approves silently — no record + 4. A tool whose view carries a buffered veto contributes exactly one + `Violation` (the tool, the attributed vetoing hook, the reason) + 5. A tool with a raising hook contributes exactly one `Violation` + with the crash reason as the reason — never a raw traceback — and + the walk continues; a crash overrides the tool's buffered veto + (the crash reason replaces it); the walk NEVER stops between + tools, whatever a tool returned or raised + 6. Return the `GateVerdict` with the violations in enumeration order + + Requirements: + - Every subscribed tool's hooks run — no early stop; a non-vetoing + subscriber is invoked even when another tool already vetoed + - Exactly one `Violation` per tool: the buffered veto with its + attributed hook, or the crash reason when a hook raised (the + crash overrides the buffer) + - An address without subscriptions returns an empty verdict — approved; + with no tool packages installed the gate is inert + - The gate modifies nothing — no contribution, no mutation of any + delivered fact + + Constraints: + - Do not stop the walk at the first veto or crash — verdict collection + requires every tool's outcome + - Do not skip a subscriber of the address + - Do not read repositories or the filesystem at the checkpoint + - Do not deliver env values — the facts carry names only + "emit_build_started(moment: BuildMoment, tasks: StageFacts, review: StageFacts, skip: bool)": | + Emit the start notification — the resolved facts the gate saw, + immediately before the first pass launch. + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `BuildStarted` context from the values + 2. Emit the address domain="build", action="build_started" via + `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + - A failing hook is skipped with a warning under the soft error class + of the action — the run proceeds + "emit_pass_started(moment: BuildMoment, facts: StageFacts)": | + Emit the pass-start notification — the facts of the pass about to + launch. + + `facts`: the stage facts of the launching pass + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `PassStarted` context from the values + 2. Emit the address domain="build", action="pass_started" via + `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + "emit_pass_completed(moment: BuildMoment, facts: StageFacts, exit_code: int)": | + Emit the pass-completion notification — the facts of the finished + pass with its actual exit code. + + `facts`: the stage facts of the finished pass + `exit_code`: the actual exit code of the pass return + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `PassCompleted` context from the values + 2. Emit the address domain="build", action="pass_completed" via + `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + - The emission happens on every pass return path — zero, non-zero, + and spawn failures alike; completion is a fact + "emit_build_completed(moment: BuildMoment, exit_code: int, stages: list[str], relocation: RelocationOutcome, statuses: list[str])": | + Emit the completion notification — the outcome of the started run. + + `exit_code`: the final exit code of the run + `stages`: the executed stage sequence + `relocation`: the relocation outcome + `statuses`: the work's history statuses recomputed at the completion + moment + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `BuildCompleted` context from the values + 2. Emit the address domain="build", action="build_completed" via + `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + - The emission happens on every return path of a started run — zero, + non-zero, and spawn failures alike + +--- + +Author: Goga +CreatedAt: 21/09/26 +Description: | + Owner of the build domain hooks zone — the run-event facts, the + verdict-collecting validation gate, and the checkpoint surface over the + hooks platform. +``` + +**Cell facade note (python rules):** `__init__.py` of `goga/build/hooks` +exposes the full contract API through `__all__` (the 13 types). + +### 6. `goga/build/CODEMANIFEST` (modify) + +**Delete types:** `resolve_review_options`, `ReviewOptions` (file +`review_options.py` superseded by `run_settings.py`). + +**Header — Imports/Usages/Annotations (full new header):** + +```yaml +Imports: + - Types: + - ProjectConfig + - BuildConfig + - ReviewConfig + - AdditionalReviewConfig + - load_project_config + From: goga/config + - Types: + - resolve_wrapper_path + Usages: + - resolve-wrapper-path + From: goga/agents + - Types: + - ensure_in_docker + Usages: + - ensure-in-docker + From: goga/docker + - Types: + - run_ralphex + Usages: + - run-ralphex + From: goga/ralphex + - Types: + - BuildHooks + - BuildMoment + - StageFacts + - WorkIdentity + - RelocationOutcome + - GateVerdict + Usages: + - checkpoints + From: goga/build/hooks + - Types: + - resolve_current_branch_name + - collect_topic_statuses + Usages: + - topic-paths + - topic-statuses + From: goga/history + +Usages: + conventions: .goga/usages/conventions.md + ralphex: .goga/usages/cooks/ralphex.md + agent-wrappers: .goga/usages/cooks/agent-as-claude-wrappers.md + +Annotations: | + The `conventions` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the build domain: manifest-commit verification, two-part + settings resolution with root inheritance, the stable two-pass cycle with + the five checkpoints, ralphex config generation with the external-review + surface, vendored defaults sync with finalize materialization, and plan + relocation. Every non-skipped run is exactly two ralphex invocations — a + tasks pass then a review pass; the combined full pass does not exist. It + delegates the ralphex launch to `run_ralphex` from goga/ralphex (per the + `run-ralphex` practice) — ralphex is launched through `run_ralphex`, never + directly from this cell. + + The checkpoint facts resolve in this operation before delivery (per the + `checkpoints` practice): the work identity via `resolve_current_branch_name` + with the "unknown" fallback and the completion statuses via + `collect_topic_statuses` — both before the checkpoint moments, never at + them. Env values are never delivered to any context and never printed — + presence travels as names only. + + Use the `conventions` practice for development and testing. + Use the `ralphex` practice for the ralphex config-generation contract (the + .ralphex/config key layout, the external-review surface, the finalize step + files) written before launch. + Use the `agent-wrappers` practice for the in-container wrapper naming + convention referenced when writing claude_command into .ralphex/config. + Use the `resolve-wrapper-path` practice when calling `resolve_wrapper_path`. + Use the `run-ralphex` practice to delegate the launch. + Use the `checkpoints` practice for the checkpoint integration order and the + fact resolution. + Use the `topic-paths` practice for the work identity resolution behind the + branch hosting and the `topic-statuses` practice for the completion status + facts. + Write all output to sys.stderr (click is not used). + Run git external commands via subprocess (the manifest pre-check and the + branch resolution) — never at a checkpoint moment. +``` + +**Body — full new/changed type blocks** (locations: `build.py`, +`__main__.py`, `run_settings.py` (new file), `pass_options.py` (new file), +`review_config.py`, `ralphex_runtime.py`, `ralphex_config.py`, +`build_pass.py`, `plan_relocation.py`): + +```yaml +"build(plan: str, config: ProjectConfig, cli_options: dict) -> exit_code:int": + location: build.py + annotations: | + Orchestrates the stable two-pass build cycle with the five hooks + checkpoints. + + `plan`: path to the plan file (markdown) + `config`: loaded project configuration object + `cli_options`: dictionary of CLI options (dry_run, skip_manifest_check, + skip_review, base_ref, review_patience, session_timeout, + idle_timeout, wait, max_iterations) + `exit_code`: process exit code (0 = success, 1 = failure) + + Algorithm: + 0. (pre-check) When skip_manifest_check is not set: verify all project + CODEMANIFEST files are committed to git; reject with exit code 1 when + any are uncommitted. A pre-check failure fires no events + 1. Resolve the run settings via `resolve_run_settings` — the two parts + with root inheritance, the tri-state skip, the strategy default medium + 2. Validate the review configuration via `validate_review_config` when + the review pass will run; a validation failure fires no events + 3. Rewrite .ralphex/prompts/ and .ralphex/agents/ from the vendored + defaults via `sync_ralphex_defaults` — roles filtering, finalize + materialization when the prompt is set + 4. Resolve the checkpoint facts from this operation's own data: the + `WorkIdentity` (branch via `resolve_current_branch_name` with the + "unknown" fallback, slug/year when the branch hosts a topic), the + `BuildMoment` (plan, work, dry_run), and both `StageFacts` (env as + names) + 5. Run the validation gate via `BuildHooks.validate_build`; when the + returned `GateVerdict` is not approved: print one merged error listing + every violation (tool, hook, reason) to sys.stderr and return exit + code 1 — no pass launches, the plan is not relocated, no further + events fire + 6. Emit `build_started` with the same facts the gate saw + 7. Tasks pass: compose the options via `compose_pass_options` (stage + tasks), resolve the root agent wrapper per the `resolve-wrapper-path` + practice, emit `pass_started`, launch via `run_build_pass` with the + root env as the env layer, emit `pass_completed` with the actual exit + code + 8. When the tasks pass succeeded and review is not skipped: review pass + under the review agent's wrapper (the additional agent's wrapper under + the short strategy), the review env as the env layer, the + strategy-bound options; `pass_started` / launch / `pass_completed` + around it. A failed tasks pass never launches the review pass + 9. Relocate the plan via `move_completed_plan` with outcome = success of + the final pass (dry-run and failure leave the plan in place); take the + returned `RelocationOutcome` + 10. Recompute the work's history statuses via `collect_topic_statuses` + AFTER the relocation attempt (moved or not; branch-only form — an + empty list) + 11. Emit `build_completed` with the final exit code, the executed stage + sequence, the relocation outcome, and the recomputed statuses + 12. Return the exit code of the last executed pass + + Apply `conventions` for docstring style and intra-package imports. + Apply `ralphex` for the config-generation contract. + Apply `agent-wrappers` for the wrapper path semantics. + Apply `resolve-wrapper-path` when calling `resolve_wrapper_path`. + Apply `run-ralphex` when delegating the launch. + Apply `checkpoints` for the checkpoint order and fact resolution. + Apply `topic-paths` and `topic-statuses` for the work identity and the + completion statuses. + + Requirements: + - `ralphex` is launched only through `run_ralphex` — never via a direct + subprocess call + - Every non-skipped run is exactly two passes; a skipped review yields + exactly one tasks pass + - The run's exit code is the last executed pass's; plan relocation + happens only on success of the final pass + - The task env never reaches the review pass (secret-safe, never printed) + - On dry-run: fire the identical event structure with the dry_run fact, + print the commands of both passes without env layers, relocate nothing + - Pre-launch failures (steps 0-3) fire no events — the moment never + happened + - A blocked run (step 5) fires nothing after the gate + - Warnings and errors of the checkpoints name the tool, the action, and + the reason (delivered by the zone per its practices) + + Constraints: + - Do not assemble the ralphex command or invoke ralphex directly — + delegate to `run_ralphex` + - Do not read git at a checkpoint moment — the facts resolve before + delivery + - Do not deliver or print env values — names only, in every fact + - The .ralphex/ directory lifecycle is owned by the host launcher + (goga/commands/build) + +"main() -> exit_code:int": + location: __main__.py + annotations: | + Entry point for python -m goga.build execution inside a Docker container. + + `exit_code`: process exit code (0 = success, 1 = failure) + + Algorithm: + 0. Call `ensure_in_docker` as the very first statement (per the + `ensure-in-docker` practice) + 1. Parse CLI arguments via argparse (plan + options); the argparse + surface carries the --skip-review / --no-skip-review pair resolving + to skip_review: bool | None, --base-ref, --dry-run, + --skip-manifest-check, --session-timeout, --idle-timeout, --wait, + --max-iterations, and --review-patience (addressing + build.review.additional.patience); --worktree and --skip-finalize do + not exist + 2. Load project configuration via `load_project_config` + 3. Build cli_options from the parsed argparse results + 4. Invoke `build`(plan, `ProjectConfig`, cli_options) + 5. Return the resulting `exit_code` + + Requirements: + - The guard at step 0 MUST be covered by tests for both branches + + Apply the `ensure-in-docker` practice at step 0. + +"resolve_run_settings(config: BuildConfig, cli_options: dict) -> settings: RunSettings": + location: run_settings.py + annotations: | + Resolve the run settings of one build from the two-part configuration + and the CLI options — root inheritance applied, tri-state skip resolved, + strategy defaulted. + + `config`: build configuration (`BuildConfig`) in the two-part form; + `review` may be None — step 0 covers it + `cli_options`: CLI options dictionary; the keys read here are skip_review + (bool | None), base_ref (str | None — empty/whitespace counts as unset), + review_patience, session_timeout, idle_timeout, wait, max_iterations + (each None when the flag was not given) + `settings`: the resolved run plan (`RunSettings`) + + Algorithm: + 0. When `config.review` is None (the build.review key is absent), + treat every review field as unset: skip resolves False, the agent + and session knobs inherit the root values per step 4, + base_ref/roles/finalize stay None, and the additional part resolves + with agent = the resolved review agent and patience/max_iterations + None + 1. Resolve skip: cli_options skip_review when not None, otherwise the + skip field of `ReviewConfig`, otherwise False + 2. Resolve strategy: the strategy field of `ReviewConfig` when set, + otherwise medium + 3. Tasks part: the root agent, env, max_iterations, and session knobs + verbatim + 4. Review part: each field from `ReviewConfig` when set, otherwise the + root value (agent, session knobs; max_iterations is root-only; env + never inherits — the root env is the tasks-pass layer only, the + review env is exactly build.review.env) + 5. Additional: agent from `AdditionalReviewConfig` when set, otherwise + the resolved review agent; patience and max_iterations from + `AdditionalReviewConfig` verbatim (None when the block is absent) + 6. base_ref: cli_options base_ref when not None (empty/whitespace → + unset; padded → stripped), otherwise `ReviewConfig` base_ref + + Requirements: + - Precedence CLI > config > default > omit for every resolved knob + - Unset at both levels resolves to None — the key stays absent from the + ralphex options + - env dicts pass verbatim; the review env never inherits the root env + (secret-safe — the root env is the tasks-pass layer); an empty + review env means no review layer + + Constraints: + - Pure — no side effects, no validation of values (separate routine) + - Do not resolve wrappers — wrapper resolution belongs to the + orchestrator and the validation routine + - Do not validate the strategy value — the whitelist check belongs to + `validate_review_config` + +"RunSettings(skip: bool, tasks: PassSettings, review: ReviewPassSettings)": + location: run_settings.py + annotations: | + The resolved run plan of a single build. + + `skip`: the final skip decision (False when neither source is set) + `tasks`: the resolved tasks-pass part + `review`: the resolved review-pass part (inheritance applied; always + present — a skipped run still carries the resolved review + facts) + + Requirements: + - Immutable frozen dataclass (frozen=True, kw_only=True), per + `conventions` + - Computed by `resolve_run_settings` — never loaded from YAML directly + properties: + "skip -> bool": | + The final skip decision of the tri-state resolution. + "tasks -> PassSettings": | + The resolved tasks-pass part. + "review -> ReviewPassSettings": | + The resolved review-pass part with root inheritance applied. + +"PassSettings(agent: str | None, env: dict[str, str], max_iterations: int | None, session_timeout: str | None, idle_timeout: str | None, wait: str | None)": + location: run_settings.py + annotations: | + The resolved tasks-pass part of the run plan. + + Apply the `conventions` practice for the data-model rules and + intra-package imports. + properties: + "agent -> str | None": | + The tasks-pass executor agent name, None when unset. + "env -> dict[str, str]": | + The tasks-pass env layer, verbatim; the review pass never receives it. + "max_iterations -> int | None": | + The tasks-pass iteration cap; None when unset. + "session_timeout -> str | None": | + The tasks-pass session timeout; None when unset. + "idle_timeout -> str | None": | + The tasks-pass idle timeout; None when unset. + "wait -> str | None": | + The tasks-pass rate-limit wait; None when unset. + +"PassSettings::ReviewPassSettings(roles: list[str] | None, base_ref: str | None, strategy: str, finalize: str | None, additional: AdditionalReviewConfig)": + location: run_settings.py + annotations: | + The resolved review-pass part — a concretization of the base pass part: + the inherited agent and session-knob fields, the verbatim review env + layer (never inherited from the root), plus the review-only members. + + `roles`: the declared reviewer composition, verbatim + `base_ref`: the resolved review diff base, None when unset + `strategy`: the resolved strategy — exactly full, medium, or short + `finalize`: the user-authored finalize prompt, None when unset + `additional`: the resolved external-review block (agent inherited from + the review agent when unset) + + Requirements: + - Immutable frozen dataclass (frozen=True, kw_only=True), per + `conventions` + properties: + "roles -> list[str] | None": | + The declared reviewer composition; None or empty list mean the full + default set to the consumer. + "base_ref -> str | None": | + The resolved review diff base; None when unset. + "strategy -> str": | + The resolved review strategy — full, medium, or short. + "finalize -> str | None": | + The finalize prompt; None leaves the step at the ralphex default (off). + "additional -> AdditionalReviewConfig": | + The resolved external-review block; its agent field carries the + inherited review agent when unset in config. + +"compose_pass_options(settings: RunSettings, stage: str) -> options: dict[str, str | int | bool]": + location: pass_options.py + annotations: | + Compose the ralphex options of one pass from the resolved run settings. + + `settings`: the resolved run plan + `stage`: exactly tasks or review + `options`: the ralphex options of the pass, consumed by `run_build_pass` + + Algorithm: + 1. tasks: the tasks_only mode flag plus the resolved tasks knobs + 2. review: the mode flag by strategy — review, or external_only under + short — plus the resolved review knobs, base_ref, review_patience + from additional.patience, and max_external_iterations from + additional.max_iterations (0 = ralphex auto, passed verbatim) + + Requirements: + - Unset knobs stay absent from the dict — the assembled ralphex command + carries no flag for them + - Review-only options never appear on the tasks pass + - Exactly one pass-mode flag per composition — the modes are mutually + exclusive + + Constraints: + - Pure — no side effects, no config reads beyond `settings` + +"validate_review_config(settings: RunSettings) -> none: None": + location: review_config.py + annotations: | + Semantically validate the review configuration of a run whose review + pass will execute; raise ValueError naming the invalid value. + + `settings`: the resolved run plan (`RunSettings`) — carries every + review fact the checks read (roles, env, agent, + additional, strategy) + + Algorithm: + 1. Return without checks when `settings` says skip — a skipped run does + not validate review fields + 2. Check every role of the review part against the ralphex whitelist + (quality, implementation, testing, simplification, documentation); a + role outside the whitelist raises ValueError naming the role + 3. When the review part carries a non-empty env and no review agent — + raise ValueError naming the problem (env requires agent) + 4. Resolve the review-agent wrapper via `resolve_wrapper_path` and + require the wrapper file to exist; absence raises ValueError naming + the agent + 5. When the strategy engages the external review (short always; full + when additional.agent is set): resolve the additional-agent wrapper + the same way and require its existence + 6. Check the resolved strategy of the review part against the + whitelist full | medium | short; a value outside the whitelist + raises ValueError naming the value + + Requirements: + - Runs before any side effect — before writing .ralphex/ and before the + first checkpoint + - The error message names the invalid value + + Constraints: + - Do not validate the tasks agent wrapper here — its absence surfaces at + ralphex time + - Do not check review fields of a skipped run + +"sync_ralphex_defaults(config: BuildConfig, settings: RunSettings) -> none: None": + location: ralphex_runtime.py + annotations: | + Fully rewrite .ralphex/prompts/ and .ralphex/agents/ from the vendored + ralphex defaults (or the configured custom directories), apply the + declared reviewer composition to the review prompts, and materialize the + ralphex files of the finalize step when the finalize prompt is set. + + `config`: build configuration (`BuildConfig`) with optional prompts_dir / + agents_dir + `settings`: the resolved run plan (`RunSettings`) + + Algorithm: + 1. Choose the prompts source: the prompts_dir field of `BuildConfig` + when set, otherwise the vendored package defaults; choose the agents + source the same way + 2. Fully rewrite both target directories (clear, then copy) + 3. When the roles of the review part are a non-empty list: filter both + review prompts — keep only the {{agent:X}} lines of the selected + roles; adapt the accompanying text to the actual number of remaining + roles + 4. Copy the definition files of all review agents regardless of the + selection + 5. When the finalize prompt is set: materialize the ralphex files of the + finalize step from the prompt string per the `ralphex` practice (the + finalize prompt file of the review step) + + Requirements: + - The full rewrite happens once per build run, regardless of roles + - With the full default set (or no roles) the prompts are byte-identical + to the vendored defaults + - An empty intersection of roles with a phase's default set is a regular + phase without subagents — no error, no fallback + - Custom prompts_dir / agents_dir sources are copied as-is, without + filtering + - The finalize materialization happens only when the prompt is set; + unset leaves the vendored tree untouched + + Constraints: + - Do not touch .ralphex/config — it is written by the config routine + +"write_ralphex_config(settings: RunSettings, wrapper_path: str) -> none: None": + location: ralphex_config.py + annotations: | + Generate .ralphex/config for one ralphex pass. + + `settings`: the resolved run plan (`RunSettings`) — the external surface + and the finalize fact of the review pass + `wrapper_path`: executor wrapper path of the current pass (the + claude_command value) + + Algorithm: + 1. Set claude_command to `wrapper_path` + 2. Apply claude_args defaults when missing + 3. Set preserve_anthropic_api_key to true + 4. Set move_plan_on_completion to false — always, for every pass + 5. External surface (the review pass): under the medium strategy set + codex_enabled to false — the external review is explicitly disabled + (internal agents only); under full or short leave the ralphex + default (enabled), and when the resolved additional agent is set, + set external_review_tool to custom and custom_review_script to the + additional agent's wrapper path; when unset, leave the ralphex + default (codex) + 6. When the finalize prompt is set: set finalize_enabled to true; unset + leaves the ralphex default (false) + + Requirements: + - In a two-pass run this routine is called twice — each pass passes its + own executor wrapper (task wrapper; review wrapper — the additional + wrapper under the short strategy) + - Under the medium strategy the external review is explicitly disabled + (codex_enabled false); full and short leave it enabled + + Constraints: + - Do not write prompts or agents here + - Do not derive the external surface from anything but the resolved + settings + +"run_build_pass(plan: str, settings: RunSettings, options: dict[str, str | int | bool], wrapper_path: str, dry_run: bool, env: dict[str, str] | None = None) -> exit_code: int": + location: build_pass.py + annotations: | + Execute one ralphex pass: write the pass config, delegate the launch. + + `plan`: path to the plan file (markdown) + `settings`: the resolved run plan (`RunSettings`) — carried to the + config routine + `options`: the resolved ralphex options of the pass (composed by + `compose_pass_options`; carries exactly one pass-mode flag) + `wrapper_path`: executor wrapper of the current pass + `dry_run`: when True, print instead of launching + `env`: optional environment layer forwarded verbatim to `run_ralphex` — + the tasks pass receives the root env, the review pass the review + env; values are never printed + `exit_code`: the exit code returned by ralphex + + Algorithm: + 1. Write .ralphex/config via the config routine with `wrapper_path` and + the pass settings + 2. Delegate the launch to `run_ralphex` with `plan`, `options`, `dry_run`, + and `env` + 3. Return the exit code of `run_ralphex` + + Constraints: + - Do not assemble or invoke the ralphex command directly — only through + `run_ralphex` + +"move_completed_plan(plan: str, outcome: bool, dry_run: bool) -> relocation: RelocationOutcome": + location: plan_relocation.py + annotations: | + Relocate a completed plan file into the completed/ subdirectory of the + directory holding the plan, and report the outcome. + + `plan`: path to the plan file + `outcome`: True when the run succeeded + `dry_run`: when True, nothing was launched — leave the plan in place + `relocation`: the outcome facts — moved with the destination, or not + moved + + Algorithm: + 1. When `outcome` is False or `dry_run` is True: return the not-moved + outcome + 2. Move the plan file to /completed/, creating the + completed/ subdirectory when missing + 3. Return the moved outcome with the destination + + Requirements: + - Called after any started run — after the success of the last pass + + Constraints: + - Do not hard-code docs/plans/ — the directory follows the plan file + location +``` + +**Footer:** Author: Goga; CreatedAt: 18/08/26 (original); Description +updated to the stable two-pass cycle with checkpoints. + +### 7. `goga/commands/build/CODEMANIFEST` (modify) + +Imports/Usages keys unchanged (provider practice contents update in place: +`project-configuration`, `build-usage`). + +**Global Annotations — delete** the clause "The command surfaces the +review-phase tri-state flags to the user and rejects the two-pass × worktree +combination...". **Add:** + +```yaml + The command surfaces the review-phase tri-state pair to the user and + forwards it verbatim — the tri-state resolves in-container. The flag + surface carries no --worktree and no --skip-finalize (removed with no + replacement); --review-patience addresses build.review.additional.patience + and --base-ref addresses build.review.base_ref (forwarding only — + precedence resolves in-container); skip_manifest_check stays a CLI-only + pre-check toggle. The task env (build.env) is not written into the + container env-file — it is forwarded for the tasks-pass env layer + in-container; the env-file carries the base layers (home.env, git + identity, CLI -e, proxy). +``` + +**`build(...)` annotation deltas:** CLI options list without `--worktree` +and `--skip-finalize`; Algorithm step 2.2 guard repointed to the two-part +model — `config.build.agent is None` → ClickException ("build.agent is +required in .goga/config.yml to run 'goga build'"), replacing the retired +`config.build.task_executor.agent` path; Algorithm step 2.3 (the +review-worktree guard) deleted; step 3 cli_flags without the removed flags +(`--review-patience` forwarded as a value flag); step 7 env layering +without the task env; every Requirements/Constraints bullet naming +`task_executor` (the agent-guard wording, the env-file layering bullets) +rewritten to the two-part form — the env-file carries the base layers +(home.env, git identity, CLI -e, proxy) only, and `build.env` reaches the +container solely as the in-container tasks-pass env layer; Requirements +add "no worktree handling anywhere on the surface"; all other steps, +Requirements, and Constraints unchanged. Unchanged types: +`resolve_build_runtime_dir`, `clean_build_runtime_dir`, +`_cleanup_ralphex_in_project`. Footer unchanged. + +### Usage-file artifacts + +**Create `goga/build/hooks/.usages/checkpoints.md`** — full content: + +```md +# build — delivering the build checkpoints + +How the build operation consumes the hooks zone of the build domain: running the +validation gate before the first pass and emitting the four notifications around +the passes. For the in-container build orchestration. + +## The checkpoint surface + +One `BuildHooks` object serves every checkpoint of a run — the surface shares one +registry per run, so a run that reaches several checkpoints enumerates the tool +packages once. + + from goga.build.hooks import BuildHooks + + hooks = BuildHooks() + +## Resolve the facts in the operation + +Every context is built from the values the caller passes — the checkpoint reads +no repository. Resolve before the delivery: + +- `WorkIdentity` — the current branch with the topic slug and year when the + branch hosts a topic (`resolve_current_branch_name` with the `"unknown"` + fallback — resolved before the checkpoint). +- `BuildMoment` — the plan, the work identity, `dry_run`. +- `StageFacts` (tasks and review) — the executor agent, env presence as NAMES + (never values), the resolved option facts; the review facts carry roles, + base_ref, strategy, the additional facts, and the finalize prompt text when + configured. + +## Gate before the first pass + +After goga's own pre-checks (manifest check, settings resolution, review-config +validation, ralphex defaults sync) and before the first pass launch: + + verdict = hooks.validate_build(moment=moment, tasks=tasks_facts, + review=review_facts, skip=skip) + if not verdict.approved: + # one merged error listing every violation (tool, hook, reason); exit 1; + # no pass launches; the plan stays; no further events + +- The gate walk runs to completion: every subscribed tool's validation hooks run + — no early stop between tools; a non-vetoing subscriber is still invoked. +- A hook vetoes via the delivered view: `context.veto(reason)`. A crashing hook + counts as its tool's veto with the crash reason. +- The gate modifies nothing — observe-and-veto only. + +## Emit around the cycle + + hooks.emit_build_started(moment, tasks, review, skip) + hooks.emit_pass_started(moment, tasks_facts) + exit_code = run_build_pass(...) # tasks pass + hooks.emit_pass_completed(moment, tasks_facts, exit_code) + if exit_code == 0 and not skip: + hooks.emit_pass_started(moment, review_facts) + exit_code = run_build_pass(...) # review pass + hooks.emit_pass_completed(moment, review_facts, exit_code) + relocation = move_completed_plan(...) + statuses = collect_topic_statuses(...) # recompute after the relocation attempt + hooks.emit_build_completed(moment, exit_code, stages, relocation, statuses) + +- The four notifications are fire-and-forget: a failing hook warns naming the + tool, the action, and the reason; the run's outcome is unaffected. +- `pass_completed` and `build_completed` fire on zero, non-zero, and + spawn-failure codes alike — completion is a fact, not a success claim. +- Dry-run fires the identical structure with the `dry_run` fact; the gate runs; + nothing executes. +- With no tool packages installed the whole surface is inert — an unsubscribed + gate returns an approved verdict, emissions are unobservable. +``` + +**Create `goga/build/.usages/registering-hooks.md`** — full content: + +```md +# build — registering hooks + +How a `goga_tool_*` package subscribes its hooks to the build domain actions. +For tool package authors; no goga code changes are needed. + +The domain opens five actions. One is the validation gate — a read-and-veto +view over the resolved run facts, delivered before the first pass; it is a hard +action with verdict collection: every subscribed tool's hooks run and all +vetoes merge into one error. Four are notifications — the read-only facts of +the run: at the start, around each pass, and at the completion. + +## The events + +| Address | Error class | Fires | +|---|---|---| +| `build / validate_build` | hard | After goga's own pre-checks (manifest check, settings resolution, review-config validation, ralphex defaults sync) and before the first pass launch — including dry-run runs. | +| `build / build_started` | soft | Immediately after the gate passes, before the first pass launch. | +| `build / pass_started` | soft | Before each pass launch — tasks and review. | +| `build / pass_completed` | soft | On every pass return — zero, non-zero, and spawn-failure codes alike, carrying the actual exit code. | +| `build / build_completed` | soft | On every return of a started build — after the relocation attempt and the status recompute. | + +A failing moment fires nothing: goga pre-launch failures (uncommitted +manifests, invalid review config, unavailable defaults, missing build section +or agent) return before any checkpoint. A blocked (vetoed) run fires nothing +after the gate. + +## Subscribe + + def register_hooks(hooks): + hooks.subscribe("build", "validate_build", "policy", enforce_policy) + hooks.subscribe("build", "build_completed", "reporter", report_build) + +- `domain` — always `"build"`; `action` — from the table; `name` — unique per + tool per address; `hook` — the callable executed when the event fires. +- A hook receives values only for the parameters it declares by the fixed + offered names: `context`, `self`. + +## The gate view + +`validate_build` delivers a `BuildValidation` view per tool: `moment` (plan, +work, dry_run), `tasks` and `review` — the resolved stage facts (the executor +agent, env presence as names, the option facts; review adds roles, base_ref, +strategy, the additional facts, and the finalize prompt text), `skip`. + + def enforce_policy(context): + if violates(context): + context.veto("reason") + +- `veto(reason)` buffers your tool's single veto; a repeat call replaces the + reason whole. +- Your tool's hooks all run even when another tool already vetoed — verdict + collection requires every tool's outcome. +- A crashing hook counts as your tool's veto with the crash reason — never a + raw traceback. +- All vetoes merge into one clean error (tool, hook, reason); the run stops + before any pass: exit code 1, the plan stays in place, no + started/pass/completed events fire. + +## The notifications + +All four deliver read-only facts; a failing hook warns naming your tool, the +action, and the reason — the run's outcome is never affected. + +- `build_started` — `BuildStarted`: the same facts as the gate. +- `pass_started` — `PassStarted`: the stage facts of the pass about to launch. +- `pass_completed` — `PassCompleted`: the stage facts plus the actual + `exit_code`. Completion is a fact, not a success claim. +- `build_completed` — `BuildCompleted`: the final `exit_code`, `stages` (the + executed sequence), `relocation` (moved + destination), `statuses` (the + work's current history statuses recomputed after the relocation attempt — + empty in the branch-only form), `dry_run`. + +Env values are never delivered — presence as names only, in every context. + +## Integration scenarios + +- **Build reporting, automation, external notifications** — subscribe to the + four notifications; read the stage facts, the exit codes, the relocation + outcome, `dry_run`; keep state in your `self` context. +- **Artifact → history-status on completion** — subscribe to + `build_completed`; read `relocation` and `work`; register your status on the + statuses domain keyed by your artifact. +- **Policy enforcement** — subscribe to `validate_build`; inspect the resolved + facts; `context.veto(reason)` when policy is violated — or stay silent to use + the gate as a pre-start notification. +``` + +**Rewrite `goga/build/.usages/build-usage.md`** — the in-container invocation +contract for the host launcher: remove worktree/skip_finalize and the +executor-induced two-pass wording; document the always-two-pass cycle, the +two-part settings with inheritance, strategies, `additional`, `finalize`, the +five checkpoints (gate + four notifications, dry-run rehearsal), the updated +cli_options key list (`dry_run`, `skip_manifest_check`, `skip_review`, +`base_ref`, `review_patience`, `session_timeout`, `idle_timeout`, `wait`, +`max_iterations`), and the `build(...)` invocation example. + +**Update `goga/config/.usages/project-configuration.md`** — the build chapter +rewritten to the two-part form (root keys; `build.review` keys with the +strategy triple and the additional block; inheritance rules applied by the +consumer; retired keys silently ignored; a YAML example; the migration note +that old block names become unknown keys and silently disable the build +section). + +**Update `goga/commands/build/.usages/build.md`** — the command doc to the +new flag surface (two flags removed, env layering note). + +### Config migration (dogfooding) + +`.goga/config.yml` — the build section migrates to the two-part form in the +same change: the current `task_executor`/`review_executor` blocks become the +`build` root (agent, env from `task_executor`) and `build.review` (agent, env +from `review_executor`, plus the existing `base_ref`), preserving this +repository's effective settings. + +## Dependency Map + +``` +goga/config/project ──► goga/config (embeddings) +goga/hooks/catalog ──► goga/hooks (facade, unchanged) ◄── goga/history (unchanged) + ▲ facade primitives + declaring-actions / + │ per-tool-delivery / registering-hooks + goga/build/hooks (NEW: 13 types + checkpoints.md) + ▲ BuildHooks, BuildMoment, StageFacts, WorkIdentity, + │ RelocationOutcome, GateVerdict + checkpoints practice + goga/config ───────────────┐│ + goga/agents ───────────────┼┤ + goga/docker ───────────────┼┼► goga/build ──► goga/commands/build + goga/ralphex ──────────────┼┤ ▲ (build AS run_build + build-usage) + goga/history ──────────────┘└───────┘ +``` + +No circular imports (verified in the cell-distribution phase). + +## Verification Checklist + +After each artifact lands: + +- **`goga lint`** passes over every modified/created CODEMANIFEST (DSL + syntax: casing, signature rules, `location` restrictions, section order). +- **Facade checks** — `python -c "from goga.build.hooks import BuildHooks, + BuildMoment, StageFacts, WorkIdentity, AdditionalFacts, RelocationOutcome, + Violation, GateVerdict, BuildValidation, BuildStarted, PassStarted, + PassCompleted, BuildCompleted"` and the re-export facade `from goga.config + import BuildConfig, ReviewConfig, AdditionalReviewConfig`. +- **Absence checks** (breaking removals): no `--worktree`, no + `--skip-finalize`, no `worktree`/`skip_finalize`/`codex_review` config + keys, no `task_executor`/`review_executor` block names — across the CLI, + the loader, and the manifests. +- **Catalog check**: `declared_actions()` carries the five `build` records; + the existing records are byte-identical. +- **Schema check**: `goga schema` shows `goga/build/hooks` with 13 types and + its dependency on `goga/hooks` only. +- **Tests**: `pytest tests/ -x`; `ruff check` over the touched packages. +- **Dogfooding**: `.goga/config.yml` build section in the two-part form; a + build run executes the two passes. +- **Docs**: `registering-hooks.md` answers every integration scenario + (reporting/automation/notifications; artifact → status; policy + enforcement) from moments, context members, and failure semantics alone. +- **Acceptance criteria SC1–SC10** re-checked against the implemented + behavior (per the task file's validation commands). diff --git a/.goga/history/2026/add-hooks-to-build/design.md b/.goga/history/2026/add-hooks-to-build/design.md new file mode 100644 index 00000000..c87d99c0 --- /dev/null +++ b/.goga/history/2026/add-hooks-to-build/design.md @@ -0,0 +1,1155 @@ +# Design Document: `add-hooks-to-build` + +Complete architectural specification for implementing the materialized +contracts (post-`apply-architecture` CODEMANIFEST state, plus the contract +fixes applied during this design stage) as Python code. The implementation +order and the per-file details below are fully elaborated; the implementing +agent executes them without further design decisions. + +Normative inputs, in order: the CODEMANIFEST files listed under Contract +Changes (authoritative), `arch.md` (plan), `adr.md` (decisions), `task.md` +(success criteria SC1–SC10), and the practices listed under Usages Analysis. + +--- + +## Contract Changes + +### Changed CODEMANIFEST Files + +- `goga/config/project/CODEMANIFEST` — the build section is restructured to + the two-part form: `BuildConfig` reshaped (root tasks-pass fields + + `review: ReviewConfig | None`), `TaskExecutorConfig` and + `ReviewExecutorConfig` deleted, `ReviewConfig` and `AdditionalReviewConfig` + added, `load_project_config` algorithm steps 6–7 rewritten; header + Annotations carry the two-part stance. +- `goga/config/CODEMANIFEST` — facade embeddings updated mechanically: + `TaskExecutorConfig`/`ReviewExecutorConfig` dropped, `ReviewConfig`/ + `AdditionalReviewConfig` embedded. +- `goga/ralphex/CODEMANIFEST` — `run_ralphex` option table updated: bool + flags `tasks_only`/`review`/`external_only` (`-e`); scalars gain + `max_external_iterations`; `worktree`/`skip_finalize` removed; the + zero-valued external-flags rule added (`review_patience` 0 and + `max_external_iterations` 0 ARE passed). +- `goga/hooks/catalog/CODEMANIFEST` — five additive `build` records + (`validate_build` hard; `build_started`, `pass_started`, `pass_completed`, + `build_completed` soft). +- `goga/build/hooks/CODEMANIFEST` — NEW cell: 13 types (7 facts, 5 contexts, + 1 checkpoint surface) + `.usages/checkpoints.md`. +- `goga/build/CODEMANIFEST` — new imports (`goga/build/hooks`, `goga/history`), + `build()` rewritten to the 12-step checkpoint cycle, `main()` argparse + surface updated, `resolve_run_settings`/`RunSettings`/`PassSettings`/ + `PassSettings::ReviewPassSettings`/`compose_pass_options` added, + `resolve_review_options`/`ReviewOptions` deleted, `validate_review_config`/ + `sync_ralphex_defaults`/`write_ralphex_config`/`run_build_pass`/ + `move_completed_plan` re-signatured. +- `goga/commands/build/CODEMANIFEST` — flag surface without + `--worktree`/`--skip-finalize`; the agent guard repointed to + `config.build.agent`; the two-pass × worktree guard (step 2.3) deleted; + the task env removed from the container env-file. +- `goga/onboarding/generator/CODEMANIFEST` — snapshot→YAML mapping repointed + to the two-part build root (`build.agent`, `build.env`) — fixed during this + design stage (user-approved). +- `goga/commands/config/CODEMANIFEST` — dot-notation examples repointed to + live keys (`build.agent`, `build.review.strategy`) — fixed during this + design stage (user-approved). + +### New Entities + +Zone `goga/build/hooks` (all `@dataclass(kw_only=True)`, non-frozen — +mutability is closed by the delivery proxy, following the +`goga/pipeline/hooks` precedent): + +- `WorkIdentity(branch, slug=None, year=None)` — `facts.py` +- `BuildMoment(plan, work, dry_run)` — `facts.py` +- `StageFacts(stage, agent, env, max_iterations, session_timeout, idle_timeout, wait, roles, base_ref, strategy, finalize, additional)` — `facts.py` +- `AdditionalFacts(agent, patience, max_iterations)` — `facts.py` +- `RelocationOutcome(moved, destination)` — `facts.py` +- `Violation(tool, hook, reason)` — `facts.py` +- `GateVerdict(violations)` with `approved` property — `facts.py` +- `BuildValidation(moment, tasks, review, skip)` with `veto(reason)` — `contexts.py` +- `BuildStarted(moment, tasks, review, skip)` — `contexts.py` +- `PassStarted(moment, facts)` — `contexts.py` +- `PassCompleted(moment, facts, exit_code)` — `contexts.py` +- `BuildCompleted(moment, exit_code, stages, relocation, statuses)` — `contexts.py` +- `BuildHooks()` — `events.py`: `validate_build`, `emit_build_started`, + `emit_pass_started`, `emit_pass_completed`, `emit_build_completed` + +Cell `goga/build`: + +- `resolve_run_settings(config: BuildConfig, cli_options: dict) -> RunSettings` — `run_settings.py` (new file) +- `RunSettings(skip, tasks, review)` — `run_settings.py` (frozen, kw_only) +- `PassSettings(agent, env, max_iterations, session_timeout, idle_timeout, wait)` — `run_settings.py` (frozen, kw_only) +- `PassSettings::ReviewPassSettings(roles, base_ref, strategy, finalize, additional)` — `run_settings.py` (frozen, kw_only) +- `compose_pass_options(settings, stage) -> options` — `pass_options.py` (new file) + +Cell `goga/config/project`: + +- `ReviewConfig(skip, agent, env, roles, base_ref, strategy, finalize, additional, session_timeout, idle_timeout, wait)` — `config.py` (frozen, kw_only) +- `AdditionalReviewConfig(agent, patience, max_iterations)` — `config.py` (frozen, kw_only) + +### Changed Entities + +- `BuildConfig(agent, env, max_iterations, session_timeout, idle_timeout, wait, prompts_dir, agents_dir, proxy, hosts, review)` — two-part form; `worktree`, `skip_finalize`, `codex_review`, `task_executor`, `review_executor` fields deleted. +- `load_project_config()` — build-block extraction rewritten (two-part, retired keys silently ignored). +- `build(plan, config, cli_options)` (goga/build) — 12-step checkpoint cycle. +- `main()` (goga/build `__main__.py`) — argparse surface: `--skip-review`/`--no-skip-review` pair, `--base-ref`, `--review-patience`, `--dry-run`, `--skip-manifest-check`, `--session-timeout`, `--idle-timeout`, `--wait`, `--max-iterations`; no `--worktree`/`--skip-finalize`. +- `validate_review_config(settings)` — re-signatured from `(config, review)` to `(settings: RunSettings)`; new checks (strategy whitelist, additional-agent wrapper). +- `sync_ralphex_defaults(config, settings)` — reads roles and the finalize prompt from `RunSettings`; materializes the finalize step file. +- `write_ralphex_config(settings, wrapper_path)` — external-review surface + `finalize_enabled`. +- `run_build_pass(plan, settings, options, wrapper_path, dry_run, env)` — carries `RunSettings` instead of `BuildConfig`. +- `move_completed_plan(plan, outcome, dry_run) -> RelocationOutcome` — returns the outcome facts. +- `run_ralphex` (goga/ralphex) — flag table per above. +- `build(...)` (goga/commands/build) — flag removals, guard repoint, env-file without the task env. +- `_build_config_document` (goga/onboarding/generator, code change only) — emits the two-part build root. + +### Deleted Entities + +- `TaskExecutorConfig`, `ReviewExecutorConfig` (goga/config/project) — replaced by the two-part `BuildConfig` + `ReviewConfig`. +- `resolve_review_options`, `ReviewOptions` (goga/build, file `review_options.py`) — superseded by `resolve_run_settings`/`RunSettings`; **delete the file**. +- CLI `--worktree` / `--skip-finalize` (host and in-container) and config keys `worktree`, `skip_finalize`, `codex_review`, `task_executor`, `review_executor` — breaking removals, no compatibility paths. + +### Usages and Annotations Changes + +- `goga/build` imports `checkpoints` (goga/build/hooks), `topic-paths` + `topic-statuses` (goga/history); header Annotations describe the checkpoint fact resolution (branch, hosting, statuses) and the always-two-pass stance. +- `goga/build/hooks` imports `declaring-actions`, `per-tool-delivery`, `registering-hooks` (goga/hooks) and declares the domain-local hard-delivery deviation in its header. +- `goga/commands/build` Annotations: flag surface paragraph rewritten (no worktree/skip-finalize; `--review-patience` addresses `build.review.additional.patience`; the task env is not written into the env-file). +- Project practice `.goga/usages/cooks/ralphex.md` (updated during grooming): always-two-pass wording, external-review surface, finalize step, `--base-ref` source key. +- Usage-file artifacts created/rewritten by `apply-architecture`: `goga/build/hooks/.usages/checkpoints.md`, `goga/build/.usages/registering-hooks.md`, rewritten `goga/build/.usages/build-usage.md`, rewritten build chapter of `goga/config/.usages/project-configuration.md`, updated `goga/commands/build/.usages/build.md`. +- `.goga/config.yml` — this repository's build section already migrated to the two-part form (dogfooding; landed by `apply-architecture`, verified `yaml.safe_load`-clean). + +## Applied Fixes + +### Fixed CODEMANIFEST Defects + +All fixed during this design stage, user-approved (answer: fix all three + the +trace-discovered import gap): + +- `goga/onboarding/generator/CODEMANIFEST`: `build.task_executor.agent → build.task_executor.agent` / `build.task_executor.env → build.task_executor.env` → `build.agent → build.agent` / `build.env → build.env` (reason: interface↔implementation drift — the generator would emit a silently-disabled build section under the new loader). +- `goga/commands/config/CODEMANIFEST`: examples `build.task_executor.agent build.worktree` → `build.agent build.review.strategy`, in both the `options` annotation and the output-format sample (reason: stale references to deleted keys). +- `goga/build/CODEMANIFEST`: `Imports → goga/history` gained `resolve_topic_dir`, and the annotations (global fact-resolution paragraph + `build()` step 4) now reference it (reason: interface↔interface gap — the topic-hosting decision of step 4 was unimplementable from the declared import surface; the `topic-paths` practice was imported without the type it documents). +- `goga/ralphex/.usages/run-ralphex.md`: removed `worktree` from the options example, replaced the conditional two-pass wording with the always-two-pass form, added `external_only` (`-e`) and `max_external_iterations` to the parameter contract, documented the zero-valued external-flags rule, and repointed the anti-pattern `TaskExecutorConfig` mention to `ReviewConfig` (reason: practice file lagged the updated CODEMANIFEST). + +`goga lint` after all fixes: **79 cells, 0 errors**. + +## Entity Interaction and Data Flow + +### Interaction Diagram + +``` +goga/commands/build (host CLI, click) + │ guards: config.build present, config.build.agent set + │ env-file: home.env < git identity < CLI -e (+proxy) [NO build env] + │ docker run ... python -m goga.build + ▼ +goga/build build() ── in-container orchestrator ─────────────────────────────┐ + │ 0 git pre-check (uncommitted CODEMANIFEST → exit 1, no events) │ + │ 1 resolve_run_settings(config.build, cli_options) → RunSettings │ + │ 2 validate_review_config(settings) ──► resolve_wrapper_path (goga/agents) + │ 3 sync_ralphex_defaults(config.build, settings) [.ralphex/prompts|agents]│ + │ 4 facts: resolve_current_branch_name ── resolve_topic_dir ──► WorkIdentity│ + │ BuildMoment; StageFacts(tasks) + StageFacts(review) │ + │ 5 BuildHooks.validate_build(...) ──► GateVerdict │ + │ │ not approved → merged error, exit 1, nothing else fires │ + │ 6 BuildHooks.emit_build_started(...) │ + │ 7 tasks pass: compose_pass_options(settings,"tasks") │ + │ emit_pass_started → run_build_pass → emit_pass_completed │ + │ 8 review pass (tasks OK and not skip): compose_pass_options(,"review") │ + │ wrapper = additional agent (short) else review agent │ + │ emit_pass_started → run_build_pass → emit_pass_completed │ + │ 9 move_completed_plan(...) ──► RelocationOutcome │ + │ 10 collect_topic_statuses(year) → statuses of work.slug │ + │ 11 BuildHooks.emit_build_completed(moment, exit_code, stages, │ + │ relocation, statuses) │ + └ 12 return exit code of the last executed pass │ + │ +run_build_pass ──► write_ralphex_config(settings, wrapper) [.ralphex/config] │ + └► run_ralphex(plan, options, dry_run, env) (goga/ralphex) │ + └► subprocess: ralphex --config-dir .ralphex/ │ + │ +goga/build/hooks BuildHooks │ + ├ validate_build: staged per-tool walk over HookRegistry subscriptions │ + │ (wrap_context + build_hook_arguments + registry.self_context), │ + │ veto buffer per tool, Violation collection → GateVerdict │ + └ emit_*: emit_hook_event(registry, "build", , context_for) │ + all five addresses resolve via declared_actions() (goga/hooks/catalog) │ + │ +goga/config load_project_config → ProjectConfig(build=BuildConfig( │ + review=ReviewConfig(additional=AdditionalReviewConfig))) │ +``` + +### Data Flows + +**Flow A — configuration (once per run, in-container):** +`load_project_config()` reads `.goga/config.yml` (the mounted `/workspace`) +→ structural two-part extraction → `BuildConfig` (root fields verbatim, +`review: ReviewConfig | None`, `additional: AdditionalReviewConfig | None`) +→ `resolve_run_settings(config.build, cli_options)` applies CLI > config > +default > omit and root→review→additional inheritance → frozen `RunSettings`. + +**Flow B — the gate (before any pass):** +`build()` resolves `WorkIdentity` (git subprocess once, then +`resolve_topic_dir` composition — no reads at the checkpoint) and both +`StageFacts` (env presence as sorted names) → `BuildHooks.validate_build` +walks the subscriptions of `build/validate_build` per tool → each tool gets a +fresh `BuildValidation` view wrapped read-only → vetoes buffer per tool → +`GateVerdict(violations)` returns to `build()` → not approved → one merged +`logger.error` (tool, hook, reason per violation) → exit 1. + +**Flow C — a pass (twice per non-skipped run):** +`compose_pass_options` (pure) → pass options dict → +`run_build_pass(plan, settings, options, wrapper, dry_run, env)` → +`write_ralphex_config` rewrites `.ralphex/config` (whole file, never merged) +→ `run_ralphex` maps options to flags and launches (or prints on dry-run) → +exit code propagates unchanged → `emit_pass_completed` carries the actual +code. + +**Flow D — completion (every return path of a started run):** +`move_completed_plan` → `RelocationOutcome` → `collect_topic_statuses(year)` +re-read AFTER the relocation attempt → statuses list (empty in the +branch-only form) → `emit_build_completed(moment, exit_code, stages, +relocation, statuses)` → soft emission; the exit code is already final. + +### Entity Dependencies + +Implementation order (leaves first — matches the dependency map; no cycles): + +1. `goga/config/project` (`config.py` model, `loader.py`) → `goga/config` facade +2. `goga/hooks/catalog` (`catalog.py` — five records) +3. `goga/ralphex` (`run_ralphex.py` flag table) +4. `goga/build/hooks` (`facts.py` → `contexts.py` → `events.py` → `__init__.py`) +5. `goga/build`: `run_settings.py` → `pass_options.py` → `review_config.py` → + `ralphex_runtime.py` → `ralphex_config.py` → `build_pass.py` → + `plan_relocation.py` → `build.py` → `__main__.py`; delete `review_options.py` +6. `goga/commands/build` (`build.py` flag/guard/env changes) +7. `goga/onboarding/generator` (`_executor_block` docstring + two-part emission) + +Runtime initialization order inside one build run: config → settings → +validation → defaults sync → facts → `BuildHooks()` (cheap; the single +`HookRegistry` builds lazily on the first checkpoint and is shared by all +five) → passes → relocation → statuses → completion. + +## Code Stack Trace + +### Trace: `load_project_config` (loader steps 6–7) + +#### Chain +1. **Input**: `.goga/config.yml` at the project root (cwd), read + `yaml.safe_load`. +2. **Step**: top mapping guard (unchanged), `lang`/`image`/`dockerfile`, `pipeline`, then `build` block: absent → `build=None`; non-mapping → `ValueError` → checkpoint: matches step 6 (passed). +3. **Step**: root extraction — `agent` (empty/whitespace→None, non-str→ValueError), `env` (str mapping, default `{}`), `max_iterations` (int; bool→ValueError), `session_timeout`/`idle_timeout`/`wait` (agent pattern), `prompts_dir`/`agents_dir`/`proxy` (optional str), `hosts` (str mapping, default `{}`); unknown keys (incl. stale `worktree`, `task_executor`, `codex_review`) silently ignored → checkpoint: verbatim extraction, no default merge (passed). +4. **Step**: `build.review` sub-mapping: absent/null→None; non-mapping→ValueError; `skip` (bool|None), `agent` (pattern), `env` (pattern, default `{}`), `roles` (list[str]|None, empty passes verbatim), `base_ref` (agent pattern), `strategy` (empty/whitespace→None, non-str→ValueError, no whitelist), `finalize` (agent pattern — stored verbatim), `additional` (mapping: `agent` pattern, `patience` int with bool→ValueError, `max_iterations` same) → checkpoint: every field of `ReviewConfig`/`AdditionalReviewConfig` covered (passed). +5. **Output**: `ProjectConfig(build=BuildConfig(..., review=ReviewConfig(..., additional=AdditionalReviewConfig(...))))`; `codemanifest`/`lint`/`topics`/`tools`/`usages` blocks extracted exactly as today. + +#### Checkpoint Summary +- Two-part shape ↔ `BuildConfig`/`ReviewConfig`/`AdditionalReviewConfig` signatures: passed. +- Retired-key silence (loader extracts known fields only): passed. +- Structural-only stance (strategy/finalize/roles semantics deferred to consumers): passed. + +### Trace: `resolve_run_settings` + +#### Chain +1. **Input**: `config: BuildConfig` (review may be None), `cli_options` dict (keys `skip_review`, `base_ref`, `review_patience`, `session_timeout`, `idle_timeout`, `wait`, `max_iterations` — None when the flag was absent). +2. **Step 0**: `config.review is None` → every review field unset: skip=False, agent/session knobs inherit root, `base_ref`/`roles`/`finalize` None, additional = `AdditionalReviewConfig(agent=, patience=None, max_iterations=None)` → checkpoint: `ReviewPassSettings.additional` is non-optional — always constructed (passed). +3. **Step 1**: skip = CLI `skip_review` when not None, else `ReviewConfig.skip`, else False → checkpoint: tri-state resolves (passed). +4. **Step 2**: strategy = `ReviewConfig.strategy` or `"medium"` → checkpoint: default per ADR (passed). +5. **Step 3**: tasks part — root `agent`/`env` verbatim; `max_iterations` and each session knob = the CLI value when given (not None), else the root value. +6. **Step 4**: review part — `agent`: review value when set else root; each of `session_timeout`/`idle_timeout`/`wait`: CLI value when given, else review value when set, else root; `max_iterations` NOT inherited (root-only); `env` = exactly `review.env` (never inherits); `roles`/`base_ref`/`finalize` verbatim → checkpoint: inheritance rules match `ReviewConfig` property docs with CLI precedence on top (passed). +7. **Step 5**: additional — `agent` = additional.agent when set else resolved review agent; `patience` = CLI `review_patience` when given else the additional value verbatim; `max_iterations` verbatim (None when block absent) → checkpoint: matches the ADR ("`additional.agent` inherits `review.agent`") (passed). +8. **Step 6**: base_ref = CLI value when not None (strip; empty→None), else `ReviewConfig.base_ref` → checkpoint: whitespace semantics identical to the retired `resolve_review_options` (passed). +9. **Output**: frozen `RunSettings(skip, tasks=PassSettings(...), review=ReviewPassSettings(roles, base_ref, strategy, finalize, additional, ))`. + +#### Checkpoint Summary +- CLI > config > default > omit for every knob: passed. +- Review env never inherits the root env (secret boundary): passed. +- Purity (no I/O, no wrapper resolution, no strategy validation): passed. + +### Trace: `BuildHooks.validate_build` (the gate) + +#### Chain +1. **Input**: `moment: BuildMoment`, `tasks/review: StageFacts`, `skip: bool` — values the operation already resolved. +2. **Step**: `self._ensure_registry()` — `HookRegistry()` + `build_once()` on first checkpoint; reused by every later checkpoint of the same `BuildHooks` instance → checkpoint: one registry per run (passed; `HookRegistry.build_once` is idempotent). +3. **Step**: resolve `domain="build", action="validate_build"` against `declared_actions()` → unknown address → `ValueError` (defensive; the record exists by catalog) → checkpoint: catalog record present after the additive change (passed). +4. **Step**: group `registry.subscriptions_for("build", "validate_build")` per tool preserving enumeration order; per tool build a fresh `BuildValidation(moment, tasks, review, skip)` (private `_veto: str | None = None` buffer), wrap via `wrap_context`, call each hook with `build_hook_arguments(hook, proxy, registry.self_context(tool))`; snapshot `view._veto` before each call — a change after the call attributes the veto to that subscription's name (a later veto replaces the earlier attribution) → checkpoint: proxy blocks writes but `veto()` mutates the target's buffer through the bound method — same mechanism as `WorkflowAmendment._contribution` (passed). +5. **Step**: a raising hook → record `(tool, subscription.name, str(reason))` as the tool's single crash violation and STOP that tool's remaining hooks (exactly one Violation per tool); the walk continues with the next tool → checkpoint: "a crash overrides the tool's buffered veto" and "the walk NEVER stops between tools" both satisfied (passed). +6. **Step**: a tool whose every hook returned and whose buffer is not None → `Violation(tool, attributed_hook, buffered_reason)`; buffer None → silent approval. +7. **Output**: `GateVerdict(violations)` — `approved` is `not violations`; empty when the address has no subscriptions (inert with no tool packages). + +#### Checkpoint Summary +- Per-tool isolation of the veto buffer: passed (fresh view per tool). +- Exactly one Violation per tool (buffered veto XOR crash): passed. +- Verdict is data only — acting on it belongs to `build()`: passed. + +### Trace: `BuildHooks.emit_*` (the four notifications) + +#### Chain +1. **Input**: resolved facts from `build()` (same objects the gate saw, or pass facts / completion facts). +2. **Step**: construct the context (`BuildStarted`/`PassStarted`/`PassCompleted`/`BuildCompleted`) from the values. +3. **Step**: `emit_hook_event(self._ensure_registry(), "build", "", context_for=lambda _tool: context)` — the same instance for every tool (read-only contexts, no buffer) → checkpoint: matches the `declaring-actions` pattern and the `PipelineHooks` precedent (passed). +4. **Output**: nothing returns; a failing hook warns inside the platform (soft class); the run is unaffected. + +#### Checkpoint Summary +- Fire-and-forget on every return path (zero, non-zero, spawn-failure codes): passed — `build()` emits `pass_completed` immediately after each `run_build_pass` return and `build_completed` at the end of every started run. + +### Trace: `compose_pass_options` + +#### Chain +1. **Input**: `settings: RunSettings`, `stage: str` (`"tasks"` | `"review"`). +2. **Step (tasks)**: `{"tasks_only": True}` + each of `session_timeout`, `idle_timeout`, `wait`, `max_iterations` from `settings.tasks` when not None → checkpoint: no review-only keys, no agent (agent → wrapper), no env (env → layer) (passed). +3. **Step (review)**: mode flag — `{"external_only": True}` when `strategy == "short"`, else `{"review": True}`; plus `session_timeout`/`idle_timeout`/`wait` from the review part (inheritance already applied), `base_ref` when not None, `review_patience` from `additional.patience` when not None, `max_external_iterations` from `additional.max_iterations` when not None → checkpoint: keys ⊆ the `run_ralphex` table; 0-values kept for the two external flags (passed). +4. **Output**: `dict[str, str | int | bool]` — unset knobs absent; exactly one pass-mode flag. + +#### Checkpoint Summary +- Mutual exclusivity of pass modes: passed (single flag per composition). +- Interface to `run_build_pass`/`run_ralphex`: passed (both consume the dict verbatim). + +### Trace: `validate_review_config` + +#### Chain +1. **Input**: `settings: RunSettings`. +2. **Step 1**: `settings.skip` → return (a skipped run validates nothing). +3. **Step 2**: each role of `settings.review.roles` against `ROLE_WHITELIST` (`quality`, `implementation`, `testing`, `simplification`, `documentation`) → first outsider raises `ValueError` naming it. +4. **Step 3**: `settings.review.env` non-empty and `settings.review.agent is None` → `ValueError` (env requires agent). Design decision for the degenerate case: a None resolved agent at step 4 raises `ValueError("no review agent resolved: set build.agent or build.review.agent")` — `resolve_wrapper_path(None)` would produce a nonsense path; the clean error honors "the error message names the invalid value". (Unreachable through the host launcher — its step-2.2 guard requires `build.agent` — but reachable on direct in-container invocation.) +5. **Step 4**: `wrapper = resolve_wrapper_path(settings.review.agent)`; `not Path(wrapper).is_file()` → `ValueError` naming agent and path. +6. **Step 5**: strategy engages the external review — `"short"` always; `"full"` when `settings.review.additional.agent` is set (always set after inheritance in practice) → resolve + existence-check the additional wrapper the same way. +7. **Step 6**: `settings.review.strategy` in `{full, medium, short}` else `ValueError` naming the value. +8. **Output**: None; runs before any side effect (before `.ralphex/` writes and before the first checkpoint). + +#### Checkpoint Summary +- Check order fixed (roles → env gate → review wrapper → additional wrapper → strategy): passed. +- Constraint "do not validate the tasks agent wrapper here": passed (only the review/additional wrappers). + +### Trace: `sync_ralphex_defaults` + +#### Chain +1. **Input**: `config.build` (custom `prompts_dir`/`agents_dir`), `settings` (roles, finalize). +2. **Step**: sources = custom dirs when set else vendored `goga/assets/ralphex/{prompts,agents}`; missing source → `ValueError` (as today). +3. **Step**: full rewrite of `.ralphex/prompts/` and `.ralphex/agents/` (clear + copy regular files). +4. **Step**: `settings.review.roles` non-empty list and vendored prompts → filter `{{agent:X}}` lines of `review_first.txt`/`review_second.txt` + counter rewrites (existing logic, unchanged); custom prompts_dir copied as-is. +5. **Step (new)**: `settings.review.finalize is not None` → write the prompt string verbatim to `.ralphex/agents/finalize.txt` (the finalize step is a ralphex review agent carrying the `finalize.txt` prompt, per the `ralphex` practice). Materialization applies regardless of a custom `agents_dir` — the file is goga's own step artifact, not part of the source tree. Unset → nothing written; the step stays at the ralphex default (off). +6. **Output**: the `.ralphex/` prompts/agents tree on disk. + +#### Checkpoint Summary +- Byte-identity of the default composition (full role set / no roles): passed (existing guard values kept). +- Finalize materialization gated on the prompt being set: passed. + +### Trace: `write_ralphex_config` + +#### Chain +1. **Input**: `settings: RunSettings`, `wrapper_path: str` (the pass's executor wrapper). +2. **Step**: rewrite `.ralphex/config` whole with the fixed key block: + `claude_command = `, `claude_args = `, + `preserve_anthropic_api_key = true`, `move_plan_on_completion = false`. +3. **Step**: external surface (keys derived only from `settings`): + `strategy == "medium"` → `codex_enabled = false` (explicitly disabled); + `full`/`short` → `codex_enabled` stays unwritten (ralphex default enabled) + and, when `settings.review.additional.agent` is not None → + `external_review_tool = custom` and + `custom_review_script = resolve_wrapper_path(additional.agent)`; + agent None (degenerate) → both stay unwritten (ralphex default codex). +4. **Step**: `settings.review.finalize is not None` → `finalize_enabled = true`; else unwritten (default false). +5. **Output**: `.ralphex/config` INI; called twice per two-pass run — same settings, only the wrapper differs. + +#### Checkpoint Summary +- Key set ↔ the `ralphex` practice table: passed. +- `resolve_wrapper_path` import needed here (new import in `ralphex_config.py`): passed — the routine is on the `goga/agents` facade already imported by the cell. +- Tasks-pass config carries the review keys too — harmless: `--tasks-only` ignores every review-phase key (practice note); keeps the routine a pure function of (settings, wrapper). + +### Trace: `run_build_pass` / `run_ralphex` + +#### Chain +1. **Input**: plan, settings, options, wrapper, dry_run, env (tasks: root env layer; review: review env layer; None/empty → pure inheritance). +2. **Step**: `write_ralphex_config(settings, wrapper_path)` → `.ralphex/config` of this pass. +3. **Step**: `run_ralphex(plan, options, dry_run, env=env)` — bool mapping: `tasks_only`→`--tasks-only`, `review`→`--review`, `external_only`→`-e` (True emits, False/absent omits); scalar mapping: `session_timeout`, `idle_timeout`, `wait`, `max_iterations`, `review_patience`, `max_external_iterations`, `base_ref` → `-- `, omitted when None/"" — EXCEPT `review_patience`/`max_external_iterations`, where 0 IS emitted (`--review-patience 0`, `--max-external-iterations 0`); `worktree`/`skip_finalize` no longer exist in the table. +4. **Step**: dry_run prints `shlex.join(cmd)` to stderr (never the env layer) and returns 0; else PATH check → `subprocess.call(cmd[, env={**os.environ, **env}])`; missing binary / pre-exec rejection → clean one-line stderr message, exit 1. +5. **Output**: the ralphex exit code, propagated unchanged. + +#### Checkpoint Summary +- Options keys from `compose_pass_options` all map 1:1: passed. +- Zero-valued external flags vs `value not in (None, "", 0)` drop rule: the launcher needs a per-key exception for exactly the two external flags (design-fixed). +- Secret safety (env never in argv/logs/dry-run): passed. + +### Trace: `build()` (goga/build) — the full cycle + +#### Chain +1. **Input**: plan path, `ProjectConfig`, cli_options. +2. **Step 0**: manifest pre-check (existing `_find_uncommitted_manifests`) — failure → `return 1`, no events (the moment never happened). +3. **Steps 1–3**: `resolve_run_settings` → `validate_review_config` (ValueError → log + `return 1`) → `sync_ralphex_defaults` (ValueError → log + `return 1`). `config.build` is guaranteed non-None by the host guard (step 2.2 of `goga/commands/build`); a build-less config invoked directly in-container is out of contract. **Step 3.5 guard**: `settings.tasks.agent is None` → `logger.error("no build agent resolved: set build.agent in .goga/config.yml")` → `return 1` — the degenerate skip-run case (`validate_review_config` returns early on skip, so the run would otherwise crash at step 7's `resolve_wrapper_path(None)` with a TypeError after `emit_build_started` fired); keeps the `registering-hooks` claim "missing agent returns before any checkpoint" true on the direct in-container path, mirroring the degenerate-case precedent of `validate_review_config` step 4. +4. **Step 4**: `branch = resolve_current_branch_name() or "unknown"`; `topic_dir = resolve_topic_dir(branch)` guarded (`ValueError` → None, an unsluggable branch hosts no topic); hosted (`topic_dir.is_dir()`) → `WorkIdentity(branch, slug=topic_dir.name, year=topic_dir.parent.name)` else `WorkIdentity(branch)`; `moment = BuildMoment(plan, work, dry_run)`; `tasks_facts`/`review_facts` per `StageFacts` (env = `sorted(env)` — names only, deterministic) with the review-only members None on tasks and `AdditionalFacts(agent, patience, max_iterations)` on review → checkpoint: no git/process reads AFTER this step (passed). +5. **Step 5**: `hooks = BuildHooks()`; `verdict = hooks.validate_build(moment, tasks_facts, review_facts, settings.skip)`; `not verdict.approved` → one merged `logger.error("build blocked by hook vetoes", extra={"violations": [f"{v.tool}/{v.hook}: {v.reason}" for v in verdict.violations]})` → `return 1`; no pass, no relocation, no further events. +6. **Step 6**: `hooks.emit_build_started(moment, tasks_facts, review_facts, settings.skip)`. +7. **Step 7**: tasks pass — `options = compose_pass_options(settings, "tasks")`; `wrapper = resolve_wrapper_path(settings.tasks.agent)`; `hooks.emit_pass_started(moment, tasks_facts)`; `exit_code = run_build_pass(plan, settings, options, wrapper, dry_run, env=tasks_layer)` where `tasks_layer = settings.tasks.env or None`; `hooks.emit_pass_completed(moment, tasks_facts, exit_code)`; `stages = ["tasks"]`. +8. **Step 8**: `exit_code == 0 and not settings.skip` → review pass — `options = compose_pass_options(settings, "review")`; `wrapper = resolve_wrapper_path(settings.review.additional.agent if strategy == "short" else settings.review.agent)`; the same emit/launch/emit triple with `review_facts` and `review_layer = settings.review.env or None`; `stages.append("review")`. A failed tasks pass never reaches here. +9. **Step 9**: `relocation = move_completed_plan(plan, outcome=(exit_code == 0), dry_run=dry_run)`. +10. **Step 10**: statuses — `work.slug is None` → `[]`; else `[r.statuses for r in collect_topic_statuses(year=work.year) if r.topic == work.slug][0]`-style lookup (absent topic → `[]`). +11. **Step 11**: `hooks.emit_build_completed(moment, exit_code, stages, relocation, statuses)`. +12. **Output**: `return exit_code` (the last executed pass's code). + +#### Checkpoint Summary +- Dry-run parity: identical steps; `run_ralphex` prints and returns 0; relocation stays (dry_run guard); events carry `moment.dry_run=True` (passed). +- Pre-launch failures (0–3) and a blocked run (5) fire no events (passed). +- Exit code = last executed pass's code; relocation only on final-pass success (passed). + +### Trace: `main()` (goga/build `__main__.py`) + +`ensure_in_docker()` first → argparse (`plan`, `--dry-run`, `--skip-manifest-check`, `--skip-review`/`--no-skip-review` → `skip_review: bool | None`, `--base-ref`, `--review-patience`, `--session-timeout`, `--idle-timeout`, `--wait`, `--max-iterations`; **no** `--worktree`/`--skip-finalize`) → `cli_options` with exactly those keys (worktree/skip_finalize keys removed from the dict) → `load_project_config()` → `build(...)` → exit code. → checkpoint: cli_options keys match what `resolve_run_settings` reads (passed). + +### Trace: `build(...)` (goga/commands/build, host) + +Existing 19-step algorithm with three deltas: step 2.2 guard message/key becomes `config.build.agent is None → ClickException("build.agent is required in .goga/config.yml to run 'goga build'")`; step 2.3 (two-pass × worktree guard) deleted; step 7 env assembly becomes `{**home.env, **git_env, **cli_env}` — the task env (`config.build.env`) is NOT written to the env-file (it reaches the container only through the mounted `.goga/config.yml` and is applied in-container as the tasks-pass layer). CLI flags: `--worktree`/`--skip-finalize` options and their forwarding removed; `--review-patience`/`--base-ref` forwarding unchanged (value-flag pattern). → checkpoint: host forwards, container resolves (passed). + +### Trace: `_build_config_document` (goga/onboarding/generator) + +`data["build"] = build_block` (agent, env at the two-part root) instead of `{"task_executor": build_block}`; `_executor_block` docstring reworded (build root / pipeline content). → checkpoint: generated file passes the new loader (`config.build.agent` resolves) (passed). + +## Algorithm Design + +### `BuildHooks` (`goga/build/hooks/events.py`) + +**Responsibility**: the checkpoint surface — the verdict-collecting gate and the four soft emissions over the `goga/hooks` facade; owns the single lazily-built run registry. + +**Algorithm** (gate; the per-tool-delivery walk with the recorded refinement): +``` +1. registry = _ensure_registry() # once per run +2. record = find(declared_actions(), domain="build", name="validate_build") + IF record is None: raise ValueError("unknown hook action: build.validate_build") +3. groups = {} ; for sub in registry.subscriptions_for("build","validate_build"): + groups.setdefault(sub.tool, []).append(sub) +4. violations = [] +5. FOR (tool, subs) in groups (enumeration order): + a. view = BuildValidation(moment, tasks, review, skip) # fresh buffer per tool + b. attributed_hook, attributed_reason = None, None ; crash = None + c. FOR sub in subs: + - before = view._veto + - TRY sub.hook(**build_hook_arguments(sub.hook, + wrap_context(view), registry.self_context(tool))) + EXCEPT Exception as reason: crash = (sub.name, str(reason)) ; BREAK + - IF view._veto != before: + attributed_hook, attributed_reason = sub.name, view._veto + d. IF crash: violations.append(Violation(tool, crash[0], crash[1])) + ELIF view._veto is not None: + violations.append(Violation(tool, attributed_hook, view._veto)) + ELSE: pass # silent approval +6. RETURN GateVerdict(violations) # never stops between tools +``` + +**Errors**: unknown address → `ValueError` (defensive); a broken tool-package import surfaces from `build_once` as `ImportError` (platform's single fatal case) — it escapes `build()` as an unhandled pre-pass failure after step 3 side effects but before events; acceptable per platform contract (single fatal case, clean message). + +**Edge cases**: no subscriptions → empty verdict (approved); empty/whitespace veto reason stored and rendered verbatim; a later `veto()` replaces reason and attribution whole; crash overrides the buffered veto. + +### `BuildValidation` (`contexts.py`) + +`veto(reason)` sets `self._veto = reason` (whole replacement). Constraints: no cancellation/deflection — the veto acts only through the collected verdict. + +### Emissions + +Each builds its context and calls +`emit_hook_event(self._ensure_registry(), "build", , context_for=lambda _tool: context)`. + +### `resolve_run_settings` (`run_settings.py`) + +``` +1. review = config.review ; absent → treat all review fields unset (step 0 semantics) +2. skip = cli.skip_review ?? review.skip ?? False +3. strategy = review.strategy or "medium" +4. knob(k) = cli.k if cli.k is not None else root.k # max_iterations + session knobs + tasks = PassSettings(agent=root.agent, env=root.env, + max_iterations=knob("max_iterations"), + session_timeout=knob("session_timeout"), + idle_timeout=knob("idle_timeout"), wait=knob("wait")) +5. review_agent = review.agent or root.agent + review_knob(k) = cli.k if cli.k is not None else (review.k if set else root.k) + # session knobs only + review_env = review.env # exactly; never root env +6. additional = AdditionalReviewConfig( + agent=(review.additional.agent if review.additional else None) or review_agent, + patience=(cli.review_patience if cli.review_patience is not None else + review.additional.patience if review.additional else None), + max_iterations=review.additional.max_iterations if review.additional else None) +7. base_ref = strip(cli.base_ref) if cli.base_ref is not None else review.base_ref +8. RETURN RunSettings(skip, tasks, + ReviewPassSettings(agent=review_agent, env=review_env, roles=review.roles, + base_ref=base_ref, strategy=strategy, finalize=review.finalize, + additional=additional, session_timeout=…, idle_timeout=…, wait=…)) +``` +Pure; frozen value objects; no wrapper resolution; no strategy validation. + +### `compose_pass_options` (`pass_options.py`) + +``` +tasks: {"tasks_only": True} ∪ {k: v for knobs of settings.tasks when v is not None} +review: mode = {"external_only": True} if strategy=="short" else {"review": True} + ∪ {k: v for session_timeout/idle_timeout/wait of settings.review when not None} + ∪ {"base_ref": v} when not None + ∪ {"review_patience": additional.patience} when not None # 0 kept + ∪ {"max_external_iterations": additional.max_iterations} when not None # 0 kept +``` + +### `validate_review_config` (`review_config.py`) + +Fixed order: skip-return → role whitelist → env-requires-agent → review wrapper existence (None agent → clean `ValueError`) → additional wrapper existence (short always; full when additional.agent set) → strategy whitelist. Raises `ValueError` naming the invalid value; returns None. + +### `sync_ralphex_defaults` (`ralphex_runtime.py`) + +Existing rewrite + roles filtering, re-signatured to `(config: BuildConfig, settings: RunSettings)` (roles from `settings.review.roles`); new step: when `settings.review.finalize` is not None → write it verbatim to `.ralphex/agents/finalize.txt`. + +### `write_ralphex_config` (`ralphex_config.py`) + +Fixed block (`claude_command`, `claude_args`, `preserve_anthropic_api_key=true`, `move_plan_on_completion=false`) + `codex_enabled=false` under medium; under full/short: `external_review_tool=custom` + `custom_review_script=resolve_wrapper_path(additional.agent)` when additional.agent set; `finalize_enabled=true` when finalize set. Whole-file rewrite; INI lines joined with `\n` + trailing newline. + +### `run_build_pass` (`build_pass.py`) + +`write_ralphex_config(settings, wrapper_path)` → `return run_ralphex(plan, options, dry_run, env=env)`. + +### `move_completed_plan` (`plan_relocation.py`) + +`not outcome or dry_run` → `RelocationOutcome(moved=False, destination=None)`; else `Path.replace` into `/completed/` (mkdir parents, exist_ok) → `RelocationOutcome(moved=True, destination=str(dest))`. + +### `build()` (`build.py`) + +The 12-step cycle of the trace above, with the step-3.5 guard (tasks agent None → `logger.error("no build agent resolved: set build.agent in .goga/config.yml")` + `return 1`, before facts/gate/events — the degenerate skip-run case of a direct in-container invocation; unreachable through the host launcher's step-2.2 guard). Deletions: `_resolve_options`, `_review_scoped_options` (superseded by `resolve_run_settings`/`compose_pass_options`); the git pre-check helpers stay. New private helper `_completion_statuses(work) -> list[str]` (slug None → `[]`; else the matching `collect_topic_statuses(year=work.year)` record's statuses; absent → `[]`). + +### `run_ralphex` (`goga/ralphex/run_ralphex.py`) + +`_BOOL_FLAGS = (("review", "--review"), ("tasks_only", "--tasks-only"), ("external_only", "-e"))`; +`_SCALAR_FLAGS = (…, ("review_patience", "--review-patience"), ("max_external_iterations", "--max-external-iterations"), ("base_ref", "--base-ref"))`; +scalar emission rule: `value is not None and value != ""` — with the non-external keys additionally dropping 0 (`max_iterations`, `session_timeout`, `idle_timeout`, `wait`, `base_ref`), i.e. keep the historical `not in (None, "", 0)` for those and use the wider rule for exactly `review_patience`/`max_external_iterations`. + +### `main()` (`__main__.py`) + +argparse per the trace; `cli_options` keys: `dry_run`, `skip_manifest_check`, `skip_review`, `base_ref`, `review_patience`, `session_timeout`, `idle_timeout`, `wait`, `max_iterations` (worktree/skip_finalize removed). + +### Host `build(...)` (`goga/commands/build/build.py`) + +Remove the two click options + `_build_cli_args` worktree/skip_finalize branches; repoint the step-2.2 guard to `config.build.agent`; delete step 2.3; drop `config.build.env` from the env-file assembly (step 7) and from the comments that call the env-file "task_executor secrets"; `--review-patience`/`--base-ref` help text repointed to `build.review.additional.patience` / `build.review.base_ref`. + +### Loader (`goga/config/project/loader.py`) and model (`config.py`) + +Model: delete `TaskExecutorConfig`/`ReviewExecutorConfig`; `BuildConfig` per the new signature (all fields kw_only, `env`/`hosts` default empty dicts, everything else None-able); add `ReviewConfig`, `AdditionalReviewConfig` (frozen, kw_only). Loader: `_parse_build` extracts the root fields then the optional `review` sub-mapping (fields per the CODEMANIFEST step 7 patterns — `skip` bool|None, agent/env/base_ref/strategy/finalize emptiness patterns, `roles` list[str]|None with empty passing verbatim, `additional` mapping with int-typed `patience`/`max_iterations` rejecting YAML bools); error messages use the new key names (`build.agent must be a string…`, `build.review.strategy must be a string…`); unknown keys ignored. `_parse_task_executor`/`_parse_review_executor`/`_parse_review_scoped` deleted. `goga/config/__init__.py` re-exports `ReviewConfig`, `AdditionalReviewConfig`; drops the retired names. + +### Catalog (`goga/hooks/catalog/catalog.py`) + +Append five records (list order irrelevant — `declared_actions` sorts by domain then name): +`Action(domain="build", name="validate_build", error_class="hard")`, +`Action("build", "build_started", "soft")`, `Action("build", "pass_started", "soft")`, +`Action("build", "pass_completed", "soft")`, `Action("build", "build_completed", "soft")`. Existing records untouched. + +### `goga/build/hooks/__init__.py` + +Facade re-exporting all 13 types with `__all__` (alphabetical), module docstring naming the zone (mirror `goga/pipeline/hooks/__init__.py`). + +## Cross-cutting Concerns + +- **Error handling**: pre-launch failures (uncommitted manifests, invalid review config, unavailable defaults) and a vetoed gate return exit code 1 with a `logger.error` carrying structured `extra` — never a traceback; `ValueError` from validation is caught at the `build()` boundary. The single exception is the platform's fatal `ImportError` of a broken tool-package import, which escapes `build()` unhandled as documented in the `BuildHooks` errors section (the pipeline precedent — one clean message naming the package, still before any event). The gate's merged error lists every violation (tool, hook, reason). Soft hook failures warn inside the platform (tool, action, reason); the run is unaffected. `run_ralphex` launch rejections surface as one-line stderr + exit 1 and propagate as the pass's exit code (a completion fact). +- **Logging**: stdlib `logging`, `logger = logging.getLogger(__name__)`; lowercase stable event names; `extra={...}` metadata; **env values never appear in any log or print** — presence travels as sorted names in facts; the pass env layers are never logged and never printed on dry-run. +- **Validation**: three tiers — structural (loader, two-part extraction, retired keys ignored), semantic (`validate_review_config`: roles, env-requires-agent, wrapper existence ×2, strategy whitelist), and policy (the gate, tool-owned). All before the first side effect or event. +- **Caching**: none new. The `HookRegistry` builds once per run (lazily, on the first checkpoint) and is shared by all five checkpoints of the same `BuildHooks` instance; registration is never cached across runs. `.ralphex/` state persists on the host runtime mount (host-owned lifecycle). +- **Concurrency**: single-threaded in-container execution; no shared mutable state across processes. The only mutable checkpoint state is the per-tool veto buffer, scoped to one `validate_build` call. +- **Secret safety (C5)**: env values never delivered to any context, never written to the host env-file (the task env reaches the container only via the mounted config), never printed. + +## Usages Analysis + +### `conventions` / `convention` +- **What**: mandatory Python rules — relative imports, `dataclasses(kw_only=True)`, Google docstrings, logging style, blank-line blocking, test layout, validation commands. +- **Where used**: every touched file; every test file. +- **Why chosen**: project-wide mandate. +- **How exactly**: relative intra-package imports; frozen kw_only for `RunSettings`/`PassSettings`/`ReviewPassSettings` and the config model; plain kw_only for the zone facts/contexts; `logger = logging.getLogger(__name__)` with `extra`; tests mirror source under `tests/`. + +### `ralphex` (project practice, updated during grooming) +- **What**: the external ralphex binary contract — CLI flags, config keys, review-agent composition, external-review surface, finalize step, vendorable defaults. +- **Where used**: `write_ralphex_config`, `sync_ralphex_defaults`, `compose_pass_options`, `run_ralphex` mapping. +- **Why chosen**: ralphex is the pass executor; goga observes only its own moments. +- **How exactly**: config keys `claude_command`/`claude_args`/`codex_enabled`/`external_review_tool`/`custom_review_script`/`finalize_enabled`/`preserve_anthropic_api_key`/`move_plan_on_completion`; flags per the option table; `{{agent:X}}` composition filtering; `agents/finalize.txt` materialization. + +### `agent-wrappers` +- **What**: `/home/goga/bin/-as-claude.sh` naming for wrappers referenced by absolute path. +- **Where used**: wrapper resolution feeding `claude_command`. +- **Why chosen**: ralphex consumes the claude invocation shape. +- **How exactly**: `resolve_wrapper_path(agent)` (facade `goga.agents`), existence-checked only where the contract mandates (review + additional wrappers). + +### `checkpoints` (goga/build/hooks/.usages — consumer doc of the zone) +- **What**: how the build operation consumes the zone — one `BuildHooks` per run, facts resolved in the operation, gate before the first pass, emissions around the cycle. +- **Where used**: `build()` steps 4–11. +- **Why chosen**: it IS the integration contract of this design. +- **How exactly**: per the trace (gate → started → per-pass started/completed → relocation → statuses → completed). + +### `topic-paths` / `topic-statuses` (goga/history) +- **What**: topic dir composition (slug grammar, year default) and status listing (`collect_topic_statuses`, `TopicRecord`). +- **Where used**: work identity (hosting decision) and completion statuses. +- **Why chosen**: `goga/history` owns the tree; no new git surface. +- **How exactly**: `resolve_current_branch_name() or "unknown"`; `resolve_topic_dir(branch)` (+`ValueError` guard, `.is_dir()`) → slug/year; `collect_topic_statuses(year=work.year)` filtered to `work.slug`. + +### `resolve-wrapper-path`, `run-ralphex`, `ensure-in-docker`, `ensure-in-docker`-adjacent host practices (`docker-*`, `click`, `home-configuration`, `project-configuration`, `build-usage`, `resolve-credential-mounts`, `runtime-paths`) +- **What**: unchanged consumer contracts of the surrounding cells. +- **Where used**: per their cells (host launcher, config facade, docker lifecycle). +- **Why chosen**: unchanged by this design (only the build-flag/env deltas of the host command touch them). +- **How exactly**: as documented in each file; `run-ralphex.md` was refreshed this stage (worktree removed, always-two-pass, external flags, zero rule). + +### Imported by the zone (`goga/build/hooks` from `goga/hooks`) +- `declaring-actions` — `goga/hooks/.usages/declaring-actions.md` — the emission contract of the four notifications (`emit_hook_event` + `context_for`). +- `per-tool-delivery` — `goga/hooks/.usages/per-tool-delivery.md` — the staged per-tool walk of the gate (loop skeleton, primitives, per-tool grouping), with the recorded refinement: run to completion, collect vetoes, no contribution commit. +- `registering-hooks` — `goga/hooks/.usages/registering-hooks.md` — the hook signature (`context`/`self`) and failure handling behind every checkpoint. + +## `.usages/` Update + +### Cell: `goga/build` +- **`build-usage`** → `goga/build/.usages/build-usage.md` — Status: **current** (rewritten by apply-architecture; verified against the manifest: two-part settings, strategies, checkpoints, cli_options list). Additions/Updates: none. +- **`registering-hooks`** → `goga/build/.usages/registering-hooks.md` — Status: **current** (new file; answers the three integration scenarios). none. + +### Cell: `goga/build/hooks` +- **`checkpoints`** → `goga/build/hooks/.usages/checkpoints.md` — Status: **current** (new file; consumer doc for the operation side). none. + +### Cell: `goga/ralphex` +- **`run-ralphex`** → `goga/ralphex/.usages/run-ralphex.md` — Status: was **outdated** (worktree example, conditional two-pass wording, missing external flags) → **updated during this design stage**: worktree removed, always-two-pass composition, `external_only`/`max_external_iterations` + zero-valued rule documented, `TaskExecutorConfig` anti-pattern repointed. + +### Cell: `goga/config` +- **`project-configuration`** → `goga/config/.usages/project-configuration.md` — Status: **current** (build chapter rewritten to the two-part form incl. migration note). none. + +### Cell: `goga/commands/build` +- **`build`** → `goga/commands/build/.usages/build.md` — Status: **current** (flag surface without the two removed flags; env layering note). none. + +### Cells without `.usages/` changes +`goga/hooks/catalog` (no `.usages/` directory), `goga/config/project` (no `.usages/`), `goga/onboarding/generator` (no `.usages/`), `goga/hooks` (provider practices unchanged). No new `.usages/` files are needed — every new domain (zone facts, gate, checkpoints, tool-author subscription) is already covered by the four files above. + +## Test Stack Trace + +### General Setup + +- Zone tests reuse the platform boundary fixtures: `tests/hooks/conftest.py` + (`pin_package_environment` — pins `packages_distributions`; + `install_tool_package` — installs a fake `goga_tool_*` into `sys.modules`), + re-exported by `tests/build/hooks/conftest.py` (mirror + `tests/pipeline/hooks/conftest.py`). +- Wrapper existence tests monkeypatch `resolve_wrapper_path` at its import + point (`goga.build.review_config.resolve_wrapper_path` / + `goga.build.ralphex_config.resolve_wrapper_path`) to a real `tmp_path` + file — the established pattern of `tests/build/test_review_config.py`. +- Orchestration tests monkeypatch `goga.build.build.run_build_pass` (or + `goga.ralphex.run_ralphex.run_ralphex`) with a recording stub, run inside + `tmp_path` (`.ralphex/` writes land there), and monkeypatch + `resolve_current_branch_name`/`resolve_topic_dir`/`collect_topic_statuses` + at `goga.build.build`'s import point. +- `tests/conftest.py` provides `is_kw_only_dataclass`. + +### Source File Registry + +Created: `goga/build/hooks/{__init__,facts,contexts,events}.py`, +`goga/build/{run_settings,pass_options}.py`, +`tests/build/hooks/{__init__,conftest,test_facts,test_contexts,test_events}.py`, +`tests/build/{test_run_settings,test_pass_options}.py`. +Rewritten: `goga/config/project/{config,loader}.py`, `goga/build/{build,__main__,review_config,ralphex_runtime,ralphex_config,build_pass,plan_relocation}.py`, `goga/ralphex/run_ralphex.py`, `goga/hooks/catalog/catalog.py`, `goga/commands/build/build.py`, `goga/onboarding/generator/generator.py`, `goga/config/__init__.py`. +Deleted: `goga/build/review_options.py`, `tests/build/test_review_options.py`. +Updated tests: `tests/build/{test_build,test_main,test_review_config,test_ralphex_config,test_ralphex_runtime,test_build_pass,test_plan_relocation,test_contract}.py`, `tests/config/{test_config,test_loader}.py`, `tests/ralphex/test_run_ralphex.py`, `tests/hooks/catalog/test_catalog.py`, `tests/commands/build/test_build.py`, `tests/onboarding/generator/test_generator.py`. + +Affected tests outside the main registry — verified stale against the +retired schema during the design review; rewrite/update them as part of +the implementation (the validation run `pytest tests/ -x` covers them): + +- `tests/build/test_build_resolved_wrapper.py` — rewrite the six tests + onto the two-part schema (`build.agent` at the root); delete the + `codex_review → codex_enabled` case (the key is retired; the strategy + table test of `test_ralphex_config` covers the new derivation); keep the + uncommitted-manifests / ralphex-missing / custom-prompts-dir cases on + the new fixtures. +- `tests/build/test_shipped_ralphex_assets.py` — replace the + `BuildConfig(task_executor=TaskExecutorConfig(...))` construction with + the two-part `BuildConfig(agent=..., env={})`; the vendored asset + assertions are unchanged. +- `tests/config/test_integration.py` — rewrite the build-section fixtures + and assertions onto the two-part model (root fields plus + `build.review`); drop or repoint the `worktree`/`skip_finalize`/ + `codex_review`/`task_executor`/`review_executor` assertions to the + retired-key silence semantics already covered by the loader tests. +- `tests/commands/conftest.py` — update the shared config-writing helpers + to the two-part schema (`build: {agent: ...}`); drop the + `worktree`/`skip_finalize`/`codex_review`/`review_executor` lines. +- `tests/commands/test_build.py` — replace the `worktree`-option + assertion with the removed-surface assertion (unknown option, exit 2); + repoint the `task_executor` config fixture to `build.agent`. +- `tests/integration/test_base_ref_end_to_end.py`, + `tests/integration/test_skip_review_end_to_end.py`, + `tests/integration/test_resolved_wrapper_flow.py` — rewrite onto the + two-part config and the always-two-pass cycle (the skip form: exactly + one tasks pass). +- `tests/commands/build/test_build_home_integration.py`, + `tests/commands/build/test_build_proxy_hosts_update.py`, + `tests/commands/build/test_build_runtime_isolation_integration.py`, + `tests/commands/build/test_build_credential_mount_integration.py` — + update fixtures to the two-part schema and the env-file assertions (the + task env is no longer written into the env-file). +- Incidental `task_executor` text in the fixtures of the pipeline / + contract / usages-sync suites stays load-compatible (the loader + silently ignores unknown keys) — no rewrite needed; confirmed by the + full-suite run of the validation step. + +--- + +### Positive Tests + +#### `test_load_project_config_parses_two_part_build` + +**Setup**: `tmp_path` with `.goga/config.yml`: +`language: python`, `build: {agent: claude, env: {A: "1"}, max_iterations: 7, session_timeout: 30m, review: {agent: codex, env: {B: "2"}, roles: [quality], base_ref: main, strategy: short, finalize: "do it", additional: {agent: cursor, patience: 2, max_iterations: 4}}}`; monkeypatch cwd. + +**Input**: `load_project_config()`. + +**Trace**: +``` +load_project_config() + → yaml.safe_load(doc) # mapping + → _parse_build(build mapping) + → root: agent="claude", env={"A":"1"}, max_iterations=7, session_timeout="30m" + → review mapping → ReviewConfig(agent="codex", env={"B":"2"}, roles=["quality"], + base_ref="main", strategy="short", finalize="do it", + additional=AdditionalReviewConfig(agent="cursor", patience=2, max_iterations=4)) + → ProjectConfig(build=BuildConfig(..., review=...)) +``` + +**Assertions**: +``` +config.build.agent == "claude"; config.build.env == {"A": "1"} +config.build.review.agent == "codex"; config.build.review.additional.patience == 2 +not hasattr(config.build, "task_executor"); not hasattr(config.build, "worktree") +``` + +**Sufficiency**: pins the two-part loader shape every downstream resolution depends on; prevents regression to the executor-block model. + +#### `test_resolve_run_settings_full_inheritance` + +**Setup**: `BuildConfig(agent="claude", env={"A":"1"}, max_iterations=9, session_timeout="30m", idle_timeout="5m", wait="1m", review=ReviewConfig(skip=None, agent=None, env={}, roles=["quality"], base_ref="main", strategy=None, finalize=None, additional=AdditionalReviewConfig(agent=None, patience=3, max_iterations=None), session_timeout=None, idle_timeout=None, wait=None))`; `cli_options={}` (all None). + +**Input**: `resolve_run_settings(config, cli_options)`. + +**Trace**: +``` +resolve_run_settings(config, {}) + → skip=False; strategy="medium" (default) + → tasks=PassSettings(agent="claude", env={"A":"1"}, max_iterations=9, "30m","5m","1m") + → review.agent="claude" (inherited); knobs "30m"/"5m"/"1m" (inherited) + → additional.agent="claude" (inherits review agent), patience=3 + → base_ref="main" + → RunSettings(skip=False, ...) +``` + +**Assertions**: `settings.review.agent == "claude"`; `settings.review.strategy == "medium"`; `settings.review.additional.agent == "claude"`; `settings.review.additional.patience == 3`; `settings.review.env == {}`; `settings.review.base_ref == "main"`. + +**Sufficiency**: the inheritance spine (root→review→additional) is the core of the two-part model; SC2's "bound per-stage settings". + +#### `test_resolve_run_settings_cli_overrides_and_tri_state` + +**Setup**: config with `review=ReviewConfig(skip=True, session_timeout="10m", ...)`; `cli_options={"skip_review": False, "session_timeout": "99m"}`. + +**Input**: `resolve_run_settings(config, cli_options)`. + +**Trace**: skip: CLI False (not None) wins over config True → False; `session_timeout`: CLI "99m" wins. + +**Assertions**: `settings.skip is False`; `settings.review.session_timeout == "99m"`. + +**Sufficiency**: precedence CLI > config and the tri-state kill switch (task item 2/`skip`). + +#### `test_resolve_run_settings_review_absent` + +**Setup**: `BuildConfig(agent="claude", env={}, max_iterations=5, ..., review=None)`; `cli_options={}`. + +**Input**: `resolve_run_settings(config, {})`. + +**Trace**: step 0 — every review field unset; skip False; strategy medium; additional constructed with `agent="claude"`, patience/max_iterations None. + +**Assertions**: `settings.review.additional.agent == "claude"`; `settings.review.additional.patience is None`; `settings.review.roles is None`; `settings.skip is False`. Repeat with `review=ReviewConfig(roles=[])` → `settings.review.roles == []` (the empty list travels verbatim, never coerced to None). + +**Sufficiency**: the non-optional `additional` member must resolve even with no `build.review` key (a `ReviewPassSettings` construction crash would break every plain config). + +#### `test_resolve_run_settings_base_ref_normalization` + +**Setup**: `cli_options={"base_ref": " release/1.3.0 "}`; config review `base_ref="main"`. + +**Input**: `resolve_run_settings(...)` → **Assertions**: `settings.review.base_ref == "release/1.3.0"`. Repeat with `cli_options={"base_ref": " "}` → `base_ref == "main"` (empty CLI counts as unset). + +**Sufficiency**: whitespace semantics preserved from the retired resolver; prevents padded/empty CLI values silently overriding config. + +#### `test_compose_pass_options_tasks` + +**Setup**: `RunSettings` with tasks knobs (`session_timeout="30m"`, `max_iterations=9`) and review part carrying `base_ref="main"`, `additional.patience=0`. + +**Input**: `compose_pass_options(settings, "tasks")`. + +**Trace**: `{"tasks_only": True, "session_timeout": "30m", "max_iterations": 9}`. + +**Assertions**: exactly those keys; no `review`/`external_only`/`base_ref`/`review_patience`. + +**Sufficiency**: "Review-only options never appear on the tasks pass". + +#### `test_compose_pass_options_review_medium_and_short` + +**Setup**: same settings, `strategy="medium"`. + +**Input**: `compose_pass_options(settings, "review")`. + +**Assertions**: `options["review"] is True` and `"external_only" not in options`; `options["base_ref"] == "main"`; `options["review_patience"] == 0` (zero kept); `"max_external_iterations" in options` iff `additional.max_iterations is not None`. + +**Trace (short)**: rebuild with `strategy="short"` → **Assertion**: `options["external_only"] is True and "review" not in options`. + +**Sufficiency**: mutually exclusive pass modes and the short-strategy `-e` binding (SC2 / task item 2). + +#### `test_validate_review_config_accepts_clean_settings` + +**Setup**: `tmp_path` wrapper file; monkeypatch `goga.build.review_config.resolve_wrapper_path` → `str(wrapper)`; `RunSettings(skip=False, review=ReviewPassSettings(agent="claude", env={"X":"1"}, roles=["quality"], strategy="medium", additional=AdditionalReviewConfig(agent="claude", patience=None, max_iterations=None), ...))`. + +**Input**: `validate_review_config(settings)`. + +**Trace**: roles pass → env non-empty but agent set → wrapper resolves + exists → medium does not engage the additional check → strategy whitelisted → returns None. + +**Assertions**: returns None; no exception. + +**Sufficiency**: the happy path must not raise — the fixed check order is observable via the negative tests below. + +#### `test_write_ralphex_config_strategies` + +**Setup**: tmp cwd; three `RunSettings` variants (medium / full with additional agent "codex" / finalize set), wrapper path `"/home/goga/bin/claude-as-claude.sh"`; monkeypatch `goga.build.ralphex_config.resolve_wrapper_path` for the additional wrapper. + +**Input**: `write_ralphex_config(settings, wrapper)` per variant. + +**Trace**: file `.ralphex/config` rewritten whole per call. + +**Assertions**: +``` +medium: "codex_enabled = false" in text; "external_review_tool" not in text +full+additional: "external_review_tool = custom" in text + and f"custom_review_script = {additional_wrapper}" in text + and "codex_enabled" not in text +finalize set: "finalize_enabled = true" in text; unset variant: not in text +always: "move_plan_on_completion = false", "preserve_anthropic_api_key = true", + f"claude_command = {wrapper}" +``` + +**Sufficiency**: the external surface and finalize wiring of the review pass — SC-level behavior of the strategy triple. + +#### `test_sync_ralphex_defaults_materializes_finalize` + +**Setup**: tmp cwd; vendored sources exist (use the real vendored dirs, custom `prompts_dir`/`agents_dir` pointing at tmp copies when isolation is needed); `RunSettings` with `finalize="Final pass: merge the review."`. + +**Input**: `sync_ralphex_defaults(config, settings)`. + +**Trace**: full rewrite of prompts/agents → finalize step: `.ralphex/agents/finalize.txt` written with the prompt verbatim. + +**Assertions**: `(tmp_path / ".ralphex/agents/finalize.txt").read_text() == "Final pass: merge the review."`; unset-finalize variant → file absent. + +**Sufficiency**: the finalize materialization (task item 2; ADR finalize decision). + +#### `test_move_completed_plan_returns_relocation_outcome` + +**Setup**: `tmp_path/docs/plans/plan.md` exists. + +**Input**: `move_completed_plan(str(plan), outcome=True, dry_run=False)`. + +**Trace**: not-moved guard passes → `completed/` created → `Path.replace` → outcome built. + +**Assertions**: `relocation.moved is True`; `relocation.destination == str(tmp_path/"docs/plans/completed/plan.md")`; source gone. Variants: `outcome=False` → `moved is False, destination is None`; `dry_run=True` → same not-moved outcome and file stays. + +**Sufficiency**: `BuildCompleted.relocation` facts (SC5) depend on the outcome object. + +#### `test_build_runs_two_passes_with_bound_settings` + +**Setup**: tmp cwd with config; monkeypatch `goga.build.build.run_build_pass` recording `(options, wrapper, env)` and returning 0; monkeypatch `resolve_current_branch_name` → `"add-hooks-to-build"`, `resolve_topic_dir` → raises ValueError (branch-only), `collect_topic_statuses` → `[]`; `cli_options={... all None, dry_run False}`; no tool packages pinned (inert hooks). + +**Input**: `build("plan.md", config, cli_options)`. + +**Trace**: +``` +build(...) + → resolve_run_settings → validate (patched wrappers) → sync (tmp .ralphex) + → work = WorkIdentity("add-hooks-to-build") # unsluggable → branch-only + → gate: no subscriptions → approved + → emit_build_started (inert) + → pass 1: options {"tasks_only": True,...}, wrapper claude-as-claude.sh, env={"A":"1"} + → pass 2 (exit 0, not skip): options {"review": True,...}, wrapper review/additional, env=review env + → move_completed_plan(outcome=True) → relocation.moved True + → statuses [] (branch-only) → emit_build_completed → return 0 +``` + +**Assertions**: `run_build_pass` called exactly twice; first call `options["tasks_only"] is True` and `env == {"A":"1"}`; second call `options["review"] is True` and env is the review layer (never the root env); return 0; plan relocated. + +**Sufficiency**: SC2 — the stable cycle including the formerly combined case; secret boundary (root env never on the review pass). + +#### `test_build_skipped_review_single_tasks_pass` + +**Setup**: as above with `cli_options={"skip_review": True}`. + +**Assertions**: exactly one `run_build_pass` call (`tasks_only`); return value = that pass's code; no review-pass call. + +**Sufficiency**: SC2 second sentence ("a skipped review yields exactly one tasks pass"). + +#### `test_gate_collects_vetoes_without_early_stop` + +**Setup**: `pin_package_environment({"goga_tool_a": ["goga_tool_a"], "goga_tool_b": ["goga_tool_b"]})`; install both with `register_hooks` subscribing `("build","validate_build","policy", hook)`; hook A vetoes `"no deploys on friday"`, hook B records `self.calls` and approves; facts built directly. + +**Input**: `hooks = BuildHooks(); verdict = hooks.validate_build(moment, tasks, review, skip=False)`. + +**Trace**: registry builds once → groups {A, B} → A's view buffers the veto → B still invoked → verdict `[Violation(tool="goga_tool_a", hook="policy", reason="no deploys on friday")]`. + +**Assertions**: `verdict.approved is False`; single violation naming tool+hook+reason; B's hook ran (recorded flag True). + +**Sufficiency**: SC3 — verdict collection requires every tool's outcome; a non-vetoing subscriber is still invoked. + +#### `test_gate_attributes_veto_to_hook_and_replaces_whole` + +**Setup**: one tool, two hooks `first` (vetoes "one") and `second` (vetoes "two") on the same address. + +**Input**: `validate_build(...)`. + +**Assertions**: exactly one `Violation`; `violation.hook == "second"`; `violation.reason == "two"` (later veto replaces reason AND attribution). + +**Sufficiency**: the whole-replacement attribution rule from the zone contract. + +#### `test_gate_crash_overrides_veto_and_walk_continues` + +**Setup**: tool A: hook `broken` raises `RuntimeError("boom")` after a hook `vetoer` buffered "blocked"; tool B approves. + +**Input**: `validate_build(...)`. + +**Assertions**: violations == `[Violation(A, "broken", "boom")]` only — crash reason replaces the buffered veto, exactly one violation for A, B still ran, no exception escapes. + +**Sufficiency**: "a crashing hook counts as its tool's veto with the crash reason — never a raw traceback" + no early stop. + +#### `test_gate_empty_verdict_when_no_subscriptions` + +**Setup**: `pin_package_environment({})`. + +**Input**: `validate_build(...)` → **Assertions**: `verdict.approved is True; verdict.violations == []`. + +**Sufficiency**: SC7/SC8-adjacent — the hooks layer is inert with no tool packages. + +#### `test_notifications_carry_completion_facts` + +**Setup**: one tool subscribing all four soft actions with hooks recording `context` (via `self`); orchestration as in the two-pass test with the tasks pass returning 0 and the review pass returning 2. + +**Input**: `build(...)`. + +**Trace**: started → pass_started(tasks) → pass_completed(tasks, 0) → pass_started(review) → pass_completed(review, 2) → relocation (not moved — failure) → build_completed(exit_code=2, stages=["tasks","review"], relocation.moved=False, statuses=[]). + +**Assertions**: recorded contexts expose `PassCompleted.exit_code == 2` for the review facts; `BuildCompleted.exit_code == 2`; `stages == ["tasks", "review"]`; a crashing notification hook (separate variant) warns and the return code stays 2. + +**Sufficiency**: SC4 — completion fires on non-zero codes with the actual exit code; SC5 facts on `build_completed`. + +#### `test_build_dry_run_rehearses_event_structure` + +**Setup**: two-pass setup with `dry_run=True`; `run_build_pass` NOT patched at the pass level — patch `goga.ralphex.run_ralphex.run_ralphex` to assert it is called with `dry_run=True` (it prints and returns 0). + +**Input**: `build(...)` with `cli_options={"dry_run": True}`. + +**Assertions**: both passes "ran" (launcher called twice, both dry); the plan file still at its original path (relocation not moved); `BuildCompleted.relocation.moved is False`; recorded notification `moment.dry_run is True`. + +**Sufficiency**: SC6 — identical event structure, gate runs, nothing executes, plan not relocated. + +#### `test_registry_built_once_across_checkpoints` + +**Setup**: one tool subscribing `validate_build` + `build_started` + `build_completed`; pin the enumeration boundary mock and count reads. + +**Input**: full `build(...)` run. + +**Assertions**: the `packages_distributions` boundary was read exactly once (one registry build shared by all reached checkpoints). + +**Sufficiency**: "one HookRegistry per run carries every checkpoint" (performance + enumeration-once invariant). + +#### `test_catalog_carries_the_five_build_records` + +**Setup/Input**: `declared_actions()`. + +**Assertions**: the five `(domain="build", name∈{validate_build, build_started, pass_started, pass_completed, build_completed})` records exist with error classes hard/soft×4; pre-existing records byte-identical (compare against a frozen expected list); ordering deterministic (domain, then name). + +**Sufficiency**: C4 catalog additivity — no existing record changes; SC1 addressing. + +#### `test_zone_facade_exports_thirteen_types` + +**Input**: `python -c`-style import in test: `from goga.build.hooks import BuildHooks, BuildMoment, StageFacts, WorkIdentity, AdditionalFacts, RelocationOutcome, Violation, GateVerdict, BuildValidation, BuildStarted, PassStarted, PassCompleted, BuildCompleted`. + +**Assertions**: all resolve; `RunSettings`/`PassSettings` importable from `goga.build.run_settings`; facade `from goga.config import BuildConfig, ReviewConfig, AdditionalReviewConfig` resolves and retired names are gone (`ImportError` on `TaskExecutorConfig`). + +**Sufficiency**: the arch-plan facade check, executable. + +#### `test_main_argparse_surface_matches_contract` + +**Setup**: monkeypatch `sys.argv` / `ensure_in_docker`; patch `goga.build.__main__.build`. + +**Input**: `main()` with `["goga.build", "plan.md", "--skip-review", "--review-patience", "3"]`; repeat with `["goga.build", "plan.md", "--no-skip-review"]`. + +**Assertions**: forwarded `cli_options["skip_review"] is True` and `cli_options["review_patience"] == 3`; the `--no-skip-review` variant forwards `cli_options["skip_review"] is False` (the tri-state False arm of the argparse pair); parsing `--worktree` or `--skip-finalize` exits with argparse error (SystemExit 2); guard `ensure_in_docker` called first (both branches covered per the manifest requirement). + +**Sufficiency**: the in-container CLI surface; SC8 (flags removed end to end). + +#### `test_host_command_surface_and_env_file` + +**Setup**: click runner (`CliRunner`); tmp config with two-part build; existing host fixtures. + +**Input**: invoke `goga build plan.md` (and with `--base-ref x --review-patience 2 --skip-review`). + +**Assertions**: `--worktree`/`--skip-finalize` are unknown options (exit 2 + message); guard message names `build.agent`; forwarded args contain `--base-ref x` / `--review-patience 2` / `--skip-review` only when set; the written env-file contains home/git/cli env keys and NOT the `build.env` values (secret boundary); docker args carry `-m goga.build `. + +**Sufficiency**: SC8 host side + the env-file layering change; guard repoint. + +#### `test_onboarding_generator_emits_two_part_build` + +**Setup**: onboarding answers `build: {agent: "claude", env: {API_KEY: "secret"}}` (existing fixture pattern). + +**Input**: `generate_goga_config(answers)` → load the written file with `load_project_config`. + +**Assertions**: `cfg["build"] == {"agent": "claude", "env": {"API_KEY": "secret"}}` (no `task_executor` nesting); `config.build.agent == "claude"` (the generated file actually drives a build). + +**Sufficiency**: the fixed defect — fresh onboarding must produce a working build section. + +#### `test_run_ralphex_external_flags_and_zero_rule` + +**Setup**: patch `subprocess.call` (via the existing launcher test pattern) recording argv; `dry_run=False`. + +**Input**: `run_ralphex("p.md", {"external_only": True, "review_patience": 0, "max_external_iterations": 0, "max_iterations": 0, "base_ref": "main"}, False)`. + +**Trace**: `_build_command` → bool loop emits `-e`; scalar loop: `review_patience 0` and `max_external_iterations 0` emitted; `max_iterations` 0 dropped; `base_ref main` emitted. + +**Assertions**: +``` +argv == ["ralphex", "p.md", "--config-dir", ".ralphex/", "-e", + "--review-patience", "0", "--max-external-iterations", "0", + "--base-ref", "main"] +``` +and `--worktree`/`--skip-finalize` never appear for any input. + +**Sufficiency**: the launcher flag contract incl. the zero-valued external rule (0 = disabled / ralphex auto are meaningful). + +### Negative Tests + +#### `test_load_project_config_ignores_retired_keys` + +**Setup**: config carrying `build: {worktree: true, skip_finalize: true, codex_review: false, task_executor: {agent: claude}, review_executor: {agent: codex}, agent: claude}`. + +**Input**: `load_project_config()`. + +**Assertions**: no error; `config.build.agent == "claude"`; `config.build.review is None` (the old blocks are unknown keys — silently ignored, not parsed). + +**Sufficiency**: SC8 — retired keys vanish without compatibility paths; the "stale config silently disables review, not build" semantics. + +#### `test_load_project_config_rejects_malformed_review` + +**Setup/Input**: parametrize: `review: "x"` (non-mapping), `review: {skip: "yes"}`, `review: {roles: [1]}`, `review: {strategy: 5}`, `review: {additional: {patience: true}}`, `build: {max_iterations: true}`, `build: {agent: 7}`. + +**Assertions**: each raises `ValueError` naming the key (`build.review.skip must be a bool…` etc.). + +**Sufficiency**: structural typing of the new block (manifest step 7 patterns, incl. YAML-bool-as-int rejection). + +#### `test_validate_review_config_rejects_bad_fields` + +**Setup**: clean baseline settings; wrapper monkeypatched to an existing tmp file; parametrize mutations: role `"auditor"`; review env non-empty + agent None; wrapper path to a missing file (`/home/goga/bin/ghost-as-claude.sh` via the patch); strategy `"fast"`. + +**Input**: `validate_review_config(mutated)`. + +**Assertions**: `pytest.raises(ValueError, match=...)` naming the role / the env-requires-agent problem / the agent+path / the strategy value; a `skip=True` variant of every mutation returns None (skipped runs validate nothing). + +**Sufficiency**: the semantic tier incl. the skip short-circuit; the fixed check order. + +#### `test_validate_review_config_rejects_missing_additional_wrapper` + +**Setup**: clean baseline settings with `strategy="full"` and `additional.agent="codex"`; monkeypatch `goga.build.review_config.resolve_wrapper_path` so the review agent resolves to an existing `tmp_path` file and the additional agent to a missing path (`/home/goga/bin/ghost-as-claude.sh`). + +**Input**: `validate_review_config(settings)`. + +**Trace**: roles pass → env gate pass → review wrapper resolves + exists → strategy full engages the external review (additional.agent set) → the additional wrapper resolves to the missing path → `not Path(wrapper).is_file()` → raise. + +**Assertions**: `pytest.raises(ValueError, match="ghost-as-claude.sh")` naming the additional agent and its path; a `skip=True` variant of the same settings returns None (skipped runs validate nothing). + +**Sufficiency**: the step-5 branch of the fixed check order (the additional-wrapper existence under full/short) has no other negative coverage — without this test the external-surface validation never has a failing exercise. + +#### `test_build_vetoed_run_blocks_before_any_pass` + +**Setup**: two-pass orchestration setup + one tool whose `validate_build` hook vetoes `"policy"`; `run_build_pass` patched with a recorder. + +**Input**: `build(...)`. + +**Assertions**: return 1; `run_build_pass` never called; the plan file still in place; NO notification hook of the tool ran (recorded `build_started`/`pass_*`/`build_completed` absent); exactly one `logger.error` record carrying the violation triple (caplog). + +**Sufficiency**: SC3 end-to-end — one merged error, exit 1, nothing executes, no post-gate events. + +#### `test_build_pre_launch_failures_fire_no_events` + +**Setup**: tool subscribed to all five actions (recorder); parametrize: uncommitted CODEMANIFEST in tmp git-less setup (patch `_find_uncommitted_manifests` → `["x/CODEMANIFEST"]`), invalid review config (patch `validate_review_config` → raise), unavailable defaults (patch `sync_ralphex_defaults` → raise), no build agent on a skip run (config with root agent None + `cli_options={"skip_review": True}` — the step-3.5 guard path; `validate_review_config` returns early on skip, so the guard is the only pre-event check that fires). + +**Input**: `build(...)` per variant. + +**Assertions**: return 1; zero hook invocations across all five actions in every variant. + +**Sufficiency**: "a failing moment fires nothing" (registering-hooks doc; task edge semantics). + +### Edge Case Tests + +#### `test_stage_facts_carry_env_names_only` + +**Setup/Input**: settings with `tasks.env={"A":"1","B":"2"}` → orchestration facts (or direct construction helper under test). + +**Assertions**: `StageFacts.env == ["A", "B"]` (sorted names); no fact object exposes any env value (walk `dataclasses.fields` of every context and assert no string member equals `"1"`/`"2"`). + +**Sufficiency**: C5/SC5 — "no context ever carries env values". + +#### `test_build_statuses_recomputed_after_relocation` + +**Setup**: topic-hosting branch: `resolve_current_branch_name → "add-hooks-to-build"`, `resolve_topic_dir → Path(".goga/history/2026/add-hooks-to-build")` (is_dir True); `collect_topic_statuses` stub returning `[TopicRecord("add-hooks-to-build", ["backlog", "designed"])]`; successful run. + +**Input**: `build(...)`. + +**Assertions**: the recorded `BuildCompleted.statuses == ["backlog", "designed"]`; `collect_topic_statuses` called with `year="2026"` AFTER `move_completed_plan` (order recorded); branch-only variant delivers `[]`. + +**Sufficiency**: SC5 — artifact → history-status integration buildable from documented facts; the recompute-after-relocation ordering. + +#### `test_build_failed_tasks_pass_skips_review` + +**Setup**: `run_build_pass` first call returns 1. + +**Input**: `build(...)`. + +**Assertions**: one pass call only; `pass_completed` for tasks carries `exit_code == 1`; `BuildCompleted.stages == ["tasks"]`; relocation not moved; return 1. + +**Sufficiency**: "a failed tasks pass never launches the review pass" + completion facts on failure. + +#### `test_gate_veto_empty_reason_rendered_verbatim` + +**Setup**: tool hook calls `context.veto(" ")`. + +**Input**: `validate_build(...)` → **Assertions**: `Violation.reason == " "`; approved False. + +**Sufficiency**: the stored-as-given rule for empty reasons. + +#### `test_max_iterations_zero_dropped_by_launcher` + +**Setup/Input**: `run_ralphex("p.md", {"tasks_only": True, "max_iterations": 0}, dry_run=True)` (capture stderr). + +**Assertions**: printed command has no `--max-iterations`; contrast `review_patience: 0` prints `--review-patience 0`. + +**Sufficiency**: the asymmetric zero rule (only the two external flags carry meaningful zeros). + +#### `test_unsluggable_branch_falls_back_to_branch_only` + +**Setup**: `resolve_topic_dir` raises `ValueError`; `resolve_current_branch_name → None`. + +**Input**: `build(...)` → **Assertions**: `WorkIdentity.branch == "unknown"`, `slug is None`; statuses `[]`; run proceeds normally (hosting failure is not a run failure). + +**Sufficiency**: the degraded branch-only form is allowed (task item 5). + +#### `test_move_completed_plan_is_idempotent_by_name` + +**Setup**: run the relocation twice on the same plan (recreate the source between calls). + +**Assertions**: second call overwrites `completed/plan.md` without error. + +**Sufficiency**: the documented idempotency (existing behavior preserved through the re-signature). + +--- + +## Additional Instructions for the Implementation Agent + +- Read the practices before coding: `conventions`, `ralphex`, `agent-wrappers`, `checkpoints`, `registering-hooks` (goga/build), `per-tool-delivery`, `declaring-actions` (goga/hooks), `topic-paths`, `topic-statuses` (goga/history), `run-ralphex` (goga/ralphex), `resolve-wrapper-path` (goga/agents), `build-usage`, `project-configuration`. +- Implement in the dependency order of Entity Dependencies; keep every file's module docstring in the zone style of `goga/pipeline/hooks` (which zone it is, which entities live here). +- Google docstrings everywhere; CLI callback docstrings verbatim-help (no Args/Returns/Raises); relative intra-package imports only; `from __future__ import annotations` at the top of every touched module. +- Match the established code idioms: the gate walk mirrors `PipelineHooks.amend_workflow` (grouping, `wrap_context`, `build_hook_arguments`, `registry.self_context`) with the recorded deviation (collect, never stop); the emissions mirror `emit_run_created`; the merged veto error mirrors the existing `logger.error(..., extra={...})` style of `build.py`. +- Delete `goga/build/review_options.py` and `tests/build/test_review_options.py`; no compatibility shims anywhere (major-version window, C7). +- `.goga/config.yml` is already migrated — do not touch it. +- The env layers: `settings.tasks.env or None` / `settings.review.env or None` (an empty dict means pure inheritance, never an empty overlay). +- `StageFacts.env` is `sorted(env)` — names only, deterministic. +- Validation after implementation: `pytest tests/ -x`; `ruff check` over every touched package; facade checks (`from goga.build.hooks import ...` 13 names; `from goga.config import BuildConfig, ReviewConfig, AdditionalReviewConfig`); absence greps for `--worktree`, `--skip-finalize`, `worktree`, `skip_finalize`, `codex_review`, `task_executor`, `review_executor` across `goga/` (expected hits: only the retirement/migration documentation in manifests/usages, and the loader's ignore-everything stance); `goga lint` (79 cells, 0 errors); `goga schema` shows +`goga/build/hooks` with exactly 13 types and a single dependency on `goga/hooks`. +- Dogfooding acceptance: a real `goga build` run on this repository executes the two passes over the migrated config; `goga hooks` lists build subscriptions of any installed tool. +- SC1–SC10 map to tests as annotated in the Test Stack Trace (SC1 catalog/facade/`goga hooks`; SC2 two-pass; SC3 veto; SC4 notification failure/exit codes; SC5 completion facts + env names; SC6 dry-run; SC7 inert-no-tools; SC8 absence; SC9 the `registering-hooks` doc answers the three scenarios; SC10 no-caching is platform behavior — assert registration re-reads by a second run seeing an edited hook). diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md new file mode 100644 index 00000000..5073b83d --- /dev/null +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -0,0 +1,1555 @@ +# Plan: `add-hooks-to-build` + +Result of compiling the reviewed design document +(`.goga/history/2026/add-hooks-to-build/design.md`, post-review 1155 lines) into +ralphex-executable tasks. All traces, algorithms, and test scenarios below are +transferred verbatim from the design document — they are verified knowledge. + +--- + +## Purpose + +Implement the materialized contracts for the build domain hooks: the two-part +build configuration model (`build` root + `build.review`), the new hooks zone +`goga/build/hooks` (13 types: run-event facts, read-only contexts, the +verdict-collecting gate, and the `BuildHooks` checkpoint surface), the stable +always-two-pass build cycle with five checkpoints in `goga/build/build()`, the +ralphex launcher flag table with the external-review surface, the additive +`build` action catalog records, and the breaking removals +(`--worktree`/`--skip-finalize`, `TaskExecutorConfig`/`ReviewExecutorConfig`, +`review_options.py`) with no compatibility paths. + +After implementation the package provides: a structural two-part loader, +`resolve_run_settings` with CLI > config > default > omit precedence and +root→review→additional inheritance, `compose_pass_options` per stage, the gate +before the first pass, four soft notifications around the cycle, finalize +materialization, plan relocation returning `RelocationOutcome`, and the host +launcher forwarding only live keys. + +Overall strategy: leaf cells first, exactly the dependency order of the design +(`goga/config/project` → `goga/hooks/catalog` → `goga/ralphex` → +`goga/build/hooks` → `goga/build` → `goga/commands/build` → +`goga/onboarding/generator`), each task following the TDD workflow. The most +important gaps between contract and code are listed under Gap Analysis. + +## Context + +### Contract Surface + +#### Cell `goga/config/project` (files `config.py`, `loader.py`) + +**Entity: `ReviewConfig(skip, agent, env, roles, base_ref, strategy, finalize, additional, session_timeout, idle_timeout, wait)`** +- Type: class — frozen dataclass (`frozen=True, kw_only=True`) +- Declared `location`: `config.py` +- Facade obligation: importable from `goga.config` (embedding in `goga/config/CODEMANIFEST`) +- Properties: `skip -> bool | None`, `agent -> str | None`, `env -> dict[str, str]`, + `roles -> list[str] | None`, `base_ref -> str | None`, `strategy -> str | None`, + `finalize -> str | None`, `additional -> AdditionalReviewConfig | None`, + `session_timeout/idle_timeout/wait -> str | None` +- Semantic requirements: every field verbatim; unset is None (empty dict for env); + no normalization, no whitelist — structural typing only; "inherit from the root" + semantics belong to the consumer +- Annotation context: two-part stance in the header annotations (loader extracts + known fields only; retired keys silently ignored) + +**Entity: `AdditionalReviewConfig(agent, patience, max_iterations)`** +- Type: class — frozen dataclass (`frozen=True, kw_only=True`) +- Declared `location`: `config.py` +- Facade obligation: importable from `goga.config` +- Properties: `agent -> str | None` (None = consumer inherits `review.agent`), + `patience -> int | None` (0 = disabled), `max_iterations -> int | None` + (0 = ralphex auto) +- Semantic requirements: values verbatim; 0 is a meaningful value, not an unset marker + +**Entity: `BuildConfig(agent, env, max_iterations, session_timeout, idle_timeout, wait, prompts_dir, agents_dir, proxy, hosts, review)`** (changed) +- Type: class — frozen dataclass (`frozen=True, kw_only=True`) +- Declared `location`: `config.py` +- Facade obligation: importable from `goga.config` +- Properties: root tasks-pass fields + `review -> ReviewConfig | None` +- Semantic requirements: all fields may be None; `env`/`hosts` default empty dicts; + values verbatim, no inheritance applied here +- Deleted: fields `worktree`, `skip_finalize`, `codex_review`, `task_executor`, `review_executor` + +**Routine: `load_project_config()`** (changed, algorithm steps 6–7 rewritten) +- Declared `location`: `loader.py` +- Facade obligation: importable from `goga.config` +- Behavior: two-part extraction per the verbatim trace in Task 1 + +#### Cell `goga/config` (facade, file `__init__.py`) + +Re-export embeddings updated mechanically: `TaskExecutorConfig`/ +`ReviewExecutorConfig` dropped; `ReviewConfig`/`AdditionalReviewConfig` embedded +alongside the existing names (`ProjectConfig`, `load_project_config`, +`BuildConfig`, `PipelineConfig`, `CodemanifestConfig`, `DepConfig`, `LintConfig`, +`HomeConfig`, `DockerArgsConfig`, `load_home_config`, `resolve_project_name`, +`TopicsConfig`). + +#### Cell `goga/hooks/catalog` (file `catalog.py`) + +**Routine: `declared_actions() -> actions: list[Action]`** (changed, additive) +- Five new records: `Action(domain="build", name="validate_build", error_class="hard")`, + `Action("build", "build_started", "soft")`, `Action("build", "pass_started", "soft")`, + `Action("build", "pass_completed", "soft")`, `Action("build", "build_completed", "soft")` +- Existing records untouched; deterministic order (domain, then name) + +#### Cell `goga/ralphex` (file `run_ralphex.py`) + +**Routine: `run_ralphex(plan, options, dry_run, env=None) -> exit_code: int`** (changed) +- Bool flags `tasks_only` (`--tasks-only`), `review` (`--review`), `external_only` (`-e`) +- Scalar flags gain `review_patience` (`--review-patience`) and + `max_external_iterations` (`--max-external-iterations`); `base_ref` (`--base-ref`) stays +- `worktree`/`skip_finalize` removed from the table +- Zero-valued external-flags rule: `review_patience` 0 and + `max_external_iterations` 0 ARE passed; other scalar keys keep the historical + `not in (None, "", 0)` drop rule + +#### Cell `goga/build/hooks` (NEW — files `facts.py`, `contexts.py`, `events.py`, `__init__.py`) + +All zone types are `@dataclass(kw_only=True)`, non-frozen — mutability is closed +by the delivery proxy (`wrap_context`), following the `goga/pipeline/hooks` +precedent. + +**Facts (`facts.py`):** +- `WorkIdentity(branch, slug=None, year=None)` — hosting decision made in the + constructing operation; branch-only form has `slug`/`year` None +- `BuildMoment(plan, work, dry_run)` — the uniform envelope of every context +- `StageFacts(stage, agent, env, max_iterations, session_timeout, idle_timeout, wait, roles, base_ref, strategy, finalize, additional)` — + resolved facts of one stage; `env` carries NAMES only (values never appear); + review-only members None on the tasks part; pure facts +- `AdditionalFacts(agent, patience, max_iterations)` — delivered mirror of the + external-review block +- `RelocationOutcome(moved, destination)` — relocation attempt outcome +- `Violation(tool, hook, reason)` — one collected veto; reason never a raw traceback +- `GateVerdict(violations)` with `approved -> bool` property — data only + +**Contexts (`contexts.py`):** +- `BuildValidation(moment, tasks, review, skip)` with `veto(reason)` — the gate's + per-tool view; private `_veto: str | None = None` buffer; whole replacement; no + cancellation/deflection +- `BuildStarted(moment, tasks, review, skip)` — read-only +- `PassStarted(moment, facts)` — read-only +- `PassCompleted(moment, facts, exit_code)` — completion is a fact, not a success claim +- `BuildCompleted(moment, exit_code, stages, relocation, statuses)` — read-only; + the artifact → history-status integration builds from these facts alone + +**Checkpoint surface (`events.py`):** +- `BuildHooks()` with `validate_build(moment, tasks, review, skip) -> GateVerdict`, + `emit_build_started(moment, tasks, review, skip)`, + `emit_pass_started(moment, facts)`, + `emit_pass_completed(moment, facts, exit_code)`, + `emit_build_completed(moment, exit_code, stages, relocation, statuses)` +- Cheap construction; one lazily-built `HookRegistry` per run shared by all five + checkpoints; contexts built from caller values (no repository reads) + +**Facade (`__init__.py`):** re-exports all 13 types with `__all__` (alphabetical), +module docstring naming the zone (mirror `goga/pipeline/hooks/__init__.py`). + +#### Cell `goga/build` (files `run_settings.py` NEW, `pass_options.py` NEW, `review_config.py`, `ralphex_runtime.py`, `ralphex_config.py`, `build_pass.py`, `plan_relocation.py`, `build.py`, `__main__.py`; DELETE `review_options.py`) + +- `resolve_run_settings(config: BuildConfig, cli_options: dict) -> RunSettings` — + pure resolution (algorithm in Task 8) +- `RunSettings(skip, tasks, review)` — frozen, kw_only +- `PassSettings(agent, env, max_iterations, session_timeout, idle_timeout, wait)` — + frozen, kw_only +- `PassSettings::ReviewPassSettings(roles, base_ref, strategy, finalize, additional)` — + concretization of `PassSettings`; frozen, kw_only; inherited agent/session-knob + fields plus review-only members; `additional` is non-optional +- `compose_pass_options(settings, stage) -> dict[str, str | int | bool]` — pure +- `validate_review_config(settings: RunSettings)` — re-signatured from + `(config, review)`; fixed check order (algorithm in Task 10) +- `sync_ralphex_defaults(config: BuildConfig, settings: RunSettings)` — reads roles + and finalize from `RunSettings`; materializes `.ralphex/agents/finalize.txt` +- `write_ralphex_config(settings: RunSettings, wrapper_path: str)` — external-review + surface + `finalize_enabled` (algorithm in Task 12) +- `run_build_pass(plan, settings, options, wrapper_path, dry_run, env)` — carries + `RunSettings` instead of `BuildConfig` +- `move_completed_plan(plan, outcome, dry_run) -> RelocationOutcome` +- `build(plan, config, cli_options) -> exit_code` — the 12-step checkpoint cycle + (trace in Task 15) +- `main()` — argparse surface per the trace in Task 16 +- Deleted: `resolve_review_options`, `ReviewOptions` (file `review_options.py` — + delete the file) + +#### Cell `goga/commands/build` (file `build.py`, changed) + +- Flag surface without `--worktree`/`--skip-finalize` +- Step-2.2 guard repointed to `config.build.agent` with message + `"build.agent is required in .goga/config.yml to run 'goga build'"` +- Step 2.3 (two-pass × worktree guard) deleted +- Step 7 env assembly: `{**home.env, **git_env, **cli_env}` — the task env + (`config.build.env`) is NOT written into the env-file +- `--review-patience` help addresses `build.review.additional.patience`; + `--base-ref` help addresses `build.review.base_ref` + +#### Cell `goga/onboarding/generator` (file `generator.py`, code change only) + +- `FileGenerator.generate_goga_config` snapshot→YAML build mapping (code + change in the private `_build_config_document`/`_executor_block` helpers): + `data["build"] = build_block` (agent, env at the two-part root) instead of + `{"task_executor": build_block}`; `_executor_block` docstring reworded + (build root / pipeline content) + +#### Cell `goga/commands/config` (manifest-only, no code change) + +- `goga/commands/config/CODEMANIFEST` — dot-notation examples repointed to + live keys (`build.agent`, `build.review.strategy`); fixed and user-approved + during the design stage — already materialized in the workspace (code and + tests carry no stale references); no implementation task needed + +### Re-exports + +- `goga/config` facade: embeds `ReviewConfig` and `AdditionalReviewConfig` (new); + `TaskExecutorConfig`/`ReviewExecutorConfig` removed. Facade obligation: every + embedded name importable from `goga.config`. +- `goga/build/hooks` facade: re-exports its own 13 contract types through + `__all__` — `AdditionalFacts`, `BuildCompleted`, `BuildHooks`, `BuildMoment`, + `BuildStarted`, `BuildValidation`, `GateVerdict`, `PassCompleted`, `PassStarted`, + `RelocationOutcome`, `StageFacts`, `Violation`, `WorkIdentity`. +- `goga/build` facade: unchanged (`build` only — the manifest declares no new + embeddings; `RunSettings`/`PassSettings` are consumed via + `goga.build.run_settings` module imports per the design test). + +### Usages Context + +- `conventions` / `convention` (`.goga/usages/conventions.md`) — mandatory Python + rules: relative intra-package imports, `dataclasses(kw_only=True)`, Google + docstrings (CLI callbacks verbatim-help, no Args/Returns/Raises), stdlib + logging with `extra`, blank-line blocking, tests mirror source under `tests/`, + validation commands (`pytest tests/ -x`, `ruff check`, facade `python -c`). + Relevant to every task. +- `ralphex` (`.goga/usages/cooks/ralphex.md`, updated during grooming) — the + external ralphex binary contract: config keys + `claude_command`/`claude_args`/`codex_enabled`/`external_review_tool`/ + `custom_review_script`/`finalize_enabled`/`preserve_anthropic_api_key`/ + `move_plan_on_completion`, flags per the option table, `{{agent:X}}` + composition filtering, `agents/finalize.txt` materialization. Relevant to + Tasks 3, 9, 11, 12, 13. +- `agent-wrappers` (`.goga/usages/cooks/agent-as-claude-wrappers.md`) — + `/home/goga/bin/-as-claude.sh` naming for wrappers referenced by + absolute path. Relevant to Tasks 10, 12, 15. +- `click` (`.goga/usages/cooks/click.md`) — host command surface. Relevant to + Task 17. +- `yaml` (inline in `goga/config/project` and `goga/onboarding/generator` + manifests) — `yaml.safe_load()` / `yaml.dump(default_flow_style=False)`. + Relevant to Tasks 1, 18. + +### Imported Usages + +- `checkpoints` — from `goga/build/hooks`, source + `goga/build/hooks/.usages/checkpoints.md` — how the build operation consumes + the zone: one `BuildHooks` per run, facts resolved in the operation, gate + before the first pass, emissions around the cycle. Relevant to Tasks 7, 15, 19. +- `topic-paths`, `topic-statuses` — from `goga/history`, source + `goga/history/.usages/{topic-paths,topic-statuses}.md` — topic dir composition + (slug grammar, year default) and status listing (`collect_topic_statuses`, + `TopicRecord`). Relevant to Task 15. +- `resolve-wrapper-path` — from `goga/agents`, source + `goga/agents/.usages/resolve-wrapper-path.md`. Relevant to Tasks 10, 12, 15. +- `run-ralphex` — from `goga/ralphex`, source + `goga/ralphex/.usages/run-ralphex.md` (refreshed this stage: worktree removed, + always-two-pass, external flags, zero rule). Relevant to Tasks 3, 13, 15. +- `ensure-in-docker` — from `goga/docker`. Relevant to Task 16. +- `build-usage` — from `goga/build`, source `goga/build/.usages/build-usage.md` + (rewritten by apply-architecture: two-part settings, strategies, checkpoints, + cli_options list). Relevant to Tasks 15, 16, 17. +- `project-configuration`, `home-configuration` — from `goga/config`. Relevant to + Tasks 1, 17. +- `declaring-actions`, `per-tool-delivery`, `registering-hooks` — from + `goga/hooks`, sources `goga/hooks/.usages/*.md` — the emission contract + (`emit_hook_event` + `context_for`), the staged per-tool walk of the gate (with + the recorded refinement: run to completion, collect vetoes, no contribution + commit), and the hook signature (`context`/`self`) and failure handling behind + every checkpoint. Relevant to Tasks 4–7. +- `docker-builder`, `docker-runner`, `docker-image-version`, + `resolve-credential-mounts`, `runtime-paths`, `docker-auth-mounts` — from their + cells, unchanged consumer contracts touched only via the Task 17 deltas. + +### Local Usages + +All usage-file artifacts were created/rewritten by `apply-architecture` or +updated during the design stage — they are current. No creation tasks needed; +each task that consumes them verifies currency by reading them. + +- `goga/build/hooks/.usages/checkpoints.md` — consumer doc of the zone (status: + current; consumed in Tasks 7, 15, 19) +- `goga/build/.usages/registering-hooks.md` — tool-author doc answering the three + integration scenarios; its "missing agent returns before any checkpoint" claim + is kept true by the Task 15 step-3.5 guard (status: current) +- `goga/build/.usages/build-usage.md` — rewritten to the two-pass + checkpoints + contract (status: current) +- `goga/config/.usages/project-configuration.md` — build chapter rewritten to the + two-part form incl. migration note (status: current) +- `goga/commands/build/.usages/build.md` — flag surface without the two removed + flags; env layering note (status: current) +- `goga/ralphex/.usages/run-ralphex.md` — updated during the design stage + (status: current) + +### Entity Interaction and Data Flow (verbatim from the design) + +Interaction diagram — the authoritative shape of the whole feature; Tasks 15, +17, and 19 implement and verify against it: + +``` +goga/commands/build (host CLI, click) + │ guards: config.build present, config.build.agent set + │ env-file: home.env < git identity < CLI -e (+proxy) [NO build env] + │ docker run ... python -m goga.build + ▼ +goga/build build() ── in-container orchestrator ─────────────────────────────┐ + │ 0 git pre-check (uncommitted CODEMANIFEST → exit 1, no events) │ + │ 1 resolve_run_settings(config.build, cli_options) → RunSettings │ + │ 2 validate_review_config(settings) ──► resolve_wrapper_path (goga/agents)│ + │ 3 sync_ralphex_defaults(config.build, settings) [.ralphex/prompts|agents]│ + │ 4 facts: resolve_current_branch_name ── resolve_topic_dir ──► WorkIdentity│ + │ BuildMoment; StageFacts(tasks) + StageFacts(review) │ + │ 5 BuildHooks.validate_build(...) ──► GateVerdict │ + │ │ not approved → merged error, exit 1, nothing else fires │ + │ 6 BuildHooks.emit_build_started(...) │ + │ 7 tasks pass: compose_pass_options(settings,"tasks") │ + │ emit_pass_started → run_build_pass → emit_pass_completed │ + │ 8 review pass (tasks OK and not skip): compose_pass_options(,"review") │ + │ wrapper = additional agent (short) else review agent │ + │ emit_pass_started → run_build_pass → emit_pass_completed │ + │ 9 move_completed_plan(...) ──► RelocationOutcome │ + │ 10 collect_topic_statuses(year) → statuses of work.slug │ + │ 11 BuildHooks.emit_build_completed(moment, exit_code, stages, │ + │ relocation, statuses) │ + └ 12 return exit code of the last executed pass │ + │ +run_build_pass ──► write_ralphex_config(settings, wrapper) [.ralphex/config] │ + └► run_ralphex(plan, options, dry_run, env) (goga/ralphex) │ + └► subprocess: ralphex --config-dir .ralphex/ │ + │ +goga/build/hooks BuildHooks │ + ├ validate_build: staged per-tool walk over HookRegistry subscriptions │ + │ (wrap_context + build_hook_arguments + registry.self_context), │ + │ veto buffer per tool, Violation collection → GateVerdict │ + └ emit_*: emit_hook_event(registry, "build", , context_for) │ + all five addresses resolve via declared_actions() (goga/hooks/catalog) │ + │ +goga/config load_project_config → ProjectConfig(build=BuildConfig( │ + review=ReviewConfig(additional=AdditionalReviewConfig))) │ +``` + +Data flows: + +- **Flow A — configuration (once per run, in-container):** + `load_project_config()` reads `.goga/config.yml` (the mounted `/workspace`) + → structural two-part extraction → `BuildConfig` (root fields verbatim, + `review: ReviewConfig | None`, `additional: AdditionalReviewConfig | None`) + → `resolve_run_settings(config.build, cli_options)` applies CLI > config > + default > omit and root→review→additional inheritance → frozen `RunSettings`. +- **Flow B — the gate (before any pass):** + `build()` resolves `WorkIdentity` (git subprocess once, then + `resolve_topic_dir` composition — no reads at the checkpoint) and both + `StageFacts` (env presence as sorted names) → `BuildHooks.validate_build` + walks the subscriptions of `build/validate_build` per tool → each tool gets a + fresh `BuildValidation` view wrapped read-only → vetoes buffer per tool → + `GateVerdict(violations)` returns to `build()` → not approved → one merged + `logger.error` (tool, hook, reason per violation) → exit 1. +- **Flow C — a pass (twice per non-skipped run):** + `compose_pass_options` (pure) → pass options dict → + `run_build_pass(plan, settings, options, wrapper, dry_run, env)` → + `write_ralphex_config` rewrites `.ralphex/config` (whole file, never merged) + → `run_ralphex` maps options to flags and launches (or prints on dry-run) → + exit code propagates unchanged → `emit_pass_completed` carries the actual + code. +- **Flow D — completion (every return path of a started run):** + `move_completed_plan` → `RelocationOutcome` → `collect_topic_statuses(year)` + re-read AFTER the relocation attempt → statuses list (empty in the + branch-only form) → `emit_build_completed(moment, exit_code, stages, + relocation, statuses)` → soft emission; the exit code is already final. + +Runtime initialization order inside one build run: config → settings → +validation → defaults sync → facts → `BuildHooks()` (cheap; the single +`HookRegistry` builds lazily on the first checkpoint and is shared by all +five) → passes → relocation → statuses → completion. + +### External Dependencies + +- The external `ralphex` binary — PATH-resolved, invoked only through + `run_ralphex`; exit codes propagated; CLI flags per the `ralphex` practice table +- PyYAML (`yaml.safe_load` / `yaml.dump`) — stdlib-adjacent third-party, already + in `pyproject.toml` +- click (host command), pytest + ruff + pytest-cov (test tooling), docker + git + subprocesses (host launcher and manifest pre-check / branch resolution) +- The `goga/hooks` platform facade: `HookRegistry`, `wrap_context`, + `build_hook_arguments`, `emit_hook_event`, `declared_actions` — stable, no + changes in this plan + +## Facts + +- Python 3.10+; `pyproject.toml`; all commands run in the existing `.venv` + virtualenv +- The workspace branch is `add-hooks-to-build`; `goga lint` after the design + stage: **79 cells, 0 errors**; `goga schema` shows `goga/build/hooks` = 13 + types with a single dependency on `goga/hooks` +- `.goga/config.yml` of this repository is already migrated to the two-part form + (dogfooding, landed by `apply-architecture`) — **do not touch it** +- The currently checked-in loader still enforces the retired model + (`loader.py:598` raises `KeyError("build.task_executor is required in + .goga/config.yml")`) — this is why any `goga` CLI subcommand that loads config + currently fails against the migrated `.goga/config.yml` (e.g. + `goga config language`); Task 1 closes this gap +- `goga/pipeline/hooks` is the structural precedent for the zone: incremental + facade building ("each entity task added its module's import and `__all__` + entry"), zone-style module docstrings, non-frozen zone dataclasses with + mutability closed by `wrap_context` +- `goga/hooks/__init__.py` exports `HookRegistry`, `ToolHooks`, + `build_hook_arguments`, `declared_actions`, `emit_hook_event`, + `enumerate_tool_packages`, `wrap_context` +- `HookRegistry.build_once` is idempotent — one registry per run is built lazily + on the first checkpoint and shared by all five; a broken tool-package import + surfaces from `build_once` as `ImportError` (the platform's single fatal case) +- `tests/conftest.py` provides the `is_kw_only_dataclass` helper; + `tests/hooks/conftest.py` provides `pin_package_environment` and + `install_tool_package` (the platform boundary fixtures) +- The gate's bound-method write channel: `wrap_context` blocks attribute writes + on the proxy but `veto()` mutates the target's buffer through the bound method — + the same mechanism as `WorkflowAmendment._contribution` +- `goga/build/review_options.py` and `tests/build/test_review_options.py` exist + and are deleted by this plan; no compatibility shims anywhere (major-version + window) +- Retired CLI/config surface (`--worktree`, `--skip-finalize`, `worktree`, + `skip_finalize`, `codex_review`, `task_executor`, `review_executor`) must be + absent from `goga/` after implementation — expected remaining hits are only + retirement/migration documentation in manifests/usages and the loader's + ignore-everything stance +- The env layers rule: `settings.tasks.env or None` / `settings.review.env or None` + (an empty dict means pure inheritance, never an empty overlay); + `StageFacts.env` is `sorted(env)` — names only, deterministic +- SC1–SC10 of `task.md` map to tests as annotated in the design (SC1 catalog/ + facade/`goga hooks`; SC2 two-pass; SC3 veto; SC4 notification failure/exit + codes; SC5 completion facts + env names; SC6 dry-run; SC7 inert-no-tools; SC8 + absence; SC9 the `registering-hooks` doc; SC10 registration re-read) + +## Gap Analysis + +Comparing the contract with the current workspace state: + +- **Missing contract entities**: + - `goga/build/hooks/` contains only `CODEMANIFEST` + `.usages/` — no + `facts.py`, `contexts.py`, `events.py`, `__init__.py` + - `goga/build/run_settings.py`, `goga/build/pass_options.py` do not exist + - `ReviewConfig`, `AdditionalReviewConfig` do not exist + - `tests/build/hooks/` does not exist; `tests/build/test_run_settings.py`, + `tests/build/test_pass_options.py` do not exist +- **Stale model (must be rewritten)**: + - `goga/config/project/config.py:5,57,85–98` — `TaskExecutorConfig`, + `ReviewExecutorConfig`, old `BuildConfig(task_executor=..., review_executor=...)` + - `goga/config/project/loader.py:46,464,507,587–604` — `_parse_task_executor`, + `_parse_review_scoped_fields`, `_parse_review_executor`, old `_parse_build`; + line 598 raises the retired `KeyError` + - `goga/config/__init__.py:11–12,26–27` re-exports the retired names +- **Stale launcher**: `goga/ralphex/run_ralphex.py:12–14` — `_BOOL_FLAGS` + carries `worktree`/`skip_finalize`; no `external_only`/`-e` bool flag and no + `max_external_iterations` scalar (`review_patience`/`base_ref` already exist + in `_SCALAR_FLAGS`); no zero-valued external rule +- **Stale orchestrator**: `goga/build/build.py:61,101,128` — `_resolve_options`, + `_review_scoped_options`, the old single/dual-pass `build()`; no checkpoints, + no facts, no relocation outcome +- **Stale CLI**: `goga/build/__main__.py:22–23,38–39` — `--worktree`/ + `--skip-finalize` flags and their `cli_options` keys +- **Stale host command**: `goga/commands/build/build.py:118–120,215,265–266` + (`--worktree`/`--skip-finalize` options and `_build_cli_args` branches), + `:317–342` (guard on `config.build.task_executor.agent`; step 2.3 two-pass × + worktree guard), and the task env written into the env-file +- **Stale generator**: `goga/onboarding/generator/generator.py:59–60,159–163` — + emits `{"task_executor": build_block}` +- **Missing catalog records**: `goga/hooks/catalog/catalog.py` has no `build` + domain records +- **Test coverage gaps**: every suite listed in the design's Source File Registry + and the "Affected tests outside the main registry" addendum is stale against + the retired schema (`tests/build/test_review_options.py` to delete; + `tests/build/test_build_resolved_wrapper.py`, `tests/build/test_shipped_ralphex_assets.py`, + `tests/config/test_integration.py`, `tests/commands/conftest.py`, + `tests/commands/test_build.py`, three `tests/integration/test_*` files, four + `tests/commands/build/test_build_*_integration.py` to rewrite/update) +- **Existing code that can be reused**: the git pre-check helpers in + `goga/build/build.py` (`_unquote_git_path`, `_parse_porcelain_path`, + `_find_uncommitted_manifests`), the `sync_ralphex_defaults` rewrite/roles + filtering logic, `move_completed_plan`'s `Path.replace` core, the + `run_ralphex` PATH-check/subprocess/dry-run skeleton, the host launcher's + steps 1–19 minus the three deltas, and the platform fixtures in + `tests/hooks/conftest.py` +- **Missing visibility**: none — all cells are tracked in git on this branch + +--- + +## Tasks + +> **Package ordering rule**: coding tasks for each package are completed before +> starting the next. Within each coding task, contract tests are written first +> (TDD workflow). Only ONE task is executed per ralphex iteration. The order +> below follows the design's Entity Dependencies (leaves first; no cycles). +> +> **Instructions for the implementation agent** (from the design's Additional +> Instructions): read the practices before coding — `conventions`, `ralphex`, +> `agent-wrappers`, `checkpoints`, `registering-hooks` (goga/build), +> `per-tool-delivery`, `declaring-actions` (goga/hooks), `topic-paths`, +> `topic-statuses` (goga/history), `run-ralphex` (goga/ralphex), +> `resolve-wrapper-path` (goga/agents), `build-usage`, +> `project-configuration`. Keep every file's module docstring in the zone style +> of `goga/pipeline/hooks` (which zone it is, which entities live here). Google +> docstrings everywhere; CLI callback docstrings verbatim-help (no +> Args/Returns/Raises); relative intra-package imports only; +> `from __future__ import annotations` at the top of every touched module. +> Match the established code idioms: the gate walk mirrors +> `PipelineHooks.amend_workflow` with the recorded deviation (collect, never +> stop); the emissions mirror `emit_run_created`; the merged veto error mirrors +> the existing `logger.error(..., extra={...})` style of `build.py`. No +> compatibility shims anywhere (major-version window). `.goga/config.yml` is +> already migrated — do not touch it. + +### Task 1: Two-part build configuration model, loader, and facade re-exports (TDD coding) + +Rewrites `goga/config/project` to the two-part build model and updates the +`goga/config` facade in the same task (the facade change is mechanical and must +land together with the model — deleting `TaskExecutorConfig` while the facade +still imports it would break every consumer). Covers contract entities +`BuildConfig` (reshaped), `ReviewConfig`, `AdditionalReviewConfig` (new), +`load_project_config` (steps 6–7 rewritten) in `goga/config/project/CODEMANIFEST`, +and the embedding list of `goga/config/CODEMANIFEST`. Locations: +`goga/config/project/config.py`, `goga/config/project/loader.py`, +`goga/config/__init__.py`. + +Structural validation only — semantic validation (roles whitelist, +env-requires-agent, strategy whitelist, patience range) belongs to the consumer +(Task 10). The loader extracts known fields only; unknown keys (incl. stale +`worktree`, `task_executor`, `codex_review`) are silently ignored. + +**Usages relevant to this task:** +- `convention` (`goga/config/project`): frozen kw_only dataclasses, stdlib + dataclasses (NOT pydantic), relative imports, Google docstrings +- `yaml` (inline): `yaml.safe_load()` via PyYAML +- `project-configuration` (`goga/config/.usages/project-configuration.md`): the + two-part build chapter incl. the migration note — verify the loader matches it + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +Verified design trace — implement exactly: + +``` +1. Input: .goga/config.yml at the project root (cwd), read + yaml.safe_load. +2. Step: top mapping guard (unchanged), lang/image/dockerfile, pipeline, then + build block: absent → build=None; non-mapping → ValueError. +3. Step: root extraction — agent (empty/whitespace→None, non-str→ValueError), + env (str mapping, default {}), max_iterations (int; bool→ValueError), + session_timeout/idle_timeout/wait (agent pattern), prompts_dir/agents_dir + (optional str), proxy (optional str), hosts (str mapping, default {}); + unknown keys (incl. stale worktree, task_executor, codex_review) silently + ignored — verbatim extraction, no default merge. +4. Step: build.review sub-mapping: absent/null→None; non-mapping→ValueError; + skip (bool|None), agent (pattern), env (pattern, default {}), + roles (list[str]|None, empty passes verbatim), base_ref (agent pattern), + strategy (empty/whitespace→None, non-str→ValueError, no whitelist), + finalize (agent pattern — stored verbatim), additional (mapping: agent + pattern, patience int with bool→ValueError, max_iterations same). +5. Output: ProjectConfig(build=BuildConfig(..., review=ReviewConfig(..., + additional=AdditionalReviewConfig(...)))); codemanifest/lint/topics/tools/ + usages blocks extracted exactly as today. +``` + +Model rules: delete `TaskExecutorConfig`/`ReviewExecutorConfig`; +`BuildConfig` per the new signature (all fields kw_only, `env`/`hosts` default +empty dicts, everything else None-able); `ReviewConfig` and +`AdditionalReviewConfig` frozen kw_only. Loader: rewrite `_parse_build`; +delete `_parse_task_executor` (loader.py:46), `_parse_review_scoped_fields` +(loader.py:464), `_parse_review_executor` (loader.py:507); the +`KeyError("build.task_executor is required")` at loader.py:598 disappears +(`build.agent` is optional). Error messages use the new key names +(`build.agent must be a string…`, `build.review.strategy must be a string…`). +`goga/config/__init__.py`: add `ReviewConfig`, `AdditionalReviewConfig` to +imports and `__all__`; drop `TaskExecutorConfig`, `ReviewExecutorConfig`. + +- [ ] **Declaration**: Task 1 — two-part build configuration model, loader, and facade re-exports +- [ ] **Contract tests**: in `tests/config/test_config.py` — `ReviewConfig` and `AdditionalReviewConfig` importable from `goga.config` and `goga.config.project`; both pass `is_kw_only_dataclass` (fixture from `tests/conftest.py`) and are frozen; `BuildConfig` exposes exactly the new field set (`review` present; `task_executor`/`worktree`/`review_executor` absent); in `tests/config/test_loader.py` — `load_project_config` still importable from `goga.config` (expected to fail at this stage) +- [ ] **Code**: rewrite `goga/config/project/config.py` — delete `TaskExecutorConfig`/`ReviewExecutorConfig`, reshape `BuildConfig`, add `ReviewConfig`/`AdditionalReviewConfig` (frozen, kw_only, Google docstrings, `from __future__ import annotations`) +- [ ] **Code**: rewrite `goga/config/project/loader.py` — `_parse_build` two-part extraction per the trace; delete the three retired parse helpers; new-key error messages; unknown keys ignored +- [ ] **Code**: update `goga/config/__init__.py` — embed `ReviewConfig`/`AdditionalReviewConfig`, drop the retired names +- [ ] **Interface verification**: `pytest tests/config/test_config.py tests/config/test_loader.py -x -q` — contract tests pass +- [ ] **Logic tests**: in `tests/config/test_loader.py` — `test_load_project_config_parses_two_part_build` (setup: tmp `.goga/config.yml` with `language: python`, `build: {agent: claude, env: {A: "1"}, max_iterations: 7, session_timeout: 30m, review: {agent: codex, env: {B: "2"}, roles: [quality], base_ref: main, strategy: short, finalize: "do it", additional: {agent: cursor, patience: 2, max_iterations: 4}}}`; assert `config.build.agent == "claude"`, `config.build.env == {"A": "1"}`, `config.build.review.agent == "codex"`, `config.build.review.additional.patience == 2`, `not hasattr(config.build, "task_executor")`, `not hasattr(config.build, "worktree")`); `test_load_project_config_ignores_retired_keys` (config carrying `build: {worktree: true, skip_finalize: true, codex_review: false, task_executor: {agent: claude}, review_executor: {agent: codex}, agent: claude}` → no error; `config.build.agent == "claude"`; `config.build.review is None`); `test_load_project_config_rejects_malformed_review` (parametrize: `review: "x"`, `review: {skip: "yes"}`, `review: {roles: [1]}`, `review: {strategy: 5}`, `review: {additional: {patience: true}}`, `build: {max_iterations: true}`, `build: {agent: 7}` — each raises `ValueError` naming the key) +- [ ] **Code**: rewrite `tests/config/test_integration.py` onto the two-part model (root fields plus `build.review`; drop or repoint the `worktree`/`skip_finalize`/`codex_review`/`task_executor`/`review_executor` assertions to the retired-key silence semantics already covered by the loader tests) +- [ ] **Debugging**: `pytest tests/config/ -x -q` — fix implementation code until all tests pass (do NOT fix test code) +- [ ] **Contract re-verification**: facade check `python -c "from goga.config import BuildConfig, ReviewConfig, AdditionalReviewConfig"` resolves; `python -c "from goga.config import TaskExecutorConfig"` raises `ImportError` +- [ ] **Lint**: `ruff check goga/config tests/config` — fix formatting if necessary +- [ ] **Completion**: mark all checkboxes of this task complete + +### Task 2: Five build action catalog records (TDD coding) + +Adds the five additive `build` records to the action catalog. Covers +`declared_actions()` in `goga/hooks/catalog/CODEMANIFEST` (location +`catalog.py`). The catalog is data only — maintained, not discovered; published +records are never rewritten; `declared_actions` sorts by domain then name. +Location: `goga/hooks/catalog/catalog.py`. + +**Usages relevant to this task:** +- `convention`: docstring style, relative imports + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +Append exactly (list order in the source is irrelevant — `declared_actions` +sorts by domain then name): + +``` +Action(domain="build", name="validate_build", error_class="hard"), +Action("build", "build_started", "soft"), +Action("build", "pass_started", "soft"), +Action("build", "pass_completed", "soft"), +Action("build", "build_completed", "soft") +``` + +Existing records (onboarding 2, pipeline 3, statuses 1, topics 7) stay +byte-identical. + +- [ ] **Declaration**: Task 2 — five build action catalog records +- [ ] **Contract tests**: in `tests/hooks/catalog/test_catalog.py` — `declared_actions()` returns the five `(domain="build", name=…)` records with error classes hard/soft×4 (expected to fail at this stage) +- [ ] **Code**: append the five records to the catalog list in `goga/hooks/catalog/catalog.py` +- [ ] **Interface verification**: `pytest tests/hooks/catalog/test_catalog.py -x -q` — contract tests pass +- [ ] **Logic tests**: `test_catalog_carries_the_five_build_records` — the five build records exist with error classes `validate_build` hard and the four notifications soft; pre-existing records byte-identical (compare against a frozen expected list); ordering deterministic (domain, then name) +- [ ] **Debugging**: `pytest tests/hooks/ -x -q` — fix implementation code until all tests pass +- [ ] **Contract re-verification**: `python -c "from goga.hooks import declared_actions; assert sum(1 for a in declared_actions() if a.domain == 'build') == 5"` +- [ ] **Lint**: `ruff check goga/hooks tests/hooks` — fix formatting if necessary +- [ ] **Completion**: mark all checkboxes of this task complete + +### Task 3: Ralphex launcher flag table with external-review flags (TDD coding) + +Updates the launcher's option→flag table. Covers `run_ralphex` in +`goga/ralphex/CODEMANIFEST` (location `run_ralphex.py`). This cell is a thin +launcher — no config generation, no option resolution, no wrapper resolution; +PATH check, subprocess, dry-run print, exit-code propagation stay as today. +Location: `goga/ralphex/run_ralphex.py`, tests `tests/ralphex/test_run_ralphex.py`. + +**Usages relevant to this task:** +- `conventions`: docstrings, logging, test layout +- `ralphex` (`.goga/usages/cooks/ralphex.md`): the flag contract incl. the + zero-valued external-flags rule +- `run-ralphex` (`goga/ralphex/.usages/run-ralphex.md`): refreshed this stage — + worktree removed, always-two-pass wording, `external_only` (`-e`) and + `max_external_iterations` documented, zero rule documented; the implementation + must match it + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +New tables: + +``` +_BOOL_FLAGS = (("review", "--review"), ("tasks_only", "--tasks-only"), ("external_only", "-e")) +_SCALAR_FLAGS = (…, ("review_patience", "--review-patience"), + ("max_external_iterations", "--max-external-iterations"), + ("base_ref", "--base-ref")) +``` + +Scalar emission rule: `value is not None and value != ""` — with the +non-external keys additionally dropping 0 (`max_iterations`, `session_timeout`, +`idle_timeout`, `wait`, `base_ref`), i.e. keep the historical +`not in (None, "", 0)` for those and use the wider rule for exactly +`review_patience`/`max_external_iterations`. `worktree`/`skip_finalize` no +longer exist in the table. + +- [ ] **Declaration**: Task 3 — ralphex launcher flag table +- [ ] **Contract tests**: in `tests/ralphex/test_run_ralphex.py` — bool mapping `review`→`--review`, `tasks_only`→`--tasks-only`, `external_only`→`-e` (True emits, False/absent omits); scalar mapping for the seven scalar keys; `worktree`/`skip_finalize` absent from any emitted command (expected to fail at this stage) +- [ ] **Code**: replace `_BOOL_FLAGS` (run_ralphex.py:12–14) and extend `_SCALAR_FLAGS` (run_ralphex.py:18) per the tables above; implement the asymmetric zero rule +- [ ] **Interface verification**: `pytest tests/ralphex/test_run_ralphex.py -x -q` — contract tests pass +- [ ] **Logic tests**: `test_run_ralphex_external_flags_and_zero_rule` (patch `subprocess.call` recording argv; input `run_ralphex("p.md", {"external_only": True, "review_patience": 0, "max_external_iterations": 0, "max_iterations": 0, "base_ref": "main"}, False)`; assert + `argv == ["ralphex", "p.md", "--config-dir", ".ralphex/", "-e", "--review-patience", "0", "--max-external-iterations", "0", "--base-ref", "main"]` + and `--worktree`/`--skip-finalize` never appear for any input); `test_max_iterations_zero_dropped_by_launcher` (`run_ralphex("p.md", {"tasks_only": True, "max_iterations": 0}, dry_run=True)` capture stderr — printed command has no `--max-iterations`; contrast `review_patience: 0` prints `--review-patience 0`) +- [ ] **Debugging**: `pytest tests/ralphex/ -x -q` — fix implementation code until all tests pass +- [ ] **Contract re-verification**: dry-run still prints `shlex.join(cmd)` to stderr and never the env layer; PATH-missing → clean one-line stderr + exit 1 +- [ ] **Lint**: `ruff check goga/ralphex tests/ralphex` — fix formatting if necessary +- [ ] **Completion**: mark all checkboxes of this task complete + +### Task 4: Build hooks zone skeleton (infrastructure) + +Creates the package structure of the new zone `goga/build/hooks` and its test +scaffolding. No contract entities land yet — this task creates the package +`__init__.py` with the zone-style module docstring (mirroring +`goga/pipeline/hooks/__init__.py`) and empty `__all__` (each later entity task +adds its module's import and `__all__` entry, the pipeline precedent), plus the +test package with the fixture re-export. Locations: +`goga/build/hooks/__init__.py`, `tests/build/hooks/__init__.py`, +`tests/build/hooks/conftest.py`. + +**Usages relevant to this task:** +- `convention`: relative imports, package structure +- `registering-hooks`, `declaring-actions`, `per-tool-delivery` (imported from + `goga/hooks/.usages/`): read before writing the zone docstring — the docstring + names the zone's five moments and the hard gate with the domain-local + never-stop deviation + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] Create `goga/build/hooks/__init__.py` — module docstring naming the zone (the fact vocabulary, the gate, the checkpoint surface over the platform facade; the never-stop deviation), `from __future__ import annotations`, empty `__all__: list[str]` +- [ ] Create `tests/build/hooks/__init__.py` (empty, per the each-test-directory-has-`__init__.py` rule) +- [ ] Create `tests/build/hooks/conftest.py` re-exporting the platform boundary fixtures from `tests/hooks/conftest.py` (`pin_package_environment`, `install_tool_package`) — mirror `tests/pipeline/hooks/conftest.py` +- [ ] Verify importability: `python -c "import goga.build.hooks"` (from the repo root, in `.venv`) +- [ ] Lint: `ruff check goga/build tests/build` — fix formatting if necessary + +### Task 5: Zone fact vocabulary — `facts.py` (TDD coding) + +Implements the seven fact dataclasses. Covers `WorkIdentity`, `BuildMoment`, +`StageFacts`, `AdditionalFacts`, `RelocationOutcome`, `Violation`, `GateVerdict` +in `goga/build/hooks/CODEMANIFEST` (all `location: facts.py`). All are +`@dataclass(kw_only=True)`, NON-frozen — mutability is closed by the delivery +proxy (`wrap_context`), following the `goga/pipeline/hooks` precedent. Pure +facts: the constructing operation passes resolved values with inheritance +already applied; nothing is read or derived here. Locations: +`goga/build/hooks/facts.py`, `tests/build/hooks/test_facts.py`; extends the +facade. + +**Usages relevant to this task:** +- `convention` (`goga/build/hooks`): kw_only dataclasses, relative imports, + Google docstrings, blank-line blocking +- `registering-hooks` (from `goga/hooks`): the hook signature that consumes + these facts — shapes the property docs + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +Signatures (from the manifest, verbatim): + +``` +WorkIdentity(branch: str, slug: str | None = None, year: str | None = None) +BuildMoment(plan: str, work: WorkIdentity, dry_run: bool) +StageFacts(stage: str, agent: str | None, env: list[str], max_iterations: int | None, + session_timeout: str | None, idle_timeout: str | None, wait: str | None, + roles: list[str] | None, base_ref: str | None, strategy: str | None, + finalize: str | None, additional: AdditionalFacts | None) +AdditionalFacts(agent: str | None, patience: int | None, max_iterations: int | None) +RelocationOutcome(moved: bool, destination: str | None) +Violation(tool: str, hook: str, reason: str) +GateVerdict(violations: list[Violation]) # property approved -> bool +``` + +- [ ] **Declaration**: Task 5 — zone fact vocabulary +- [ ] **Contract tests**: in `tests/build/hooks/test_facts.py` — each of the seven names importable from `goga.build.hooks`; each passes `is_kw_only_dataclass`; `GateVerdict.approved` exists as a property (expected to fail at this stage) +- [ ] **Code**: create `goga/build/hooks/facts.py` with the seven dataclasses (kw_only, non-frozen, zone-style module docstring, Google docstrings carrying the manifest property descriptions) +- [ ] **Code**: add the seven imports + `__all__` entries to `goga/build/hooks/__init__.py` +- [ ] **Interface verification**: `pytest tests/build/hooks/test_facts.py -x -q` — contract tests pass +- [ ] **Logic tests**: `GateVerdict([]).approved is True`; `GateVerdict([Violation("t", "h", "r")]).approved is False`; `WorkIdentity("feature-x")` branch-only form gives `slug is None and year is None`; `StageFacts` review-only members accept None on the tasks part; `AdditionalFacts` stores 0 verbatim (`patience=0` stays 0) +- [ ] **Debugging**: `pytest tests/build/hooks/ -x -q` — fix implementation code until all tests pass +- [ ] **Contract re-verification**: the seven names importable from the facade `goga.build.hooks` +- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [ ] **Completion**: mark all checkboxes of this task complete + +### Task 6: Zone read-only contexts — `contexts.py` (TDD coding) + +Implements the five context dataclasses. Covers `BuildValidation` (with +`veto`), `BuildStarted`, `PassStarted`, `PassCompleted`, `BuildCompleted` in +`goga/build/hooks/CODEMANIFEST` (all `location: contexts.py`). Same dataclass +stance as Task 5 (kw_only, non-frozen). `BuildValidation` additionally carries +the private per-tool veto buffer `_veto: str | None = None` (not part of the +contract signature — an internal `init=False` field). Locations: +`goga/build/hooks/contexts.py`, `tests/build/hooks/test_contexts.py`; extends +the facade. + +**Usages relevant to this task:** +- `convention`: data-model rules +- `registering-hooks` (from `goga/hooks`): the hook signature that receives + `BuildValidation` + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +`veto(reason)` semantics (manifest, verbatim): the call buffers into the buffer +of this tool alone and changes nothing until the walk collects it; the +replacement is whole — a later call replaces the earlier reason; the view +records no hook identity (the walk attributes by observing the buffer change); +an empty or whitespace-only reason is stored as given. Constraints: no +cancellation, redirect, or deferral — a veto stops the run through the +collected verdict only. + +- [ ] **Declaration**: Task 6 — zone read-only contexts +- [ ] **Contract tests**: in `tests/build/hooks/test_contexts.py` — the five names importable from `goga.build.hooks`; kw_only; `BuildValidation.veto` callable (expected to fail at this stage) +- [ ] **Code**: create `goga/build/hooks/contexts.py` — the five contexts + `veto()` writing `self._veto` (whole replacement) with the private `init=False` buffer field +- [ ] **Code**: add the five imports + `__all__` entries to the facade +- [ ] **Interface verification**: `pytest tests/build/hooks/test_contexts.py -x -q` — contract tests pass +- [ ] **Logic tests**: `veto("one")` then `veto("two")` → buffer holds exactly `"two"` (whole replacement); `veto(" ")` stores the whitespace verbatim; read-only fields (`moment`, `tasks`, `review`, `skip`, `facts`, `exit_code`, `stages`, `relocation`, `statuses`) carry the constructed values unchanged +- [ ] **Debugging**: `pytest tests/build/hooks/ -x -q` — fix implementation code until all tests pass +- [ ] **Contract re-verification**: the twelve fact+context names importable from the facade +- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [ ] **Completion**: mark all checkboxes of this task complete + +### Task 7: Checkpoint surface `BuildHooks` — `events.py` + facade completion (TDD coding) + +Implements the gate and the four emissions — the hardest entity of the zone. +Covers `BuildHooks()` in `goga/build/hooks/CODEMANIFEST` (location +`events.py`): `validate_build`, `emit_build_started`, `emit_pass_started`, +`emit_pass_completed`, `emit_build_completed`. Finalizes the facade (13 names, +`__all__` alphabetical). The gate walk mirrors `PipelineHooks.amend_workflow` +(grouping, `wrap_context`, `build_hook_arguments`, `registry.self_context`) +with the recorded deviation: collect, never stop. The emissions mirror +`emit_run_created`. Locations: `goga/build/hooks/events.py`, +`goga/build/hooks/__init__.py`, `tests/build/hooks/test_events.py`. + +**Usages relevant to this task:** +- `per-tool-delivery` (from `goga/hooks/.usages/per-tool-delivery.md`): the + staged walk — loop skeleton, primitives, per-tool grouping — with the recorded + refinement: run to completion, collect vetoes, no contribution commit +- `declaring-actions` (from `goga/hooks/.usages/declaring-actions.md`): the + emission contract — `emit_hook_event(registry, domain, action, context_for)` +- `registering-hooks` (from `goga/hooks/.usages/registering-hooks.md`): the hook + signature (`context`/`self`) and failure handling behind every checkpoint +- `checkpoints` (`goga/build/hooks/.usages/checkpoints.md`): the consumer-side + integration order this surface serves + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +Verified design trace — the gate: + +``` +1. Input: moment: BuildMoment, tasks/review: StageFacts, skip: bool — values the + operation already resolved. +2. Step: self._ensure_registry() — HookRegistry() + build_once() on first + checkpoint; reused by every later checkpoint of the same BuildHooks instance. +3. Step: resolve domain="build", action="validate_build" against + declared_actions() → unknown address → ValueError (defensive; the record + exists by catalog — Task 2). +4. Step: group registry.subscriptions_for("build", "validate_build") per tool + preserving enumeration order; per tool build a fresh + BuildValidation(moment, tasks, review, skip) (private _veto buffer), wrap via + wrap_context, call each hook with build_hook_arguments(hook, proxy, + registry.self_context(tool)); snapshot view._veto before each call — a change + after the call attributes the veto to that subscription's name (a later veto + replaces the earlier attribution). +5. Step: a raising hook → record (tool, subscription.name, str(reason)) as the + tool's single crash violation and STOP that tool's remaining hooks (exactly + one Violation per tool); the walk continues with the next tool. +6. Step: a tool whose every hook returned and whose buffer is not None → + Violation(tool, attributed_hook, buffered_reason); buffer None → silent + approval. +7. Output: GateVerdict(violations) — approved is not violations; empty when the + address has no subscriptions (inert with no tool packages). +``` + +Algorithm listing (gate): + +``` +1. registry = _ensure_registry() # once per run +2. record = find(declared_actions(), domain="build", name="validate_build") + IF record is None: raise ValueError("unknown hook action: build.validate_build") +3. groups = {} ; for sub in registry.subscriptions_for("build","validate_build"): + groups.setdefault(sub.tool, []).append(sub) +4. violations = [] +5. FOR (tool, subs) in groups (enumeration order): + a. view = BuildValidation(moment, tasks, review, skip) # fresh buffer per tool + b. attributed_hook, attributed_reason = None, None ; crash = None + c. FOR sub in subs: + - before = view._veto + - TRY sub.hook(**build_hook_arguments(sub.hook, + wrap_context(view), registry.self_context(tool))) + EXCEPT Exception as reason: crash = (sub.name, str(reason)) ; BREAK + - IF view._veto != before: + attributed_hook, attributed_reason = sub.name, view._veto + d. IF crash: violations.append(Violation(tool, crash[0], crash[1])) + ELIF view._veto is not None: + violations.append(Violation(tool, attributed_hook, view._veto)) + ELSE: pass # silent approval +6. RETURN GateVerdict(violations) # never stops between tools +``` + +Errors: unknown address → `ValueError` (defensive); a broken tool-package +import surfaces from `build_once` as `ImportError` (the platform's single fatal +case) — it escapes `build()` unhandled as documented (the pipeline precedent). +Edge cases: no subscriptions → empty verdict; empty/whitespace veto reason +stored and rendered verbatim; a later `veto()` replaces reason and attribution +whole; crash overrides the buffered veto. + +Emissions — each builds its context and calls +`emit_hook_event(self._ensure_registry(), "build", , context_for=lambda _tool: context)` +(the same instance for every tool — read-only contexts, no buffer); nothing +returns; a failing hook warns inside the platform (soft class); the run is +unaffected. + +- [ ] **Declaration**: Task 7 — checkpoint surface BuildHooks +- [ ] **Contract tests**: in `tests/build/hooks/test_events.py` — `BuildHooks` importable from `goga.build.hooks`; the five methods exist with the declared signatures; construction performs no enumeration and no imports (expected to fail at this stage) +- [ ] **Code**: create `goga/build/hooks/events.py` — `BuildHooks` with `_ensure_registry()` (lazy, once per instance) and the five checkpoints per the algorithm above +- [ ] **Code**: finalize `goga/build/hooks/__init__.py` — all 13 types in `__all__`, alphabetical, zone docstring updated +- [ ] **Interface verification**: `pytest tests/build/hooks/test_events.py -x -q` — contract tests pass +- [ ] **Logic tests** (all in `tests/build/hooks/test_events.py`, using `pin_package_environment` + `install_tool_package`): `test_gate_collects_vetoes_without_early_stop` (two tools `goga_tool_a`/`goga_tool_b` subscribing `("build","validate_build","policy", hook)`; A vetoes `"no deploys on friday"`, B records `self.calls` and approves → `verdict.approved is False`; single violation naming tool+hook+reason; B's hook ran); `test_gate_attributes_veto_to_hook_and_replaces_whole` (one tool, hooks `first` vetoes "one", `second` vetoes "two" → exactly one `Violation` with `hook == "second"` and `reason == "two"`); `test_gate_crash_overrides_veto_and_walk_continues` (tool A: hook `broken` raises `RuntimeError("boom")` after hook `vetoer` buffered "blocked"; tool B approves → violations == `[Violation(A, "broken", "boom")]` only; B still ran; no exception escapes); `test_gate_empty_verdict_when_no_subscriptions` (`pin_package_environment({})` → `approved is True; violations == []`); `test_gate_veto_empty_reason_rendered_verbatim` (hook calls `context.veto(" ")` → `Violation.reason == " "`; approved False); plus emission tests — each `emit_*` delegates to `emit_hook_event` with the right action name and the same context instance for every tool (one tool subscribing all four soft actions, recording `context` via `self`) +- [ ] **Debugging**: `pytest tests/build/hooks/ tests/hooks/ -x -q` — fix implementation code until all tests pass +- [ ] **Contract re-verification**: facade check — `python -c "from goga.build.hooks import BuildHooks, BuildMoment, StageFacts, WorkIdentity, AdditionalFacts, RelocationOutcome, Violation, GateVerdict, BuildValidation, BuildStarted, PassStarted, PassCompleted, BuildCompleted"`; `goga schema` shows `goga/build/hooks` with exactly 13 types and a single dependency on `goga/hooks` +- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting, apply decomposition if necessary +- [ ] **Completion**: mark all checkboxes of this task complete + +### Task 8: Run settings resolution — `run_settings.py` (TDD coding) + +Implements the two-part settings resolver and its frozen value objects. Covers +`resolve_run_settings`, `RunSettings`, `PassSettings`, +`PassSettings::ReviewPassSettings` in `goga/build/CODEMANIFEST` (location +`run_settings.py` — new file). Pure — no I/O, no wrapper resolution, no +strategy validation. `ReviewPassSettings` is a concretization of `PassSettings` +(dataclass inheritance, frozen over frozen). Locations: +`goga/build/run_settings.py` (new), `tests/build/test_run_settings.py` (new). + +**Usages relevant to this task:** +- `conventions`: frozen kw_only value objects, relative imports, test layout + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +Verified design trace (CLI precedence amendment from the design review is +included — the CLI layer was dead in the pre-review draft; steps 3–5 apply it): + +``` +1. Input: config: BuildConfig (review may be None), cli_options dict (keys + skip_review, base_ref, review_patience, session_timeout, idle_timeout, wait, + max_iterations — None when the flag was absent). +2. Step 0: config.review is None → every review field unset: skip=False, agent/ + session knobs inherit root, base_ref/roles/finalize None, additional = + AdditionalReviewConfig(agent=, patience=None, + max_iterations=None) — ReviewPassSettings.additional is non-optional, always + constructed. +3. Step 1: skip = CLI skip_review when not None, else ReviewConfig.skip, else + False (tri-state). +4. Step 2: strategy = ReviewConfig.strategy or "medium". +5. Step 3 (tasks): root agent/env verbatim; max_iterations and each session + knob = the CLI value when given (not None), else the root value. +6. Step 4 (review): agent = review value when set else root; each of + session_timeout/idle_timeout/wait = CLI value when given, else review value + when set, else root; max_iterations NOT inherited (root-only); env = exactly + review.env (never inherits); roles/base_ref/finalize verbatim. +7. Step 5 (additional): agent = additional.agent when set else resolved review + agent; patience = CLI review_patience when given else the additional value + verbatim; max_iterations verbatim (None when block absent). +8. Step 6: base_ref = CLI value when not None (strip; empty→None), else + ReviewConfig.base_ref. +9. Output: frozen RunSettings(skip, tasks=PassSettings(...), + review=ReviewPassSettings(roles, base_ref, strategy, finalize, additional, + )). +``` + +Algorithm listing: + +``` +1. review = config.review ; absent → treat all review fields unset (step 0 semantics) +2. skip = cli.skip_review ?? review.skip ?? False +3. strategy = review.strategy or "medium" +4. knob(k) = cli.k if cli.k is not None else root.k # max_iterations + session knobs + tasks = PassSettings(agent=root.agent, env=root.env, + max_iterations=knob("max_iterations"), + session_timeout=knob("session_timeout"), + idle_timeout=knob("idle_timeout"), wait=knob("wait")) +5. review_agent = review.agent or root.agent + review_knob(k) = cli.k if cli.k is not None else (review.k if set else root.k) + # session knobs only + review_env = review.env # exactly; never root env +6. additional = AdditionalReviewConfig( + agent=(review.additional.agent if review.additional else None) or review_agent, + patience=(cli.review_patience if cli.review_patience is not None else + review.additional.patience if review.additional else None), + max_iterations=review.additional.max_iterations if review.additional else None) +7. base_ref = strip(cli.base_ref) if cli.base_ref is not None else review.base_ref +8. RETURN RunSettings(skip, tasks, + ReviewPassSettings(agent=review_agent, env=review_env, roles=review.roles, + base_ref=base_ref, strategy=strategy, finalize=review.finalize, + additional=additional, session_timeout=…, idle_timeout=…, wait=…)) +``` + +- [ ] **Declaration**: Task 8 — run settings resolution +- [ ] **Contract tests**: in `tests/build/test_run_settings.py` — `resolve_run_settings`, `RunSettings`, `PassSettings`, `ReviewPassSettings` importable from `goga.build.run_settings`; the three dataclasses pass `is_kw_only_dataclass` and are frozen; `ReviewPassSettings` is a `PassSettings` subclass (expected to fail at this stage) +- [ ] **Code**: create `goga/build/run_settings.py` per the algorithm above (Google docstrings, `from __future__ import annotations`, relative imports) +- [ ] **Interface verification**: `pytest tests/build/test_run_settings.py -x -q` — contract tests pass +- [ ] **Logic tests**: `test_resolve_run_settings_full_inheritance` (setup: `BuildConfig(agent="claude", env={"A":"1"}, max_iterations=9, session_timeout="30m", idle_timeout="5m", wait="1m", review=ReviewConfig(skip=None, agent=None, env={}, roles=["quality"], base_ref="main", strategy=None, finalize=None, additional=AdditionalReviewConfig(agent=None, patience=3, max_iterations=None), session_timeout=None, idle_timeout=None, wait=None))`, `cli_options={}` → assert `settings.review.agent == "claude"`, `settings.review.strategy == "medium"`, `settings.review.additional.agent == "claude"`, `settings.review.additional.patience == 3`, `settings.review.env == {}`, `settings.review.base_ref == "main"`); `test_resolve_run_settings_cli_overrides_and_tri_state` (config with `review=ReviewConfig(skip=True, session_timeout="10m", …)`, `cli_options={"skip_review": False, "session_timeout": "99m"}` → `settings.skip is False`; `settings.review.session_timeout == "99m"`); `test_resolve_run_settings_review_absent` (`BuildConfig(agent="claude", env={}, max_iterations=5, …, review=None)`, `cli_options={}` → `settings.review.additional.agent == "claude"`, `settings.review.additional.patience is None`, `settings.review.roles is None`, `settings.skip is False`; repeat with `review=ReviewConfig(roles=[])` → `settings.review.roles == []` — the empty list travels verbatim, never coerced to None); `test_resolve_run_settings_base_ref_normalization` (`cli_options={"base_ref": " release/1.3.0 "}`, config review `base_ref="main"` → `settings.review.base_ref == "release/1.3.0"`; repeat `cli_options={"base_ref": " "}` → `base_ref == "main"` — empty CLI counts as unset) +- [ ] **Debugging**: `pytest tests/build/test_run_settings.py -x -q` — fix implementation code until all tests pass +- [ ] **Contract re-verification**: `python -c "from goga.build.run_settings import RunSettings, PassSettings, ReviewPassSettings, resolve_run_settings"`; `from goga.config import BuildConfig, ReviewConfig, AdditionalReviewConfig` still resolves +- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [ ] **Completion**: mark all checkboxes of this task complete + +### Task 9: Pass options composition — `pass_options.py` (TDD coding) + +Implements the pure options composer. Covers `compose_pass_options` in +`goga/build/CODEMANIFEST` (location `pass_options.py` — new file). Keys of the +output must be a subset of the `run_ralphex` option table (Task 3). Locations: +`goga/build/pass_options.py` (new), `tests/build/test_pass_options.py` (new). + +**Usages relevant to this task:** +- `conventions`: purity, test layout +- `ralphex`: the option-table key set the composition must respect + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +Verified design trace: + +``` +1. Input: settings: RunSettings, stage: str ("tasks" | "review"). +2. Step (tasks): {"tasks_only": True} + each of session_timeout, idle_timeout, + wait, max_iterations from settings.tasks when not None — no review-only + keys, no agent (agent → wrapper), no env (env → layer). +3. Step (review): mode flag — {"external_only": True} when strategy == "short", + else {"review": True}; plus session_timeout/idle_timeout/wait from the + review part (inheritance already applied), base_ref when not None, + review_patience from additional.patience when not None, + max_external_iterations from additional.max_iterations when not None — + 0-values kept for the two external flags. +4. Output: dict[str, str | int | bool] — unset knobs absent; exactly one + pass-mode flag. +``` + +- [ ] **Declaration**: Task 9 — pass options composition +- [ ] **Contract tests**: in `tests/build/test_pass_options.py` — `compose_pass_options` importable from `goga.build.pass_options`; returns a plain dict (expected to fail at this stage) +- [ ] **Code**: create `goga/build/pass_options.py` per the trace above +- [ ] **Interface verification**: `pytest tests/build/test_pass_options.py -x -q` — contract tests pass +- [ ] **Logic tests**: `test_compose_pass_options_tasks` (settings with tasks knobs `session_timeout="30m"`, `max_iterations=9`, review part carrying `base_ref="main"`, `additional.patience=0` → `{"tasks_only": True, "session_timeout": "30m", "max_iterations": 9}` exactly; no `review`/`external_only`/`base_ref`/`review_patience`); `test_compose_pass_options_review_medium_and_short` (same settings, `strategy="medium"` → `options["review"] is True` and `"external_only" not in options`; `options["base_ref"] == "main"`; `options["review_patience"] == 0` — zero kept; `"max_external_iterations" in options` iff `additional.max_iterations is not None`; rebuild with `strategy="short"` → `options["external_only"] is True and "review" not in options`) +- [ ] **Debugging**: `pytest tests/build/test_pass_options.py -x -q` — fix implementation code until all tests pass +- [ ] **Contract re-verification**: every emitted key maps 1:1 to a `run_ralphex` flag (cross-check against the Task 3 table) +- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [ ] **Completion**: mark all checkboxes of this task complete + +### Task 10: Review config semantic validation — `review_config.py` re-signature (TDD coding) + +Re-signatures the validator from `(config, review)` to `(settings: RunSettings)` +and adds the new checks. Covers `validate_review_config` in +`goga/build/CODEMANIFEST` (location `review_config.py`). Runs before any side +effect — before `.ralphex/` writes and before the first checkpoint. Locations: +`goga/build/review_config.py`, `tests/build/test_review_config.py`. + +**Usages relevant to this task:** +- `conventions`: validation tier separation, test layout +- `resolve-wrapper-path` (from `goga/agents`): `resolve_wrapper_path(agent)` +- `agent-wrappers`: the wrapper naming the existence checks see + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +Verified design trace — fixed check order: + +``` +1. Input: settings: RunSettings. +2. Step 1: settings.skip → return (a skipped run validates nothing). +3. Step 2: each role of settings.review.roles against ROLE_WHITELIST + (quality, implementation, testing, simplification, documentation) → first + outsider raises ValueError naming it. +4. Step 3: settings.review.env non-empty and settings.review.agent is None → + ValueError (env requires agent). Degenerate case: a None resolved agent at + step 4 raises ValueError("no review agent resolved: set build.agent or + build.review.agent") — resolve_wrapper_path(None) would produce a nonsense + path; the clean error honors "the error message names the invalid value". + (Unreachable through the host launcher — its step-2.2 guard requires + build.agent — but reachable on direct in-container invocation.) +5. Step 4: wrapper = resolve_wrapper_path(settings.review.agent); + not Path(wrapper).is_file() → ValueError naming agent and path. +6. Step 5: strategy engages the external review — "short" always; "full" when + settings.review.additional.agent is set (always set after inheritance in + practice) → resolve + existence-check the additional wrapper the same way. +7. Step 6: settings.review.strategy in {full, medium, short} else ValueError + naming the value. +8. Output: None. +``` + +Constraint: do not validate the tasks agent wrapper here — only the +review/additional wrappers. Tests monkeypatch `resolve_wrapper_path` at its +import point (`goga.build.review_config.resolve_wrapper_path`) to a real +`tmp_path` file — the established pattern of `tests/build/test_review_config.py`. + +- [ ] **Declaration**: Task 10 — review config semantic validation +- [ ] **Contract tests**: in `tests/build/test_review_config.py` — `validate_review_config(settings)` accepts exactly one positional argument of type `RunSettings` (expected to fail at this stage) +- [ ] **Code**: rewrite `goga/build/review_config.py` per the fixed order above (ROLE_WHITELIST constant; relative import of `resolve_wrapper_path` from `..agents`) +- [ ] **Interface verification**: `pytest tests/build/test_review_config.py -x -q` — contract tests pass +- [ ] **Logic tests**: `test_validate_review_config_accepts_clean_settings` (tmp wrapper file; monkeypatched `goga.build.review_config.resolve_wrapper_path` → `str(wrapper)`; `RunSettings(skip=False, review=ReviewPassSettings(agent="claude", env={"X":"1"}, roles=["quality"], strategy="medium", additional=AdditionalReviewConfig(agent="claude", patience=None, max_iterations=None), …)` → returns None, no exception); `test_validate_review_config_rejects_bad_fields` (clean baseline; wrapper monkeypatched to an existing tmp file; parametrize mutations: role `"auditor"`; review env non-empty + agent None; wrapper path to a missing file (`/home/goga/bin/ghost-as-claude.sh` via the patch); strategy `"fast"` → `pytest.raises(ValueError, match=…)` naming the role / the env-requires-agent problem / the agent+path / the strategy value; a `skip=True` variant of every mutation returns None); `test_validate_review_config_rejects_missing_additional_wrapper` (baseline `strategy="full"`, `additional.agent="codex"`; monkeypatch so the review agent resolves to an existing `tmp_path` file and the additional agent to a missing path → `pytest.raises(ValueError, match="ghost-as-claude.sh")` naming the additional agent and its path; a `skip=True` variant of the same settings returns None) +- [ ] **Debugging**: `pytest tests/build/test_review_config.py -x -q` — fix implementation code until all tests pass +- [ ] **Contract re-verification**: check order observable via the negative tests (roles → env gate → review wrapper → additional wrapper → strategy) +- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [ ] **Completion**: mark all checkboxes of this task complete + +### Task 11: Ralphex defaults sync with finalize materialization — `ralphex_runtime.py` (TDD coding) + +Re-signatures the defaults sync to read roles and the finalize prompt from +`RunSettings` and materializes the finalize step file. Covers +`sync_ralphex_defaults` in `goga/build/CODEMANIFEST` (location +`ralphex_runtime.py`). The existing rewrite + roles-filtering logic is reused +unchanged; the delta is the signature and the new step 5. Locations: +`goga/build/ralphex_runtime.py`, `tests/build/test_ralphex_runtime.py`. + +**Usages relevant to this task:** +- `conventions`: logging style, test layout +- `ralphex`: the finalize step is a ralphex review agent carrying the + `finalize.txt` prompt; `{{agent:X}}` composition filtering + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +Verified design trace: + +``` +1. Input: config.build (custom prompts_dir/agents_dir), settings (roles, + finalize). +2. Step: sources = custom dirs when set else vendored + goga/assets/ralphex/{prompts,agents}; missing source → ValueError (as today). +3. Step: full rewrite of .ralphex/prompts/ and .ralphex/agents/ (clear + copy + regular files). +4. Step: settings.review.roles non-empty list and vendored prompts → filter + {{agent:X}} lines of review_first.txt/review_second.txt + counter rewrites + (existing logic, unchanged); custom prompts_dir copied as-is. +5. Step (new): settings.review.finalize is not None → write the prompt string + verbatim to .ralphex/agents/finalize.txt. Materialization applies regardless + of a custom agents_dir — the file is goga's own step artifact, not part of + the source tree. Unset → nothing written; the step stays at the ralphex + default (off). +6. Output: the .ralphex/ prompts/agents tree on disk. +``` + +Checkpoint: byte-identity of the default composition (full role set / no roles) +— existing guard values kept; finalize materialization gated on the prompt +being set. + +- [ ] **Declaration**: Task 11 — ralphex defaults sync with finalize +- [ ] **Contract tests**: in `tests/build/test_ralphex_runtime.py` — `sync_ralphex_defaults(config, settings)` two-argument signature over `(BuildConfig, RunSettings)` (expected to fail at this stage) +- [ ] **Code**: re-signature `goga/build/ralphex_runtime.py`; roles now read from `settings.review.roles`; add the finalize materialization step +- [ ] **Interface verification**: `pytest tests/build/test_ralphex_runtime.py -x -q` — contract tests pass +- [ ] **Logic tests**: `test_sync_ralphex_defaults_materializes_finalize` (tmp cwd; vendored sources exist — use the real vendored dirs, custom `prompts_dir`/`agents_dir` pointing at tmp copies when isolation is needed; `RunSettings` with `finalize="Final pass: merge the review."` → `(tmp_path / ".ralphex/agents/finalize.txt").read_text() == "Final pass: merge the review."`; unset-finalize variant → file absent); keep the existing roles-filtering tests green on the new signature (full default set → byte-identical prompts) +- [ ] **Debugging**: `pytest tests/build/test_ralphex_runtime.py -x -q` — fix implementation code until all tests pass +- [ ] **Contract re-verification**: `.ralphex/config` untouched by this routine (owned by Task 12's routine) +- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [ ] **Completion**: mark all checkboxes of this task complete + +### Task 12: Ralphex config generation with external surface — `ralphex_config.py` (TDD coding) + +Re-signatures the config writer to `(settings, wrapper_path)` and derives the +external-review surface + finalize flag from the settings. Covers +`write_ralphex_config` in `goga/build/CODEMANIFEST` (location +`ralphex_config.py`). Whole-file rewrite — never merged. New import: +`resolve_wrapper_path` (from `..agents`) for the additional wrapper. Locations: +`goga/build/ralphex_config.py`, `tests/build/test_ralphex_config.py`. + +**Usages relevant to this task:** +- `ralphex`: the `.ralphex/config` key layout incl. + `codex_enabled`/`external_review_tool`/`custom_review_script`/`finalize_enabled` +- `resolve-wrapper-path` (from `goga/agents`), `agent-wrappers`: the additional + wrapper path written as `custom_review_script` +- `conventions`: test layout + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +Verified design trace: + +``` +1. Input: settings: RunSettings, wrapper_path: str (the pass's executor wrapper). +2. Step: rewrite .ralphex/config whole with the fixed key block: + claude_command = , claude_args = , + preserve_anthropic_api_key = true, move_plan_on_completion = false. +3. Step: external surface (keys derived only from settings): + strategy == "medium" → codex_enabled = false (explicitly disabled); + full/short → codex_enabled stays unwritten (ralphex default enabled) and, + when settings.review.additional.agent is not None → external_review_tool = + custom and custom_review_script = resolve_wrapper_path(additional.agent); + agent None (degenerate) → both stay unwritten (ralphex default codex). +4. Step: settings.review.finalize is not None → finalize_enabled = true; else + unwritten (default false). +5. Output: .ralphex/config INI; called twice per two-pass run — same settings, + only the wrapper differs. +``` + +INI lines joined with `\n` + trailing newline. Tasks-pass config carries the +review keys too — harmless: `--tasks-only` ignores every review-phase key +(practice note); keeps the routine a pure function of (settings, wrapper). + +- [ ] **Declaration**: Task 12 — ralphex config generation with external surface +- [ ] **Contract tests**: in `tests/build/test_ralphex_config.py` — `write_ralphex_config(settings, wrapper_path)` signature (expected to fail at this stage) +- [ ] **Code**: rewrite `goga/build/ralphex_config.py` per the trace; add the `resolve_wrapper_path` import +- [ ] **Interface verification**: `pytest tests/build/test_ralphex_config.py -x -q` — contract tests pass +- [ ] **Logic tests**: `test_write_ralphex_config_strategies` (tmp cwd; three `RunSettings` variants — medium / full with additional agent "codex" / finalize set; wrapper path `"/home/goga/bin/claude-as-claude.sh"`; monkeypatch `goga.build.ralphex_config.resolve_wrapper_path` for the additional wrapper; assert + `medium: "codex_enabled = false" in text; "external_review_tool" not in text`; + `full+additional: "external_review_tool = custom" in text and f"custom_review_script = {additional_wrapper}" in text and "codex_enabled" not in text`; + `finalize set: "finalize_enabled = true" in text; unset variant: not in text`; + `always: "move_plan_on_completion = false", "preserve_anthropic_api_key = true", f"claude_command = {wrapper}"`) +- [ ] **Debugging**: `pytest tests/build/test_ralphex_config.py -x -q` — fix implementation code until all tests pass +- [ ] **Contract re-verification**: key set matches the `ralphex` practice table exactly +- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [ ] **Completion**: mark all checkboxes of this task complete + +### Task 13: Pass executor — `build_pass.py` re-signature (TDD coding) + +Re-signatures the pass executor to carry `RunSettings`. Covers `run_build_pass` +in `goga/build/CODEMANIFEST` (location `build_pass.py`). Locations: +`goga/build/build_pass.py`, `tests/build/test_build_pass.py`. + +**Usages relevant to this task:** +- `run-ralphex` (from `goga/ralphex`): the delegation contract +- `ralphex`: config written before launch +- `conventions`: test layout + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +Verified design trace: + +``` +1. Input: plan, settings, options, wrapper, dry_run, env (tasks: root env layer; + review: review env layer; None/empty → pure inheritance). +2. Step: write_ralphex_config(settings, wrapper_path) → .ralphex/config of this + pass. +3. Step: run_ralphex(plan, options, dry_run, env=env). +4. Output: the ralphex exit code, propagated unchanged. +``` + +Constraint: do not assemble or invoke the ralphex command directly — only +through `run_ralphex`. Secret safety: env values never in argv/logs/dry-run. + +- [ ] **Declaration**: Task 13 — pass executor re-signature +- [ ] **Contract tests**: in `tests/build/test_build_pass.py` — `run_build_pass(plan, settings, options, wrapper_path, dry_run, env=None)` signature; delegates config write to `write_ralphex_config(settings, wrapper_path)` and launch to `run_ralphex(plan, options, dry_run, env=env)` (expected to fail at this stage) +- [ ] **Code**: rewrite `goga/build/build_pass.py` per the trace +- [ ] **Interface verification**: `pytest tests/build/test_build_pass.py -x -q` — contract tests pass +- [ ] **Logic tests**: positive — config file written before launch (order recorded via monkeypatched collaborators), exit code propagated unchanged (stub returns 7 → 7); negative — env layer forwarded verbatim to `run_ralphex` and never printed; edge — `env=None` passes pure inheritance +- [ ] **Debugging**: `pytest tests/build/test_build_pass.py -x -q` — fix implementation code until all tests pass +- [ ] **Contract re-verification**: no direct subprocess call to ralphex in the module +- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [ ] **Completion**: mark all checkboxes of this task complete + +### Task 14: Plan relocation outcome — `plan_relocation.py` (TDD coding) + +Changes the relocation to return the outcome facts. Covers `move_completed_plan` +in `goga/build/CODEMANIFEST` (location `plan_relocation.py`). Locations: +`goga/build/plan_relocation.py`, `tests/build/test_plan_relocation.py`. + +**Usages relevant to this task:** +- `conventions`: test layout, tmp_path file I/O + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +Algorithm (manifest, verbatim): `not outcome or dry_run` → +`RelocationOutcome(moved=False, destination=None)`; else `Path.replace` into +`/completed/` (mkdir parents, exist_ok) → +`RelocationOutcome(moved=True, destination=str(dest))`. Constraint: do not +hard-code `docs/plans/` — the directory follows the plan file location. + +- [ ] **Declaration**: Task 14 — plan relocation outcome +- [ ] **Contract tests**: in `tests/build/test_plan_relocation.py` — return type is `RelocationOutcome` (importable from `goga.build.hooks`) (expected to fail at this stage) +- [ ] **Code**: rewrite `goga/build/plan_relocation.py` per the algorithm +- [ ] **Interface verification**: `pytest tests/build/test_plan_relocation.py -x -q` — contract tests pass +- [ ] **Logic tests**: `test_move_completed_plan_returns_relocation_outcome` (setup `tmp_path/docs/plans/plan.md`; input `move_completed_plan(str(plan), outcome=True, dry_run=False)` → `relocation.moved is True`; `relocation.destination == str(tmp_path/"docs/plans/completed/plan.md")`; source gone; variants: `outcome=False` → `moved is False, destination is None`; `dry_run=True` → same not-moved outcome and file stays); `test_move_completed_plan_is_idempotent_by_name` (run the relocation twice on the same plan, recreating the source between calls → second call overwrites `completed/plan.md` without error) +- [ ] **Debugging**: `pytest tests/build/test_plan_relocation.py -x -q` — fix implementation code until all tests pass +- [ ] **Contract re-verification**: directory follows the plan file location (test with a non-default plan dir) +- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [ ] **Completion**: mark all checkboxes of this task complete + +### Task 15: The 12-step build cycle — `build.py` rewrite and retired-module deletion (TDD coding) + +The central rewrite. Covers `build()` in `goga/build/CODEMANIFEST` (location +`build.py`): the 12-step checkpoint cycle. Deletes `resolve_review_options`/ +`ReviewOptions` (file `goga/build/review_options.py`) and +`tests/build/test_review_options.py`, and the private helpers `_resolve_options` +/ `_review_scoped_options` (superseded by `resolve_run_settings` / +`compose_pass_options`). Adds the private helper `_completion_statuses(work) -> +list[str]`. The git pre-check helpers (`_unquote_git_path`, +`_parse_porcelain_path`, `_find_uncommitted_manifests`) stay. Locations: +`goga/build/build.py`, delete `goga/build/review_options.py`, +`tests/build/test_build.py`, delete `tests/build/test_review_options.py`, +rewrite `tests/build/test_build_resolved_wrapper.py`, update +`tests/build/test_shipped_ralphex_assets.py`. + +**Usages relevant to this task:** +- `checkpoints` (from `goga/build/hooks/.usages/checkpoints.md`): the + checkpoint integration order and fact resolution — read first +- `topic-paths`, `topic-statuses` (from `goga/history`): `resolve_current_branch_name() or "unknown"`; + `resolve_topic_dir(branch)` (+`ValueError` guard, `.is_dir()`) → slug/year; + `collect_topic_statuses(year=work.year)` filtered to `work.slug` +- `ralphex`, `agent-wrappers`, `resolve-wrapper-path`, `run-ralphex`: per their + cells (wrapper resolution feeding the passes; delegation of the launch) +- `build-usage` (`goga/build/.usages/build-usage.md`): the in-container + invocation contract — verify the implementation matches it +- `registering-hooks` (`goga/build/.usages/registering-hooks.md`): its "missing + agent returns before any checkpoint" claim is kept true by the step-3.5 guard +- `conventions`: logging with `extra`, never a traceback + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +Verified design trace — the full cycle: + +``` +1. Input: plan path, ProjectConfig, cli_options. +2. Step 0: manifest pre-check (existing _find_uncommitted_manifests) — failure + → return 1, no events (the moment never happened). +3. Steps 1–3: resolve_run_settings → validate_review_config (ValueError → log + + return 1) → sync_ralphex_defaults (ValueError → log + return 1). + config.build is guaranteed non-None by the host guard (step 2.2 of + goga/commands/build); a build-less config invoked directly in-container is + out of contract. STEP 3.5 GUARD: settings.tasks.agent is None → + logger.error("no build agent resolved: set build.agent in .goga/config.yml") + → return 1 — the degenerate skip-run case (validate_review_config returns + early on skip, so the run would otherwise crash at step 7's + resolve_wrapper_path(None) with a TypeError after emit_build_started fired); + keeps the registering-hooks claim "missing agent returns before any + checkpoint" true on the direct in-container path. +4. Step 4: branch = resolve_current_branch_name() or "unknown"; topic_dir = + resolve_topic_dir(branch) guarded (ValueError → None, an unsluggable branch + hosts no topic); hosted (topic_dir.is_dir()) → + WorkIdentity(branch, slug=topic_dir.name, year=topic_dir.parent.name) else + WorkIdentity(branch); moment = BuildMoment(plan, work, dry_run); + tasks_facts/review_facts per StageFacts (env = sorted(env) — names only, + deterministic) with the review-only members None on tasks and + AdditionalFacts(agent, patience, max_iterations) on review — no git/process + reads AFTER this step. +5. Step 5: hooks = BuildHooks(); verdict = hooks.validate_build(moment, + tasks_facts, review_facts, settings.skip); not verdict.approved → one merged + logger.error("build blocked by hook vetoes", extra={"violations": + [f"{v.tool}/{v.hook}: {v.reason}" for v in verdict.violations]}) → return 1; + no pass, no relocation, no further events. +6. Step 6: hooks.emit_build_started(moment, tasks_facts, review_facts, + settings.skip). +7. Step 7: tasks pass — options = compose_pass_options(settings, "tasks"); + wrapper = resolve_wrapper_path(settings.tasks.agent); + hooks.emit_pass_started(moment, tasks_facts); exit_code = run_build_pass( + plan, settings, options, wrapper, dry_run, env=tasks_layer) where + tasks_layer = settings.tasks.env or None; hooks.emit_pass_completed(moment, + tasks_facts, exit_code); stages = ["tasks"]. +8. Step 8: exit_code == 0 and not settings.skip → review pass — options = + compose_pass_options(settings, "review"); wrapper = + resolve_wrapper_path(settings.review.additional.agent if strategy == "short" + else settings.review.agent); the same emit/launch/emit triple with + review_facts and review_layer = settings.review.env or None; + stages.append("review"). A failed tasks pass never reaches here. +9. Step 9: relocation = move_completed_plan(plan, outcome=(exit_code == 0), + dry_run=dry_run). +10. Step 10: statuses — work.slug is None → []; else the matching + collect_topic_statuses(year=work.year) record's statuses (absent topic → + []) — re-read AFTER the relocation attempt. +11. Step 11: hooks.emit_build_completed(moment, exit_code, stages, relocation, + statuses). +12. Output: return exit_code (the last executed pass's code). +``` + +Checkpoint summary (design): dry-run parity — identical steps; `run_ralphex` +prints and returns 0; relocation stays (dry_run guard); events carry +`moment.dry_run=True`. Pre-launch failures (0–3) and a blocked run (5) fire no +events. Exit code = last executed pass's code; relocation only on final-pass +success. + +Test setup (General Setup of the design's Test Stack Trace, verbatim): +orchestration tests monkeypatch `goga.build.build.run_build_pass` (or +`goga.ralphex.run_ralphex.run_ralphex`) with a recording stub, run inside +`tmp_path` (`.ralphex/` writes land there), and monkeypatch +`resolve_current_branch_name`/`resolve_topic_dir`/`collect_topic_statuses` at +`goga.build.build`'s import point. Wrapper existence tests monkeypatch +`resolve_wrapper_path` at its import point. + +- [ ] **Declaration**: Task 15 — the 12-step build cycle +- [ ] **Contract tests**: in `tests/build/test_build.py` — `build(plan, config, cli_options)` importable from `goga.build`; the cycle calls `resolve_run_settings`, `validate_review_config`, `sync_ralphex_defaults`, `compose_pass_options`, `run_build_pass`, `move_completed_plan`, and the five `BuildHooks` checkpoints in the traced order (expected to fail at this stage) +- [ ] **Code**: rewrite `goga/build/build.py` per the 12-step trace (steps 0–3.5 guard, 4–11, return); new `_completion_statuses(work)` helper; imports from `.run_settings`, `.pass_options`, `.review_config`, `.ralphex_runtime`, `.build_pass`, `.plan_relocation`, and the zone facade via `from .hooks import …` (relative intra-package, mirroring `goga/pipeline/run_pipeline.py`); `from ..history import resolve_current_branch_name, resolve_topic_dir, collect_topic_statuses` +- [ ] **Code**: delete `goga/build/review_options.py` and `tests/build/test_review_options.py`; remove `_resolve_options` and `_review_scoped_options` from `build.py`; no compatibility shims +- [ ] **Interface verification**: `pytest tests/build/test_build.py -x -q` — contract tests pass +- [ ] **Logic tests** (in `tests/build/test_build.py`): `test_build_runs_two_passes_with_bound_settings` (setup: tmp cwd with config; monkeypatch `goga.build.build.run_build_pass` recording `(options, wrapper, env)` returning 0; `resolve_current_branch_name` → `"add-hooks-to-build"`, `resolve_topic_dir` → raises ValueError (branch-only), `collect_topic_statuses` → `[]`; `cli_options` all None, `dry_run` False; no tool packages pinned → assert `run_build_pass` called exactly twice; first call `options["tasks_only"] is True` and `env == {"A":"1"}`; second call `options["review"] is True` and env is the review layer — never the root env; return 0; plan relocated); `test_build_skipped_review_single_tasks_pass` (same + `cli_options={"skip_review": True}` → exactly one `run_build_pass` call (`tasks_only`); return value = that pass's code); `test_build_failed_tasks_pass_skips_review` (`run_build_pass` first call returns 1 → one pass call only; `pass_completed` for tasks carries `exit_code == 1`; `BuildCompleted.stages == ["tasks"]`; relocation not moved; return 1); `test_build_vetoed_run_blocks_before_any_pass` (two-pass setup + one tool whose `validate_build` hook vetoes `"policy"`; `run_build_pass` recorder → return 1; `run_build_pass` never called; plan file in place; NO notification hook of the tool ran; exactly one `logger.error` record carrying the violation triple — caplog); `test_build_pre_launch_failures_fire_no_events` (tool subscribed to all five actions — recorder; parametrize: uncommitted CODEMANIFEST (patch `_find_uncommitted_manifests` → `["x/CODEMANIFEST"]`), invalid review config (patch `validate_review_config` → raise), unavailable defaults (patch `sync_ralphex_defaults` → raise), no build agent on a skip run (config with root agent None + `cli_options={"skip_review": True}` — the step-3.5 guard path) → return 1; zero hook invocations across all five actions in every variant); `test_unsluggable_branch_falls_back_to_branch_only` (`resolve_topic_dir` raises ValueError; `resolve_current_branch_name` → None → `WorkIdentity.branch == "unknown"`, `slug is None`; statuses `[]`; run proceeds normally); `test_build_statuses_recomputed_after_relocation` (topic-hosting branch: `resolve_current_branch_name → "add-hooks-to-build"`, `resolve_topic_dir → Path(".goga/history/2026/add-hooks-to-build")` (is_dir True); `collect_topic_statuses` stub returning `[TopicRecord("add-hooks-to-build", ["backlog", "designed"])]`; successful run → recorded `BuildCompleted.statuses == ["backlog", "designed"]`; `collect_topic_statuses` called with `year="2026"` AFTER `move_completed_plan` (order recorded); branch-only variant delivers `[]`); `test_stage_facts_carry_env_names_only` (settings with `tasks.env={"A":"1","B":"2"}` → orchestration facts: `StageFacts.env == ["A", "B"]` sorted names; walk `dataclasses.fields` of every context and assert no string member equals `"1"`/`"2"`) +- [ ] **Code**: rewrite `tests/build/test_build_resolved_wrapper.py` onto the two-part schema (`build.agent` at the root); delete the `codex_review → codex_enabled` case (the key is retired; the strategy-table test of Task 12 covers the new derivation); keep the uncommitted-manifests / ralphex-missing / custom-prompts-dir cases on the new fixtures +- [ ] **Code**: update `tests/build/test_shipped_ralphex_assets.py` — replace the `BuildConfig(task_executor=TaskExecutorConfig(...))` construction with the two-part `BuildConfig(agent=..., env={})`; the vendored asset assertions are unchanged +- [ ] **Debugging**: `pytest tests/build/ -x -q` — fix implementation code until all tests pass +- [ ] **Contract re-verification**: facade `from goga.build import build` resolves; no compatibility shims (`grep -rn "review_options" goga/ tests/` empty); `--worktree`/`--skip-finalize`/`worktree`/`skip_finalize` absent from `goga/build/` +- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting, apply decomposition if necessary +- [ ] **Completion**: mark all checkboxes of this task complete + +### Task 16: In-container CLI surface — `__main__.py` (TDD coding) + +Updates the argparse surface. Covers `main()` in `goga/build/CODEMANIFEST` +(location `__main__.py`). `ensure_in_docker()` stays the very first statement; +both guard branches must be covered by tests (manifest requirement). Locations: +`goga/build/__main__.py`, `tests/build/test_main.py`, +`tests/build/test_contract.py`. + +**Usages relevant to this task:** +- `ensure-in-docker` (from `goga/docker`): the guard contract at step 0 +- `build-usage`: the cli_options list the container entrypoint accepts +- `conventions`: CLI docstring rules, test layout + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +Verified design trace: + +``` +ensure_in_docker() first → argparse (plan, --dry-run, --skip-manifest-check, +--skip-review/--no-skip-review → skip_review: bool | None, --base-ref, +--review-patience, --session-timeout, --idle-timeout, --wait, --max-iterations; +NO --worktree/--skip-finalize) → cli_options with exactly those keys +(dry_run, skip_manifest_check, skip_review, base_ref, review_patience, +session_timeout, idle_timeout, wait, max_iterations — worktree/skip_finalize +keys removed from the dict) → load_project_config() → build(...) → exit code. +→ checkpoint: cli_options keys match what resolve_run_settings reads. +``` + +Current stale lines: `goga/build/__main__.py:22–23` (`--worktree`, +`--skip-finalize` add_argument) and `:38–39` (their cli_options keys). + +- [ ] **Declaration**: Task 16 — in-container CLI surface +- [ ] **Contract tests**: in `tests/build/test_main.py` — `main()` forwards exactly the nine cli_options keys; `--worktree`/`--skip-finalize` exit with argparse error (expected to fail at this stage) +- [ ] **Code**: update `goga/build/__main__.py` per the trace (remove the two flags and their dict keys) +- [ ] **Interface verification**: `pytest tests/build/test_main.py -x -q` — contract tests pass +- [ ] **Logic tests**: `test_main_argparse_surface_matches_contract` (monkeypatch `sys.argv` / `ensure_in_docker`; patch `goga.build.__main__.build`; input `["goga.build", "plan.md", "--skip-review", "--review-patience", "3"]`; repeat with `["goga.build", "plan.md", "--no-skip-review"]` → forwarded `cli_options["skip_review"] is True` / `cli_options["review_patience"] == 3`; the `--no-skip-review` variant forwards `cli_options["skip_review"] is False` (the tri-state False arm); parsing `--worktree` or `--skip-finalize` exits with SystemExit 2; guard `ensure_in_docker` called first — both branches covered per the manifest requirement) +- [ ] **Code**: update `tests/build/test_contract.py` to the new cell surface (facade `build`; module imports `goga.build.run_settings` / `goga.build.pass_options`; no retired names) +- [ ] **Debugging**: `pytest tests/build/ -x -q` — fix implementation code until all tests pass +- [ ] **Contract re-verification**: cli_options keys match `resolve_run_settings`'s read set exactly +- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [ ] **Completion**: mark all checkboxes of this task complete + +### Task 17: Host launcher surface — `goga/commands/build` (TDD coding) + +Applies the three host-command deltas and the flag removals. Covers `build(...)` +in `goga/commands/build/CODEMANIFEST` (location `build.py`): the existing +19-step algorithm with three deltas; `resolve_build_runtime_dir`, +`clean_build_runtime_dir`, `_cleanup_ralphex_in_project` unchanged. Locations: +`goga/commands/build/build.py`, `tests/commands/conftest.py`, +`tests/commands/test_build.py`, `tests/commands/build/test_build.py`, and the +four `tests/commands/build/test_build_*_integration.py` fixture updates. + +**Usages relevant to this task:** +- `click` (`.goga/usages/cooks/click.md`): option declarations; command + docstrings verbatim-help (no Args/Returns/Raises) +- `build-usage` (from `goga/build`): the in-container invocation contract + (`-m goga.build `) +- `project-configuration`, `home-configuration` (from `goga/config`): the + two-part build schema and the env base-layering +- `docker-builder`, `docker-runner`, `docker-image-version`, + `resolve-credential-mounts`, `runtime-paths`, `docker-auth-mounts`: unchanged + consumer contracts — only the flag/env deltas touch them +- `conventions`: test layout, CliRunner pattern + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +Verified design trace (three deltas on the existing 19-step algorithm): + +``` +- step 2.2 guard message/key becomes: config.build.agent is None → + ClickException("build.agent is required in .goga/config.yml to run + 'goga build'") +- step 2.3 (two-pass × worktree guard) DELETED +- step 7 env assembly becomes {**home.env, **git_env, **cli_env} — the task env + (config.build.env) is NOT written to the env-file (it reaches the container + only through the mounted .goga/config.yml and is applied in-container as the + tasks-pass layer) +CLI flags: --worktree/--skip-finalize options and their forwarding removed +(_build_cli_args branches at build.py:118–120, the click option at :215, the +callback params at :265–266); --review-patience/--base-ref forwarding unchanged +(value-flag pattern); help text repointed to build.review.additional.patience / +build.review.base_ref. +→ checkpoint: host forwards, container resolves. +``` + +Also drop `config.build.env` from the comments that call the env-file +"task_executor secrets". Constraint from the manifest: no worktree handling +anywhere on the surface — no flag, no guard, no worktree-related rejection. + +- [ ] **Declaration**: Task 17 — host launcher surface +- [ ] **Contract tests**: in `tests/commands/build/test_build.py` — `--worktree`/`--skip-finalize` are unknown options (exit 2 + message); the step-2.2 guard message names `build.agent` (expected to fail at this stage) +- [ ] **Code**: apply the three deltas + flag removals to `goga/commands/build/build.py` per the trace +- [ ] **Code**: update `tests/commands/conftest.py` shared config-writing helpers to the two-part schema (`build: {agent: …}`); drop the `worktree`/`skip_finalize`/`codex_review`/`review_executor` lines +- [ ] **Code**: update `tests/commands/test_build.py` — replace the `worktree`-option assertion with the removed-surface assertion (unknown option, exit 2); repoint the `task_executor` config fixture to `build.agent` +- [ ] **Interface verification**: `pytest tests/commands/ -x -q` — contract tests pass +- [ ] **Logic tests**: `test_host_command_surface_and_env_file` (click runner `CliRunner`; tmp config with two-part build; existing host fixtures; invoke `goga build plan.md` and with `--base-ref x --review-patience 2 --skip-review` → `--worktree`/`--skip-finalize` unknown options (exit 2 + message); guard message names `build.agent`; forwarded args contain `--base-ref x` / `--review-patience 2` / `--skip-review` only when set; the written env-file contains home/git/cli env keys and NOT the `build.env` values (secret boundary); docker args carry `-m goga.build `) +- [ ] **Code**: update the four integration files to the two-part schema and the env-file assertions (the task env is no longer written into the env-file): `tests/commands/build/test_build_home_integration.py`, `test_build_proxy_hosts_update.py`, `test_build_runtime_isolation_integration.py`, `test_build_credential_mount_integration.py` +- [ ] **Debugging**: `pytest tests/commands/ -x -q` — fix implementation code until all tests pass +- [ ] **Contract re-verification**: `grep -rn -e "--worktree" -e "--skip-finalize" goga/commands/` empty; host does not resolve the tri-state or base-ref precedence (forwarding only) +- [ ] **Lint**: `ruff check goga/commands tests/commands` — fix formatting if necessary +- [ ] **Completion**: mark all checkboxes of this task complete + +### Task 18: Onboarding two-part build emission — `goga/onboarding/generator` (TDD coding) + +Repoints the generated config to the two-part build root. Covers the +`FileGenerator.generate_goga_config` snapshot→YAML build mapping in +`goga/onboarding/generator/CODEMANIFEST` (location `generator.py`; code change +in the private `_build_config_document`/`_executor_block` helpers). An existing +`.goga/config.yml` is never rewritten — that guarantee is untouched. Locations: +`goga/onboarding/generator/generator.py`, `tests/onboarding/generator/test_generator.py`. + +**Usages relevant to this task:** +- `convention`: docstring style, relative imports +- `yaml` (inline): `yaml.dump(default_flow_style=False)` + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +Verified design trace: + +``` +data["build"] = build_block (agent, env at the two-part root) instead of +{"task_executor": build_block}; _executor_block docstring reworded (build root / +pipeline content). → checkpoint: the generated file passes the new loader +(config.build.agent resolves). +``` + +Current stale lines: `generator.py:59–60` (`_executor_block` assembling a +`build.task_executor` dict) and `:159–163` (`data["build"] = +{"task_executor": build_block}`). This fixes the design-review defect: the +generator would otherwise emit a silently-disabled build section under the new +loader. + +- [ ] **Declaration**: Task 18 — onboarding two-part build emission +- [ ] **Contract tests**: in `tests/onboarding/generator/test_generator.py` — the generated `build` block has `agent`/`env` at the root, no `task_executor` nesting (expected to fail at this stage) +- [ ] **Code**: update `goga/onboarding/generator/generator.py` per the trace (emission + `_executor_block` docstring reword) +- [ ] **Interface verification**: `pytest tests/onboarding/generator/test_generator.py -x -q` — contract tests pass +- [ ] **Logic tests**: `test_onboarding_generator_emits_two_part_build` (onboarding answers `build: {agent: "claude", env: {API_KEY: "secret"}}` — existing fixture pattern; input `generate_goga_config(answers)` → load the written file with `load_project_config` → `cfg["build"] == {"agent": "claude", "env": {"API_KEY": "secret"}}` (no `task_executor` nesting); `config.build.agent == "claude"` — the generated file actually drives a build) +- [ ] **Debugging**: `pytest tests/onboarding/ -x -q` — fix implementation code until all tests pass +- [ ] **Contract re-verification**: round-trip — generated file passes `load_project_config` with the two-part extraction +- [ ] **Lint**: `ruff check goga/onboarding tests/onboarding` — fix formatting if necessary +- [ ] **Completion**: mark all checkboxes of this task complete + +### Task 19: Integration tests for the build cycle and the end-to-end flows (integration tests) + +Cross-entity and cross-package scenarios: the three rewritten end-to-end suites +plus the orchestration integration scenarios that exercise `goga/build` × +`goga/build/hooks` × the fake tool packages together. Test setup follows the +design's General Setup verbatim (zone fixtures re-exported by +`tests/build/hooks/conftest.py`; orchestration monkeypatching at +`goga.build.build`'s import point; runs inside `tmp_path`). + +**Usages relevant to this task:** +- `checkpoints` (from `goga/build/hooks/.usages/checkpoints.md`): the expected + checkpoint sequence the end-to-end assertions verify against +- `build-usage`: the in-container invocation contract of the end-to-end flows +- `run-ralphex` (from `goga/ralphex`): flag expectations of the launcher-level + assertions + +**CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** + +- [ ] Rewrite `tests/integration/test_base_ref_end_to_end.py` onto the two-part config and the always-two-pass cycle (base_ref flows CLI > `build.review.base_ref` > omit) +- [ ] Rewrite `tests/integration/test_skip_review_end_to_end.py` onto the two-part config and the always-two-pass cycle (the skip form: exactly one tasks pass) +- [ ] Rewrite `tests/integration/test_resolved_wrapper_flow.py` onto the two-part config and the always-two-pass cycle (wrapper resolution per pass; the additional wrapper under short) +- [ ] Add to `tests/build/test_build.py` the orchestration integration scenarios: `test_notifications_carry_completion_facts` (one tool subscribing all four soft actions with hooks recording `context` via `self`; orchestration as in the two-pass test with the tasks pass returning 0 and the review pass returning 2 → recorded contexts expose `PassCompleted.exit_code == 2` for the review facts; `BuildCompleted.exit_code == 2`; `stages == ["tasks", "review"]`; a crashing notification hook — separate variant — warns and the return code stays 2); `test_build_dry_run_rehearses_event_structure` (two-pass setup with `dry_run=True`; `run_build_pass` NOT patched at the pass level — patch `goga.ralphex.run_ralphex.run_ralphex` to assert it is called with `dry_run=True` → both passes "ran" (launcher called twice, both dry); the plan file still at its original path; `BuildCompleted.relocation.moved is False`; recorded notification `moment.dry_run is True`); `test_registry_built_once_across_checkpoints` (one tool subscribing `validate_build` + `build_started` + `build_completed`; pin the enumeration boundary mock and count reads → the `packages_distributions` boundary read exactly once across a full `build(...)` run) +- [ ] Test edge case: second run sees an edited hook (SC10 — registration re-reads; assert a second `build(...)` run in the same process picks up a hook edit between runs) +- [ ] Run validation: `pytest tests/integration/ tests/build/ -x -q`, then the full suite `pytest tests/ -x` + +--- + +## Validation Commands + +All commands run in the `.venv` virtualenv from the repo root. + +- `pytest tests/ -x`: Run all tests (the design's validation run; covers every + suite listed in the Source File Registry and the affected-tests addendum) +- `ruff check goga/config goga/hooks goga/ralphex goga/build goga/commands goga/onboarding tests/config tests/hooks tests/ralphex tests/build tests/commands tests/onboarding tests/integration`: Lint check over every touched package +- `python -c "from goga.build.hooks import BuildHooks, BuildMoment, StageFacts, WorkIdentity, AdditionalFacts, RelocationOutcome, Violation, GateVerdict, BuildValidation, BuildStarted, PassStarted, PassCompleted, BuildCompleted"`: Zone facade accessibility (13 names) +- `python -c "from goga.config import BuildConfig, ReviewConfig, AdditionalReviewConfig"`: Config facade accessibility (and `python -c "from goga.config import TaskExecutorConfig"` must raise `ImportError`) +- `grep -rn -e "--worktree" -e "--skip-finalize" -e "worktree" -e "skip_finalize" -e "codex_review" -e "task_executor" -e "review_executor" goga/`: Absence check — expected hits are ONLY the retirement/migration documentation in manifests/usages and the loader's ignore-everything stance +- `goga lint`: Cell/manifest integrity (79 cells, 0 errors) +- `goga schema`: Dependency graph — `goga/build/hooks` shows exactly 13 types and a single dependency on `goga/hooks` +- Dogfooding acceptance (post-implementation, manual): a real `goga build` run on this repository executes the two passes over the migrated config; `goga hooks` lists build subscriptions of any installed tool + +--- + +## Completion Criteria + +- [ ] Every contract entity is implemented in the correct `location` (13 zone + types across `facts.py`/`contexts.py`/`events.py`; `run_settings.py`, + `pass_options.py` created; all re-signatured routines updated) +- [ ] Every contract entity is accessible from its facade + (`goga.build.hooks` 13 names; `goga.config` embeddings; `goga.build.build`) +- [ ] Properties and methods match the declared API (kw_only dataclasses; + frozen where the contract says frozen — `RunSettings`/`PassSettings`/ + `ReviewPassSettings` and the config model — non-frozen zone facts/contexts) +- [ ] Descriptions are reflected in behavior (checkpoint order, inheritance + rules, zero-valued external flags, veto semantics, secret boundaries) +- [ ] Contract dependencies are met (imports from `goga/hooks`, `goga/history`, + `goga/agents`, `goga/ralphex`, `goga/docker`, `goga/config` resolve as declared) +- [ ] Re-exports are accessible from the facade (`ReviewConfig`, + `AdditionalReviewConfig` from `goga.config`; retired names gone) +- [ ] Every coding task followed the TDD workflow (contract tests → code → + verification → logic tests → debugging → re-verification → lint) +- [ ] Contract tests and logic tests cover facade, API, and behavior within each + coding task — 39 named scenarios (26 positive, 6 negative, 7 edge) plus + the rewritten existing suites +- [ ] Integration tests exist where cross-entity scenarios require them + (Task 19: three end-to-end rewrites + notifications/dry-run/registry-once + orchestration scenarios) +- [ ] No package boundary was expanded (no new cells beyond the contract-declared + `goga/build/hooks`; internal helpers only within existing cells) +- [ ] `CODEMANIFEST` files were not modified (contract is read-only); + `.goga/config.yml` was not touched (already migrated) +- [ ] All validation commands pass (`pytest tests/ -x`, ruff, facade checks, + absence greps, `goga lint` 79 cells / 0 errors, `goga schema` 13 types) +- [ ] Every Usages entry is mentioned in at least one task (calibration table: + `conventions`/`convention` all tasks; `ralphex` 3/9/11/12/13; + `agent-wrappers` 10/12/15; `checkpoints` 7/15/19; `topic-paths`/ + `topic-statuses` 15; `resolve-wrapper-path` 10/12/15; `run-ralphex` + 3/13/15/19; `ensure-in-docker` 16; `build-usage` 15/16/17/19; + `project-configuration` 1/17; `home-configuration` 17; `declaring-actions`/ + `per-tool-delivery`/`registering-hooks` 4–7; `click` 17; `yaml` 1/18; + docker practices 17) diff --git a/.goga/history/2026/add-hooks-to-build/prd.md b/.goga/history/2026/add-hooks-to-build/prd.md new file mode 100644 index 00000000..6f3eca12 --- /dev/null +++ b/.goga/history/2026/add-hooks-to-build/prd.md @@ -0,0 +1,459 @@ +# Opening the Build Domain to Tool Package Integrations + +## Problem + +Third-party tool package authors cannot integrate with the build domain — +the code-build execution domain of goga (plan orchestration through +ralphex: task and review passes, exit outcomes, plan relocation into +`completed/`). The product already offers a domain extension surface for +installed tool packages — the onboarding, statuses, topics, and pipeline +domains are open — but builds accept no tool participation: a tool can +neither observe build moments nor connect build outcomes to the history +status model or the work's artifacts. + +Blocked demand exists today: reporting, automation, and external +notifications over build moments, and advancing the history status model +from build activity — plus policy enforcement over what may be built at +all. + +Consequence: any integration between builds and a tool's logic requires +changes inside goga itself. The ecosystem cannot self-serve on the build +domain, the platform's extension promise stays asymmetric, and a tool +author facing an integration task around builds has no defined path — +every "how do I integrate with builds" scenario is currently unanswerable. + +A second, structural problem compounds this: the run model itself is +unstable for integration purposes. Whether tasks and review run as one +combined pass or as two separate passes depends on incidental executor +configuration (a differing review agent or a non-empty review env), and +executor settings are not bound to stages (the task env applies to the +whole container, not to the tasks stage). There is no stable, predictable +cycle of build moments for a contract to describe. + +## Users + +### Primary: tool package author (integration developer) + +A developer extending goga by writing an installed `goga_tool_*` package. +Python author; reads the goga usage docs; subscribes through the +package's `register_hooks` facade callback; already familiar with the +hooks platform from the topics, onboarding, statuses, and pipeline +domains. + +- **Trying to:** solve an integration task around builds — report on or + automate build moments (external notifications, CI, dashboards), + connect build outcomes to the history status model and the work's + artifacts (plan relocation, run facts), or enforce build policy by + vetoing runs that violate it. +- **When:** while designing and iterating on the tool package; edits apply + from the next command without reinstall. +- **What matters:** a complete, predictable contract — which build + moments exist, what each moment delivers, what a hook may change and + what it cannot, how hook failures are treated. No integration scenario + around builds should be left without a documented answer. +- **Constraints:** runs inside the goga container at the trust level of + the installation; cannot assume goga code changes; tool identity is + assigned by goga. + +### Secondary: build runner (project developer) + +Uses goga in a project; runs `goga build `; installs tool packages +into the environment. Also the owner of the build configuration +(`.goga/config.yml` build section, CLI flags) — the closest thing build +has to an "author" role today. + +- **Trying to:** get the build executed as expected, with installed tools + adding value — and still understand what the tools did to the build. +- **What matters:** builds keep working with tools installed; behavior + stays predictable; tool participation is observable and diagnosable; a + failing tool degrades the way the declared error class promises; the + hooks layer adds nothing observable when no tools are installed. + +## Goals + +Priority order: G1 → G2 → G3. When openness and predictability conflict, +integration power wins; G3 is preserved as the residual guarantee, not as +a veto. + +- **G1 — Openness.** A tool package author can connect their logic to + build operation — observe build moments and participate where the + domain allows it — without changes inside goga, exactly as they + already can in topics, onboarding, statuses, and pipeline. +- **G2 — Contract completeness.** The space of realistic integration + scenarios around builds is covered definitively: reporting, automation, + and external notifications over build moments; connecting build + outcomes to the history status model and the work's artifacts; and + policy enforcement over runs. A tool author understands from the + contract how to solve their task — no scenario is left unanswered. +- **G3 — Trustworthy builds.** Opening the domain does not break the + build experience: for the runner the build stays predictable, tool + participation is observable and diagnosable, and tool failures degrade + according to the declared semantics without corrupting builds. + +## User Experience + +### Established decisions + +- **D1 Stable cycle.** Tasks and review always run as separate passes; + the combined full pass disappears. Every non-skipped run is: tasks + pass → review pass. Settings divide into two stage-bound parts with no + universal category: the `build` section root carries the tasks-pass + settings (`agent`; `env` as the tasks-pass layer — the review pass + does not receive it; `max_iterations`; the session knobs + `session_timeout`, `idle_timeout`, `wait`), and the `build.review` key + carries the review-pass settings (`agent` — inherited from the root + agent when unset; `env` as the review-pass layer; `roles`; `base_ref`; + `strategy`; `finalize`; `additional` — the external-review block: + `agent` inheriting `review.agent`, `patience`, `max_iterations`; the + session knobs inherit the root values when unset). The worktree + setting is retired outright — removed from the CLI and the build + config with no compatibility path (a major-version breaking change; a + stale `worktree` key in an existing config is simply ignored — the + loader reads known fields only) — so no run mode depends on it and the + cycle is uniform. +- **D2 Moment set.** Five build-domain actions: the soft notifications + `build_started`, `pass_started`, `pass_completed` (per stage pass: + tasks/review), `build_completed` — and the hard validation gate + `validate_build`. +- **D3 Validation gate.** `validate_build` fires after goga's own + pre-checks, before the first pass, with the resolved run facts. Every + subscribed tool's validation hooks run; each either approves silently + or vetoes with a reason. Any veto stops the build before start with + one clean merged error listing every violation (tool, hook, reason). A + blocked build fires no started/pass events. The gate modifies nothing. +- **D4 Error classes.** The four notifications are soft: a failing hook + warns naming tool, action, reason; the build is unaffected. + `validate_build` is hard: vetoes stop the build (merged error). +- **D5 Edge semantics.** goga pre-launch failures (uncommitted + manifests, invalid review config, unavailable defaults, missing build + section or agent) fire no events — the moment never happened. + Pass-failure semantics unchanged: a failed tasks pass skips the review + pass; the run's exit code is the last executed pass's; plan relocation + only on success. +- **D6 Dry-run.** Events fire with a `dry_run` fact (rehearsal); the + gate runs too — a veto blocks the rehearsal consistently; nothing + executes and the plan never relocates. +- **D7 Secrets.** Env layer values are never delivered — presence/names + only, in every context. +- **D8 Config hooks deferred.** Amendment/personalization of build + config by tools is a separate future topic — out of scope here. + +### Tool package author + +**Entry.** The build hooks usage document — a table of the domain's five +actions: the address (domain `build` + action name), when each fires, +what its context carries, what a hook may do. + +**Primary flow.** In their `goga_tool_*` package they subscribe inside +`register_hooks`. They verify with `goga hooks` — their build +subscriptions appear in the inspection tree. Edits apply from the next +command; no reinstall. + +**Building an integration.** + +- *To observe builds:* subscribe to the notifications; read the plan + identity, the stage facts (stage identity, executor, option facts), + the actual exit codes, the relocation outcome, `dry_run`, and the work + identity — external notifications, CI, dashboards. +- *To enforce policy:* subscribe to `validate_build`; inspect the + resolved facts and veto with a reason when policy is violated — or + silently log (the gate doubles as a pre-start notification for its + subscribers). +- *To connect outcomes to statuses and artifacts:* `build_completed` + carries the outcome, the relocation fact, the work identity, and that + work's history status at the completion moment — the artifact → + history-status integration builds here. + +**Failure behavior.** + +- A wrong address, an empty name, or a name collision on the same + address: the registration is skipped with a warning naming the tool + and the reason; the remaining registrations apply. +- A vetoing or crashing gate hook: the build stops before start with a + clean error naming the tool, the hook, the action, and the reason — + all vetoes merged into one message. +- A crashing notification hook: a warning naming the tool, the action, + and the reason; the build's outcome is unaffected (soft). +- A broken package import: the single fatal case — a clean error naming + the package. + +### Build runner + +Commands are unchanged (`goga build `; no new flags — the +restructure itself removes `--worktree` and `--skip-finalize`, and +`--review-patience` now addresses `review.additional.patience`). With +tools installed: + +- **A policy tool may block a build before start:** one clean error + lists every violation (tool, reason); fix or remove the cause and + retry. Nothing heavy ever starts; the plan stays in place. +- **The stable cycle is visible:** a tasks pass then a review pass, each + under its own executor and settings; a skipped review yields exactly + one tasks pass. +- **Pass outcomes and warnings always name** the tool, the action, and + the reason when tools are involved. +- **No tools installed:** the hooks layer is unobservable — no + registration, no gate walk, no events, no output differences. (The + stable-cycle restructure and the worktree removal themselves apply to + every run — they are the deliberate product change, not a tool + effect.) +- **Dry-run:** rehearses the same cycle — events carry the `dry_run` + fact, the gate runs, nothing executes, the plan stays in place. + +### States and feedback + +- Errors and warnings always name the tool, the action, and the reason. +- Registration is never cached — tool edits apply on the next command. +- `goga hooks` shows the build subscriptions of the installed tools. + +## Requirements + +### Domain actions + +- **R1.1** The build domain must open exactly five actions: the hard + validation gate `validate_build` and the soft notifications + `build_started`, `pass_started`, `pass_completed`, `build_completed`. +- **R1.2** Catalog records are added additively under the `build` + domain; no existing action or domain record is altered. +- **R1.3** Subscriptions follow the platform envelope — + `subscribe("build", action, name, hook)`; a wrong address, an empty + name, or a name collision is skipped with a warning naming the tool + and the reason; the remaining registrations apply. +- **R1.4** `goga hooks` must show the build subscriptions of installed + tool packages. + +### Stable cycle + +- **R2.1** Tasks and review must always run as separate passes; the + combined full pass is removed. Every non-skipped run is exactly: tasks + pass, then review pass. +- **R2.2** Stage binding of settings, two parts with no universal + category: the tasks pass runs under the `build` root settings — + `agent`, `env` as the tasks-pass layer which the review pass does not + receive, `max_iterations`, and the session knobs. The review pass runs + under `build.review`: `agent` — inherited from the root `agent` when + unset; `env` as the review-pass layer; `roles`; `base_ref`; `strategy` + (`full` — internal agents plus external review; `medium` — internal + only, the default, with external review explicitly disabled; `short` — + external only, the review pass running ralphex `-e` under the + additional agent's wrapper); `finalize` (the user-authored final + review prompt — when set, goga materializes the ralphex files for the + step and enables it; unset leaves the step at ralphex's default, off); + `additional` (external review: `agent` — inherited from + `review.agent` when unset; `patience`; `max_iterations`); and the + session knobs, each inherited from the root when unset. +- **R2.3** Review skip (tri-state CLI > config) yields exactly one tasks + pass and an absent review stage. +- **R2.4** Exit semantics: a failed tasks pass never launches the review + pass; the run's exit code is the last executed pass's code; plan + relocation happens only on success of the final pass. +- **R2.5** The worktree setting is removed from the CLI and the build + config with no compatibility path (a major-version breaking change); + no run mode depends on it; the host-side two-pass × worktree guard is + removed with it. + +### Validation gate + +- **R3.1** `validate_build` must fire after goga's own pre-checks + (manifest check, review-config validation, ralphex defaults sync) and + before the first pass launch — including dry-run runs. +- **R3.2** The gate context must carry the resolved run facts: the plan + identity, the bound stage composition — both resolved parts (tasks: + executor, env presence; review: executor with inheritance, env + presence, roles, base_ref, strategy, the additional facts — agent, + patience, max_iterations — and the finalize fact with its full prompt + text when configured), the skip state, `dry_run`, and the current work + identity (branch + topic when resolvable, branch-only otherwise). Env + values are never delivered. +- **R3.3** Every subscribed tool's validation hooks must run — no early + stop between tools; each approves silently or vetoes with a reason; + any veto stops the build before start with one clean merged error + listing every violation (tool, hook, reason); exit code 1; no passes + run; the plan is not relocated; no `build_started`, pass, or + `build_completed` events fire for a blocked run. +- **R3.4** A crashing gate hook counts as that tool's veto with the + crash reason named — never a raw traceback. +- **R3.5** The gate modifies nothing: no contribution, no mutation of + any delivered fact. + +### Run notifications + +- **R4.1** `build_started` must fire immediately after the gate passes + and before the first pass launch, carrying the same resolved facts as + the gate context. +- **R5.1** `pass_started` must fire before each pass launch, + identifying the stage (tasks/review), the pass executor, the pass + option facts, and `dry_run`. +- **R5.2** `pass_completed` must fire on every pass return — zero, + non-zero, and spawn-failure codes alike — carrying the pass facts plus + the actual exit code. Completion is a fact, not a success claim. +- **R5.3** A failed tasks pass fires its own `pass_completed`; no review + pass events follow. +- **R6.1** `build_completed` must fire on every return of a started + build — zero, non-zero, and spawn-failure codes alike — carrying the + final exit code, the executed stage sequence, the relocation outcome + (moved or not, and the destination when moved), `dry_run`, the work + identity, and that work's history status at the completion moment. +- **R6.2** On dry-run, `build_completed` fires with the `dry_run` fact + and a not-relocated outcome. + +### Edge semantics + +- **R7.1** Pre-launch failures (uncommitted manifests, invalid review + config, unavailable ralphex defaults, missing build section or agent) + fire no events — the moment never happened. +- **R7.2** Env layer values must never be delivered in any context — + presence/names only, matching the secret-safe dry-run behavior. +- **R7.3** Facts delivered at a checkpoint come from the operation's own + data and the history store — no git reads happen at a checkpoint + moment. + +### Zero impact and diagnostics + +- **R8.1** With no tool packages installed, the hooks layer is inert: + no registration, no gate walk, no events, no output differences. (The + stable-cycle restructure and worktree removal are deliberate product + changes and apply to every run.) +- **R8.2** Registration is never cached — package edits apply from the + next command without reinstall. +- **R8.3** Every warning and error of the domain must name the tool, the + action, and the reason. +- **R8.4** A broken tool-package import surfaces as the single clean + fatal error naming the package. + +### Documentation + +- **R9.1** Tool-author usage docs must cover all five build actions — + address, firing moment, context members, failure semantics — so every + build integration scenario is answerable from the docs. + +## Constraints + +- **C1 Execution boundary.** Pass execution belongs to the external + ralphex binary; goga observes only its own moments — settings + resolution and stage binding, pass launch, pass return code, plan + relocation. No inside-pass or stage-internal events exist, and ralphex + itself is not changed. +- **C2 Runtime boundary.** Build hooks delivery runs inside the goga + Docker container; tool packages must be installed in that environment. + The host-side launcher moments (secret env-file preparation, + `.ralphex/` mount lifecycle, `--clean`) are not hook moments. +- **C3 Platform delivery.** Hooks-platform rules hold for the build + domain: delivery is never filtered by tool eligibility; tools run at + the trust level of their installation (no isolation, no sandbox); one + registry per run; hooks receive values only for parameters declared by + the fixed offered names; per-tool isolated self contexts; a broken + package import is the only fatal case. +- **C4 Catalog additivity.** The action catalog is extended additively; + published records are never rewritten — the build actions must not + alter any existing action or domain record. +- **C5 Secret safety.** Env layer values are never delivered to hooks + and never printed — presence/names only, identical to the established + dry-run secret-safety behavior. +- **C6 Veto-only hard surface.** In this scope a tool cannot modify + build settings: the only hard action is the validation veto. Settings + personalization by tools (config hooks) is a separate future topic. +- **C7 Major-version window.** The stable-cycle restructure, the + two-part flattened settings model, and the worktree removal are + breaking changes shipped without compatibility paths; they are + accepted for the major version this PRD targets. +- **C8 Hooks-layer zero impact.** With no tool packages installed, the + hooks layer must add no observable difference — no registration, no + gate walk, no events, no output changes. +- **C9 No git reads at checkpoints.** Facts delivered at a checkpoint + come from the operation's own data and the history store; no git reads + happen at a checkpoint moment. +- **C10 Documentation surface.** The contract reaches tool authors + through the repo's usage documentation: every build action's moment, + context members, and failure semantics must be documented. + +## Scope + +### In Scope + +- The five build-domain actions on the hooks platform with their + additive catalog records: `validate_build` (hard — verdict-collecting + veto gate with a merged error) and the soft notifications + `build_started`, `pass_started`, `pass_completed`, `build_completed`. +- The stable-cycle restructure: tasks and review always run as separate + passes; the combined full pass is removed; the two-part flattened + settings model (the `build` root as the tasks-pass settings; the + `build.review` key as the review-pass settings with root inheritance, + `strategy` full | medium | short, the `additional` external-review + block, and the `finalize` prompt); tri-state review skip; exit-code + and plan-relocation semantics. +- The worktree retirement: removal of the `--worktree` flag, the + `build.worktree` config setting, and the host-side two-pass × worktree + guard — no compatibility path (major version). +- The delivered checkpoint contexts: resolved run facts, bound stage + composition, work identity (degraded branch-only form allowed), + history status at completion, relocation outcome, `dry_run` — with env + presence instead of values. +- Edge semantics: pre-launch failures fire no events; dry-run fires + events with the `dry_run` fact; a blocked run fires nothing after the + gate. +- Diagnostics: `goga hooks` inspection of build subscriptions; + never-cached registration; warnings/errors naming tool, action, + reason. +- The hooks-layer zero-impact guarantee with no tool packages installed. +- Tool-author usage documentation of the five actions (moments, context + members, failure semantics). + +### Out of Scope + +- Any consumer of the new surface: bundled, built-in, or reference + `goga_tool_*` packages — including the artifact → history-status + integration (third-party territory). +- Config hooks — tool personalization or modification of build settings + (a separate future topic by decision). +- Inside-pass or stage-level events during ralphex execution, and any + change to ralphex itself (execution boundary). +- Host-side launcher moments as hook moments (secret env-file + preparation, `.ralphex/` mount lifecycle, `--clean`) and any new host + CLI flags. +- Behavior of the other hook domains (topics, statuses, onboarding, + pipeline). +- Reporting, analytics, or telemetry products built on the events. +- Migration aids or deprecation shims for the removed or reshaped + settings — the `worktree` flag and key, the `--skip-finalize` flag and + `skip_finalize` key, the `codex_review` key, and the `task_executor` / + `review_executor` block names (explicitly rejected — breaking + changes). + +## Success Criteria + +- **SC1** A third-party `goga_tool_*` package, with no goga code change, + can subscribe to all five build actions; `goga hooks` shows its build + subscriptions. +- **SC2** Every non-skipped run executes exactly a tasks pass then a + review pass with the bound per-stage settings — including the formerly + combined case of identical executors with no env; a skipped review + yields exactly one tasks pass. +- **SC3** With a policy tool subscribed, a vetoed run stops before any + pass with one merged error listing every violation (tool, hook, + reason); exit code 1; nothing executes; the plan stays in place; no + started/pass/completed events fire for the blocked run; a + non-vetoing gate subscriber is still invoked. +- **SC4** A crashing notification hook warns naming tool, action, and + reason — the run's exit code and outcome are unaffected; + `pass_completed` and `build_completed` fire on zero, non-zero, and + spawn-failure returns alike, carrying the actual exit code. +- **SC5** `build_completed` carries the relocation outcome, `dry_run`, + the work identity, and that work's history status at the moment — the + artifact → history-status integration is buildable by a third-party + tool from documented facts alone; no context ever carries env values. +- **SC6** A dry-run rehearses the identical event structure with the + `dry_run` fact; the gate runs; nothing executes; the plan is not + relocated. +- **SC7** With no tool packages installed, the hooks layer is + unobservable — same output, errors, and exit codes as before the + change (modulo the deliberate stable-cycle and worktree changes). +- **SC8** The worktree flag and config setting no longer exist anywhere + on the build surface; the run model contains no worktree mode. +- **SC9** The tool-author documentation answers each integration + scenario named in the problem — reporting, automation, and external + notifications over build moments; connecting outcomes to statuses and + artifacts; and policy enforcement via the gate — from moments, context + members, and failure semantics alone. +- **SC10** Tool package edits apply from the next command without + reinstall. diff --git a/.goga/history/2026/add-hooks-to-build/task.md b/.goga/history/2026/add-hooks-to-build/task.md new file mode 100644 index 00000000..a9624699 --- /dev/null +++ b/.goga/history/2026/add-hooks-to-build/task.md @@ -0,0 +1,307 @@ +# Open the build domain over a stable two-pass cycle with a verdict-collecting gate + +Normative inputs: `adr.md` (accepted) and `prd.md` in this topic directory, +aligned with each other. Where any text differs, the ADR wins. + +## Current State + +- The build domain orchestrates code builds through the external ralphex + binary (cell `goga/build`: pass composition, review-phase orchestration, + vendored ralphex defaults sync, plan relocation). The run model is unstable + for integration: whether tasks and review run as one combined pass or as two + separate passes depends on incidental executor configuration (a differing + review agent or a non-empty review env). +- Build settings live in the `task_executor` / `review_executor` blocks of the + build config; executor settings are not bound to stages — the task env + applies to the whole container, not to the tasks stage. A `worktree` run mode + exists (CLI flag + config key + a host-side two-pass × worktree guard in + `goga/commands/build`). External review is toggled by a `codex_review` + setting; finalize by a `skip_finalize` flag/mirror. +- The hooks platform (facade `goga/hooks`: `HookRegistry`, + `emit_hook_event`, `wrap_context`, `declared_actions`; per-tool delivery) + already opens the pipeline, topics, onboarding, and statuses domains through + per-domain hooks zones (`goga/pipeline/hooks`, `goga/topics/hooks`). The + build domain is closed: an installed tool package can neither observe build + moments, nor enforce build policy, nor connect build outcomes to the history + status model or the work's artifacts. + +## Description + +Implement the accepted ADR as one task — restructure the run model into a +stable two-pass cycle and open the build domain to tool package integrations +over it: + +1. **Stable cycle.** Every non-skipped run is exactly two ralphex invocations: + the tasks pass (`--tasks-only`, the root agent's wrapper, the root env as + the tasks-pass env layer) then the review pass (`--review`, the review + agent's wrapper, the review env layer, review-scoped knobs). The combined + full pass is removed. A failed tasks pass never launches the review pass; + the run's exit code is the last executed pass's; plan relocation happens + only on success. The task env never reaches the review pass (secret-safe, + never printed). +2. **Two-part settings, no universal category.** The `build` config root + carries the tasks-pass settings (`agent`, `env`, `max_iterations`, + `session_timeout`, `idle_timeout`, `wait`); the `build.review` key carries + the review-pass settings (`agent`, `env`, `roles`, `base_ref`, `strategy`, + `finalize`, `additional`, plus the session knobs). Every unset review value + inherits the root value; unset at both levels → omit; `additional.agent` + inherits `review.agent`. Review strategy `full | medium | short` — default + `medium` (internal only; external review explicitly disabled); `short` runs + the review pass as ralphex `-e` under the additional agent's wrapper; + `skip` remains the tri-state all-or-nothing kill switch (CLI > config). + `build.review.additional` threads onto ralphex's external-review surface: + `agent`, `patience` (`--review-patience`, 0 = disabled), `max_iterations` + (`--max-external-iterations`; 0 = ralphex auto). `build.review.finalize` is + a user-authored string prompt: when set, goga materializes the ralphex + files for the finalize step and enables it (`finalize_enabled = true`) + during the defaults sync; when unset the step stays at ralphex's default + (off). +3. **Worktree retirement and breaking removals.** The `--worktree` flag, the + `build.worktree` key, and the host-side two-pass × worktree guard are + removed outright; a stale `worktree` key in an existing config is simply + ignored (the loader extracts known fields only). `--skip-finalize` / + `skip_finalize` and `codex_review` disappear with no replacement. Breaking + changes, no compatibility paths (major-version window). This repository's + own build section migrates to the new two-part form in the same change + (the project dogfoods its build). +4. **Hooks surface.** Five additive catalog records under the `build` domain — + the hard `validate_build` gate plus the soft notifications + `build_started`, `pass_started`, `pass_completed`, `build_completed`; no + existing record changes. A per-domain hooks zone fully symmetric with the + open domains consumes the `goga/hooks` facade; one `HookRegistry` per run + is shared by all five checkpoints. The gate is a staged per-tool walk run + to completion: every subscribed tool's validation hooks run (no early + stop); each approves silently or vetoes with a reason; a crashing hook + counts as that tool's veto with the crash reason. All vetoes merge into + one clean error listing every violation (tool, hook, reason); exit code 1; + no pass launches; the plan is not relocated; no started/pass/completed + events fire. The gate modifies nothing — a deliberate domain-local + deviation from the platform's stop-at-first-failure hard semantics, + following the `per-tool-delivery` precedent. The four notifications use + the standard fire-and-forget soft emission (a failing hook warns naming + tool, action, reason). +5. **Checkpoint contexts.** A uniform envelope (`plan`, `work` — branch + + topic when hosted, branch-only otherwise, `"unknown"` branch fallback — + and `dry_run`) plus moment facts. The gate and `build_started` carry both + resolved parts (env presence as names only). The pass contexts carry the + stage, executor, pass option facts (including strategy/additional/finalize + facts on the review pass) and the actual exit code. + `build_completed` carries the final exit code, the executed stage + sequence, the relocation outcome, and the work's current history statuses + at the completion moment — recomputed after the relocation attempt (moved + or not; branch-only form delivers an empty list). Contexts carry the full + `finalize` prompt text when configured. All contexts are read-only; env + values are never delivered anywhere; facts resolve in the operation before + delivery from the operation's own data and the history store — no git + reads at a checkpoint moment. +6. **CLI.** No new flags. `--worktree` and `--skip-finalize` are removed; + `--review-patience` addresses `build.review.additional.patience`; + `skip_manifest_check` stays a CLI-only pre-check toggle outside both + parts. +7. **Documentation.** Tool-author usage docs covering all five actions — + address, firing moment, context members, failure semantics — so every + build integration scenario is answerable from the docs. + +## Scope + +**In scope:** + +- The five build-domain actions on the hooks platform with their additive + catalog records: `validate_build` (verdict-collecting veto gate with a + merged error) and the soft notifications `build_started`, `pass_started`, + `pass_completed`, `build_completed`. +- The stable-cycle restructure: always two separate passes; the two-part + flattened settings model with root inheritance; `strategy` + full | medium | short; the `additional` external-review block; the + `finalize` prompt; tri-state review skip; exit-code and plan-relocation + semantics. +- The breaking-removals and CLI surface: the worktree retirement + (flag, config key, host-side guard — no compatibility path), the + `--skip-finalize` / `skip_finalize` and `codex_review` removals, and the + `--review-patience` repointing to `build.review.additional.patience` + (`skip_manifest_check` unchanged, CLI-only). +- The delivered checkpoint contexts: resolved run facts, bound stage + composition, work identity (degraded branch-only form allowed), history + statuses at completion, relocation outcome, `dry_run` — env presence + instead of values. +- Edge semantics: pre-launch failures fire no events; dry-run fires events + with the `dry_run` fact; a blocked run fires nothing after the gate. +- Diagnostics: `goga hooks` inspection of build subscriptions; never-cached + registration; warnings/errors naming tool, action, reason. +- The hooks-layer zero-impact guarantee with no tool packages installed. +- Tool-author usage documentation of the five actions. +- Updating this repository's own `.goga/config.yml` build section to the new + two-part form (the project dogfoods its build; the old block names become + unknown keys and would silently disable the build section). + +**Out of scope:** + +- Any consumer of the new surface: bundled, built-in, or reference + `goga_tool_*` packages — including the artifact → history-status + integration (third-party territory). +- Config hooks — tool personalization or modification of build settings. +- Inside-pass or stage-level events during ralphex execution, and any change + to ralphex itself (execution boundary). +- Host-side launcher moments as hook moments (secret env-file preparation, + `.ralphex/` mount lifecycle, `--clean`) and any new host CLI flags. +- Behavior of the other hook domains (topics, statuses, onboarding, + pipeline). +- Reporting, analytics, or telemetry products built on the events. +- Migration aids or deprecation shims for the removed or reshaped settings — + the `worktree` flag and key, the `--skip-finalize` flag and `skip_finalize` + key, the `codex_review` key, and the `task_executor` / `review_executor` + block names (explicitly rejected — breaking changes). + +## Acceptance Criteria + +Verbatim from the PRD's success criteria: + +- **SC1** A third-party `goga_tool_*` package, with no goga code change, can + subscribe to all five build actions; `goga hooks` shows its build + subscriptions. +- **SC2** Every non-skipped run executes exactly a tasks pass then a review + pass with the bound per-stage settings — including the formerly combined + case of identical executors with no env; a skipped review yields exactly + one tasks pass. +- **SC3** With a policy tool subscribed, a vetoed run stops before any pass + with one merged error listing every violation (tool, hook, reason); exit + code 1; nothing executes; the plan stays in place; no + started/pass/completed events fire; a non-vetoing gate subscriber is still + invoked. +- **SC4** A crashing notification hook warns naming tool, action, and reason — + the run's exit code and outcome are unaffected; `pass_completed` and + `build_completed` fire on zero, non-zero, and spawn-failure returns alike, + carrying the actual exit code. +- **SC5** `build_completed` carries the relocation outcome, `dry_run`, the + work identity, and that work's history status at the moment — the artifact + → history-status integration is buildable by a third-party tool from + documented facts alone; no context ever carries env values. +- **SC6** A dry-run rehearses the identical event structure with the + `dry_run` fact; the gate runs; nothing executes; the plan is not + relocated. +- **SC7** With no tool packages installed, the hooks layer is unobservable — + same output, errors, and exit codes as before the change (modulo the + deliberate stable-cycle and worktree changes). +- **SC8** The worktree flag and config setting no longer exist anywhere on + the build surface; the run model contains no worktree mode. +- **SC9** The tool-author documentation answers each integration scenario + named in the problem — reporting, automation, and external notifications + over build moments; connecting outcomes to statuses and artifacts; and + policy enforcement via the gate — from moments, context members, and + failure semantics alone. +- **SC10** Tool package edits apply from the next command without reinstall. + +Validation commands (project conventions): `pytest tests/ -x`, `ruff check` +over the touched packages, facade checks for the new zone, and absence +checks for the removed surface — `--worktree`, `--skip-finalize`, the +`worktree` / `skip_finalize` / `codex_review` config keys, and the +`task_executor` / `review_executor` block names — across the CLI and the +config loader. + +## Stack + +- **Frameworks:** none new — Python 3.10+ stdlib only (project language: + python) +- **Libraries:** stdlib `dataclasses` (`kw_only=True`) for the restructured + settings model; `click` for the existing `goga build` CLI edits; stdlib + `logging` for structured, secret-safe diagnostics; PyYAML (existing) for + the restructured loader's yaml.safe_load parsing +- **Infrastructure:** goga Docker container execution (existing + `DockerRunner`); the external ralphex binary as the pass executor — + unchanged, observed only at goga's own moments + +## External Dependencies + +| Component | Usage file | Status | +|-----------|-----------------------------------|----------| +| ralphex | `.goga/usages/cooks/ralphex.md` | updated | +| click | `.goga/usages/cooks/click.md` | existing | +| PyYAML | inline `yaml` practice of `goga/config/project` | existing | + +`ralphex.md` was updated during grooming (approved): the external review +surface (`external_review_tool` codex|custom, `custom_review_script`, +`--max-external-iterations` with the 0 = auto rule), the finalize step +(`finalize.txt` + `finalize_enabled` materialization), the always-two-pass +wording, and the `--base-ref` source key repointed to `build.review.base_ref`. + +## Risks and Constraints + +- **C1 Execution boundary** — pass execution belongs to ralphex; goga observes + only its own moments; no inside-pass events; ralphex is not changed. +- **C2 Runtime boundary** — hooks delivery runs inside the goga Docker + container; host-side launcher moments are not hook moments. +- **C3 Platform delivery** — no filtering by eligibility; installation trust + level; one registry per run; fixed offered parameter names; per-tool + isolated contexts; a broken package import is the only fatal case. +- **C4 Catalog additivity** — published records are never rewritten. +- **C5 Secret safety** — env values never delivered, never printed; names + only. +- **C6 Veto-only hard surface** — tools cannot modify build settings in this + scope. +- **C7 Major-version window** — breaking changes without compatibility paths. +- **C8 Hooks-layer zero impact** — with no tools installed, no observable + difference. +- **C9 No git reads at checkpoints** — facts come from the operation's own + data and the history store. +- **C10 Documentation surface** — every action's moment, context members, and + failure semantics documented for tool authors. +- Deferred to the architecture stage (recorded in the ADR): cell boundaries + and contract shapes for the hooks zone (context types, signatures, the gate + walk's composition over the facade); the exact threading of + `additional.agent` onto ralphex's external-review surface; the file form of + the finalize materialization. + +## Scope Estimate + +Single task — no subtask breakdown (approved). Large but coherent: four +modified cells plus one new per-domain hooks zone (by precedent, the +pipeline and topics zones carry ~11 types each) plus usage documentation. +Decomposition into cells and execution plans belongs to the architecture +stage. + +## Existing Architecture + +Link connections examined against the cell schema: + +- `goga/config/project` (re-exported through the `goga/config` facade): + `BuildConfig`, `TaskExecutorConfig`, `ReviewExecutorConfig`, + `load_project_config` — restructured into the two-part model; consumed by + `goga/build` (types) and `goga/commands/build` (loaders). The + `goga/config` facade's re-export list follows the reshaped type names + mechanically (its `TaskExecutorConfig` / `ReviewExecutorConfig` embeddings + update or drop out with the two-part model) — a consumer that tracks the + restructuring, not a design owner. +- `goga/build` — the run model: pass composition, exit semantics, ralphex + defaults sync, plan relocation; integrates the new checkpoints. Imports + today from `goga/agents` (wrapper resolution), `goga/config`, `goga/docker` + (ensure-in-docker), `goga/ralphex` (`run_ralphex`). +- `goga/commands/build` — the host-side CLI wrapper: flag removals, + `--review-patience` repointing, removal of the two-pass × worktree guard. +- `goga/hooks/catalog` — `Action` / `declared_actions`: five additive `build` + domain records; the catalog is consumed by the facade, the tools/dispatch + sub-cells, and `goga/history/statuses` — additivity keeps those consumers + untouched. +- New per-domain hooks zone (placement follows the `goga/pipeline/hooks` / + `goga/topics/hooks` precedent) — consumes the `goga/hooks` facade + (`HookRegistry`, `build_hook_arguments`, `declared_actions`, + `emit_hook_event`, `wrap_context`) with the `declaring-actions`, + `per-tool-delivery`, `registering-hooks` practices; work identity and + completion statuses come from `goga/history` + (`resolve_current_branch_name`, `collect_topic_statuses`) without new git + surface. +- `goga/ralphex` — the thin launcher `run_ralphex` consumes the resolved + per-pass options; option resolution stays in `goga/build`. + +## Notes + +- No code examples in this task (stage constraint); no architecture is fixed + here — cell boundaries and contract shapes are the architecture stage's + territory. +- The gate's verdict-collecting walk is a domain-local deviation from the + platform's hard semantics; the platform cells are not touched. +- The default strategy `medium` explicitly disables external review — + deliberate (reflects goga's current effective behavior), per the ADR. +- Grooming decisions (all user-approved): formulation and boundaries as + above; the stack (existing components only, no new usage files); the + `ralphex.md` update (five edits, applied); a single task with no + breakdown. From b14ffc532a0c045f1945b947705d12f4ec56cc48 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 19:42:47 +0000 Subject: [PATCH 082/205] feat: add build hooks zone cell and specs Open the build domain over the hooks platform: add the goga/build/hooks zone cell (checkpoint surface, gate view, run-event contexts), register the five build actions in the hooks catalog, and repoint the two-part build settings model (build.agent / build.review) across the affected CODEMANIFESTs, usages, and .goga/config.yml. --- .goga/config.yml | 11 +- .goga/usages/cooks/ralphex.md | 35 +- goga/build/.usages/build-usage.md | 195 +++--- goga/build/.usages/registering-hooks.md | 85 +++ goga/build/CODEMANIFEST | 631 +++++++++++-------- goga/build/hooks/.usages/checkpoints.md | 69 ++ goga/build/hooks/CODEMANIFEST | 533 ++++++++++++++++ goga/commands/build/.usages/build.md | 37 +- goga/commands/build/CODEMANIFEST | 91 ++- goga/commands/config/CODEMANIFEST | 8 +- goga/config/.usages/project-configuration.md | 191 +++--- goga/config/CODEMANIFEST | 18 +- goga/config/project/CODEMANIFEST | 406 +++++------- goga/hooks/catalog/CODEMANIFEST | 21 + goga/onboarding/generator/CODEMANIFEST | 6 +- goga/ralphex/.usages/run-ralphex.md | 38 +- goga/ralphex/CODEMANIFEST | 87 +-- 17 files changed, 1655 insertions(+), 807 deletions(-) create mode 100644 goga/build/.usages/registering-hooks.md create mode 100644 goga/build/hooks/.usages/checkpoints.md create mode 100644 goga/build/hooks/CODEMANIFEST diff --git a/.goga/config.yml b/.goga/config.yml index 79e97b85..95f5e578 100644 --- a/.goga/config.yml +++ b/.goga/config.yml @@ -8,12 +8,11 @@ dockerfile: Dockerfile ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic" build: - task_executor: - agent: claude - env: - <<: *claude-env - ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" - review_executor: + agent: claude + env: + <<: *claude-env + ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" + review: agent: claude base_ref: release/1.3.0 env: diff --git a/.goga/usages/cooks/ralphex.md b/.goga/usages/cooks/ralphex.md index ff67ffec..add3c54f 100644 --- a/.goga/usages/cooks/ralphex.md +++ b/.goga/usages/cooks/ralphex.md @@ -52,10 +52,10 @@ ralphex --review docs/plans/my-feature.md ``` -Two-pass orchestration is the standard composition when the task executor and the -review executor differ: pass 1 `--tasks-only` with the task wrapper, then on -its success pass 2 `--review` with the review wrapper (different -`claude_command` values, shared `--config-dir`). The `--review` mode does not +Two-pass orchestration is the only composition: pass 1 `--tasks-only` with the +task wrapper, then on its success pass 2 `--review` with the review wrapper +(separate `claude_command` values, shared `--config-dir`) — regardless of +executor configuration. The `--review` mode does not touch the branch and does not move the plan itself when `move_plan_on_completion = false`. @@ -108,6 +108,7 @@ ralphex will find the first incomplete task (`- [ ]`) and continue from there. | `--no-color` | Disable colored output | false | | `-b, --base-ref` | Override default branch for review diffs | — | | `--review-patience` | Stop external review after N unchanged rounds | 0 (disabled) | +| `--max-external-iterations` | Maximum external review iterations; `0` = ralphex auto (`max(3, max_iterations/5)`) | 0 (auto) | | `--session-timeout` | Session timeout (Go duration) | disabled | | `--idle-timeout` | Idle timeout (Go duration) | disabled | | `--wait` | Rate-limit retry wait (Go duration) | — | @@ -122,7 +123,7 @@ Note: `--base-ref` overrides ralphex's default-branch detection for review diffs; the value is a branch name or a commit hash. ralphex auto-detects the default branch via the remote HEAD (fallbacks: main/master/trunk/develop) — when detection fails, review agents lose the diff scope. goga threads -`--base-ref` from its `build.review_executor.base_ref` config key / CLI +`--base-ref` from its `build.review.base_ref` config key / CLI `--base-ref` onto review-carrying passes only. ## Configuration @@ -151,6 +152,8 @@ ralphex uses `~/.config/ralphex/` (global) or `.ralphex/` in the project root (l | `claude_command` | Claude CLI command (accepts any absolute path to a `*-as-claude.sh` wrapper script under `/home/goga/bin/`, not just the bare `claude` command) | `claude` | | `plans_dir` | Directory with plans | `docs/plans` | | `codex_enabled` | Enable codex review phase | `true` | +| `external_review_tool` | External review tool selection: `codex` or `custom` | `codex` | +| `custom_review_script` | Script invoked for external review when `external_review_tool = custom` | — | | `task_retry_count` | Number of retries per task | `1` | | `finalize_enabled` | Final step after review | `false` | | `move_plan_on_completion` | Move the plan file to `completed/` after a successful run. goga always sets this to `false` and moves the plan itself after any successful run | `true` | @@ -170,6 +173,28 @@ By default, ralphex launches 5 parallel agents: Agents are customizable — you can add, remove, and modify them via `~/.config/ralphex/agents/`. +## External review surface + +The external review phase is controlled by `-e/--external-only` (external-only +review pass), `--review-patience` (stop after N unchanged rounds; 0 = disabled) +and `--max-external-iterations` (0 = auto: `max(3, max_iterations/5)`). +The tool backend is selected by config: `external_review_tool` (`codex` | +`custom`) with `custom_review_script` naming the script for the `custom` +backend. goga threads these from the `build.review.additional` block: the +additional agent's wrapper becomes the `custom_review_script` of the external +review surface (with `external_review_tool = custom`), `patience` → +`--review-patience`, `max_iterations` → +`--max-external-iterations`. + +## Finalize step + +`finalize` is the final review step — a ralphex review agent carrying the +`finalize.txt` prompt — gated by `finalize_enabled` (default `false`). When +`build.review.finalize` is set, goga materializes the ralphex files for the +step from the user-authored prompt string and sets `finalize_enabled = true` +during the defaults sync; when unset, the step stays at ralphex's default +(off). + ## Vendorable defaults ralphex ships its built-in defaults (4 prompts: `task.txt`, `review_first.txt`, diff --git a/goga/build/.usages/build-usage.md b/goga/build/.usages/build-usage.md index 30b276ca..0e9c9cf5 100644 --- a/goga/build/.usages/build-usage.md +++ b/goga/build/.usages/build-usage.md @@ -2,14 +2,22 @@ ## Overview -The `goga.build` module orchestrates code builds through ralphex — handling environment -preparation, ralphex config generation, default prompt/agent copying, ralphex option -resolution, and delegation of the launch to `run_ralphex` (goga/ralphex). - -The AI agent is selected via `.goga/config.yml` `build.task_executor.agent`. The agent name -is resolved at runtime to the absolute in-container path of its `*-as-claude.sh` wrapper, -and that path is written into `.ralphex/config` `claude_command`. ralphex option precedence -(CLI > ProjectConfig > omit) is resolved in `build()` before delegating the launch. +The `goga.build` module orchestrates the stable two-pass build cycle through +ralphex — settings resolution with root inheritance, the five hooks checkpoints, +ralphex config generation with the external-review surface, vendored +defaults sync with finalize materialization, and delegation of the launch to +`run_ralphex` (goga/ralphex). + +Every non-skipped run is exactly two ralphex invocations: a tasks pass +(`--tasks-only`) then a review pass (`--review`, or `-e` under the short +strategy). A skipped review yields exactly one tasks pass. The combined full +pass does not exist. + +The executor agent comes from `.goga/config.yml`: the `build.agent` root key +for the tasks pass, `build.review.agent` for the review pass (inheriting the +root agent when unset). The agent name is resolved at runtime to the absolute +in-container path of its `*-as-claude.sh` wrapper, and that path is written +into `.ralphex/config` `claude_command`. ## Usage @@ -24,10 +32,14 @@ exit_code = build( config=config, cli_options={ "dry_run": False, - "worktree": True, - "skip_finalize": False, "skip_manifest_check": False, - "base_ref": "origin/1.2.x", # review diff base (review-scoped) + "skip_review": None, # tri-state: True / False / None + "base_ref": "origin/1.2.x", # review diff base (review pass only) + "review_patience": 3, + "session_timeout": None, + "idle_timeout": None, + "wait": None, + "max_iterations": None, }, ) ``` @@ -36,83 +48,119 @@ exit_code = build( - `plan` — path to the plan file (markdown) - `config` — ProjectConfig object loaded via `load_project_config` -- `cli_options` — options dictionary (dry_run, worktree, skip_finalize, skip_manifest_check, - skip_review, session_timeout, idle_timeout, wait, max_iterations, review_patience, - base_ref) - -## Review-phase control - -### Skipping review - -cli_options={'skip_review': True} or .goga/config.yml build.review_executor.skip: true -→ the run executes tasks only (ralphex --tasks-only); codex_enabled stays as configured. - -### Two-pass (different review executor or review env) - -build.review_executor.agent != build.task_executor.agent OR a non-empty -build.review_executor.env (with agent set) → pass 1 --tasks-only (task wrapper, -no env layer), pass 2 --review (review wrapper, review env layered over the -container environment). Pass-1 failure exits with its code. Review env requires -agent: a non-empty env without agent fails validation when the review phase -runs. With skip: true the review env is ignored entirely. - -### Reviewer roles - -build.review_executor.roles filters {{agent:X}} lines in both review prompts; empty list -or absent = full default set; files of all 5 agents are always present in .ralphex/agents/. - -### Review-scoped options - -`base_ref` (review diff base — branch name or commit hash) and `patience` -(external-review stop threshold) are review-scoped: they resolve with -precedence CLI > `build.review_executor.*` > omit and join the ralphex -options of review-carrying passes only — the full-mode single pass and the -two-pass review pass. A skipped run and the tasks-only pass never carry -them. - -cli_options={'base_ref': 'origin/1.2.x'} or .goga/config.yml -build.review_executor.base_ref: origin/1.2.x → ralphex receives ---base-ref origin/1.2.x on the review-carrying pass. The same precedence -holds for the review_patience cli_options key / -build.review_executor.patience → --review-patience. - -When neither source sets them, the keys stay absent and the assembled -ralphex command carries no extra flags. +- `cli_options` — options dictionary (`dry_run`, `skip_manifest_check`, + `skip_review`, `base_ref`, `review_patience`, `session_timeout`, + `idle_timeout`, `wait`, `max_iterations`); each knob is None when the CLI + flag was not given + +## Two-part settings and inheritance + +The build section of `.goga/config.yml` is two-part. The `build` root carries +the tasks-pass settings (agent, env, max_iterations, session_timeout, +idle_timeout, wait, prompts_dir, agents_dir, proxy, hosts); the `build.review` +key carries the review-pass settings (skip, agent, env, roles, base_ref, +strategy, finalize, additional, and the session knobs). + +Resolution (in `resolve_run_settings`, precedence CLI > config > default > +omit): + +- `skip` — cli_options `skip_review` when not None, else `build.review.skip`, + else False +- `strategy` — `build.review.strategy` when set, else medium +- review agent and session knobs — the review value when set, else the root + value +- `max_iterations` — root-only (the tasks-pass knob) +- review env — exactly `build.review.env`; it NEVER inherits the root env + (the root env is the tasks-pass layer, secret-safe) +- `additional.agent` — the additional value when set, else the resolved + review agent + +## Strategies + +- **full** — the review pass runs internal review with the external review + enabled (ralphex default); an `additional.agent` customizes it + (`external_review_tool: custom` + the agent's wrapper script) +- **medium** (default) — the external review is explicitly disabled + (`codex_enabled: false`); internal reviewer agents only +- **short** — the review pass runs as the external-only pass (`-e`) under + the additional agent's wrapper (falling back to the review agent) + +## additional (external-review block) + +`build.review.additional` carries `agent` (inherits `build.review.agent`), +`patience` (stop after N consecutive unchanged rounds; 0 = disabled; forwarded +as `--review-patience`), and `max_iterations` (external review iteration cap; +0 = ralphex auto; forwarded as `--max-external-iterations`). + +## finalize + +`build.review.finalize` is the user-authored final review prompt, stored +verbatim. When set, `sync_ralphex_defaults` materializes the ralphex finalize +step files and `.ralphex/config` sets `finalize_enabled: true`. Unset leaves +the finalize step at the ralphex default (off). + +## Reviewer roles + +`build.review.roles` filters {{agent:X}} lines in both review prompts; None or +an empty list = full default set; files of all 5 agents are always present in +.ralphex/agents/. + +## The five checkpoints + +The cycle delivers five hooks checkpoints (see the `checkpoints` practice of +goga/build/hooks and `registering-hooks` here for subscribing): + +1. `validate_build` (hard gate) — after goga's pre-checks, before the first + pass; every subscribed tool's hooks run to completion, vetoes merge into + one error (tool, hook, reason), exit 1, nothing launches +2. `build_started` (soft) — immediately after the gate passes +3. `pass_started` (soft) — before each pass launch +4. `pass_completed` (soft) — on every pass return, with the actual exit code +5. `build_completed` (soft) — after the relocation attempt and the status + recompute + +On dry-run the identical event structure fires with the `dry_run` fact; the +gate runs; nothing executes. Pre-launch failures (uncommitted manifests, +invalid review config, unavailable defaults) and a blocked (vetoed) run fire +no events. ## Review-pass environment -build.review_executor.env (mapping of strings) overrides same-named variables -for the review pass only; every other container variable (home.env, git -identity, task_executor.env, CLI -e) passes through unchanged. The tasks pass -is unaffected. Dry-run does not print the layer (secret-safe); an active -worktree combined with an env-induced two-pass run is rejected by the host -launcher before the container starts. +`build.review.env` (mapping of strings) overrides same-named variables for the +review pass subprocess only; every other container variable passes through +unchanged. The tasks pass receives `build.env` as its env layer the same way. +Neither layer is printed on dry-run (secret-safe). ## Plan relocation -After ANY successful run (full / skip / two-pass) the plan moves to /completed/; -on failure or --dry-run it stays in place. .ralphex/config always has +After a successful run the plan moves to /completed/; on failure or +dry-run it stays in place. .ralphex/config always has move_plan_on_completion = false — goga moves the plan itself. ## Agent resolution -`.goga/config.yml` field `build.task_executor.agent` is the agent name. `build()` resolves it -through `resolve_wrapper_path` and writes the absolute path into `.ralphex/config` -`claude_command`. ralphex then invokes the wrapper directly (launched via `run_ralphex`). +`.goga/config.yml` field `build.agent` is the tasks-pass agent name; +`build.review.agent` (default: the root agent) is the review-pass agent name. +`build()` resolves each through `resolve_wrapper_path` and writes the absolute +path into `.ralphex/config` `claude_command` of the respective pass. ralphex +then invokes the wrapper directly (launched via `run_ralphex`). ## Return value - `0` — success -- `1` — failure (uncommitted manifests, ralphex not found, build error) +- `1` — failure (uncommitted manifests, a vetoed gate, ralphex not found, + build error) ## Side effects -- Creates `.ralphex/config` with `claude_command` set to the resolved wrapper path and - `move_plan_on_completion = false` -- Fully rewrites prompts and agents in `.ralphex/` from the vendored ralphex defaults - (or the configured custom directories) on every run -- Delegates the launch to `run_ralphex` (goga/ralphex), which spawns the `ralphex` subprocess - (the build env is delivered through the container env-file by the host launcher) +- Creates `.ralphex/config` per pass with `claude_command` set to the pass's + resolved wrapper path and `move_plan_on_completion = false` +- Fully rewrites prompts and agents in `.ralphex/` from the vendored ralphex + defaults (or the configured custom directories) on every run; materializes + the finalize step files when `build.review.finalize` is set +- Delegates each launch to `run_ralphex` (goga/ralphex), which spawns the + `ralphex` subprocess (the pass env layers are applied in-container, not + through the host env-file) - Relocates the plan file to `/completed/` after a successful run ## .ralphex/ lifecycle @@ -123,4 +171,7 @@ prepares the mount before launch and wipes it only on `goga build --clean`. The ## Docker entry point -`main()` calls `ensure_in_docker()` first, then argparse handles parsing (including `--base-ref`) and calls `build()`. +`main()` calls `ensure_in_docker()` first, then argparse handles parsing +(`--skip-review`/`--no-skip-review`, `--base-ref`, `--dry-run`, +`--skip-manifest-check`, `--session-timeout`, `--idle-timeout`, `--wait`, +`--max-iterations`, `--review-patience`) and calls `build()`. diff --git a/goga/build/.usages/registering-hooks.md b/goga/build/.usages/registering-hooks.md new file mode 100644 index 00000000..417a086c --- /dev/null +++ b/goga/build/.usages/registering-hooks.md @@ -0,0 +1,85 @@ +# build — registering hooks + +How a `goga_tool_*` package subscribes its hooks to the build domain actions. +For tool package authors; no goga code changes are needed. + +The domain opens five actions. One is the validation gate — a read-and-veto +view over the resolved run facts, delivered before the first pass; it is a hard +action with verdict collection: every subscribed tool's hooks run and all +vetoes merge into one error. Four are notifications — the read-only facts of +the run: at the start, around each pass, and at the completion. + +## The events + +| Address | Error class | Fires | +|---|---|---| +| `build / validate_build` | hard | After goga's own pre-checks (manifest check, settings resolution, review-config validation, ralphex defaults sync) and before the first pass launch — including dry-run runs. | +| `build / build_started` | soft | Immediately after the gate passes, before the first pass launch. | +| `build / pass_started` | soft | Before each pass launch — tasks and review. | +| `build / pass_completed` | soft | On every pass return — zero, non-zero, and spawn-failure codes alike, carrying the actual exit code. | +| `build / build_completed` | soft | On every return of a started build — after the relocation attempt and the status recompute. | + +A failing moment fires nothing: goga pre-launch failures (uncommitted +manifests, invalid review config, unavailable defaults, missing build section +or agent) return before any checkpoint. A blocked (vetoed) run fires nothing +after the gate. + +## Subscribe + + def register_hooks(hooks): + hooks.subscribe("build", "validate_build", "policy", enforce_policy) + hooks.subscribe("build", "build_completed", "reporter", report_build) + +- `domain` — always `"build"`; `action` — from the table; `name` — unique per + tool per address; `hook` — the callable executed when the event fires. +- A hook receives values only for the parameters it declares by the fixed + offered names: `context`, `self`. + +## The gate view + +`validate_build` delivers a `BuildValidation` view per tool: `moment` (plan, +work, dry_run), `tasks` and `review` — the resolved stage facts (the executor +agent, env presence as names, the option facts; review adds roles, base_ref, +strategy, the additional facts, and the finalize prompt text), `skip`. + + def enforce_policy(context): + if violates(context): + context.veto("reason") + +- `veto(reason)` buffers your tool's single veto; a repeat call replaces the + reason whole. +- Your tool's hooks all run even when another tool already vetoed — verdict + collection requires every tool's outcome. +- A crashing hook counts as your tool's veto with the crash reason — never a + raw traceback. +- All vetoes merge into one clean error (tool, hook, reason); the run stops + before any pass: exit code 1, the plan stays in place, no + started/pass/completed events fire. + +## The notifications + +All four deliver read-only facts; a failing hook warns naming your tool, the +action, and the reason — the run's outcome is never affected. + +- `build_started` — `BuildStarted`: the same facts as the gate. +- `pass_started` — `PassStarted`: the stage facts of the pass about to launch. +- `pass_completed` — `PassCompleted`: the stage facts plus the actual + `exit_code`. Completion is a fact, not a success claim. +- `build_completed` — `BuildCompleted`: the final `exit_code`, `stages` (the + executed sequence), `relocation` (moved + destination), `statuses` (the + work's current history statuses recomputed after the relocation attempt — + empty in the branch-only form), `dry_run`. + +Env values are never delivered — presence as names only, in every context. + +## Integration scenarios + +- **Build reporting, automation, external notifications** — subscribe to the + four notifications; read the stage facts, the exit codes, the relocation + outcome, `dry_run`; keep state in your `self` context. +- **Artifact → history-status on completion** — subscribe to + `build_completed`; read `relocation` and `work`; register your status on the + statuses domain keyed by your artifact. +- **Policy enforcement** — subscribe to `validate_build`; inspect the resolved + facts; `context.veto(reason)` when policy is violated — or stay silent to use + the gate as a pre-start notification. diff --git a/goga/build/CODEMANIFEST b/goga/build/CODEMANIFEST index 2a66ff19..b13d4100 100644 --- a/goga/build/CODEMANIFEST +++ b/goga/build/CODEMANIFEST @@ -2,8 +2,8 @@ Imports: - Types: - ProjectConfig - BuildConfig - - TaskExecutorConfig - - ReviewExecutorConfig + - ReviewConfig + - AdditionalReviewConfig - load_project_config From: goga/config - Types: @@ -21,6 +21,24 @@ Imports: Usages: - run-ralphex From: goga/ralphex + - Types: + - BuildHooks + - BuildMoment + - StageFacts + - WorkIdentity + - RelocationOutcome + - GateVerdict + Usages: + - checkpoints + From: goga/build/hooks + - Types: + - resolve_current_branch_name + - resolve_topic_dir + - collect_topic_statuses + Usages: + - topic-paths + - topic-statuses + From: goga/history Usages: conventions: .goga/usages/conventions.md @@ -35,134 +53,131 @@ Annotations: | - Organizing the test infrastructure - Understanding the general principles and rules of development and testing in the project - This cell owns the build domain: manifest-commit verification, agent-wrapper resolution, - ralphex config generation, default prompt/agent copying, and ralphex option resolution - (CLI > ProjectConfig > omit). It delegates the ralphex launch to `run_ralphex` from - goga/ralphex (per the `run-ralphex` practice) — ralphex is launched through `run_ralphex`, - never directly from this cell. - - Option resolution follows two zones: universal options (worktree, - skip_finalize, session_timeout, idle_timeout, wait, max_iterations) - resolve with precedence CLI > `BuildConfig` > omit and apply to every - ralphex pass; review-scoped options (base_ref, patience) resolve with - precedence CLI > `ReviewExecutorConfig` > omit inside `resolve_review_options` - and apply only to review-carrying passes (the full-mode single pass and the - two-pass review pass). - - This cell also owns the review-phase orchestration of the build: tri-state - skip resolution, review-env handling (two-pass induction by a non-empty - review env, the env-requires-agent gate, the per-pass env layer of the - review pass), vendored ralphex defaults synced into .ralphex/, review-prompt - filtering by declared roles, single- and two-pass launch through `run_ralphex` - (the review pass carries the review env as its env layer), plan relocation - after a successful run, and semantic validation of the review configuration — - all expressed via the ralphex launch, with no review implementation of its own. + This cell owns the build domain: manifest-commit verification, two-part + settings resolution with root inheritance, the stable two-pass cycle with + the five checkpoints, ralphex config generation with the external-review + surface, vendored defaults sync with finalize materialization, and plan + relocation. Every non-skipped run is exactly two ralphex invocations — a + tasks pass then a review pass; the combined full pass does not exist. It + delegates the ralphex launch to `run_ralphex` from goga/ralphex (per the + `run-ralphex` practice) — ralphex is launched through `run_ralphex`, never + directly from this cell. + + The checkpoint facts resolve in this operation before delivery (per the + `checkpoints` practice): the work identity via `resolve_current_branch_name` + with the "unknown" fallback, the topic hosting via `resolve_topic_dir`, and + the completion statuses via `collect_topic_statuses` — all before the + checkpoint moments, never at them. Env values are never delivered to any + context and never printed — presence travels as names only. Use the `conventions` practice for development and testing. - Use the `ralphex` practice for the ralphex config-generation contract (the .ralphex/config - key layout written before launch). - Use the `agent-wrappers` practice for the in-container wrapper naming convention referenced - when writing claude_command into .ralphex/config. + Use the `ralphex` practice for the ralphex config-generation contract (the + .ralphex/config key layout, the external-review surface, the finalize step + files) written before launch. + Use the `agent-wrappers` practice for the in-container wrapper naming + convention referenced when writing claude_command into .ralphex/config. Use the `resolve-wrapper-path` practice when calling `resolve_wrapper_path`. Use the `run-ralphex` practice to delegate the launch. + Use the `checkpoints` practice for the checkpoint integration order and the + fact resolution. + Use the `topic-paths` practice for the work identity resolution behind the + branch hosting and the `topic-statuses` practice for the completion status + facts. Write all output to sys.stderr (click is not used). - Run git external commands via subprocess (the manifest pre-check). + Run git external commands via subprocess (the manifest pre-check and the + branch resolution) — never at a checkpoint moment. --- "build(plan: str, config: ProjectConfig, cli_options: dict) -> exit_code:int": location: build.py annotations: | - Orchestrates code builds through `ralphex`. The function prepares the - execution environment and launches the build runner. + Orchestrates the stable two-pass build cycle with the five hooks + checkpoints. `plan`: path to the plan file (markdown) `config`: loaded project configuration object - `cli_options`: dictionary of CLI options (dry_run, worktree, skip_finalize, - skip_manifest_check, skip_review, session_timeout, - idle_timeout, wait, max_iterations, review_patience, - base_ref) + `cli_options`: dictionary of CLI options (dry_run, skip_manifest_check, + skip_review, base_ref, review_patience, session_timeout, + idle_timeout, wait, max_iterations) `exit_code`: process exit code (0 = success, 1 = failure) Algorithm: - 0. (pre-check) When skip_manifest_check is not set: - - Verify all project CODEMANIFEST files are committed to git - - Reject with exit code 1 if any uncommitted manifests are found - 1. Resolve the agent wrapper path by calling `resolve_wrapper_path` with the - agent field of `TaskExecutorConfig`, per the `resolve-wrapper-path` practice (absolute - in-container path /home/goga/bin/-as-claude.sh per `agent-wrappers`) - 2. Resolve the review options (skip, review agent, roles, review env, - two-pass mode, review-scoped options base_ref and patience) via - `resolve_review_options` - 3. Validate the review configuration via `validate_review_config` when the - review phase will run - 4. Write .ralphex/config for the first pass via `write_ralphex_config` - 5. Fully rewrite .ralphex/prompts/ and .ralphex/agents/ from the vendored - defaults via `sync_ralphex_defaults`, applying the declared roles to the - review prompts - 6. Resolve the universal ralphex options with precedence CLI options > - `BuildConfig` > omit — worktree, skip_finalize, session_timeout, - idle_timeout, wait, max_iterations — producing the universal options for - `run_ralphex`; review-scoped options are NOT resolved here (their owner - is `resolve_review_options` of step 2) - 7. Launch each planned pass via `run_build_pass` (which delegates the launch - to `run_ralphex`), forwarding the dry_run flag of `cli_options` so a dry - run prints the commands of every planned pass instead of launching; - compose the options of each pass: - - universal options join the options of every pass - - review-scoped options — base_ref (options key base_ref) and patience - (options key review_patience) of `ReviewOptions` — join the options of - review-carrying passes ONLY: the full-mode single pass and the two-pass - review pass; a skip run and the tasks-only pass carry universal - options only - - skip run: one pass with tasks_only — the review env is ignored - entirely; other phases unchanged - - two-pass run: pass 1 with tasks_only and the task wrapper, no env - layer; when pass 1 succeeds — pass 2 in the review-only mode (options - key review → the ralphex --review bare flag) with the review wrapper - and the review env as the env layer (the layer overlays the container - environment on the pass-2 subprocess only); a pass-1 failure exits - with its code and skips pass 2 - - otherwise: one full pass, no env layer - 8. Relocate the plan via `move_completed_plan` with outcome = success of - the final pass and the dry_run flag forwarded (a dry run leaves the plan - in place) - 9. Return the exit code of the last pass + 0. (pre-check) When skip_manifest_check is not set: verify all project + CODEMANIFEST files are committed to git; reject with exit code 1 when + any are uncommitted. A pre-check failure fires no events + 1. Resolve the run settings via `resolve_run_settings` — the two parts + with root inheritance, the tri-state skip, the strategy default medium + 2. Validate the review configuration via `validate_review_config` when + the review pass will run; a validation failure fires no events + 3. Rewrite .ralphex/prompts/ and .ralphex/agents/ from the vendored + defaults via `sync_ralphex_defaults` — roles filtering, finalize + materialization when the prompt is set + 4. Resolve the checkpoint facts from this operation's own data: the + `WorkIdentity` (branch via `resolve_current_branch_name` with the + "unknown" fallback, slug/year via `resolve_topic_dir` when the branch + hosts a topic), the `BuildMoment` (plan, work, dry_run), and both + `StageFacts` (env as names) + 5. Run the validation gate via `BuildHooks` (its validate_build + checkpoint); when the + returned `GateVerdict` is not approved: print one merged error listing + every violation (tool, hook, reason) to sys.stderr and return exit + code 1 — no pass launches, the plan is not relocated, no further + events fire + 6. Emit build_started with the same facts the gate saw + 7. Tasks pass: compose the options via `compose_pass_options` (stage + tasks), resolve the root agent wrapper per the `resolve-wrapper-path` + practice, emit pass_started, launch via `run_build_pass` with the + root env as the env layer, emit pass_completed with the actual exit + code + 8. When the tasks pass succeeded and review is not skipped: review pass + under the review agent's wrapper (the additional agent's wrapper under + the short strategy), the review env as the env layer, the + strategy-bound options; pass_started / launch / pass_completed + around it. A failed tasks pass never launches the review pass + 9. Relocate the plan via `move_completed_plan` with outcome = success of + the final pass (dry-run and failure leave the plan in place); take the + returned `RelocationOutcome` + 10. Recompute the work's history statuses via `collect_topic_statuses` + AFTER the relocation attempt (moved or not; branch-only form — an + empty list) + 11. Emit build_completed with the final exit code, the executed stage + sequence, the relocation outcome, and the recomputed statuses + 12. Return the exit code of the last executed pass Apply `conventions` for docstring style and intra-package imports. Apply `ralphex` for the config-generation contract. - Apply `agent-wrappers` for the wrapper path semantics in step 1. - Apply `resolve-wrapper-path` when calling `resolve_wrapper_path` in step 1. - Apply `run-ralphex` when delegating the launch in step 7. + Apply `agent-wrappers` for the wrapper path semantics. + Apply `resolve-wrapper-path` when calling `resolve_wrapper_path`. + Apply `run-ralphex` when delegating the launch. + Apply `checkpoints` for the checkpoint order and fact resolution. + Apply `topic-paths` and `topic-statuses` for the work identity and the + completion statuses. Requirements: - - `ralphex` is launched only through `run_ralphex` — never via a direct subprocess call - - Return code: the exit code returned by the last `run_build_pass` (ralphex exit code on success, 1 on error) - - Minimal output: log each step to sys.stderr - - claude_command in .ralphex/config MUST be the resolved wrapper path - - preserve_anthropic_api_key in .ralphex/config MUST be true - - move_plan_on_completion in .ralphex/config MUST be false — goga relocates - the plan itself via `move_completed_plan` - - Resolve ralphex option precedence (CLI > ProjectConfig > omit) before delegating - - A skipped run performs no review phase of any kind — internal agents, - second pass, codex review - - On dry-run print the commands of every planned pass and leave the plan - in place - - On dry-run print the commands of every planned pass without the env layer - contents — the review env never reaches logs or dry-run output - - Review-scoped options never appear in the options of a skip run or the - tasks-only pass - - When neither the CLI nor the config sets base_ref/patience, the keys stay - absent from the options — the assembled ralphex command carries no - --base-ref / --review-patience flags + - `ralphex` is launched only through `run_ralphex` — never via a direct + subprocess call + - Every non-skipped run is exactly two passes; a skipped review yields + exactly one tasks pass + - The run's exit code is the last executed pass's; plan relocation + happens only on success of the final pass + - The task env never reaches the review pass (secret-safe, never printed) + - On dry-run: fire the identical event structure with the dry_run fact, + print the commands of both passes without env layers, relocate nothing + - Pre-launch failures (steps 0-3) fire no events — the moment never + happened + - A blocked run (step 5) fires nothing after the gate + - Warnings and errors of the checkpoints name the tool, the action, and + the reason (delivered by the zone per its practices) Constraints: - - Wrappers live in the image at /home/goga/bin/ and are referenced by absolute path - - Agent resolution is uniform — do not branch by agent name - - Do not assemble the ralphex command or invoke ralphex directly — delegate to `run_ralphex` - - The .ralphex/ directory lifecycle is owned by the host launcher (goga/commands/build) - - Do not resolve review-scoped option precedence in the universal resolution - step — the owner is `resolve_review_options` + - Do not assemble the ralphex command or invoke ralphex directly — + delegate to `run_ralphex` + - Do not read git at a checkpoint moment — the facts resolve before + delivery + - Do not deliver or print env values — names only, in every fact + - The .ralphex/ directory lifecycle is owned by the host launcher + (goga/commands/build) "main() -> exit_code:int": location: __main__.py @@ -172,263 +187,353 @@ Annotations: | `exit_code`: process exit code (0 = success, 1 = failure) Algorithm: - 0. Call `ensure_in_docker` as the very first statement — refuse to proceed - when the process is not running inside the goga Docker image (per the + 0. Call `ensure_in_docker` as the very first statement (per the `ensure-in-docker` practice) 1. Parse CLI arguments via argparse (plan + options); the argparse surface - carries the --skip-review / --no-skip-review pair resolving to - skip_review: bool | None (None when neither flag is given) and the - --base-ref option (type str, default None — the review diff base) + carries the --skip-review / --no-skip-review pair resolving + to skip_review: bool | None, --base-ref, --dry-run, + --skip-manifest-check, --session-timeout, --idle-timeout, --wait, + --max-iterations, and --review-patience (addressing + build.review.additional.patience); --worktree and --skip-finalize do + not exist 2. Load project configuration via `load_project_config` - 3. Build cli_options from the parsed argparse results; cli_options carries - the skip_review key and the base_ref key + 3. Build cli_options from the parsed argparse results 4. Invoke `build`(plan, `ProjectConfig`, cli_options) 5. Return the resulting `exit_code` Requirements: - - The guard at step 0 MUST be covered by tests for both branches: - success path with GOGA_DOCKER=1 proceeds to argparse; refusal path - without the marker writes to stderr and exits with code 1 before any - filesystem or process work + - The guard at step 0 MUST be covered by tests for both branches Apply the `ensure-in-docker` practice at step 0. -"resolve_review_options(config: BuildConfig, cli_options: dict) -> review: ReviewOptions": - location: review_options.py +"resolve_run_settings(config: BuildConfig, cli_options: dict) -> settings: RunSettings": + location: run_settings.py annotations: | - Resolve the review-phase execution plan of one build from the tri-state CLI - value and the project configuration. + Resolve the run settings of one build from the two-part configuration + and the CLI options — root inheritance applied, tri-state skip resolved, + strategy defaulted. - `config`: build configuration (`BuildConfig`) with the optional review_executor sub-configuration + `config`: build configuration (`BuildConfig`) in the two-part form; + its review part may be None — step 0 covers it `cli_options`: CLI options dictionary; the keys read here are skip_review - (bool | None — None = flag not given), base_ref (str | None — an empty - or whitespace-only value counts as unset), and review_patience - (int | None) - `review`: resolved review options (`ReviewOptions`) + (bool | None), base_ref (str | None — empty/whitespace counts as unset), + review_patience, session_timeout, idle_timeout, wait, max_iterations + (each None when the flag was not given) + `settings`: the resolved run plan (`RunSettings`) Algorithm: - 1. Resolve skip: take cli_options skip_review when it is not None, otherwise - the skip field of `ReviewExecutorConfig`, otherwise False - 2. Take review_agent from `ReviewExecutorConfig` verbatim - 3. Compute two_pass: review_agent is set AND (review_agent differs from the - agent field of `TaskExecutorConfig` OR the env field of - `ReviewExecutorConfig` is non-empty) - 4. Take roles verbatim (None or an empty list stay as they are) - 5. Take the review env verbatim into review_env (an empty dict stays empty; - a review env equal to task_executor.env is still non-empty and keeps - two_pass true) - 6. Resolve base_ref: take the cli_options base_ref when not None, otherwise - the base_ref field of `ReviewExecutorConfig`, otherwise None; an empty - or whitespace-only value from either source resolves as unset (None); - a padded value (non-empty with surrounding whitespace) resolves to its - stripped form — the CLI path and directly-constructed configs are not - loader-normalized - 7. Resolve patience: take the cli_options review_patience when not None, - otherwise the patience field of `ReviewExecutorConfig`, otherwise None + 0. When config.review is None (the build.review key is absent), + treat every review field as unset: skip resolves False, the agent + and session knobs inherit the root values per step 4, + base_ref/roles/finalize stay None, and the additional part resolves + with agent = the resolved review agent and patience/max_iterations + None + 1. Resolve skip: cli_options skip_review when not None, otherwise the + skip field of `ReviewConfig`, otherwise False + 2. Resolve strategy: the strategy field of `ReviewConfig` when set, + otherwise medium + 3. Tasks part: the root agent and env verbatim; max_iterations and + each session knob resolve the cli_options value when given (not + None), otherwise the root value + 4. Review part: agent from `ReviewConfig` when set, otherwise the root + value; each session knob resolves the cli_options value when given, + otherwise the review value when set, otherwise the root value + (max_iterations is root-only; env never inherits — the root env is + the tasks-pass layer only, the review env is exactly + build.review.env) + 5. Additional: agent from `AdditionalReviewConfig` when set, otherwise + the resolved review agent; patience resolves the cli_options + review_patience when given, otherwise the `AdditionalReviewConfig` + value verbatim; max_iterations from `AdditionalReviewConfig` + verbatim (None when the block is absent) + 6. base_ref: cli_options base_ref when not None (empty/whitespace → + unset; padded → stripped), otherwise `ReviewConfig` base_ref Requirements: - - Precedence CLI > ProjectConfig > omit - - An empty roles list reaches the consumers as an empty list - - A non-empty review env induces two_pass regardless of dictionary equality - with task_executor.env - - Precedence for base_ref and patience: CLI > build.review_executor.* > omit - - An absent review_executor section leaves base_ref and patience None (like - the other review fields) - - The code docstring of `resolve_review_options` lists the same three - cli_options keys — skip_review, base_ref, and review_patience + - Precedence CLI > config > default > omit for every resolved knob + - Unset at both levels resolves to None — the key stays absent from the + ralphex options + - env dicts pass verbatim; the review env never inherits the root env + (secret-safe — the root env is the tasks-pass layer); an empty + review env means no review layer Constraints: - Pure — no side effects, no validation of values (separate routine) - - The resolved base_ref is never checked for resolvability or format — - diagnostics of the review diff base belong to ralphex + - Do not resolve wrappers — wrapper resolution belongs to the + orchestrator and the validation routine + - Do not validate the strategy value — the whitelist check belongs to + `validate_review_config` -"ReviewOptions(skip: bool, review_agent: str | None, roles: list[str] | None, two_pass: bool, review_env: dict[str, str], base_ref: str | None, patience: int | None)": - location: review_options.py +"RunSettings(skip: bool, tasks: PassSettings, review: ReviewPassSettings)": + location: run_settings.py annotations: | - Resolved review-phase execution plan of a single build. - - `skip`: final skip decision (False when neither source is set) - `review_agent`: review executor name, None when unset - `roles`: declared reviewer composition, verbatim (None or [] = full default set to the consumer) - `two_pass`: True when the review executor differs from the task executor OR a - non-empty review env is declared (with an agent set) - `review_env`: review-pass environment layer, verbatim from - `ReviewExecutorConfig` (an empty dict when unset); forwarded as - the env layer of the review pass by the orchestrator - `base_ref`: resolved review diff base (branch name or commit hash), None - when unset; forwarded to review-carrying passes only - `patience`: resolved external-review stop threshold, None when unset; - forwarded to review-carrying passes only + The resolved run plan of a single build. + + `skip`: the final skip decision (False when neither source is set) + `tasks`: the resolved tasks-pass part + `review`: the resolved review-pass part (inheritance applied; always + present — a skipped run still carries the resolved review + facts) Requirements: - - Immutable frozen dataclass (frozen=True, kw_only=True), per `conventions` - - Computed by `resolve_review_options` — never loaded from YAML directly + - Immutable frozen dataclass (frozen=True, kw_only=True), per + `conventions` + - Computed by `resolve_run_settings` — never loaded from YAML directly properties: "skip -> bool": | - Final skip decision of the tri-state resolution. - "review_agent -> str | None": | - Review executor name matching the wrapper convention; None when unset. + The final skip decision of the tri-state resolution. + "tasks -> PassSettings": | + The resolved tasks-pass part. + "review -> ReviewPassSettings": | + The resolved review-pass part with root inheritance applied. + +"PassSettings(agent: str | None, env: dict[str, str], max_iterations: int | None, session_timeout: str | None, idle_timeout: str | None, wait: str | None)": + location: run_settings.py + annotations: | + The resolved tasks-pass part of the run plan. + + Apply the `conventions` practice for the data-model rules and + intra-package imports. + properties: + "agent -> str | None": | + The tasks-pass executor agent name, None when unset. + "env -> dict[str, str]": | + The tasks-pass env layer, verbatim; the review pass never receives it. + "max_iterations -> int | None": | + The tasks-pass iteration cap; None when unset. + "session_timeout -> str | None": | + The tasks-pass session timeout; None when unset. + "idle_timeout -> str | None": | + The tasks-pass idle timeout; None when unset. + "wait -> str | None": | + The tasks-pass rate-limit wait; None when unset. + +"PassSettings::ReviewPassSettings(roles: list[str] | None, base_ref: str | None, strategy: str, finalize: str | None, additional: AdditionalReviewConfig)": + location: run_settings.py + annotations: | + The resolved review-pass part — a concretization of the base pass part: + the inherited agent and session-knob fields, the verbatim review env + layer (never inherited from the root), plus the review-only members. + + `roles`: the declared reviewer composition, verbatim + `base_ref`: the resolved review diff base, None when unset + `strategy`: the resolved strategy — exactly full, medium, or short + `finalize`: the user-authored finalize prompt, None when unset + `additional`: the resolved external-review block (agent inherited from + the review agent when unset) + + Requirements: + - Immutable frozen dataclass (frozen=True, kw_only=True), per + `conventions` + properties: "roles -> list[str] | None": | - Declared reviewer composition, verbatim; None or empty list mean the full + The declared reviewer composition; None or empty list mean the full default set to the consumer. - "two_pass -> bool": | - Whether the build runs as two ralphex passes (tasks pass + review pass). - "review_env -> dict[str, str]": | - Review-pass environment layer, verbatim; an empty dict when the - configuration declares no env. The layer overlays the container - environment on the review-pass subprocess only. "base_ref -> str | None": | - Resolved review diff base — branch name or commit hash, verbatim. - None when neither the CLI option nor the build.review_executor.base_ref - config field declares one (an empty or whitespace-only value counts as - unset). Consumed by the pass composition: forwarded to `run_ralphex` as - the base_ref options key on review-carrying passes only. - "patience -> int | None": | - Resolved external-review stop threshold — stop the external review - after N consecutive unchanged rounds. None when neither the CLI option - nor the build.review_executor.patience config field declares one. - Forwarded as the review_patience options key on review-carrying passes - only. - -"validate_review_config(config: BuildConfig, review: ReviewOptions) -> none: None": + The resolved review diff base; None when unset. + "strategy -> str": | + The resolved review strategy — full, medium, or short. + "finalize -> str | None": | + The finalize prompt; None leaves the step at the ralphex default (off). + "additional -> AdditionalReviewConfig": | + The resolved external-review block; its agent field carries the + inherited review agent when unset in config. + +"compose_pass_options(settings: RunSettings, stage: str) -> options: dict[str, str | int | bool]": + location: pass_options.py + annotations: | + Compose the ralphex options of one pass from the resolved run settings. + + `settings`: the resolved run plan + `stage`: exactly tasks or review + `options`: the ralphex options of the pass, consumed by `run_build_pass` + + Algorithm: + 1. tasks: the tasks_only mode flag plus the resolved tasks knobs + 2. review: the mode flag by strategy — review, or external_only under + short — plus the resolved review knobs, base_ref, review_patience + from additional.patience, and max_external_iterations from + additional.max_iterations (0 = ralphex auto, passed verbatim) + + Requirements: + - Unset knobs stay absent from the dict — the assembled ralphex command + carries no flag for them + - Review-only options never appear on the tasks pass + - Exactly one pass-mode flag per composition — the modes are mutually + exclusive + + Constraints: + - Pure — no side effects, no config reads beyond `settings` + +"validate_review_config(settings: RunSettings) -> none: None": location: review_config.py annotations: | - Semantically validate the review configuration of a run whose review phase - will actually execute; raise ValueError naming the invalid value. + Semantically validate the review configuration of a run whose review + pass will execute; raise ValueError naming the invalid value. - `config`: build configuration (`BuildConfig`) - `review`: resolved review options (`ReviewOptions`) + `settings`: the resolved run plan (`RunSettings`) — carries every + review fact the checks read (roles, env, agent, + additional, strategy) Algorithm: - 1. Return without checks when `review` says skip — a skipped run does not - validate review fields - 2. Check every role of `review` against the ralphex whitelist (quality, - implementation, testing, simplification, documentation); a role outside - the whitelist raises ValueError naming the role - 3. When `review` carries a non-empty review_env and no review_agent — raise - ValueError naming the problem (env requires agent) - 4. When two_pass: resolve the review-agent wrapper path via - `resolve_wrapper_path` and require the wrapper file to exist; absence - raises ValueError naming the agent + 1. Return without checks when `settings` says skip — a skipped run does + not validate review fields + 2. Check every role of the review part against the ralphex whitelist + (quality, implementation, testing, simplification, documentation); a + role outside the whitelist raises ValueError naming the role + 3. When the review part carries a non-empty env and no review agent — + raise ValueError naming the problem (env requires agent) + 4. Resolve the review-agent wrapper via `resolve_wrapper_path` and + require the wrapper file to exist; absence raises ValueError naming + the agent + 5. When the strategy engages the external review (short always; full + when additional.agent is set): resolve the additional-agent wrapper + the same way and require its existence + 6. Check the resolved strategy of the review part against the + whitelist full | medium | short; a value outside the whitelist + raises ValueError naming the value Requirements: - Runs before any side effect — before writing .ralphex/ and before the - ralphex launch + first checkpoint - The error message names the invalid value - - The env-requires-agent gate fires only when the review phase will run — - a skipped run never validates env Constraints: - - Do not validate the task executor wrapper here — its absence surfaces at + - Do not validate the tasks agent wrapper here — its absence surfaces at ralphex time - Do not check review fields of a skipped run -"sync_ralphex_defaults(config: BuildConfig, review: ReviewOptions) -> none: None": +"sync_ralphex_defaults(config: BuildConfig, settings: RunSettings) -> none: None": location: ralphex_runtime.py annotations: | Fully rewrite .ralphex/prompts/ and .ralphex/agents/ from the vendored - ralphex defaults (or the configured custom directories) and apply the - declared reviewer composition to the review prompts. + ralphex defaults (or the configured custom directories), apply the + declared reviewer composition to the review prompts, and materialize the + ralphex files of the finalize step when the finalize prompt is set. - `config`: build configuration (`BuildConfig`) with optional prompts_dir / agents_dir - `review`: resolved review options (`ReviewOptions`) + `config`: build configuration (`BuildConfig`) with optional prompts_dir / + agents_dir + `settings`: the resolved run plan (`RunSettings`) Algorithm: - 1. Choose the prompts source: the prompts_dir field of `BuildConfig` when set, - otherwise the vendored package defaults under goga/assets/ralphex/prompts/; - choose the agents source the same way + 1. Choose the prompts source: the prompts_dir field of `BuildConfig` + when set, otherwise the vendored package defaults; choose the agents + source the same way 2. Fully rewrite both target directories (clear, then copy) - 3. When the roles of `review` are a non-empty list: filter both review prompts — - keep only the {{agent:X}} lines of the selected roles; adapt the - accompanying text (agent counters, launch wording) to the actual number - of remaining roles - 4. Copy the definition files of all review agents regardless of the selection + 3. When the roles of the review part are a non-empty list: filter both + review prompts — keep only the {{agent:X}} lines of the selected + roles; adapt the accompanying text to the actual number of remaining + roles + 4. Copy the definition files of all review agents regardless of the + selection + 5. When the finalize prompt is set: materialize the ralphex files of the + finalize step from the prompt string per the `ralphex` practice (the + finalize prompt file of the review step) Requirements: - - The full rewrite happens once per build run (the orchestrator calls this - routine before the pass loop), regardless of roles + - The full rewrite happens once per build run, regardless of roles - With the full default set (or no roles) the prompts are byte-identical to the vendored defaults - An empty intersection of roles with a phase's default set is a regular phase without subagents — no error, no fallback - - Custom prompts_dir / agents_dir sources are copied as-is, without filtering + - Custom prompts_dir / agents_dir sources are copied as-is, without + filtering + - The finalize materialization happens only when the prompt is set; + unset leaves the vendored tree untouched Constraints: - Do not touch .ralphex/config — it is written by the config routine -"write_ralphex_config(config: BuildConfig, wrapper_path: str) -> none: None": +"write_ralphex_config(settings: RunSettings, wrapper_path: str) -> none: None": location: ralphex_config.py annotations: | Generate .ralphex/config for one ralphex pass. - `config`: project configuration source fields (`BuildConfig`) - `wrapper_path`: executor wrapper path of the current pass (claude_command value) + `settings`: the resolved run plan (`RunSettings`) — the external surface + and the finalize fact of the review pass + `wrapper_path`: executor wrapper path of the current pass (the + claude_command value) Algorithm: 1. Set claude_command to `wrapper_path` 2. Apply claude_args defaults when missing - 3. Set codex_enabled from `BuildConfig` - 4. Set preserve_anthropic_api_key to true - 5. Set move_plan_on_completion to false — always, for every pass + 3. Set preserve_anthropic_api_key to true + 4. Set move_plan_on_completion to false — always, for every pass + 5. External surface (the review pass): under the medium strategy set + codex_enabled to false — the external review is explicitly disabled + (internal agents only); under full or short leave the ralphex + default (enabled), and when the resolved additional agent is set, + set external_review_tool to custom and custom_review_script to the + additional agent's wrapper path; when unset, leave the ralphex + default (codex) + 6. When the finalize prompt is set: set finalize_enabled to true; unset + leaves the ralphex default (false) Requirements: - - In a two-pass run this routine is called twice — each pass passes its own - executor wrapper as `wrapper_path` (task wrapper for pass 1, review - wrapper for pass 2), so the claude_command rewrite between the passes is - expressed by the two calls themselves + - In a two-pass run this routine is called twice — each pass passes its + own executor wrapper (task wrapper; review wrapper — the additional + wrapper under the short strategy) + - Under the medium strategy the external review is explicitly disabled + (codex_enabled false); full and short leave it enabled Constraints: - - Do not duplicate the skip decision into codex_enabled — the tasks-only flag - is the single source of truth - Do not write prompts or agents here + - Do not derive the external surface from anything but the resolved + settings -"run_build_pass(plan: str, config: BuildConfig, options: dict[str, str | int | bool], wrapper_path: str, dry_run: bool, env: dict[str, str] | None = None) -> exit_code: int": +"run_build_pass(plan: str, settings: RunSettings, options: dict[str, str | int | bool], wrapper_path: str, dry_run: bool, env: dict[str, str] | None = None) -> exit_code: int": location: build_pass.py annotations: | Execute one ralphex pass: write the pass config, delegate the launch. `plan`: path to the plan file (markdown) - `config`: build configuration (`BuildConfig`) - `options`: resolved ralphex options of the pass (may carry tasks_only or - review — the pass-mode bare flags) - `wrapper_path`: executor wrapper of the current pass (task wrapper or - review wrapper) + `settings`: the resolved run plan (`RunSettings`) — carried to the + config routine + `options`: the resolved ralphex options of the pass (composed by + `compose_pass_options`; carries exactly one pass-mode flag) + `wrapper_path`: executor wrapper of the current pass `dry_run`: when True, print instead of launching - `env`: optional environment layer forwarded verbatim to `run_ralphex` — the - review pass receives the review env here; the tasks pass runs - without a layer + `env`: optional environment layer forwarded verbatim to `run_ralphex` — + the tasks pass receives the root env, the review pass the review + env; values are never printed `exit_code`: the exit code returned by ralphex Algorithm: - 1. Write .ralphex/config via the config routine with `wrapper_path` + 1. Write .ralphex/config via the config routine with `wrapper_path` and + the pass settings 2. Delegate the launch to `run_ralphex` with `plan`, `options`, `dry_run`, and `env` 3. Return the exit code of `run_ralphex` Constraints: - - Do not assemble or invoke the ralphex command directly — only through `run_ralphex` + - Do not assemble or invoke the ralphex command directly — only through + `run_ralphex` -"move_completed_plan(plan: str, outcome: bool, dry_run: bool) -> none: None": +"move_completed_plan(plan: str, outcome: bool, dry_run: bool) -> relocation: RelocationOutcome": location: plan_relocation.py annotations: | Relocate a completed plan file into the completed/ subdirectory of the - directory holding the plan. + directory holding the plan, and report the outcome. `plan`: path to the plan file `outcome`: True when the run succeeded `dry_run`: when True, nothing was launched — leave the plan in place + `relocation`: the outcome facts — moved with the destination, or not + moved Algorithm: - 1. Return without changes when `outcome` is False or `dry_run` is True + 1. When `outcome` is False or `dry_run` is True: return the not-moved + outcome 2. Move the plan file to /completed/, creating the completed/ subdirectory when missing + 3. Return the moved outcome with the destination Requirements: - - Called after any successful run (full, skip, two-pass — after the success - of the last pass) + - Called after any started run — after the success of the last pass Constraints: - - Do not hard-code docs/plans/ — the directory follows the plan file location + - Do not hard-code docs/plans/ — the directory follows the plan file + location --- @@ -436,6 +541,8 @@ Author: Goga CreatedAt: 18/08/26 Description: | - Manifest describing the code build orchestration logic through ralphex, - including the review-phase orchestration: skip resolution, vendored - defaults, reviewer composition, single/two-pass launch, and plan relocation. + Manifest describing the code build orchestration logic through ralphex — + the stable two-pass cycle with the five hooks checkpoints: two-part + settings resolution with root inheritance, the validation gate, the four + notifications, the external-review surface, finalize materialization, and + plan relocation. diff --git a/goga/build/hooks/.usages/checkpoints.md b/goga/build/hooks/.usages/checkpoints.md new file mode 100644 index 00000000..541b2132 --- /dev/null +++ b/goga/build/hooks/.usages/checkpoints.md @@ -0,0 +1,69 @@ +# build — delivering the build checkpoints + +How the build operation consumes the hooks zone of the build domain: running the +validation gate before the first pass and emitting the four notifications around +the passes. For the in-container build orchestration. + +## The checkpoint surface + +One `BuildHooks` object serves every checkpoint of a run — the surface shares one +registry per run, so a run that reaches several checkpoints enumerates the tool +packages once. + + from goga.build.hooks import BuildHooks + + hooks = BuildHooks() + +## Resolve the facts in the operation + +Every context is built from the values the caller passes — the checkpoint reads +no repository. Resolve before the delivery: + +- `WorkIdentity` — the current branch with the topic slug and year when the + branch hosts a topic (`resolve_current_branch_name` with the `"unknown"` + fallback — resolved before the checkpoint). +- `BuildMoment` — the plan, the work identity, `dry_run`. +- `StageFacts` (tasks and review) — the executor agent, env presence as NAMES + (never values), the resolved option facts; the review facts carry roles, + base_ref, strategy, the additional facts, and the finalize prompt text when + configured. + +## Gate before the first pass + +After goga's own pre-checks (manifest check, settings resolution, review-config +validation, ralphex defaults sync) and before the first pass launch: + + verdict = hooks.validate_build(moment=moment, tasks=tasks_facts, + review=review_facts, skip=skip) + if not verdict.approved: + # one merged error listing every violation (tool, hook, reason); exit 1; + # no pass launches; the plan stays; no further events + +- The gate walk runs to completion: every subscribed tool's validation hooks run + — no early stop between tools; a non-vetoing subscriber is still invoked. +- A hook vetoes via the delivered view: `context.veto(reason)`. A crashing hook + counts as its tool's veto with the crash reason. +- The gate modifies nothing — observe-and-veto only. + +## Emit around the cycle + + hooks.emit_build_started(moment, tasks, review, skip) + hooks.emit_pass_started(moment, tasks_facts) + exit_code = run_build_pass(...) # tasks pass + hooks.emit_pass_completed(moment, tasks_facts, exit_code) + if exit_code == 0 and not skip: + hooks.emit_pass_started(moment, review_facts) + exit_code = run_build_pass(...) # review pass + hooks.emit_pass_completed(moment, review_facts, exit_code) + relocation = move_completed_plan(...) + statuses = collect_topic_statuses(...) # recompute after the relocation attempt + hooks.emit_build_completed(moment, exit_code, stages, relocation, statuses) + +- The four notifications are fire-and-forget: a failing hook warns naming the + tool, the action, and the reason; the run's outcome is unaffected. +- `pass_completed` and `build_completed` fire on zero, non-zero, and + spawn-failure codes alike — completion is a fact, not a success claim. +- Dry-run fires the identical structure with the `dry_run` fact; the gate runs; + nothing executes. +- With no tool packages installed the whole surface is inert — an unsubscribed + gate returns an approved verdict, emissions are unobservable. diff --git a/goga/build/hooks/CODEMANIFEST b/goga/build/hooks/CODEMANIFEST new file mode 100644 index 00000000..20f17705 --- /dev/null +++ b/goga/build/hooks/CODEMANIFEST @@ -0,0 +1,533 @@ +Imports: + - Types: + - HookRegistry + - wrap_context + - build_hook_arguments + - emit_hook_event + - declared_actions + Usages: + - declaring-actions + - per-tool-delivery + - registering-hooks + From: goga/hooks + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and + testing in the project + + This cell owns the hooks zone of the build domain: the fact vocabulary of + the run events, the read-only contexts of the five moments, the + verdict-collecting gate view, and the checkpoint surface that delivers the + gate and emits the four notifications over the platform facade. One + registry per run carries every checkpoint of a command — the checkpoints + never multiply the package enumeration. Every context is built from the + operation data the caller passes — no repository reads and no git access + happen here, at any checkpoint moment. Env values are never present in any + fact: presence is delivered as names only. The gate is the domain's hard + action with a deliberate domain-local deviation: the staged per-tool walk + runs to completion — every subscribed tool's validation hooks run, no + early stop between tools — and collects the vetoes, following the + `per-tool-delivery` precedent instead of the platform's + stop-at-first-failure hard semantics; verdict collection requires every + tool's outcome. The four notifications are soft — a failing hook warns + naming the tool, the action, and the reason, and the run's outcome is + unaffected. + Use the `per-tool-delivery` practice for the staged walk of the gate — its + loop skeleton, primitives, and per-tool grouping apply as written with one + refinement: the walk never stops early and collects vetoes instead of + committing contributions; a tool with no veto and no crash approves + silently. + Use the `declaring-actions` practice for the emission contract of the + notification checkpoints. + Use the `registering-hooks` practice for the hook signature and the failure + handling behind every checkpoint. + Use relative imports. + +--- + +"WorkIdentity(branch: str, slug: str | None = None, year: str | None = None)": + location: facts.py + annotations: | + The identity of the current work — the branch, with the topic slug and + year when the branch hosts a topic. + + `branch`: the current branch name as resolved by the operation ("unknown" + when resolution failed) + `slug`: the normalized topic slug — present when the branch hosts a topic + `year`: the resolved year as four digits — present when the branch hosts + a topic + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The hosting decision and every resolution happen in the constructing + operation — nothing is read here + - The branch-only form — `slug` and `year` None — serves a branch hosting + no topic + properties: + "branch -> str": | + The current branch name as resolved by the operation; "unknown" when + the operation could not resolve it. + "slug -> str | None": | + The normalized topic slug, or None in the branch-only form. + "year -> str | None": | + The resolved year as four digits, or None in the branch-only form. + +"BuildMoment(plan: str, work: WorkIdentity, dry_run: bool)": + location: facts.py + annotations: | + The uniform envelope of every build context — the plan under execution, + the work identity, and the rehearsal fact. + + `plan`: the plan file path of the run + `work`: the current work identity + `dry_run`: True when the run rehearses without launching anything + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "plan -> str": | + The plan file path of the run. + "work -> WorkIdentity": | + The current work identity. + "dry_run -> bool": | + True when the run rehearses the cycle without launching passes. + +"StageFacts(stage: str, agent: str | None, env: list[str], max_iterations: int | None, session_timeout: str | None, idle_timeout: str | None, wait: str | None, roles: list[str] | None, base_ref: str | None, strategy: str | None, finalize: str | None, additional: AdditionalFacts | None)": + location: facts.py + annotations: | + The resolved facts of one stage part of the run — the delivered + projection of the operation's resolved settings for that stage. + + `stage`: exactly tasks or review + `agent`: the executor agent name of the stage + `env`: the env presence of the stage layer as NAMES — values never + appear anywhere + `max_iterations`, `session_timeout`, `idle_timeout`, `wait`: the resolved + pass knobs of the stage + `roles`: the declared reviewer composition — review stage only + `base_ref`: the review diff base — review stage only + `strategy`: the resolved review strategy — review stage only + `finalize`: the full finalize prompt text when configured — review stage + only, None otherwise + `additional`: the external-review facts — review stage only + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Pure facts — the constructing operation passes resolved values with + inheritance already applied; nothing is read or derived here + - The review members are None on the tasks part + - `env` carries names only — an empty list means no env layer + properties: + "stage -> str": | + The stage identity — exactly tasks or review. + "agent -> str | None": | + The executor agent name of the stage. + "env -> list[str]": | + The env presence of the stage layer as names; never values. + "max_iterations -> int | None": | + The resolved iteration cap of the stage. + "session_timeout -> str | None": | + The resolved session timeout of the stage. + "idle_timeout -> str | None": | + The resolved idle timeout of the stage. + "wait -> str | None": | + The resolved rate-limit wait of the stage. + "roles -> list[str] | None": | + The declared reviewer composition of the review stage; None on the + tasks part. + "base_ref -> str | None": | + The review diff base of the review stage; None on the tasks part. + "strategy -> str | None": | + The resolved review strategy (full, medium, short); None on the tasks + part. + "finalize -> str | None": | + The full finalize prompt text when configured; None when unset or on + the tasks part. + "additional -> AdditionalFacts | None": | + The external-review facts of the review stage; None on the tasks part. + +"AdditionalFacts(agent: str | None, patience: int | None, max_iterations: int | None)": + location: facts.py + annotations: | + The delivered mirror of the external-review block — the documented facts + of build.review.additional for tool authors. + + `agent`: the external review agent name (after inheritance) + `patience`: the external-review stop threshold (0 = disabled) + `max_iterations`: the external review iteration cap (0 = ralphex auto) + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "agent -> str | None": | + The external review agent name, or None when unset. + "patience -> int | None": | + The external-review stop threshold; 0 = disabled; None when unset. + "max_iterations -> int | None": | + The external review iteration cap; 0 = ralphex auto; None when unset. + +"RelocationOutcome(moved: bool, destination: str | None)": + location: facts.py + annotations: | + The outcome of the plan relocation attempt. + + `moved`: True when the plan file was relocated + `destination`: the relocation destination path when moved, None otherwise + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "moved -> bool": | + True when the plan was relocated into the completed directory. + "destination -> str | None": | + The relocation destination when moved, None when not moved. + +"Violation(tool: str, hook: str, reason: str)": + location: facts.py + annotations: | + One collected veto of the gate walk. + + `tool`: the tool identity assigned by the platform + `hook`: the hook name that vetoed (or crashed) + `reason`: the veto reason — a hook-authored message or the crash reason; + never a raw traceback + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "tool -> str": | + The tool identity of the vetoing tool. + "hook -> str": | + The hook name that vetoed or crashed. + "reason -> str": | + The veto reason — authored or crash-derived; never a raw traceback. + +"GateVerdict(violations: list[Violation])": + location: facts.py + annotations: | + The collected verdict of the gate walk — every veto of every subscribed + tool, in enumeration order. + + `violations`: the collected violations; an empty list means approved + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The verdict is data only — acting on it (the merged error, the exit + code) belongs to the operation + properties: + "violations -> list[Violation]": | + The collected violations in enumeration order. + "approved -> bool": | + True when no violation was collected — the run may proceed. + +"BuildValidation(moment: BuildMoment, tasks: StageFacts, review: StageFacts, skip: bool)": + location: contexts.py + annotations: | + The gate's delivered view of one tool — the read-only facts of the run + about to start, plus the veto buffer of this tool alone. + + `moment`: the uniform envelope + `tasks`: the resolved facts of the tasks stage + `review`: the resolved facts of the review stage (always present, + including a skipped review — the facts describe the resolved + settings, not the execution) + `skip`: the resolved review skip state + + Apply the `convention` practice for the data-model rules and + intra-package imports. + Use the `registering-hooks` practice for the hook signature that + receives this view. + + Requirements: + - The reads deliver the resolved facts — read-only; a hook observes and + cannot alter anything + - The veto buffer belongs to this tool alone + properties: + "moment -> BuildMoment": | + The uniform envelope of the run. + "tasks -> StageFacts": | + The resolved facts of the tasks stage. + "review -> StageFacts": | + The resolved facts of the review stage. + "skip -> bool": | + The resolved review skip state of the run. + methods: + "veto(reason: str)": | + Buffer this tool's veto of the run. + + `reason`: the human-readable violation reason + + Requirements: + - The call buffers into the buffer of this tool alone and changes + nothing until the walk collects it + - The replacement is whole — a later call replaces the earlier reason + - The view records no hook identity — the walk attributes the veto + to a hook by observing the buffer change around each call + - An empty or whitespace-only reason is stored as given — the merged + error renders it verbatim + + Constraints: + - Do not cancel, redirect, or defer the operation — a veto stops the + run through the collected verdict only + +"BuildStarted(moment: BuildMoment, tasks: StageFacts, review: StageFacts, skip: bool)": + location: contexts.py + annotations: | + The read-only context of the start notification — the same resolved + facts the gate saw, delivered immediately before the first pass launch. + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Read-only facts of the starting run — a hook observes and cannot alter + properties: + "moment -> BuildMoment": | + The uniform envelope of the run. + "tasks -> StageFacts": | + The resolved facts of the tasks stage. + "review -> StageFacts": | + The resolved facts of the review stage. + "skip -> bool": | + The resolved review skip state of the run. + +"PassStarted(moment: BuildMoment, facts: StageFacts)": + location: contexts.py + annotations: | + The read-only context of the pass-start notification — the facts of the + pass about to launch. + + `facts`: the stage facts of the launching pass + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Read-only facts of the launching pass + properties: + "moment -> BuildMoment": | + The uniform envelope of the run. + "facts -> StageFacts": | + The stage facts of the pass about to launch. + +"PassCompleted(moment: BuildMoment, facts: StageFacts, exit_code: int)": + location: contexts.py + annotations: | + The read-only context of the pass-completion notification — the facts of + the finished pass plus its actual exit code. Completion is a fact, not a + success claim. + + `facts`: the stage facts of the finished pass + `exit_code`: the actual exit code of the pass — zero, non-zero, or a + spawn-failure code + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "moment -> BuildMoment": | + The uniform envelope of the run. + "facts -> StageFacts": | + The stage facts of the finished pass. + "exit_code -> int": | + The actual exit code of the pass return. + +"BuildCompleted(moment: BuildMoment, exit_code: int, stages: list[str], relocation: RelocationOutcome, statuses: list[str])": + location: contexts.py + annotations: | + The read-only context of the completion notification — the outcome of + the started run at the completion moment. + + `exit_code`: the final exit code of the run — the last executed pass's + code + `stages`: the executed stage sequence in execution order (a skipped + review is absent) + `relocation`: the outcome of the plan relocation attempt + `statuses`: the work's current history statuses at the completion moment, + recomputed after the relocation attempt; an empty list in + the branch-only form + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Read-only facts of the completed run — the artifact → history-status + integration builds from these facts alone + properties: + "moment -> BuildMoment": | + The uniform envelope of the run. + "exit_code -> int": | + The final exit code of the run. + "stages -> list[str]": | + The executed stage sequence in execution order. + "relocation -> RelocationOutcome": | + The outcome of the plan relocation attempt. + "statuses -> list[str]": | + The work's current history statuses at the completion moment; empty in + the branch-only form. + +"BuildHooks()": + location: events.py + annotations: | + The checkpoint surface of the build domain — the verdict-collecting gate + delivery and the four notification emissions over the platform facade. + + Apply the `convention` practice for the code style and intra-package + imports. + Use the `per-tool-delivery` practice for the staged walk of the gate — + with the recorded refinement: the walk runs to completion and collects + vetoes; no early stop, no contribution commit. + Use the `declaring-actions` practice for the emission contract of the + notification checkpoints. + Use the `registering-hooks` practice for the registration contract behind + every checkpoint. + + Requirements: + - Cheap construction — no enumeration and no imports happen at + construction + - One `HookRegistry` per run carries every checkpoint of a command — + the assembly runs once per run whatever the number of checkpoints + - Every context is built from the values the caller passes — no + repository reads happen at a checkpoint + methods: + "validate_build(moment: BuildMoment, tasks: StageFacts, review: StageFacts, skip: bool) -> verdict: GateVerdict": | + Deliver the validation gate and return the collected verdict. + + `moment`: the uniform envelope + `tasks`: the resolved facts of the tasks stage + `review`: the resolved facts of the review stage + `skip`: the resolved review skip state + `verdict`: the collected verdict — approved when no tool vetoed + + Use the `per-tool-delivery` practice for the walk (with the recorded + refinement). + + Algorithm: + 1. Resolve the address domain="build", action="validate_build" + against `declared_actions` + 2. Walk the subscriptions of the address per tool in enumeration + order: build the tool's `BuildValidation` view over the delivered + facts, wrap it via `wrap_context`, project the call arguments via + `build_hook_arguments` with the tool's own self context, and call + each hook of the tool; snapshot the view's veto buffer before + each hook call — a buffer change during a call attributes the + veto to that hook's subscription name (a later veto replaces the + earlier attribution, mirroring the whole-replacement rule) + 3. A tool whose every hook returned without raising and whose view + carries no buffered veto approves silently — no record + 4. A tool whose view carries a buffered veto contributes exactly one + `Violation` (the tool, the attributed vetoing hook, the reason) + 5. A tool with a raising hook contributes exactly one `Violation` + with the crash reason as the reason — never a raw traceback — and + the walk continues; a crash overrides the tool's buffered veto + (the crash reason replaces it); the walk NEVER stops between + tools, whatever a tool returned or raised + 6. Return the `GateVerdict` with the violations in enumeration order + + Requirements: + - Every subscribed tool's hooks run — no early stop; a non-vetoing + subscriber is invoked even when another tool already vetoed + - Exactly one `Violation` per tool: the buffered veto with its + attributed hook, or the crash reason when a hook raised (the + crash overrides the buffer) + - An address without subscriptions returns an empty verdict — approved; + with no tool packages installed the gate is inert + - The gate modifies nothing — no contribution, no mutation of any + delivered fact + + Constraints: + - Do not stop the walk at the first veto or crash — verdict collection + requires every tool's outcome + - Do not skip a subscriber of the address + - Do not read repositories or the filesystem at the checkpoint + - Do not deliver env values — the facts carry names only + "emit_build_started(moment: BuildMoment, tasks: StageFacts, review: StageFacts, skip: bool)": | + Emit the start notification — the resolved facts the gate saw, + immediately before the first pass launch. + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `BuildStarted` context from the values + 2. Emit the address domain="build", action="build_started" via + `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + - A failing hook is skipped with a warning under the soft error class + of the action — the run proceeds + "emit_pass_started(moment: BuildMoment, facts: StageFacts)": | + Emit the pass-start notification — the facts of the pass about to + launch. + + `facts`: the stage facts of the launching pass + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `PassStarted` context from the values + 2. Emit the address domain="build", action="pass_started" via + `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + "emit_pass_completed(moment: BuildMoment, facts: StageFacts, exit_code: int)": | + Emit the pass-completion notification — the facts of the finished + pass with its actual exit code. + + `facts`: the stage facts of the finished pass + `exit_code`: the actual exit code of the pass return + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `PassCompleted` context from the values + 2. Emit the address domain="build", action="pass_completed" via + `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + - The emission happens on every pass return path — zero, non-zero, + and spawn failures alike; completion is a fact + "emit_build_completed(moment: BuildMoment, exit_code: int, stages: list[str], relocation: RelocationOutcome, statuses: list[str])": | + Emit the completion notification — the outcome of the started run. + + `exit_code`: the final exit code of the run + `stages`: the executed stage sequence + `relocation`: the relocation outcome + `statuses`: the work's history statuses recomputed at the completion + moment + + Use the `declaring-actions` practice for the emission contract. + + Algorithm: + 1. Build the `BuildCompleted` context from the values + 2. Emit the address domain="build", action="build_completed" via + `emit_hook_event` + + Requirements: + - Fire-and-forget — nothing is collected and no value returns + - The emission happens on every return path of a started run — zero, + non-zero, and spawn failures alike + +--- + +Author: Goga +CreatedAt: 21/09/26 +Description: | + Owner of the build domain hooks zone — the run-event facts, the + verdict-collecting validation gate, and the checkpoint surface over the + hooks platform. diff --git a/goga/commands/build/.usages/build.md b/goga/commands/build/.usages/build.md index 6849f774..f4e11fec 100644 --- a/goga/commands/build/.usages/build.md +++ b/goga/commands/build/.usages/build.md @@ -7,7 +7,7 @@ CLI wrapper for the build command. Parses click options, loads configuration, an ## Syntax ``` -goga build [--dry-run] [--worktree] [--skip-finalize] [--skip-manifest-check] +goga build [--dry-run] [--skip-manifest-check] [--session-timeout T] [--idle-timeout T] [--wait T] [--max-iterations N] [--review-patience N] [--base-ref REF] [--skip-review | --no-skip-review] @@ -26,16 +26,14 @@ goga build [--dry-run] [--worktree] [--skip-finalize] [--skip-manifest-ch | Option | Type | Default | Description | |--------|------|---------|-------------| | `--dry-run` | flag | false | Show the command without executing | -| `--worktree` | flag | false | Isolated git worktree mode | -| `--skip-finalize` | flag | false | Skip finalization | | `--skip-manifest-check` | flag | false | Skip uncommitted CODEMANIFEST check | | `--session-timeout` | str | from config | Session timeout | | `--idle-timeout` | str | from config | Idle timeout | | `--wait` | str | from config | Wait on rate limit | | `--max-iterations` | int | from config | Maximum iterations | -| `--review-patience` | int | from config | Review stop threshold | -| `--base-ref` | str | from config | Review diff base (branch name or commit hash). Overrides `build.review_executor.base_ref` in `.goga/config.yml`; forwarded to the container only when set. Reaches ralphex as `--base-ref` on the review-carrying pass only | -| `--skip-review` / `--no-skip-review` | bool pair | tri-state | Skip the review phase (`--skip-review`) or force the full cycle (`--no-skip-review`). Overrides `build.review_executor.skip` in `.goga/config.yml`; when neither flag is given, the config decides | +| `--review-patience` | int | from config | External-review stop threshold; addresses `build.review.additional.patience` in `.goga/config.yml`; forwarded to the container only when set | +| `--base-ref` | str | from config | Review diff base (branch name or commit hash). Addresses `build.review.base_ref` in `.goga/config.yml`; forwarded to the container only when set. Reaches ralphex as `--base-ref` on the review pass only | +| `--skip-review` / `--no-skip-review` | bool pair | tri-state | Skip the review phase (`--skip-review`) or force the full cycle (`--no-skip-review`). Overrides `build.review.skip` in `.goga/config.yml`; when neither flag is given, the config decides | | `-e` / `--env` | str (multiple) | — | Pass environment variables to the container (KEY=VALUE) | | `--proxy` | str | from config | HTTP/HTTPS proxy URL; overrides `build.proxy` in `.goga/config.yml`. When set, adds HTTP_PROXY/HTTPS_PROXY/NO_PROXY to the container env-file | | `--add-host` | str (multiple) | — | Add a `docker run --add-host HOST:IP` entry. Merges on top of `build.hosts` from config; CLI wins on host-key conflict | @@ -51,7 +49,7 @@ goga build [--dry-run] [--worktree] [--skip-finalize] [--skip-manifest-ch ```bash goga build docs/plans/my-plan.md -goga build docs/plans/my-plan.md --dry-run --worktree +goga build docs/plans/my-plan.md --dry-run goga build docs/plans/my-plan.md -e ANTHROPIC_API_KEY=sk-xxx -e MODEL=claude-sonnet-4-6 # Refresh the image before launch (build when dockerfile is declared, else pull) @@ -79,12 +77,12 @@ goga build docs/plans/my-plan.md # second run reuses .ralphex/ from the first ## Requirements - Docker must be installed and available in PATH -- `.goga/config.yml` must contain a `build` section (with a `task_executor` sub-block). The loader makes the section optional (`config.build` is `None` when absent), but `goga build` cannot run without it — the command raises `ClickException("build section is required in .goga/config.yml to run 'goga build'")` before any field access and before the container is launched. The `task_executor.agent` field itself is optional at the loader level (absent/empty → `None`), but `goga build` needs an agent to resolve the in-container wrapper — when it is `None` the command raises `ClickException("build.task_executor.agent is required in .goga/config.yml to run 'goga build'")` before launch +- `.goga/config.yml` must contain a `build` section. The loader makes the section optional (`config.build` is `None` when absent), but `goga build` cannot run without it — the command raises `ClickException("build section is required in .goga/config.yml to run 'goga build'")` before any field access and before the container is launched. The `build.agent` field itself is optional at the loader level (absent/empty → `None`), but `goga build` needs an agent to resolve the in-container wrapper — when it is `None` the command raises `ClickException("build.agent is required in .goga/config.yml to run 'goga build'")` before launch - `.goga/config.yml` must have the top-level `image` field set — otherwise the command exits with error `image in .goga/config.yml is not set` - By default the image is NOT refreshed — the local image is used as-is. Use `--update`/`-u` to refresh it before launch: build when a project Dockerfile is declared (fatal on failure), else pull (warning on failure, non-fatal — the build continues with the locally available image) - First-run safety net: when `dockerfile` is declared in `.goga/config.yml` and the image is absent locally, the command builds it ONCE before launch even WITHOUT `--update` (so the first run after declaring a project Dockerfile does not need `--update`). `--update` forces a RE-build of an already-present image; the safety net is a no-op once the image exists -- Git config (user.name, user.email) is automatically passed to the container as GIT_AUTHOR_NAME/EMAIL, GIT_COMMITTER_NAME/EMAIL. If git config is absent, the build continues without error -- Credential mounts are detected automatically via `resolve_credential_mounts()` — there is no `--credential`/`--mount` flag. The routine scans the host filesystem for known AI-agent credential files (claude `~/.claude/.credentials.json`, codex `~/.codex/auth.json`, opencode `~/.local/share/opencode/auth.json`), is agent-agnostic (it is not filtered by the configured `task_executor.agent`), and returns only files that exist. Every returned file is bind-mounted read-only into the container at the mirrored path under `/home/goga/`. When none exist, no credential mount is added — see the `resolve-credential-mounts` and `docker-auth-mounts` practices for details +- Git config (user.name, user.email) is automatically passed to the container as GIT_AUTHOR_NAME/EMAIL, GIT_COMMITTER_NAME/EMAIL. If git config is absent, the build continues without error. The container env-file carries the base layers only (home.env, git identity, CLI `-e`, proxy) — the task env (`build.env`) is NOT written into the env-file; it reaches the container solely as the in-container tasks-pass env layer +- Credential mounts are detected automatically via `resolve_credential_mounts()` — there is no `--credential`/`--mount` flag. The routine scans the host filesystem for known AI-agent credential files (claude `~/.claude/.credentials.json`, codex `~/.codex/auth.json`, opencode `~/.local/share/opencode/auth.json`), is agent-agnostic (it is not filtered by the configured `build.agent`), and returns only files that exist. Every returned file is bind-mounted read-only into the container at the mirrored path under `/home/goga/`. When none exist, no credential mount is added — see the `resolve-credential-mounts` and `docker-auth-mounts` practices for details - Ralphex state (`.ralphex/`) is isolated from the project directory: the host directory `~/.goga/runtime/builds///` is bind-mounted into the container at `/workspace/.ralphex`. No `.ralphex/` appears in the project directory, even on crash/SIGKILL. By default the directory persists across runs; pass `--clean` to wipe it before launch ## Review-phase flags @@ -94,18 +92,14 @@ goga build docs/plans/plan.md --no-skip-review # force full cycle (overrides s goga build docs/plans/plan.md # tri-state: config decides Both flags are forwarded into the container; tri-state resolution against -build.review_executor.skip happens in-container (CLI wins). The reviewer +build.review.skip happens in-container (CLI wins). The reviewer composition (roles) and the review executor agent are configured only via -.goga/config.yml build.review_executor — no CLI flags for them. - -A differing build.review_executor.agent OR a non-empty build.review_executor.env -(with agent set) combined with --worktree is rejected before the container starts -(the review pass cannot follow the worktree branch). The guard is config-level and -skip-independent — the host does not resolve the tri-state --skip-review. +.goga/config.yml build.review — no CLI flags for them. `--base-ref` follows the same forwarding discipline: the host does not resolve it against config — an unset flag leaves the decision to -`build.review_executor.base_ref` in-container. +`build.review.base_ref` in-container. There is no worktree handling +anywhere on the surface. ## Proxy and hosts @@ -129,9 +123,10 @@ build is unaffected. The launcher loads it early (per the `home-configuration` practice). - **env (env-file base layer):** `home.env` is the BASE (lowest-priority) layer - of the container env-file. Project config (`build.task_executor.env`) and CLI - (`-e/--env`) override it on key conflict — - `home.env < git identity < task_executor.env < CLI extra env`. + of the container env-file. CLI (`-e/--env`) overrides it on key conflict — + `home.env < git identity < CLI extra env`. The env-file carries the base + layers only; the task env (`build.env`) is forwarded for the tasks-pass + env layer in-container, not written into the env-file. - **docker.run:** `home.docker.run` tokens are appended verbatim to the `docker run` (the runner's `extra_args` channel). - **docker.build:** `home.docker.build` tokens are forwarded verbatim to image diff --git a/goga/commands/build/CODEMANIFEST b/goga/commands/build/CODEMANIFEST index a2f65b25..91a31caa 100644 --- a/goga/commands/build/CODEMANIFEST +++ b/goga/commands/build/CODEMANIFEST @@ -77,9 +77,16 @@ Annotations: | host–image version check; the `docker-image-version` practice covers the image-side version probe. - The command surfaces the review-phase tri-state flags to the user and rejects - the two-pass × worktree combination — whether induced by a differing review - agent or by a declared review env — before any container work. + The command surfaces the review-phase tri-state pair to the user and + forwards it verbatim — the tri-state resolves in-container. The flag + surface carries no --worktree and no --skip-finalize (removed with no + replacement); --review-patience addresses build.review.additional.patience + and --base-ref addresses build.review.base_ref (forwarding only — + precedence resolves in-container); skip_manifest_check stays a CLI-only + pre-check toggle. The task env (build.env) is not written into the + container env-file — it is forwarded for the tasks-pass env layer + in-container; the env-file carries the base layers (home.env, git + identity, CLI -e, proxy). The command runs goga.build inside a Docker container. @@ -131,19 +138,20 @@ Annotations: | survive across runs of the same project on the same branch. `skip_review`: tri-state from the --skip-review / --no-skip-review click pair — True / False / None (flag not given). Forwarded in-container as - cli_flags by the --worktree pattern; combined in-container with - build.review_executor.skip (CLI wins). + cli_flags; combined in-container with build.review.skip + (CLI wins). CLI options (via click): - - --dry-run, --worktree, --skip-finalize, --skip-manifest-check + - --dry-run, --skip-manifest-check - --session-timeout, --idle-timeout, --wait - - --max-iterations, --review-patience + - --max-iterations, --review-patience (addresses + build.review.additional.patience) - --base-ref REF (str) — review diff base (branch name or commit hash); - overrides build.review_executor.base_ref; forwarded to the container - and applied to the review-carrying pass only (per the `build-usage` + addresses build.review.base_ref; forwarded to the container + and applied to the review pass only (per the `build-usage` practice) - --skip-review / --no-skip-review (bool pair) — skip the review phase; - tri-state, overrides build.review_executor.skip + tri-state, overrides build.review.skip - -e / --env KEY=VALUE (multiple) — pass environment variables to the container - --proxy URL (str) — HTTP/HTTPS proxy URL; overrides config.build.proxy - --add-host HOST:IP (multiple) — add a docker run --add-host entry; merges on top of config.build.hosts @@ -177,28 +185,19 @@ Annotations: | Host-side guard — runs BEFORE any config.build access and BEFORE the docker command is assembled, so the in-container goga.build never starts on a build-less config (no docker run). - 2.2. When config.build.task_executor.agent is None → raise ClickException( - "build.task_executor.agent is required in .goga/config.yml to run 'goga build'"). + 2.2. When config.build.agent is None → raise ClickException( + "build.agent is required in .goga/config.yml to run 'goga build'"). The agent is optional at the loader level (None when absent/empty), but the build command resolves it into the in-container wrapper path and cannot run without it. Host-side guard — runs BEFORE any agent access to avoid a downstream TypeError. - 2.3. Review-worktree guard: when config.build.review_executor is present, its - agent is set and (the agent differs from task_executor.agent OR the - review_executor env is non-empty), and the worktree option is active (CLI - --worktree or config.build.worktree) → raise ClickException naming the - conflict (the ralphex --review mode ignores worktree — the review would - run against the wrong branch) — BEFORE the docker command is assembled. - The condition is config-level and skip-independent: the host does not - resolve the tri-state --skip-review, so an env-declaring run is rejected - here regardless of the skip flags (skip-time env handling belongs to the - in-container orchestrator). - 3. Collect cli_flags from click parameters; forward the review pair by the - --worktree pattern: skip_review True → --skip-review; False → - --no-skip-review; None → neither flag (the tri-state survives to the - container); forward --base-ref by the existing value-option pattern — - the flag with its value joins cli_flags only when the option is set; - unset (None) adds no flag, leaving the decision to the container config + 3. Collect cli_flags from click parameters; forward the review pair: + skip_review True → --skip-review; False → --no-skip-review; None → + neither flag (the tri-state survives to the container); forward + --base-ref and --review-patience by the existing value-option + pattern — the flag with its value joins cli_flags only when the + option is set; unset (None) adds no flag, leaving the decision to + the container config 4. Resolve the proxy: take `proxy` when not None, otherwise fall back to config.build.proxy 5. Resolve hosts: merge config.build.hosts with parsed `add_host` entries; @@ -207,9 +206,10 @@ Annotations: | 6. Read git identity and assemble it as git env vars; tolerate absent git config and continue without error 7. Assemble the container env layering home.env as the BASE (lowest-priority) - layer, then git identity env, task_executor env, and CLI extra env - (home.env < git identity < task_executor env < CLI extra env on key - conflict — project config and CLI override home.env) + layer, then git identity env, and CLI extra env (home.env < git + identity < CLI extra env on key conflict — CLI overrides home.env); + the task env (build.env) is NOT written into the env-file — it is + forwarded for the tasks-pass env layer in-container 8. When the resolved proxy is non-None: add HTTP_PROXY, HTTPS_PROXY, and NO_PROXY (fixed at localhost,127.0.0.1) to the container env 9. Verify ProjectConfig.image is set; raise ClickException when None @@ -283,11 +283,13 @@ Annotations: | - Docker must be installed and available in PATH - Image source: ProjectConfig.image (top-level) - home.env is the lowest-priority env layer (applied in the env-file only); - project config (task_executor env) and CLI extra env override it on key - conflict. home.docker.run tokens are appended to every docker run; - home.docker.build tokens are forwarded to image build - (docker_build_if_not_exist / docker_update build branch). An absent home - file yields an empty HomeConfig — no effect. + CLI extra env overrides it on key conflict. home.docker.run tokens are + appended to every docker run; home.docker.build tokens are forwarded + to image build (docker_build_if_not_exist / docker_update build + branch). An absent home file yields an empty HomeConfig — no effect. + The env-file carries the base layers (home.env, git identity, CLI -e, + proxy) only — build.env reaches the container solely as the + in-container tasks-pass env layer - Git identity flows into the container as GIT_AUTHOR_NAME/EMAIL, GIT_COMMITTER_NAME/EMAIL; absent git config does not block the build - When `proxy` is non-None (CLI or config), HTTP_PROXY/HTTPS_PROXY/NO_PROXY @@ -328,14 +330,11 @@ Annotations: | - The --skip-review/--no-skip-review pair is forwarded verbatim; the host does NOT resolve the tri-state against config — resolution belongs to the in-container build - - The two-pass × worktree conflict is a host-side guard — it fires before - docker run, before any filesystem or container work - - The env-induced two-pass × worktree conflict is the same host-side guard - as the differing-agent one — it fires before docker run, before any - filesystem or container work, regardless of --skip-review + - No worktree handling anywhere on the surface — no --worktree flag, no + worktree guard, no worktree-related rejection - The host does NOT resolve --base-ref precedence against config — forwarding only; the unset case resolves in-container (CLI > - build.review_executor.base_ref > omit) + build.review.base_ref > omit) Constraints: - Do not refresh an EXISTING image by default — refresh only when `update` @@ -345,16 +344,16 @@ Annotations: | - Do NOT pass home.env to docker build — home.env is a docker run container-env layer only (no --build-arg). The build branch of `docker_build_if_not_exist` / `docker_update` forwards only home.docker.build, never home.env - - Do NOT let home.env override project config or CLI env — home.env is the - lowest-priority (base) layer; task_executor env and CLI extra env win on - key conflict + - Do NOT let home.env override CLI env — home.env is the + lowest-priority (base) layer; CLI extra env wins on key conflict + (build.env is not part of the env-file at all) - Do not write --add-host for hosts absent in both config and CLI - Do not validate the "HOST:IP" format of --add-host entries beyond a single-colon split — Docker itself reports malformed entries - Do not expose a separate --no-proxy CLI option; NO_PROXY is fixed at "localhost,127.0.0.1" whenever proxy is set - Do not auto-add --add-host entries to NO_PROXY - - Do not filter credential mounts by the configured task_executor agent — + - Do not filter credential mounts by the configured build agent — detection is agent-agnostic (see `resolve-credential-mounts` practice) - Do not mount anything under /workspace other than: 1. the project directory at /workspace, and diff --git a/goga/commands/config/CODEMANIFEST b/goga/commands/config/CODEMANIFEST index ef51f3ba..683e6484 100644 --- a/goga/commands/config/CODEMANIFEST +++ b/goga/commands/config/CODEMANIFEST @@ -31,7 +31,7 @@ Annotations: | Each option is output separately with a header identifying the path. `options`: paths to options in dot notation, one or more. - For example: build.task_executor.agent build.worktree + For example: build.agent build.review.strategy Algorithm: 1. Load configuration via `load_project_config` → `ProjectConfig` @@ -57,11 +57,11 @@ Annotations: | # language python - # build.task_executor.agent + # build.agent claude - # build.worktree - True + # build.review.strategy + medium ``` Output format for a single option is the same: header and value. diff --git a/goga/config/.usages/project-configuration.md b/goga/config/.usages/project-configuration.md index 83c6039c..18d2ae8e 100644 --- a/goga/config/.usages/project-configuration.md +++ b/goga/config/.usages/project-configuration.md @@ -12,8 +12,8 @@ Import all types directly from `goga.config`: from goga.config import ( ProjectConfig, BuildConfig, - TaskExecutorConfig, - ReviewExecutorConfig, + ReviewConfig, + AdditionalReviewConfig, PipelineConfig, CodemanifestConfig, DepConfig, @@ -43,15 +43,29 @@ config = load_project_config() - Required top-level field: `language`. All other top-level fields (`image`, `pipeline`, `build`, `commands`, `codemanifest`, `dockerfile`) are optional - Optional sections `pipeline` and `build` may be absent — `config.pipeline` and `config.build` are then `None`. Consumers that need them (the `pipeline` and `build` commands) guard the `None` case and raise `ClickException` before any field access - When `pipeline` is present: it must be a mapping; `pipeline.agent` is OPTIONAL — absent/null/empty/whitespace resolves to `None`, and `goga pipeline` raises `ClickException` when it needs an agent -- When `build` is present: it must be a mapping, and `build.task_executor` is required; `build.task_executor.agent` is OPTIONAL — absent/null/empty/whitespace resolves to `None`, and `goga build` raises `ClickException` when it needs an agent +- When `build` is present: it must be a mapping. The build section is + two-part: the `build` root carries the tasks-pass settings (agent, env, + max_iterations, session_timeout, idle_timeout, wait, prompts_dir, + agents_dir, proxy, hosts); the optional `build.review` key carries the + review-pass settings (skip, agent, env, roles, base_ref, strategy, + finalize, additional, and the session knobs). `build.agent` is OPTIONAL — + absent/null/empty/whitespace resolves to `None`, and `goga build` raises + `ClickException` when it needs an agent. The loader extracts known fields + only — unknown keys, including the retired `worktree`, `skip_finalize`, + `codex_review` and the retired block names `task_executor` / + `review_executor`, are silently ignored (not extracted, not stored). + Values are exposed verbatim with no default merging — inheritance (review + from root, additional.agent from review.agent) belongs to the consuming + command +- Optional `build.review` follows structural-only validation: field types, a + list-of-strings check for `roles`, a strings-mapping check for `env`, and + scalar type checks for `base_ref` (string), `strategy`/`finalize` + (strings), and `additional.patience`/`additional.max_iterations` + (integers); an empty `roles` list and an empty `env` mapping pass through + verbatim — the empty-to-full-set (roles), env-requires-agent (env), and + strategy whitelist semantics belong to the consuming command - A present-but-non-mapping `pipeline` or `build` value (e.g. `pipeline: 5`, `pipeline:` null, `build: true`) raises `ValueError`, not `AttributeError` - Raises `yaml.YAMLError` on invalid YAML syntax -- Optional `build.review_executor` follows structural-only validation: field - types, a list-of-strings check for `roles`, a strings-mapping check for - `env`, and scalar type checks for `base_ref` (string) and `patience` - (integer); an empty `roles` list and an empty `env` mapping pass through - verbatim — the empty-to-full-set (roles) and env-requires-agent (env) - semantics belong to the consuming command - Optional `topics` follows structural-only validation: `topics.base_ref` and `topics.publish_commit` are strings when present — absent/YAML-null/empty/ whitespace resolves to `None`; a present-but-non-mapping `topics` raises @@ -68,9 +82,7 @@ try: except FileNotFoundError: # .goga/config.yml not found or empty except KeyError as e: - # Missing required field — `language`, or `build.task_executor` - # when the `build` section is present (`build.task_executor.agent` itself - # is optional and resolves to None when absent) + # Missing required field — `language` print(e) except ValueError as e: # Invalid field value @@ -105,8 +117,7 @@ section — absent section → ClickException): language: python image: qarium/goga-python-3.12:latest build: - task_executor: - agent: claude + agent: claude ``` Full configuration with all options: @@ -123,34 +134,37 @@ pipeline: proxy: http://corp:3128 # HTTP/HTTPS proxy URL hosts: # docker run --add-host entries foo.local: 127.0.0.1 -build: - task_executor: - agent: claude - env: - ANTHROPIC_API_KEY: sk-xxx - MODEL: claude-sonnet-4-6 - proxy: http://corp:3128 # HTTP/HTTPS proxy URL - hosts: # docker run --add-host entries - foo.local: 127.0.0.1 - worktree: true - skip_finalize: false - session_timeout: "30m" - idle_timeout: "1h" - wait: "5m" - max_iterations: 10 +build: # two-part: the root is the tasks-pass settings + agent: claude # str | absent — tasks-pass executor agent + env: # mapping | absent — tasks-pass env layer + ANTHROPIC_API_KEY: sk-xxx + MODEL: claude-sonnet-4-6 + max_iterations: 10 # int | absent — maximum task iterations + session_timeout: "30m" # str | absent — session timeout (Go duration) + idle_timeout: "1h" # str | absent — idle timeout (Go duration) + wait: "5m" # str | absent — rate-limit retry wait prompts_dir: /custom/prompts agents_dir: /custom/agents - codex_review: true - review_executor: # optional review-phase control - skip: false # bool | absent — tri-state source - agent: codex # str | absent — review executor name - roles: # list[str] | absent — reviewer composition + proxy: http://corp:3128 # HTTP/HTTPS proxy URL + hosts: # docker run --add-host entries + foo.local: 127.0.0.1 + review: # optional review-pass settings + skip: false # bool | absent — tri-state source + agent: codex # str | absent — review executor (inherits build.agent) + env: # mapping | absent — review env layer (never inherits the root env) + ANTHROPIC_MODEL: reviewer-model + roles: # list[str] | absent — reviewer composition - quality - testing - env: # mapping | absent — review-pass env layer - ANTHROPIC_MODEL: reviewer-model - base_ref: origin/1.2.x # str | absent — review diff base (branch or hash) - patience: 3 # int | absent — stop external review after N unchanged rounds + base_ref: origin/1.2.x # str | absent — review diff base (branch or hash) + strategy: medium # full | medium | short — review strategy (default medium) + finalize: | # str | absent — user-authored final review prompt + Final review instructions here. + session_timeout: "40m" # review session knobs inherit the root when absent + additional: # optional external-review block + agent: codex # str | absent — external review agent (inherits review.agent) + patience: 3 # int | absent — stop external review after N unchanged rounds + max_iterations: 15 # int | absent — external review iteration cap (0 = ralphex auto) codemanifest: usages: usage_name: path/to/file.md @@ -175,6 +189,18 @@ topics: # optional fast-creation section publish_commit: "goga: create topic {slug}" # str | absent — commit message template ``` +#### Build section migration note + +The build section is two-part. The retired keys `worktree`, `skip_finalize`, +`codex_review` and the retired block names `task_executor` / `review_executor` +are NOT extracted — the loader extracts known fields only and silently ignores +unknown keys. A config that still carries the old block names effectively loses +its build settings: `build.task_executor.agent` no longer populates +`build.agent`, so the section behaves as if unset and `goga build` fails with +`build.agent is required in .goga/config.yml to run 'goga build'`. Migrate by +moving `task_executor.agent`/`task_executor.env` to the `build` root and the +`review_executor` fields under `build.review`, as in the example above. + ### Required Fields | Field | Type | Description | @@ -191,13 +217,12 @@ section, the command raises `ClickException` before any field access. |-----------------------------|-----------------------|--------------------------------------------------------------------------------| | `pipeline` | `goga pipeline` | Must be a mapping when present. | | `pipeline.agent` | `goga pipeline` | Optional at the loader level (absent/empty → `None`); required to actually run `goga pipeline`, which raises `ClickException` otherwise. Resolved into the in-container `*-as-claude.sh` wrapper path. | -| `build` | `goga build` | Must be a mapping when present. `build.task_executor` is required when present.| -| `build.task_executor` | `goga build` | AI agent configuration block. | -| `build.task_executor.agent` | `goga build` | Optional at the loader level (absent/empty → `None`); required to actually run `goga build`, which raises `ClickException` otherwise. Resolved into the in-container `*-as-claude.sh` wrapper path. | +| `build` | `goga build` | Must be a mapping when present. Two-part: the root tasks-pass fields plus the optional `build.review` sub-mapping. | +| `build.agent` | `goga build` | Optional at the loader level (absent/empty → `None`); required to actually run `goga build`, which raises `ClickException` otherwise. Resolved into the in-container `*-as-claude.sh` wrapper path. | #### Agent name semantics -Both `pipeline.agent` and `build.task_executor.agent` are agent names as +Both `pipeline.agent` and `build.agent` are agent names as declared in the goga Docker image — any value matching the `/home/goga/bin/-as-claude.sh` wrapper convention (e.g. `claude`, `codex`, `opencode`). The config layer does no validation: resolution and @@ -215,26 +240,32 @@ afm) that consume these fields. | `pipeline.env` | mapping | `{}` | Environment variables for pipeline runs (`{str: str}`) | | `pipeline.proxy` | str | None | HTTP/HTTPS proxy URL for the pipeline container | | `pipeline.hosts` | mapping | `{}` | Host→IP mapping for `docker run --add-host` (pipeline) | -| `build` | mapping | None | Build configuration block (conditionally required by `goga build`) | -| `build.task_executor.env` | mapping | `{}` | Environment variables for builds (`{str: str}`) | +| `build` | mapping | None | Build configuration block, two-part (conditionally required by `goga build`) | +| `build.agent` | str | None | Tasks-pass executor agent name (resolved by the consumer) | +| `build.env` | mapping | `{}` | Tasks-pass environment variables for builds (`{str: str}`); never inherited by the review pass | | `build.proxy` | str | None | HTTP/HTTPS proxy URL for the build container | | `build.hosts` | mapping | `{}` | Host→IP mapping for `docker run --add-host` (build) | -| `build.worktree` | bool | None | Run in an isolated git worktree | -| `build.skip_finalize` | bool | None | Skip the finalization step | -| `build.session_timeout` | str | None | Session timeout (Go duration format) | -| `build.idle_timeout` | str | None | Idle timeout (Go duration format) | -| `build.wait` | str | None | Rate-limit retry wait (Go duration format) | -| `build.max_iterations` | int | None | Maximum task iteration count | +| `build.session_timeout` | str | None | Session timeout (Go duration format); the tasks-pass knob | +| `build.idle_timeout` | str | None | Idle timeout (Go duration format); the tasks-pass knob | +| `build.wait` | str | None | Rate-limit retry wait (Go duration format); the tasks-pass knob | +| `build.max_iterations` | int | None | Maximum task iteration count (root-only, tasks pass) | | `build.prompts_dir` | str | None | Custom prompt directory path | | `build.agents_dir` | str | None | Custom agent directory path | -| `build.codex_review` | bool | None | Enable external codex review (mapped to ralphex `codex_enabled`) | -| `build.review_executor` | mapping | None | Review-phase control block (structural validation only) | -| `build.review_executor.skip` | bool | None | Tri-state source for skipping the review phase | -| `build.review_executor.agent` | str | None | Review executor name (resolved by the consumer) | -| `build.review_executor.roles` | list | None | Reviewer composition; empty list passes verbatim (full default set is consumer semantics) | -| `build.review_executor.env` | mapping | `{}` | Review-pass env layer ({str: str}); empty when absent/YAML-null/`{}`; requires `agent` when non-empty (enforced by the consumer) | -| `build.review_executor.base_ref` | str | None | Review diff base — branch name or commit hash; overrides ralphex's default-branch detection for review diffs. Verbatim, no validation at the config layer | -| `build.review_executor.patience` | int | None | Stop the external review after N consecutive unchanged rounds | +| `build.review` | mapping | None | Review-pass settings block (structural validation only) | +| `build.review.skip` | bool | None | Tri-state source for skipping the review pass | +| `build.review.agent` | str | None | Review executor name (inherits `build.agent` when unset; resolved by the consumer) | +| `build.review.env` | mapping | `{}` | Review-pass env layer ({str: str}); empty when absent/YAML-null/`{}`; never inherits the root env; requires `agent` when non-empty (enforced by the consumer) | +| `build.review.roles` | list | None | Reviewer composition; empty list passes verbatim (full default set is consumer semantics) | +| `build.review.base_ref` | str | None | Review diff base — branch name or commit hash; overrides ralphex's default-branch detection for review diffs. Verbatim, no validation at the config layer | +| `build.review.strategy` | str | None | Review strategy source — full, medium, or short (structural typing only; the whitelist and the default medium belong to the consumer) | +| `build.review.finalize` | str | None | User-authored final review prompt, stored verbatim; None leaves the finalize step at the ralphex default (off) | +| `build.review.session_timeout` | str | None | Review session timeout; None inherits the root value | +| `build.review.idle_timeout` | str | None | Review idle timeout; None inherits the root value | +| `build.review.wait` | str | None | Review rate-limit wait; None inherits the root value | +| `build.review.additional` | mapping | None | External-review block (structural validation only) | +| `build.review.additional.agent` | str | None | External review agent name (inherits `build.review.agent` when unset) | +| `build.review.additional.patience` | int | None | Stop the external review after N consecutive unchanged rounds; 0 = disabled | +| `build.review.additional.max_iterations` | int | None | External review iteration cap; 0 = ralphex auto | | `codemanifest` | mapping | None | CODEMANIFEST usage and annotation config | | `codemanifest.usages` | mapping | `{}` | Usage name-to-path mapping (`{str: str}`) | | `codemanifest.annotations` | str | None | Freeform annotations for the AI agent | @@ -275,7 +306,7 @@ if config.pipeline is None: if config.build is None: raise ClickException("build section is required in .goga/config.yml ...") -# now safe to read config.build.task_executor / proxy / hosts / ... +# now safe to read config.build.agent / env / review / proxy / hosts / ... # PipelineConfig fields (after the None-guard) config.pipeline.agent # str | None — afm client.command inside the container; None when not configured @@ -283,24 +314,36 @@ config.pipeline.env # dict — {str: str} config.pipeline.proxy # str | None — HTTP/HTTPS proxy URL for the pipeline container config.pipeline.hosts # dict[str, str] — docker run --add-host entries -# BuildConfig fields (after the None-guard) -config.build.task_executor # TaskExecutorConfig -config.build.worktree # bool | None +# BuildConfig fields (after the None-guard) — the root is the tasks-pass part +config.build.agent # str | None — tasks-pass executor agent, None when not configured +config.build.env # dict[str, str] — tasks-pass env layer, empty when absent +config.build.max_iterations # int | None — tasks-pass iteration cap +config.build.session_timeout # str | None — tasks-pass session knob +config.build.idle_timeout # str | None — tasks-pass session knob +config.build.wait # str | None — tasks-pass session knob +config.build.prompts_dir # str | None — custom ralphex prompt directory +config.build.agents_dir # str | None — custom ralphex agent directory config.build.proxy # str | None — HTTP/HTTPS proxy URL for the build container config.build.hosts # dict[str, str] — docker run --add-host entries -# TaskExecutorConfig fields -config.build.task_executor.agent # str | None — None when not configured -config.build.task_executor.env # dict — {str: str} - -# ReviewExecutorConfig fields — None when the review_executor section is absent -config.build.review_executor # ReviewExecutorConfig | None -config.build.review_executor.skip # bool | None — tri-state skip source -config.build.review_executor.agent # str | None — review executor name -config.build.review_executor.roles # list[str] | None — verbatim; [] means the full default set to the consumer -config.build.review_executor.env # dict — {str: str}, empty when absent -config.build.review_executor.base_ref # str | None — review diff base, verbatim -config.build.review_executor.patience # int | None — external-review stop threshold +# ReviewConfig fields — None when the build.review key is absent +config.build.review # ReviewConfig | None +config.build.review.skip # bool | None — tri-state skip source +config.build.review.agent # str | None — review executor name (inherits the root agent when unset) +config.build.review.env # dict[str, str] — review env layer, empty when absent; never inherits the root env +config.build.review.roles # list[str] | None — verbatim; [] means the full default set to the consumer +config.build.review.base_ref # str | None — review diff base, verbatim +config.build.review.strategy # str | None — full | medium | short source +config.build.review.finalize # str | None — final review prompt, verbatim +config.build.review.session_timeout # str | None — inherits the root when None +config.build.review.idle_timeout # str | None — inherits the root when None +config.build.review.wait # str | None — inherits the root when None +config.build.review.additional # AdditionalReviewConfig | None + +# AdditionalReviewConfig fields — None when the additional block is absent +config.build.review.additional.agent # str | None — external review agent (inherits review.agent) +config.build.review.additional.patience # int | None — external-review stop threshold +config.build.review.additional.max_iterations # int | None — external review iteration cap # CodemanifestConfig fields — None when the `codemanifest` section is absent config.codemanifest # CodemanifestConfig | None diff --git a/goga/config/CODEMANIFEST b/goga/config/CODEMANIFEST index 2b0d32d9..35f986f1 100644 --- a/goga/config/CODEMANIFEST +++ b/goga/config/CODEMANIFEST @@ -4,8 +4,8 @@ Imports: - load_project_config - BuildConfig - PipelineConfig - - TaskExecutorConfig - - ReviewExecutorConfig + - ReviewConfig + - AdditionalReviewConfig - CodemanifestConfig - DepConfig - LintConfig @@ -33,20 +33,20 @@ Annotations: | This cell is a re-export facade: it embeds (re-exports) all configuration types from goga/config/project (project configuration, including the - topics fast-creation section), goga/config/home (home/docker - configuration), and goga/config/git (git-environment introspection — - `resolve_project_name`) so consumers import a single entry point - (From: goga/config). It owns no behavior — all logic lives in the child - cells. + two-part build section and the topics fast-creation section), + goga/config/home (home/docker configuration), and goga/config/git + (git-environment introspection — `resolve_project_name`) so consumers + import a single entry point (From: goga/config). It owns no behavior — + all logic lives in the child cells. --- ->ProjectConfig: {} ->load_project_config: {} ->BuildConfig: {} +->ReviewConfig: {} +->AdditionalReviewConfig: {} ->PipelineConfig: {} -->TaskExecutorConfig: {} -->ReviewExecutorConfig: {} ->CodemanifestConfig: {} ->DepConfig: {} ->LintConfig: {} diff --git a/goga/config/project/CODEMANIFEST b/goga/config/project/CODEMANIFEST index c892f24f..b773e7d8 100644 --- a/goga/config/project/CODEMANIFEST +++ b/goga/config/project/CODEMANIFEST @@ -19,30 +19,20 @@ Annotations: | Use the `yaml` practice for parsing .goga/config.yml via yaml.safe_load(). - The cell enforces structural validation only: for the usages root directive, - path-safety (no "..", no absolute paths, non-string rejected) is enforced at this - config boundary; resolving root to an existing directory inside the clone is a - semantic check deferred to the usages-sync deploy consumer, consistent with this - cell's "structural validation only; semantic validation deferred to the owning - consumer" stance. - - The cell enforces structural validation only. The optional lint section - follows the same stance: the loader enforces that lint.ignore is a list of - strings; glob interpretation, path normalization, and existence are deferred - to the owning consumer. - - The optional build.review_executor sub-section follows the same structural-only - stance: field types, a list-of-strings check for roles, a strings-mapping - check for env, and scalar type checks for base_ref (string) and patience - (integer); an empty roles list and an empty env mapping pass through - verbatim (the empty-to-full-set and the env-requires-agent meanings belong - to the consuming cell). - - The optional topics section follows the same structural-only stance: the - loader enforces that topics.base_ref and topics.publish_commit are strings - when present — absent/YAML-null/empty/whitespace normalizes to None; rev - resolvability, template grammar, and the default template belong to the - consuming command. + The cell enforces structural validation only; semantic validation is + deferred to the owning consumers. + + The build section is two-part: the build root carries the tasks-pass + settings source (agent, env, max_iterations, session_timeout, idle_timeout, + wait, prompts_dir, agents_dir, proxy, hosts); the build.review key carries + the review-pass settings source (skip, agent, env, roles, base_ref, + strategy, finalize, additional, and the session knobs). The loader extracts + known fields only — unknown keys, including a stale worktree, are silently + ignored; the retired keys worktree, skip_finalize, codex_review and + the retired block names task_executor / review_executor are not + extracted. Inheritance (review from root, additional.agent from + review.agent) belongs to the consuming cell — values are exposed verbatim + with no default merging. --- @@ -56,87 +46,38 @@ Annotations: | 2. Parse the YAML via the `yaml` practice 3. Validate the parsed document is a mapping; raise FileNotFoundError when the file is absent/empty, ValueError when it is not a mapping - 4. Extract and validate required top-level fields, raising KeyError on - missing fields and ValueError on invalid values: - - lang from the language directive - - image (top-level Docker image, optional — None is a valid value) - - dockerfile (top-level path to a project Dockerfile, optional — None is a - valid value; a non-string value raises ValueError), parsed like image - 5. Extract the pipeline block. When the pipeline key is absent → set - pipeline to None (the section is optional at the loader level). When the - key is present → validate it is a mapping (raise ValueError otherwise) - and construct a `PipelineConfig` from its fields: agent (OPTIONAL — - absent/YAML-null/empty/whitespace → None; a non-string value raises - ValueError), env (optional, default empty dict), proxy (optional, default - None), hosts (optional, default empty dict) - 6. Extract the build block. When the build key is absent → set build to - None. When the key is present → validate it is a mapping (raise - ValueError otherwise), then validate the required task_executor - sub-block (raise KeyError when missing), construct a - `TaskExecutorConfig` from agent (OPTIONAL — absent/YAML-null/empty/ - whitespace → None; a non-string value raises ValueError) and env (optional, - default empty dict), and finally construct a `BuildConfig` from - task_executor, proxy (optional, default None), hosts (optional, default - empty dict), plus the remaining optional build fields - 7. Extract the optional review_executor sub-block of build: absent or - YAML-null → review_executor None. Present but not a mapping → ValueError. - skip: absent/null → None; a non-bool value → ValueError. agent: - absent/YAML-null/empty/whitespace → None; a non-string value → ValueError. - roles: absent/YAML-null → None; present but not a list or a non-string - element → ValueError; an empty list passes through as [] verbatim. - env: absent/YAML-null/empty mapping → empty dict; present but not a - mapping → ValueError; a non-string key or value → ValueError (messages - follow the build.task_executor.env pattern; YAML-null is a valid empty - mapping — null-tolerance mirrors agent and roles of this step). - base_ref: absent/YAML-null/empty/whitespace → None; a non-string value → - ValueError (the agent pattern). - patience: absent/YAML-null → None; a non-int value (including a YAML - boolean) → ValueError — a structural type check. - Construct a `ReviewExecutorConfig` from the resolved fields (including - base_ref and patience) and pass it into `BuildConfig`. - 8. Extract the optional codemanifest block; when present, construct a - `CodemanifestConfig` from its usages and annotations fields, otherwise None - 9. Extract the optional lint block. When the lint key is absent or - YAML-null → lint=None. When present but not a mapping → raise - ValueError. When a mapping → extract the optional ignore: - absent/YAML-null/empty → empty list; present → must be a list whose every - element is a string (otherwise ValueError). Perform NO semantic validation - of path contents (glob, existence, normalization) — only the structural - "list of strings" check. Construct a `LintConfig` from the resolved ignore list. - 10. Extract the optional topics block. When the topics key is absent or - YAML-null → topics=None. Present but not a mapping → ValueError. - When a mapping → extract the optional base_ref and publish_commit: - absent/YAML-null/empty/whitespace → None; a non-string value → - ValueError. Construct a `TopicsConfig` from the resolved fields. - 11. Extract the optional commands mapping (defaults to empty) - 12. Extract the optional tools mapping via the `yaml` practice. When the key - is absent or YAML-null → set tools to None. When the key is present but - the value is not a mapping → raise ValueError. When the value is a - mapping → validate structurally that every key is a string and every - value is a string (raise ValueError on non-string keys or values — - YAML-null values like 'viewer:' are rejected). Perform NO semantic - validation of value contents: operator-prefixed forms ('==1.0', - '>=1.0'), malformed forms ('1.x.0', '1.0.0a1'), and any other - non-grammar strings pass through the loader verbatim. The loader is NOT - the validation authority for the version grammar — that responsibility - belongs to the consumer. - 13. Extract the optional usages block. When the usages key is absent → set - usages to None. When present but not a mapping → raise ValueError. When a mapping - → for each group (str key → mapping) and each dep (str key → mapping): - - require dep.git (non-empty str) — KeyError when missing, ValueError when invalid - - dep.ref optional (str or absent) → None when absent - - dep.root optional (str or absent): when absent → None; when present and not a str - → raise ValueError; when present and empty/separator/whitespace-only → normalize - to None (≡ "no root", not an error); otherwise validate the root path structurally - — reject path-escape (".." as any segment) and absolute paths (leading "/" or a - UNC root "//host/share") with ValueError; a trailing separator is insignificant (normalized) - - validate each / key as a plain path segment: reject empty, "." / "..", - or any name containing "/" or "\" (ValueError) — these keys become - .goga/usages/// segments in the downstream usages-sync consumer - - construct a `DepConfig`(git, ref, root) per dep, preserving group/dep as dict keys - Build usages as dict[str, dict[str, DepConfig]]; empty mapping when present-but-empty - 14. Construct and return a `ProjectConfig` from all assembled parts, including - dockerfile, tools, usages, lint, and topics + 4. Extract and validate the top-level fields as today (lang, image, + dockerfile) + 5. Extract the pipeline block as today (unchanged semantics) + 6. Extract the build block. Absent → build None; present but not a + mapping → ValueError. From the mapping extract the tasks-pass root + fields: agent (OPTIONAL — absent/YAML-null/empty/whitespace → None; a + non-string value → ValueError), env (optional string mapping, default + empty dict), max_iterations (OPTIONAL int — absent/YAML-null → None; a + non-int value including a YAML boolean → ValueError), session_timeout, + idle_timeout, wait (OPTIONAL strings — the agent emptiness pattern), + prompts_dir and agents_dir (OPTIONAL strings), proxy (OPTIONAL string), + hosts (optional string mapping, default empty dict). Unknown keys of + the mapping are ignored — the loader extracts known fields only + 7. Extract the optional review sub-mapping of build: absent/YAML-null → + review None; present but not a mapping → ValueError. Fields: skip + (absent/null → None; non-bool → ValueError); agent (the agent + pattern); env (the env pattern); roles (absent/null → None; non-list + or a non-string element → ValueError; an empty list passes verbatim); + base_ref (the agent pattern); strategy (absent/null/empty/whitespace → + None; non-string → ValueError — structural typing only, the + full|medium|short whitelist belongs to the consumer); finalize + (absent/null/empty/whitespace → None; non-string → ValueError — the + user-authored final review prompt, stored verbatim); additional + (absent/null → None; present but not a mapping → ValueError; inside: + agent — the agent pattern; patience — absent/null → None, non-int + including a YAML boolean → ValueError; max_iterations — the patience + pattern); the session knobs (the root pattern). Construct a + `ReviewConfig` from the resolved fields and an `AdditionalReviewConfig` + from the additional mapping, and pass them into `BuildConfig` + 8. Extract the codemanifest, lint, topics, commands, tools, and usages + blocks exactly as today (unchanged) + 9. Construct and return the `ProjectConfig` from all assembled parts Requirements: - Top-level image is the Docker image (None is valid — consumers raise @@ -149,17 +90,16 @@ Annotations: | - pipeline.proxy is optional, defaults to None - pipeline.hosts is optional, defaults to an empty mapping - build block is OPTIONAL (None when the key is absent; - present-but-non-mapping → ValueError); WHEN present, build.task_executor - sub-block is required (KeyError when missing), but build.task_executor.agent is - OPTIONAL — absent/YAML-null/empty/whitespace → None; a non-string value → - ValueError. the build command raises a clean ClickException when it needs an agent + present-but-non-mapping → ValueError) + - The two-part build model: root tasks-pass fields plus the optional + review sub-mapping; values verbatim, no default merge — inheritance is + the consumer's + - The loader extracts known fields only: a stale worktree key or any + unknown key is silently ignored — not an error, not stored + - strategy and finalize are structural string checks — the whitelist and + the prompt semantics belong to the consumer - build.proxy is optional, defaults to None - build.hosts is optional, defaults to an empty mapping - - review_executor.env is stored verbatim — semantic validation - (env requires agent) belongs to the consumer - - review_executor.base_ref and review_executor.patience are parsed - structural-only and stored verbatim — branch resolvability and value - semantics belong to the consumer - codemanifest is optional - tools is optional; values are stored verbatim with NO semantic validation — invalid forms (operator-prefixed, malformed numerics) pass through @@ -210,16 +150,17 @@ Annotations: | only the structural type (list of strings) - Do NOT reject glob characters in ignore entries — they are stored verbatim; the consumer (lint/AST) documents them as unsupported - - Do NOT validate review_executor values (role whitelist, agent existence) - at the loader level — semantics belong to the consumer - - Do NOT validate review_executor.env semantics at the loader level — - semantics belong to the consumer + - Do not validate review semantics (roles whitelist, env-requires-agent, + base_ref resolvability, strategy whitelist, patience range) — consumer + territory + - Do not default-merge root values into review — the consumer applies + inheritance + - Do not rename or alias the retired keys — they simply do not exist in + the model - Do NOT validate topics.base_ref rev resolvability or topics.publish_commit template semantics at the loader level — structural typing only; the consumer applies the default template - The final `ProjectConfig` assembly MUST include topics - - The review patience field is read only from - build.review_executor.patience - The final `ProjectConfig` assembly MUST include lint "ProjectConfig(lang: str, image: str | None, dockerfile: str | None, build: BuildConfig | None, pipeline: PipelineConfig | None, commands: dict, codemanifest: CodemanifestConfig | None, tools: dict[str, str] | None, usages: dict[str, dict[str, DepConfig]] | None = None, lint: LintConfig | None = None, topics: TopicsConfig | None = None)": @@ -230,8 +171,9 @@ Annotations: | `lang`: project language directive `image`: top-level Docker image shared by build and pipeline; None is a valid value `dockerfile`: top-level path to a project Dockerfile; None is a valid value - `build`: build configuration as a `BuildConfig` instance, or None when the - build section is absent in .goga/config.yml + `build`: build configuration in the two-part form as a `BuildConfig` + instance, or None when the build section is absent in + .goga/config.yml `pipeline`: pipeline configuration as a `PipelineConfig` instance, or None when the pipeline section is absent in .goga/config.yml `commands`: command hooks — reserved for future prompt customization @@ -264,10 +206,11 @@ Annotations: | and pipeline. None is a valid value — when set, --update builds locally (docker_update → DockerBuilder); when None, --update pulls (docker_pull). "build -> BuildConfig | None": | - Build configuration from .goga/config.yml. Instance of `BuildConfig`, or None - when the build section is absent. Consumers that need it - (goga/commands/build) guard the None case and raise ClickException before - any field access. + Build configuration from .goga/config.yml in the two-part form: the + root tasks-pass settings and the optional review part. Instance of + `BuildConfig`, or None when the build section is absent. Consumers that + need it (goga/commands/build) guard the None case and raise + ClickException before any field access. "pipeline -> PipelineConfig | None": | Pipeline configuration from .goga/config.yml. Instance of `PipelineConfig`, or None when the pipeline section is absent. Consumers that need it @@ -308,148 +251,139 @@ Annotations: | `TopicsConfig`, or None when the topics section is absent. Defaults to None (kw_only) so ProjectConfig(...) callers may omit topics=. -"BuildConfig(task_executor: TaskExecutorConfig, worktree: bool | None, skip_finalize: bool | None, session_timeout: str | None, idle_timeout: str | None, wait: str | None, max_iterations: int | None, prompts_dir: str | None, agents_dir: str | None, codex_review: bool | None, review_executor: ReviewExecutorConfig | None, proxy: str | None, hosts: dict[str, str])": +"BuildConfig(agent: str | None, env: dict[str, str], max_iterations: int | None, session_timeout: str | None, idle_timeout: str | None, wait: str | None, prompts_dir: str | None, agents_dir: str | None, proxy: str | None, hosts: dict[str, str], review: ReviewConfig | None)": location: config.py annotations: | - Build execution configuration. Constructed by load_project_config from the build section of .goga/config.yml. - - `task_executor`: AI agent configuration. Required. - `proxy`: optional HTTP/HTTPS proxy URL (e.g. "http://corp:3128"). When non-None, - consumers write HTTP_PROXY/HTTPS_PROXY/NO_PROXY into the container env-file. - Defaults to None. - `hosts`: optional host→IP mapping for "docker run --add-host HOST:IP" flags. Empty dict - when the section is absent. Consumers merge CLI --add-host flags on top. - All remaining fields are optional and default to None. + Build execution configuration in the two-part form. Constructed by + load_project_config from the build section of .goga/config.yml. The root + fields are the tasks-pass settings source; the review part is the + review-pass settings source. Inheritance from root into review belongs to + the consumer. + + `agent`: tasks-pass executor agent name — None when unset + `env`: tasks-pass environment layer — the review pass never receives it + `max_iterations`: maximum task iterations + `session_timeout`, `idle_timeout`, `wait`: session knobs (Go duration strings) + `prompts_dir`, `agents_dir`: custom ralphex source directories + `proxy`: optional HTTP/HTTPS proxy URL + `hosts`: optional host→IP mapping for docker run --add-host + `review`: the review-pass settings part, or None when absent Requirements: - - task_executor is required - - All other fields may be None; hosts defaults to an empty dict + - All fields may be None; env and hosts default to empty dicts + - Values stored verbatim, no inheritance applied here properties: - "task_executor -> TaskExecutorConfig": | - AI agent configuration. `TaskExecutorConfig` instance. - Required field. - "worktree -> bool | None": | - Enable isolated git worktree execution. - "skip_finalize -> bool | None": | - Skip the ralphex finalization step. + "agent -> str | None": | + Tasks-pass executor agent name matching the wrapper convention; None + when unset. Resolution into a wrapper path belongs to the consumer. + "env -> dict[str, str]": | + Tasks-pass environment layer ({str: str}), verbatim. Applied as the + env layer of the tasks pass only — the review pass never receives it. + Empty dict when absent. + "max_iterations -> int | None": | + Maximum number of task iterations of the tasks pass. None when unset. "session_timeout -> str | None": | - Session timeout duration. Go duration format ("30m", "1h"). + Session timeout duration, Go duration format. None when unset. "idle_timeout -> str | None": | - Session idle timeout duration. Go duration format. + Session idle timeout duration, Go duration format. None when unset. "wait -> str | None": | - Rate-limit retry wait duration. Go duration format. - "max_iterations -> int | None": | - Maximum number of task iterations. + Rate-limit retry wait duration, Go duration format. None when unset. "prompts_dir -> str | None": | - Custom ralphex prompt directory path. + Custom ralphex prompt directory path. None when unset. "agents_dir -> str | None": | - Custom ralphex agent directory path. - "codex_review -> bool | None": | - Enable external codex review. - "review_executor -> ReviewExecutorConfig | None": | - Review-executor configuration. Instance of `ReviewExecutorConfig`, or None - when the review_executor section is absent. Structural validity is owned - by `load_project_config`; semantic checks belong to the consumer. + Custom ralphex agent directory path. None when unset. "proxy -> str | None": | Optional HTTP/HTTPS proxy URL for the build container. - When non-None, consumers add HTTP_PROXY, HTTPS_PROXY, and NO_PROXY to the - container env-file (NO_PROXY is fixed at "localhost,127.0.0.1"). - Defaults to None. "hosts -> dict[str, str]": | - Optional host→IP mapping for "docker run --add-host HOST:IP" flags. - Defaults to an empty dict when absent in .goga/config.yml. - -"TaskExecutorConfig(agent: str | None, env: dict)": + Optional host→IP mapping for docker run --add-host flags. Empty dict + when absent. + "review -> ReviewConfig | None": | + The review-pass settings part. Instance of `ReviewConfig`, or None + when the build.review key is absent. Structural validity is owned by + `load_project_config`; semantics and inheritance belong to the consumer. + +"ReviewConfig(skip: bool | None, agent: str | None, env: dict[str, str], roles: list[str] | None, base_ref: str | None, strategy: str | None, finalize: str | None, additional: AdditionalReviewConfig | None, session_timeout: str | None, idle_timeout: str | None, wait: str | None)": location: config.py annotations: | - AI agent configuration for task execution. - Constructed by load_project_config from the task_executor section. - - `agent`: AI executor identifier (optional — None when unset) - `env`: environment variable dictionary + The build.review settings source — the review-pass part of the two-part + build model. Constructed by load_project_config. Every field is stored + verbatim; an unset field is None (or an empty dict for env) and means + "inherit from the root" to the consumer. Requirements: - - agent is OPTIONAL — absent/YAML-null/empty/whitespace in .goga/config.yml - resolves to None; a non-string value raises ValueError. the build command raises a - clean ClickException when it needs an agent. - - env is optional and defaults to an empty dict + - Immutable frozen dataclass (frozen=True, kw_only=True), per `convention` + - No normalization of emptiness, no whitelist; env resolves + absent/YAML-null/{} to an empty dict + + Constraints: + - Do not validate role names, agent names, strategy values, or env + applicability at this level — structural typing only properties: + "skip -> bool | None": | + Tri-state source for skipping the review pass. None when the field is + absent. "agent -> str | None": | - AI executor identifier — agent name as declared in the goga Docker image - (e.g. "claude", "codex", "opencode", or any other name matching the - /home/goga/bin/-as-claude.sh wrapper convention). Resolved at - runtime by the consumer (goga/build) into an absolute wrapper path; - this cell does no resolution or validation of the value. - Optional — None when the agent is not configured in .goga/config.yml. - "env -> dict": | - Environment variable dictionary ({str: str}). - Passed to the AI executor at launch to configure models and endpoints. - Optional — defaults to an empty dict. + Review-pass executor name matching the wrapper convention. None when + unset — the consumer inherits the root agent. + "env -> dict[str, str]": | + Review-pass environment layer, verbatim. Empty dict when absent — the + consumer inherits nothing for env (the root env is the tasks-pass + layer and is never inherited). + "roles -> list[str] | None": | + Declared reviewer composition, verbatim. None when unset; an empty list + stays an empty list (the full default set is the consumer-side meaning). + "base_ref -> str | None": | + Review diff base — a branch name or a commit hash, verbatim. None when + unset (normalized by `load_project_config`). + "strategy -> str | None": | + Review strategy source — full, medium, or short; None when unset (the + consumer applies the default medium). Structural typing only. + "finalize -> str | None": | + The user-authored final review prompt, verbatim. None when unset — the + finalize step stays at the ralphex default (off). + "additional -> AdditionalReviewConfig | None": | + The external-review block source. Instance of `AdditionalReviewConfig`, + or None when absent. + "session_timeout -> str | None": | + Review-pass session timeout; None inherits the root value. + "idle_timeout -> str | None": | + Review-pass idle timeout; None inherits the root value. + "wait -> str | None": | + Review-pass rate-limit wait; None inherits the root value. -"ReviewExecutorConfig(skip: bool | None, agent: str | None, roles: list[str] | None, env: dict[str, str], base_ref: str | None, patience: int | None)": +"AdditionalReviewConfig(agent: str | None, patience: int | None, max_iterations: int | None)": location: config.py annotations: | - Review-executor configuration of the build — whether the review phase is - skipped, which agent runs it, which reviewer roles participate, which - environment variables the review pass carries, the base of the review - diff, and the external-review stop threshold. - - `skip`: tri-state source for skipping the review phase — None when the - field is absent - `agent`: review executor name — None when unset (review runs on the task - executor) - `roles`: declared reviewer composition — None when unset; an empty list is - stored verbatim and means the full default set to the consumer - `env`: environment variable dictionary for the review pass — an empty dict - when the field is absent, YAML-null, or an empty mapping - `base_ref`: review diff base — None when unset - `patience`: external-review stop threshold — None when unset + The build.review.additional external-review block source. + + `agent`: external review agent name — None when unset (the consumer + inherits review.agent) + `patience`: external-review stop threshold (0 = disabled) + `max_iterations`: external review iteration cap (0 = ralphex auto) Requirements: - Immutable frozen dataclass (frozen=True, kw_only=True), per `convention` - - Fields are stored verbatim — no normalization of emptiness, no - whitelist; env resolves absent/YAML-null/{} to an empty dict (the - documented default of that field) + - Values stored verbatim; 0 is a meaningful value, not an unset marker Constraints: - - Do not validate role names, agent names, or env applicability at this - level — structural typing only - - Do not validate base_ref or patience values at this level — branch - resolvability and threshold semantics belong to the consumer + - Do not validate agent names or ranges at this level — structural typing + only properties: - "skip -> bool | None": | - Tri-state source for skipping the review phase. None when the field is - absent in .goga/config.yml. "agent -> str | None": | - Review executor name matching the /home/goga/bin/-as-claude.sh - wrapper convention. No resolution or validation here. None when unset. - "roles -> list[str] | None": | - Declared reviewer composition, verbatim from .goga/config.yml. None when - unset; an empty list stays an empty list (full default set is the - consumer-side meaning). - "env -> dict[str, str]": | - Environment variable dictionary ({str: str}) for the review pass, verbatim - from .goga/config.yml. Empty dict when the field is absent, YAML-null, or - an empty mapping. Structural typing is enforced by `load_project_config`; - the env-requires-agent rule belongs to the consumer. - "base_ref -> str | None": | - Review diff base for the review pass — a branch name or a commit - hash, verbatim from .goga/config.yml. None when the field is absent, - YAML-null, or empty/whitespace-only (normalized by - `load_project_config`). No resolution or validation here — the value - flows to the consumer as the source of the review-scoped diff base. + External review agent name matching the wrapper convention. None when + unset — the consumer inherits review.agent. "patience -> int | None": | - External-review stop threshold — stop the external review after N - consecutive unchanged rounds. None when the field is absent or - YAML-null. Structural typing (int) is enforced by - `load_project_config`; range and semantic checks belong to the - consumer. + External-review stop threshold — stop after N consecutive unchanged + rounds; 0 = disabled. None when unset. + "max_iterations -> int | None": | + External review iteration cap; 0 = ralphex auto (max(3, + max_iterations/5)). None when unset. "PipelineConfig(agent: str | None, env: dict, proxy: str | None, hosts: dict[str, str])": location: config.py annotations: | Pipeline configuration block. Constructed by load_project_config from the pipeline section. - Semantically distinct from TaskExecutorConfig: this `agent` drives the + Semantically distinct from `BuildConfig`: this `agent` drives the afm client.command inside the container during pipeline execution. `agent`: AI executor identifier used as afm client.command inside the container @@ -611,5 +545,7 @@ Author: Goga CreatedAt: 24/07/26 Description: | - Project configuration model + loader for .goga/config.yml. Structural - validation only; semantic validation is deferred to the owning consumers. + Project configuration model + loader for .goga/config.yml, with the + two-part build section (the root tasks-pass settings plus the optional + review part). Structural validation only; semantic validation is deferred + to the owning consumers. diff --git a/goga/hooks/catalog/CODEMANIFEST b/goga/hooks/catalog/CODEMANIFEST index 7b4ede2a..d6d27dde 100644 --- a/goga/hooks/catalog/CODEMANIFEST +++ b/goga/hooks/catalog/CODEMANIFEST @@ -114,6 +114,27 @@ Annotations: | the record domain="pipeline", name="run_completed", error_class="soft": a failing hook of the action is skipped with a warning and the command continues + - The catalog carries the build validation-gate action — the record + domain="build", name="validate_build", error_class="hard": a veto + stops the build before any pass with one merged error; the domain's + delivery walk runs every subscribed tool's hooks to completion — a + deliberate domain-local deviation from the stop-at-first-failure hard + semantics, recorded by the build domain's zone + - The catalog carries the build start-notification action — the record + domain="build", name="build_started", error_class="soft": a failing + hook of the action is skipped with a warning and the run continues + - The catalog carries the build pass-start notification action — the + record domain="build", name="pass_started", error_class="soft": a + failing hook of the action is skipped with a warning and the pass + launches + - The catalog carries the build pass-completion notification action — + the record domain="build", name="pass_completed", error_class="soft": + a failing hook of the action is skipped with a warning; the completion + fact is already delivered + - The catalog carries the build completion notification action — the + record domain="build", name="build_completed", error_class="soft": a + failing hook of the action is skipped with a warning; the run's exit + code is unaffected Constraints: - Do not derive records from installed packages or imports — the diff --git a/goga/onboarding/generator/CODEMANIFEST b/goga/onboarding/generator/CODEMANIFEST index 0e555c73..23eaad03 100644 --- a/goga/onboarding/generator/CODEMANIFEST +++ b/goga/onboarding/generator/CODEMANIFEST @@ -91,8 +91,10 @@ Annotations: | - docker_image.dockerfile → dockerfile (top level, omitted when absent) - docker_image.base_image → the Dockerfile FROM line only — never emitted to the config - - build.task_executor.agent → build.task_executor.agent (omitted when absent) - - build.task_executor.env → build.task_executor.env (omitted when absent or empty) + - build.agent → build.agent (the two-part build root — omitted when + absent) + - build.env → build.env (the two-part build root — omitted when absent + or empty) - pipeline.agent / pipeline.env → the pipeline block (same omission rules) - codemanifest.usages / codemanifest.annotations → the codemanifest block - tools → tools (omitted when absent or empty) diff --git a/goga/ralphex/.usages/run-ralphex.md b/goga/ralphex/.usages/run-ralphex.md index 60cae8d6..e0106540 100644 --- a/goga/ralphex/.usages/run-ralphex.md +++ b/goga/ralphex/.usages/run-ralphex.md @@ -16,21 +16,24 @@ the CLI/config precedence applied, generates the `.ralphex/config`, and only the from goga.ralphex import run_ralphex plan = "docs/plans/my-plan.md" # resolved by the caller (goga/build) -options = { # universal ralphex options (CLI > ProjectConfig > omit applied) - "worktree": True, +options = { # resolved ralphex options (precedence applied by the caller) "max_iterations": 50, "session_timeout": "30m", - "tasks_only": False, # True → --tasks-only (skip all review phases) - "review": False, # True → --review (review-only pass) + "tasks_only": False, # True → --tasks-only (the tasks pass) + "review": False, # True → --review (the review pass) + "external_only": False, # True → -e (the external-only review pass) } dry_run = False exit_code = run_ralphex(plan, options, dry_run) ``` -Two-pass composition when the task executor and the review executor differ. The -shared `options` dict holds the universal options only — the review-scoped keys -(`base_ref`, `review_patience`) join the review pass alone, never the tasks pass: +Two-pass composition is the only form: every non-skipped run is a tasks pass +(`--tasks-only`) then, on its success, a review pass (`--review`, or `-e` under +the short strategy) — regardless of executor configuration. The pass-mode flags +are mutually exclusive per invocation; the review-scoped keys (`base_ref`, +`review_patience`, `max_external_iterations`) join the review pass alone, never +the tasks pass: ```python # Pass 1 — tasks only (task wrapper in .ralphex/config claude_command). @@ -59,15 +62,20 @@ exit_code = run_ralphex(plan, {**options, "review": True}, dry_run, env={"ANTHRO - `plan: str` — path to the plan file (markdown), resolved by the caller. Passed to ralphex as the positional argument. - `options: dict` — resolved ralphex options. The caller has already applied CLI > - ProjectConfig > omit precedence; `run_ralphex` maps each resolved key to its ralphex + config > omit precedence; `run_ralphex` maps each resolved key to its ralphex CLI flag (see the option→flag table in its CODEMANIFEST contract) — it performs no precedence resolution. Bool keys include `tasks_only` (True → bare `--tasks-only`, - tasks without any review) and `review` (True → bare `--review`, review-only pass); - False or absent omits the flag. - Review-scoped keys — `review_patience` and `base_ref` — map like any other - key but belong on review-carrying passes only (the caller decides the pass - composition). `base_ref` is forwarded verbatim and omitted from the command - when None or an empty string. + the tasks pass), `review` (True → bare `--review`, the review-only pass), and + `external_only` (True → bare `-e`, the external-only review pass); False or + absent omits the flag, and the three pass-mode flags are mutually exclusive + per invocation. + Review-scoped keys — `review_patience`, `max_external_iterations`, and + `base_ref` — map like any other key but belong on review-carrying passes only + (the caller decides the pass composition). `base_ref` is forwarded verbatim + and omitted from the command when None or an empty string. A scalar value of + 0 is omitted for every key EXCEPT the external flags: `review_patience` 0 + (disabled) and `max_external_iterations` 0 (ralphex auto) are meaningful and + ARE passed as 0. - `dry_run: bool` — when True, print the assembled ralphex command to sys.stderr and return 0 without launching. - `env: dict[str, str] | None` — optional environment layer for the ralphex @@ -106,7 +114,7 @@ the docker env-file by the host launcher). - Do not pass unresolved options expecting `run_ralphex` to apply CLI/config precedence. - Do not call `run_ralphex` before `.ralphex/config` is generated — config generation lives in the caller. -- Do not pass a build config object (`BuildConfig`/`TaskExecutorConfig`) — `run_ralphex` +- Do not pass a build config object (`BuildConfig`/`ReviewConfig`) — `run_ralphex` takes resolved primitives only and imports nothing from `goga/config`. - Do not pass `base_ref` expecting `run_ralphex` to validate or resolve the ref — the value is forwarded verbatim; ralphex resolves it. diff --git a/goga/ralphex/CODEMANIFEST b/goga/ralphex/CODEMANIFEST index 0f34da1c..211ef0f3 100644 --- a/goga/ralphex/CODEMANIFEST +++ b/goga/ralphex/CODEMANIFEST @@ -37,76 +37,51 @@ Annotations: | `plan`: path to the plan file (markdown), resolved by the caller (goga/build). Passed to ralphex as the positional argument. - `options`: resolved ralphex options — the caller (goga/build) has already applied CLI > - ProjectConfig > omit precedence. Keys are ralphex option names; each key maps + `options`: resolved ralphex options — the caller (goga/build) has already applied + precedence and stage binding. Keys are ralphex option names; each key maps to exactly one ralphex CLI flag: - - worktree (bool) → --worktree (bare flag) - - skip_finalize (bool) → --skip-finalize (bare flag) - - review (bool) → --review (bare flag) - - tasks_only (bool) → --tasks-only (bare flag) - - session_timeout (str) → --session-timeout (value flag) - - idle_timeout (str) → --idle-timeout (value flag) - - wait (str) → --wait (value flag) - - max_iterations (int) → --max-iterations (value flag) - - review_patience (int) → --review-patience (value flag) - - base_ref (str) → --base-ref (value flag) + - tasks_only (bool) → --tasks-only (bare flag) + - review (bool) → --review (bare flag) + - external_only (bool) → -e (bare flag) + - session_timeout (str) → --session-timeout (value flag) + - idle_timeout (str) → --idle-timeout (value flag) + - wait (str) → --wait (value flag) + - max_iterations (int) → --max-iterations (value flag) + - review_patience (int) → --review-patience (value flag) + - max_external_iterations (int) → --max-external-iterations (value flag) + - base_ref (str) → --base-ref (value flag) `dry_run`: when True, print the assembled ralphex command to sys.stderr and return 0 without launching. - `env`: optional environment layer ({str: str}) applied on top of the inherited - process environment for the ralphex subprocess — keys of `env` override - same-named inherited variables, every other inherited variable passes through - unchanged. None or an empty dict means pure inheritance with no layer. The - layer scopes to this subprocess only. - `exit_code`: 0 on success, 1 when the ralphex binary is missing from PATH — - including when an `env` layer's PATH override hides it from the - exec — or when the launch is rejected before the exec (an `env` - layer key that is not a legal environment variable name, an - oversized layer, or a PATH override resolving a non-executable - or non-directory ralphex), otherwise ralphex's own exit code + `env`: optional environment layer applied on top of the inherited process + environment for the ralphex subprocess only. Never logged, never printed. + `exit_code`: 0 on success, 1 when ralphex is missing from PATH or the launch is + rejected before the exec, otherwise ralphex's own exit code Algorithm: 1. Receive `plan`, `options`, `dry_run`, and `env` from the caller 2. Invoke ralphex via the `ralphex` practice with `plan` as the positional argument, - --config-dir .ralphex/, and the flags mapped from `options` (precedence already - applied by the caller) + --config-dir .ralphex/, and the flags mapped from `options` 3. On `dry_run`: print the assembled command to sys.stderr and return 0 — never print the `env` layer values - 4. Verify `ralphex` is on PATH via the `ralphex` practice; when absent — return `exit_code` 1 - 5. Execute `ralphex` via subprocess with the environment composed as the inherited - process environment overlaid with `env` (a None or empty `env` leaves the inherited - environment untouched) and propagate its exit code. A launch rejected before the - exec — FileNotFoundError when the layer's PATH override hides the binary, or - OSError/ValueError on an illegal env-variable name, an oversized layer, or a - PATH override resolving a non-executable/non-directory ralphex — is reported as a - clean one-line message to sys.stderr and exit code 1, never a traceback, and the - message never reveals the `env` layer contents - - Apply the `conventions` practice for error-handling style and docstring formatting. - Apply the `ralphex` practice for the general ralphex CLI contract (binary invocation, - --config-dir, PATH-resolved invocation, exit-code propagation); the option→flag - mapping is fixed by this contract (see `options`), not by the practice. + 4. Verify `ralphex` is on PATH; when absent — return 1 + 5. Execute ralphex via subprocess with the composed environment and propagate its + exit code; a pre-exec rejection surfaces as a clean one-line message and exit + code 1 — never a traceback, never the `env` contents Requirements: - - Always invoke ralphex with `plan` as the positional argument and --config-dir .ralphex/, - plus the mapped flags — never omit the plan or --config-dir - - Map `options` to ralphex CLI flags per the table in `options`: a bool key that is - True emits a bare -- (False or absent → omit the flag); a scalar key emits - -- and is omitted when the value is None, an empty string, or 0 - - Invoke ralphex through PATH — do not hard-code the ralphex binary path - - Compose the subprocess environment as the inherited os.environ overlaid with `env`; - apply the layer only to this subprocess - - On `dry_run`, the printed command carries no environment values - - A launch rejection (see Algorithm step 5) surfaces as a clean message plus exit - code 1 — no traceback escapes to the caller, and no `env` layer value is printed + - Map `options` to ralphex CLI flags per the table: a bool key that is True emits a + bare flag (False or absent → omit); a scalar key emits -- and is + omitted when the value is None or an empty string — EXCEPT the zero-valued + external flags: review_patience 0 and max_external_iterations 0 are meaningful + (disabled / ralphex auto) and ARE passed as 0 + - The pass-mode bare flags (tasks_only, review, external_only) are mutually + exclusive per invocation — the caller guarantees it; this launcher does not check - Apply the `ralphex` practice's exit-code rules verbatim Constraints: - - Do not generate the .ralphex/config file — that is the caller's responsibility - - Do not resolve ralphex options (CLI > ProjectConfig > omit) — caller's responsibility - - Do not resolve the agent wrapper path — caller's responsibility - - Do not hard-code the ralphex binary path — rely on PATH inside the container - - Do not construct the environment from a config object — `env` is an explicitly - passed primitive layer, not a config-derived environment + - Do not generate .ralphex/config, resolve options, or resolve wrappers — caller's + responsibility + - Do not check flag exclusivity — the caller owns pass composition - Do not log or otherwise expose the `env` layer contents --- From a74646fa43cd8e74d5f7d0a82443dfd5a377eca1 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 20:21:10 +0000 Subject: [PATCH 083/205] feat: rewrite the build config model to the two-part form (Task 1) Rewrite goga/config/project to the two-part build model: delete TaskExecutorConfig/ReviewExecutorConfig, reshape BuildConfig around the build root tasks-pass fields plus the optional review part, and add the frozen kw_only ReviewConfig and AdditionalReviewConfig value objects. Rewrite the loader's build extraction (known fields only; retired keys silently ignored; new-key error messages) and update both config facades to embed the new names and drop the retired ones. Rewrite the config test suites onto the two-part model, including the three plan-named loader logic tests. --- .goga/history/2026/add-hooks-to-build/plan.md | 24 +- goga/config/__init__.py | 8 +- goga/config/project/__init__.py | 8 +- goga/config/project/config.py | 115 +- goga/config/project/loader.py | 350 ++++-- tests/config/test_config.py | 379 +++--- tests/config/test_integration.py | 178 ++- tests/config/test_loader.py | 1092 +++++++---------- tests/config/test_project_cell_contract.py | 32 +- tests/config/test_tools_integration.py | 41 +- 10 files changed, 1186 insertions(+), 1041 deletions(-) diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index 5073b83d..32807ab9 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -562,18 +562,18 @@ delete `_parse_task_executor` (loader.py:46), `_parse_review_scoped_fields` `goga/config/__init__.py`: add `ReviewConfig`, `AdditionalReviewConfig` to imports and `__all__`; drop `TaskExecutorConfig`, `ReviewExecutorConfig`. -- [ ] **Declaration**: Task 1 — two-part build configuration model, loader, and facade re-exports -- [ ] **Contract tests**: in `tests/config/test_config.py` — `ReviewConfig` and `AdditionalReviewConfig` importable from `goga.config` and `goga.config.project`; both pass `is_kw_only_dataclass` (fixture from `tests/conftest.py`) and are frozen; `BuildConfig` exposes exactly the new field set (`review` present; `task_executor`/`worktree`/`review_executor` absent); in `tests/config/test_loader.py` — `load_project_config` still importable from `goga.config` (expected to fail at this stage) -- [ ] **Code**: rewrite `goga/config/project/config.py` — delete `TaskExecutorConfig`/`ReviewExecutorConfig`, reshape `BuildConfig`, add `ReviewConfig`/`AdditionalReviewConfig` (frozen, kw_only, Google docstrings, `from __future__ import annotations`) -- [ ] **Code**: rewrite `goga/config/project/loader.py` — `_parse_build` two-part extraction per the trace; delete the three retired parse helpers; new-key error messages; unknown keys ignored -- [ ] **Code**: update `goga/config/__init__.py` — embed `ReviewConfig`/`AdditionalReviewConfig`, drop the retired names -- [ ] **Interface verification**: `pytest tests/config/test_config.py tests/config/test_loader.py -x -q` — contract tests pass -- [ ] **Logic tests**: in `tests/config/test_loader.py` — `test_load_project_config_parses_two_part_build` (setup: tmp `.goga/config.yml` with `language: python`, `build: {agent: claude, env: {A: "1"}, max_iterations: 7, session_timeout: 30m, review: {agent: codex, env: {B: "2"}, roles: [quality], base_ref: main, strategy: short, finalize: "do it", additional: {agent: cursor, patience: 2, max_iterations: 4}}}`; assert `config.build.agent == "claude"`, `config.build.env == {"A": "1"}`, `config.build.review.agent == "codex"`, `config.build.review.additional.patience == 2`, `not hasattr(config.build, "task_executor")`, `not hasattr(config.build, "worktree")`); `test_load_project_config_ignores_retired_keys` (config carrying `build: {worktree: true, skip_finalize: true, codex_review: false, task_executor: {agent: claude}, review_executor: {agent: codex}, agent: claude}` → no error; `config.build.agent == "claude"`; `config.build.review is None`); `test_load_project_config_rejects_malformed_review` (parametrize: `review: "x"`, `review: {skip: "yes"}`, `review: {roles: [1]}`, `review: {strategy: 5}`, `review: {additional: {patience: true}}`, `build: {max_iterations: true}`, `build: {agent: 7}` — each raises `ValueError` naming the key) -- [ ] **Code**: rewrite `tests/config/test_integration.py` onto the two-part model (root fields plus `build.review`; drop or repoint the `worktree`/`skip_finalize`/`codex_review`/`task_executor`/`review_executor` assertions to the retired-key silence semantics already covered by the loader tests) -- [ ] **Debugging**: `pytest tests/config/ -x -q` — fix implementation code until all tests pass (do NOT fix test code) -- [ ] **Contract re-verification**: facade check `python -c "from goga.config import BuildConfig, ReviewConfig, AdditionalReviewConfig"` resolves; `python -c "from goga.config import TaskExecutorConfig"` raises `ImportError` -- [ ] **Lint**: `ruff check goga/config tests/config` — fix formatting if necessary -- [ ] **Completion**: mark all checkboxes of this task complete +- [x] **Declaration**: Task 1 — two-part build configuration model, loader, and facade re-exports +- [x] **Contract tests**: in `tests/config/test_config.py` — `ReviewConfig` and `AdditionalReviewConfig` importable from `goga.config` and `goga.config.project`; both pass `is_kw_only_dataclass` (fixture from `tests/conftest.py`) and are frozen; `BuildConfig` exposes exactly the new field set (`review` present; `task_executor`/`worktree`/`review_executor` absent); in `tests/config/test_loader.py` — `load_project_config` still importable from `goga.config` (expected to fail at this stage) +- [x] **Code**: rewrite `goga/config/project/config.py` — delete `TaskExecutorConfig`/`ReviewExecutorConfig`, reshape `BuildConfig`, add `ReviewConfig`/`AdditionalReviewConfig` (frozen, kw_only, Google docstrings, `from __future__ import annotations`) +- [x] **Code**: rewrite `goga/config/project/loader.py` — `_parse_build` two-part extraction per the trace; delete the three retired parse helpers; new-key error messages; unknown keys ignored +- [x] **Code**: update `goga/config/__init__.py` — embed `ReviewConfig`/`AdditionalReviewConfig`, drop the retired names +- [x] **Interface verification**: `pytest tests/config/test_config.py tests/config/test_loader.py -x -q` — contract tests pass +- [x] **Logic tests**: in `tests/config/test_loader.py` — `test_load_project_config_parses_two_part_build` (setup: tmp `.goga/config.yml` with `language: python`, `build: {agent: claude, env: {A: "1"}, max_iterations: 7, session_timeout: 30m, review: {agent: codex, env: {B: "2"}, roles: [quality], base_ref: main, strategy: short, finalize: "do it", additional: {agent: cursor, patience: 2, max_iterations: 4}}}`; assert `config.build.agent == "claude"`, `config.build.env == {"A": "1"}`, `config.build.review.agent == "codex"`, `config.build.review.additional.patience == 2`, `not hasattr(config.build, "task_executor")`, `not hasattr(config.build, "worktree")`); `test_load_project_config_ignores_retired_keys` (config carrying `build: {worktree: true, skip_finalize: true, codex_review: false, task_executor: {agent: claude}, review_executor: {agent: codex}, agent: claude}` → no error; `config.build.agent == "claude"`; `config.build.review is None`); `test_load_project_config_rejects_malformed_review` (parametrize: `review: "x"`, `review: {skip: "yes"}`, `review: {roles: [1]}`, `review: {strategy: 5}`, `review: {additional: {patience: true}}`, `build: {max_iterations: true}`, `build: {agent: 7}` — each raises `ValueError` naming the key) +- [x] **Code**: rewrite `tests/config/test_integration.py` onto the two-part model (root fields plus `build.review`; drop or repoint the `worktree`/`skip_finalize`/`codex_review`/`task_executor`/`review_executor` assertions to the retired-key silence semantics already covered by the loader tests) +- [x] **Debugging**: `pytest tests/config/ -x -q` — fix implementation code until all tests pass (do NOT fix test code) +- [x] **Contract re-verification**: facade check `python -c "from goga.config import BuildConfig, ReviewConfig, AdditionalReviewConfig"` resolves; `python -c "from goga.config import TaskExecutorConfig"` raises `ImportError` +- [x] **Lint**: `ruff check goga/config tests/config` — fix formatting if necessary +- [x] **Completion**: mark all checkboxes of this task complete ### Task 2: Five build action catalog records (TDD coding) diff --git a/goga/config/__init__.py b/goga/config/__init__.py index 977a3291..4dd1043b 100644 --- a/goga/config/__init__.py +++ b/goga/config/__init__.py @@ -2,19 +2,20 @@ from .home.home_config import DockerArgsConfig, HomeConfig from .home.loader import load_home_config from .project.config import ( + AdditionalReviewConfig, BuildConfig, CodemanifestConfig, DepConfig, LintConfig, PipelineConfig, ProjectConfig, - ReviewExecutorConfig, - TaskExecutorConfig, + ReviewConfig, TopicsConfig, ) from .project.loader import load_project_config __all__ = [ + "AdditionalReviewConfig", "BuildConfig", "CodemanifestConfig", "DepConfig", @@ -23,8 +24,7 @@ "LintConfig", "PipelineConfig", "ProjectConfig", - "ReviewExecutorConfig", - "TaskExecutorConfig", + "ReviewConfig", "TopicsConfig", "load_home_config", "load_project_config", diff --git a/goga/config/project/__init__.py b/goga/config/project/__init__.py index 29c57088..4befd4de 100644 --- a/goga/config/project/__init__.py +++ b/goga/config/project/__init__.py @@ -1,21 +1,21 @@ from .config import ( + AdditionalReviewConfig, BuildConfig, CodemanifestConfig, PipelineConfig, ProjectConfig, - ReviewExecutorConfig, - TaskExecutorConfig, + ReviewConfig, TopicsConfig, ) from .loader import load_project_config __all__ = [ + "AdditionalReviewConfig", "BuildConfig", "CodemanifestConfig", "PipelineConfig", "ProjectConfig", - "ReviewExecutorConfig", - "TaskExecutorConfig", + "ReviewConfig", "TopicsConfig", "load_project_config", ] diff --git a/goga/config/project/config.py b/goga/config/project/config.py index d895b56c..43ce3f4c 100644 --- a/goga/config/project/config.py +++ b/goga/config/project/config.py @@ -1,25 +1,12 @@ from dataclasses import dataclass, field -@dataclass(kw_only=True, frozen=True) -class TaskExecutorConfig: - """Configuration for the task execution agent and its environment. - - `agent` is optional at the config level: absent/empty in `.goga/config.yml` - resolves to None, and the consuming `goga build` command raises a clean - ClickException when it actually needs an agent. - """ - - agent: str | None = None - env: dict = field(default_factory=dict) - - @dataclass(kw_only=True, frozen=True) class PipelineConfig: """Configuration for pipeline execution inside the container. `agent` drives the afm `client.command` inside the container, semantically - distinct from `TaskExecutorConfig.agent`. Optional at the config level: + distinct from `BuildConfig.agent`. Optional at the config level: absent/empty resolves to None, and `goga pipeline` raises a clean ClickException when it needs an agent. """ @@ -54,50 +41,106 @@ class DepConfig: @dataclass(kw_only=True, frozen=True) -class ReviewExecutorConfig: - """Value-object for the optional ``build.review_executor`` section of .goga/config.yml. +class AdditionalReviewConfig: + """Value-object for the optional ``build.review.additional`` block of .goga/config.yml. + + The external-review settings source. Immutable verbatim container — + structural typing only: no agent-name validation, no range checks. ``0`` is + a meaningful value on both counters, never an unset marker (None is). + + ``agent``: external review agent name; None when unset — the consumer + inherits ``review.agent``. + + ``patience``: external-review stop threshold (stop after N consecutive + unchanged rounds; 0 = disabled); None when unset. + + ``max_iterations``: external review iteration cap (0 = ralphex auto); + None when unset. + """ + + agent: str | None = None + patience: int | None = None + max_iterations: int | None = None + + +@dataclass(kw_only=True, frozen=True) +class ReviewConfig: + """Value-object for the optional ``build.review`` section of .goga/config.yml. - Immutable verbatim container — structural typing only. Fields are stored exactly - as parsed: no empty-value normalization, no role/agent whitelists. ``roles=[]`` - is NOT coerced to None (the "full default set" reading belongs to the consumer); - semantic validation (role whitelist, agent existence) also belongs to consumers, - not to this dataclass or the loader. + The review-pass settings source of the two-part build model. Immutable + verbatim container — structural typing only. Every field is stored exactly + as parsed: no empty-value normalization beyond the loader's strip rules, no + role/strategy whitelists. An unset field is None (an empty dict for env) + and means "inherit from the root" to the consumer — the inheritance itself + belongs to the consumer, never here. + + ``roles=[]`` is NOT coerced to None (the "full default set" reading belongs + to the consumer). The env-requires-agent rule also belongs to the consumer. ``env`` is the review-pass environment layer, stored verbatim from - ``.goga/config.yml``: an empty dict when the field is absent, YAML-null, or an - empty mapping. The env-requires-agent rule belongs to the consumer, not here. + ``.goga/config.yml``: an empty dict when the field is absent, YAML-null, or + an empty mapping. The review env never inherits the root env. - The section also carries the review diff base (``base_ref``) and the - external-review stop threshold (``patience``). Both are stored verbatim — - structural typing only: branch resolvability and threshold semantics belong to - the consumer, never to this dataclass or the loader. + ``strategy`` is a structural string only — the full|medium|short whitelist + and the default medium belong to the consumer. ``finalize`` is the + user-authored final review prompt, stored verbatim. """ skip: bool | None = None agent: str | None = None - roles: list[str] | None = None env: dict[str, str] = field(default_factory=dict) + roles: list[str] | None = None base_ref: str | None = None - patience: int | None = None + strategy: str | None = None + finalize: str | None = None + additional: AdditionalReviewConfig | None = None + session_timeout: str | None = None + idle_timeout: str | None = None + wait: str | None = None @dataclass(kw_only=True, frozen=True) class BuildConfig: - """Build pipeline settings including agent, worktree, and timeout options.""" + """Build execution settings in the two-part form. + + The ``build`` root of ``.goga/config.yml`` is the tasks-pass settings + source; the optional ``review`` part carries the review-pass settings + source. Constructed by ``load_project_config``; values verbatim, no + inheritance applied here — root→review inheritance belongs to the + consumer. All fields may be None; ``env``/``hosts`` default to empty + dicts. + + ``agent``: tasks-pass executor agent name; None when unset — the + consuming ``goga build`` command raises a clean ClickException when it + actually needs an agent. + + ``env``: tasks-pass environment layer — the review pass never receives it. - task_executor: TaskExecutorConfig - worktree: bool | None = None - skip_finalize: bool | None = None + ``max_iterations``: maximum task iterations (root-only, tasks pass). + + ``session_timeout``/``idle_timeout``/``wait``: session knobs + (Go duration strings). + + ``prompts_dir``/``agents_dir``: custom ralphex source directories. + + ``proxy``: optional HTTP/HTTPS proxy URL; ``hosts``: optional host→IP + mapping for ``docker run --add-host``. + + ``review``: the review-pass settings part, or None when ``build.review`` + is absent. + """ + + agent: str | None = None + env: dict[str, str] = field(default_factory=dict) + max_iterations: int | None = None session_timeout: str | None = None idle_timeout: str | None = None wait: str | None = None - max_iterations: int | None = None prompts_dir: str | None = None agents_dir: str | None = None - codex_review: bool | None = None - review_executor: ReviewExecutorConfig | None = None proxy: str | None = None hosts: dict[str, str] = field(default_factory=dict) + review: ReviewConfig | None = None @dataclass(kw_only=True, frozen=True) diff --git a/goga/config/project/loader.py b/goga/config/project/loader.py index 8e43d320..b1e155d6 100644 --- a/goga/config/project/loader.py +++ b/goga/config/project/loader.py @@ -3,18 +3,72 @@ import yaml from .config import ( + AdditionalReviewConfig, BuildConfig, CodemanifestConfig, DepConfig, LintConfig, PipelineConfig, ProjectConfig, - ReviewExecutorConfig, - TaskExecutorConfig, + ReviewConfig, TopicsConfig, ) +def _parse_optional_stripped_str(raw, key: str) -> str | None: + """Parse an optional string field with the loader's emptiness rule. + + An unset field (absent, YAML-null, or empty/whitespace-only string) + resolves to ``None``; a present non-string value is a structural type + error. A non-empty string is stored stripped. This is the "agent pattern" + shared by agents, session knobs, strategy, finalize, and base_ref. + + Args: + raw: The raw field value from the mapping (a ``str``, or None when + absent). + key: The dotted field name for error messages (e.g. + ``"build.agent"``). + + Returns: + The stripped value, or ``None`` when unset/empty. + + Raises: + ValueError: When ``raw`` is present but not a string. + """ + if raw is None: + return None + if not isinstance(raw, str): + raise ValueError(f"{key} must be a string in .goga/config.yml") + stripped = raw.strip() + return stripped or None + + +def _parse_optional_plain_str(raw, key: str) -> str | None: + """Parse an optional string field stored verbatim (no strip, no emptiness rule). + + The path-flavored counterpart of ``_parse_optional_stripped_str``: absent + and YAML-null resolve to ``None``, a present non-string is a structural + type error, and a present string is stored exactly as written — path + semantics belong to the consumer. + + Args: + raw: The raw field value from the mapping (a ``str``, or None when + absent). + key: The dotted field name for error messages. + + Returns: + The verbatim string, or ``None`` when absent/YAML-null. + + Raises: + ValueError: When ``raw`` is present but not a string. + """ + if raw is None: + return None + if not isinstance(raw, str): + raise ValueError(f"{key} must be a string in .goga/config.yml") + return raw + + def _parse_optional_agent(raw, section: str) -> str | None: """Parse an optional agent name from a config section. @@ -27,7 +81,7 @@ def _parse_optional_agent(raw, section: str) -> str | None: raw: The raw ``agent`` value from the section mapping (a ``str``, or None when absent). section: The dotted section prefix for error messages - (e.g. ``"build.task_executor"``, ``"pipeline"``). + (e.g. ``"build"``, ``"pipeline"``). Returns: The stripped agent name, or ``None`` when unset/empty. @@ -35,25 +89,61 @@ def _parse_optional_agent(raw, section: str) -> str | None: Raises: ValueError: When ``raw`` is present but not a string. """ + return _parse_optional_stripped_str(raw, f"{section}.agent") + + +def _parse_optional_int(raw, key: str) -> int | None: + """Parse an optional int field; a YAML bool is rejected, not coerced. + + The bool check precedes the int check because ``isinstance(True, int)`` is + True, so a YAML ``true`` must be rejected explicitly instead of slipping + through as ``1``. Values are stored verbatim beyond that gate — no range + checks (those belong to the consumer). + + Args: + raw: The raw field value from the mapping (an ``int``, or None when + absent). + key: The dotted field name for error messages. + + Returns: + The verbatim int, or ``None`` when absent/YAML-null. + + Raises: + ValueError: When ``raw`` is present but not an int (a bool included). + """ if raw is None: return None - if not isinstance(raw, str): - raise ValueError(f"{section}.agent must be a string in .goga/config.yml") - stripped = raw.strip() - return stripped or None + if isinstance(raw, bool) or not isinstance(raw, int): + raise ValueError(f"{key} must be an int in .goga/config.yml") + return raw -def _parse_task_executor(task_executor_data: dict) -> TaskExecutorConfig: - """Parse and validate task_executor section into a TaskExecutorConfig instance.""" - agent = _parse_optional_agent(task_executor_data.get("agent"), "build.task_executor") +def _parse_env_mapping(raw, key: str) -> dict[str, str]: + """Parse an optional env layer — a string-keyed, string-valued mapping. - env = task_executor_data.get("env", {}) - if not isinstance(env, dict): - raise ValueError("build.task_executor.env must be a mapping in .goga/config.yml") - if not all(isinstance(k, str) and isinstance(v, str) for k, v in env.items()): - raise ValueError("build.task_executor.env must have string keys and values") + Absent/YAML-null resolves to an empty dict (a fresh dict, never a shared + default). A non-mapping or a non-string key/value is a structural type + error. - return TaskExecutorConfig(agent=agent, env=dict(env)) + Args: + raw: The raw ``env`` value from the mapping. + key: The dotted field name for error messages (e.g. + ``"build.env"``). + + Returns: + A plain dict copy of the mapping, or ``{}`` when absent/YAML-null. + + Raises: + ValueError: When ``raw`` is present but not a mapping of strings to + strings. + """ + if raw is None: + return {} + if not isinstance(raw, dict): + raise ValueError(f"{key} must be a mapping in .goga/config.yml") + if not all(isinstance(k, str) and isinstance(v, str) for k, v in raw.items()): + raise ValueError(f"{key} must have string keys and values") + return dict(raw) def _parse_proxy(proxy_data, section: str) -> str | None: @@ -222,7 +312,7 @@ def _parse_topics(data: dict) -> TopicsConfig | None: present-but-empty mapping yields a ``TopicsConfig`` with both fields ``None`` (a present section means "the section exists", not "unset"). Unknown keys inside the mapping are ignored (the cell-wide stance — same - as ``lint``, ``codemanifest``, ``review_executor``). Rev resolvability, + as ``lint``, ``codemanifest``, ``review``). Rev resolvability, template grammar, and the default template belong to the consuming command, never to this loader. @@ -461,165 +551,173 @@ def _optional_mapping(data: dict, key: str) -> dict | None: return section -def _parse_review_scoped_fields(raw: dict) -> tuple[str | None, int | None]: - """Parse the review-scoped ``base_ref``/``patience`` pair of ``review_executor``. - - Structural typing only, mirroring ``_parse_optional_agent``: ``base_ref`` is - stored stripped (an empty or whitespace-only string resolves to None) and a - present non-string is a type error. ``patience`` must be a real int — the - bool check precedes the int check because ``isinstance(True, int)`` is True, - so a YAML ``true`` is rejected instead of slipping through as ``1``. Both - are stored verbatim beyond that gate: no range checks, no - branch-resolvability checks (those belong to the consumer). - - Args: - raw: The already-parsed ``review_executor`` mapping. - - Returns: - The ``(base_ref, patience)`` pair, each None when its key is absent or - YAML-null. - - Raises: - ValueError: When ``base_ref`` is present but not a string, or when - ``patience`` is present but not an int (a bool included). - """ - base_ref_raw = raw.get("base_ref") - - if base_ref_raw is None: - base_ref = None - elif not isinstance(base_ref_raw, str): - raise ValueError("build.review_executor.base_ref must be a string in .goga/config.yml") - else: - base_ref = base_ref_raw.strip() or None +def _parse_review(build_data: dict) -> ReviewConfig | None: + """Parse the optional ``build.review`` sub-mapping (loader step 7). - patience_raw = raw.get("patience") - - if patience_raw is None: - patience = None - elif isinstance(patience_raw, bool) or not isinstance(patience_raw, int): - raise ValueError("build.review_executor.patience must be an int in .goga/config.yml") - else: - patience = patience_raw - - return base_ref, patience - - -def _parse_review_executor(build_data: dict) -> ReviewExecutorConfig | None: - """Parse the optional ``build.review_executor`` section (loader step 6.5). - - Structural-only validation, mirroring the style of the sibling ``_parse_*`` - helpers: absent or YAML-null resolves to None; a present non-mapping is a + The review-pass part of the two-part build model. Structural-only + validation: absent/YAML-null resolves to None; a present non-mapping is a type error. ``skip`` must be a real bool (``isinstance(x, bool)`` — a YAML int ``1`` is deliberately rejected, since ``isinstance(1, bool)`` is False - while ``1 == True``); ``agent`` reuses ``_parse_optional_agent`` so an empty - string normalizes to None; ``roles`` must be a list of strings and — when - empty — is passed through as an empty list verbatim (NOT coerced to None; - the "full default set" reading belongs to the consumer). ``env`` must be a - mapping with string keys and values — note the null-tolerance deliberately - differs from ``build.task_executor.env``: a YAML-null ``env`` here is a - VALID empty mapping (it resolves to ``{}``, not an error). No role/agent - whitelists and no env semantics live here — validation beyond structure + while ``1 == True``). ``agent``, ``base_ref``, ``strategy``, ``finalize`` + and the session knobs follow the agent emptiness pattern (empty/whitespace + resolves to None). ``roles`` must be a list of strings and — when empty — + passes through as an empty list verbatim (NOT coerced to None; the "full + default set" reading belongs to the consumer). ``env`` follows the env + pattern (absent/YAML-null/empty resolve to ``{}``). ``additional`` is the + optional external-review block: agent follows the agent pattern; patience + and max_iterations follow the int pattern (bool rejected, 0 meaningful). + No role/agent/strategy whitelists live here — validation beyond structure belongs to the consumer. - The review-scoped pair (``base_ref``/``patience``) is parsed by - ``_parse_review_scoped_fields`` under the same structural-only stance. - Args: build_data: The already-parsed ``build`` mapping. Returns: - A ``ReviewExecutorConfig`` storing every field verbatim (``env`` as a - fresh dict, ``{}`` when absent/YAML-null/empty), or None when the - section is absent or YAML-null. + A ``ReviewConfig`` storing every field verbatim (``env`` as a fresh + dict, ``{}`` when absent/YAML-null/empty), or None when the section is + absent or YAML-null. Raises: - ValueError: When the section is present but not a mapping, or when - ``skip``/``agent``/``roles``/``env``/``base_ref``/``patience`` is - present with an invalid type. + ValueError: When the section is present but not a mapping, or when any + known field is present with an invalid type. """ - raw = build_data.get("review_executor") + raw = build_data.get("review") if raw is None: return None if not isinstance(raw, dict): - raise ValueError("build.review_executor must be a mapping in .goga/config.yml") + raise ValueError("build.review must be a mapping in .goga/config.yml") skip = raw.get("skip") if skip is not None and not isinstance(skip, bool): - raise ValueError("build.review_executor.skip must be a bool in .goga/config.yml") + raise ValueError("build.review.skip must be a bool in .goga/config.yml") - agent = _parse_optional_agent(raw.get("agent"), "build.review_executor") + agent = _parse_optional_agent(raw.get("agent"), "build.review") + env = _parse_env_mapping(raw.get("env"), "build.review.env") roles_raw = raw.get("roles") if roles_raw is None: roles = None elif not isinstance(roles_raw, list) or not all(isinstance(x, str) for x in roles_raw): - raise ValueError("build.review_executor.roles must be a list of strings in .goga/config.yml") + raise ValueError("build.review.roles must be a list of strings in .goga/config.yml") else: roles = list(roles_raw) - env_raw = raw.get("env") + base_ref = _parse_optional_stripped_str(raw.get("base_ref"), "build.review.base_ref") + strategy = _parse_optional_stripped_str(raw.get("strategy"), "build.review.strategy") + finalize = _parse_optional_stripped_str(raw.get("finalize"), "build.review.finalize") - if env_raw is None: - env = {} - elif not isinstance(env_raw, dict): - raise ValueError("build.review_executor.env must be a mapping in .goga/config.yml") - elif not all(isinstance(k, str) and isinstance(v, str) for k, v in env_raw.items()): - raise ValueError("build.review_executor.env must have string keys and values") - else: - env = dict(env_raw) + session_timeout = _parse_optional_stripped_str( + raw.get("session_timeout"), "build.review.session_timeout" + ) + idle_timeout = _parse_optional_stripped_str(raw.get("idle_timeout"), "build.review.idle_timeout") + wait = _parse_optional_stripped_str(raw.get("wait"), "build.review.wait") - base_ref, patience = _parse_review_scoped_fields(raw) + additional = _parse_additional_review(raw.get("additional")) - return ReviewExecutorConfig( + return ReviewConfig( skip=skip, agent=agent, - roles=roles, env=env, + roles=roles, base_ref=base_ref, - patience=patience, + strategy=strategy, + finalize=finalize, + additional=additional, + session_timeout=session_timeout, + idle_timeout=idle_timeout, + wait=wait, ) -def _parse_build(build_data: dict) -> BuildConfig: - """Parse and validate the build section into a BuildConfig instance. +def _parse_additional_review(raw) -> AdditionalReviewConfig | None: + """Parse the optional ``build.review.additional`` external-review block. + + Structural-only validation mirroring the sibling helpers: absent/YAML-null + resolves to None; a present non-mapping is a type error. ``agent`` follows + the agent pattern; ``patience`` and ``max_iterations`` follow the int + pattern (a YAML bool is rejected; 0 is a meaningful value stored verbatim). - Hard-rejects the deprecated `build.image` field (schema break). + Args: + raw: The raw ``additional`` value from the ``build.review`` mapping. + + Returns: + An ``AdditionalReviewConfig`` storing the block verbatim, or None when + absent/YAML-null. + + Raises: + ValueError: When the block is present but not a mapping, or when + ``agent``/``patience``/``max_iterations`` is present with an + invalid type. """ - if "image" in build_data: - raise ValueError("build.image is no longer supported — set top-level 'image' in .goga/config.yml") + if raw is None: + return None - try: - task_executor_data = build_data["task_executor"] - except KeyError as err: - raise KeyError("build.task_executor is required in .goga/config.yml") from err + if not isinstance(raw, dict): + raise ValueError("build.review.additional must be a mapping in .goga/config.yml") - if not isinstance(task_executor_data, dict): - raise ValueError("build.task_executor must be a mapping in .goga/config.yml") + agent = _parse_optional_agent(raw.get("agent"), "build.review.additional") + patience = _parse_optional_int(raw.get("patience"), "build.review.additional.patience") + max_iterations = _parse_optional_int( + raw.get("max_iterations"), "build.review.additional.max_iterations" + ) - task_executor = _parse_task_executor(task_executor_data) - review_executor = _parse_review_executor(build_data) + return AdditionalReviewConfig(agent=agent, patience=patience, max_iterations=max_iterations) + + +def _parse_build(build_data: dict) -> BuildConfig: + """Parse and validate the build section into a two-part BuildConfig (loader step 6). + + The ``build`` root carries the tasks-pass settings source; the optional + ``review`` sub-mapping carries the review-pass settings source. The loader + extracts known fields only — unknown keys (including the retired + ``worktree``, ``skip_finalize``, ``codex_review``, ``task_executor`` and + ``review_executor``) are silently ignored, never an error and never stored. + Values are exposed verbatim with no default merge; root→review inheritance + belongs to the consumer. + + Args: + build_data: The already-parsed ``build`` mapping. + + Returns: + A ``BuildConfig`` with the root fields verbatim (``env``/``hosts`` as + fresh dicts, ``{}`` when absent) and the parsed ``review`` part. + + Raises: + ValueError: When a known root or review field is present with an + invalid type. + """ + agent = _parse_optional_agent(build_data.get("agent"), "build") + env = _parse_env_mapping(build_data.get("env"), "build.env") + max_iterations = _parse_optional_int(build_data.get("max_iterations"), "build.max_iterations") + session_timeout = _parse_optional_stripped_str( + build_data.get("session_timeout"), "build.session_timeout" + ) + idle_timeout = _parse_optional_stripped_str(build_data.get("idle_timeout"), "build.idle_timeout") + wait = _parse_optional_stripped_str(build_data.get("wait"), "build.wait") + + prompts_dir = _parse_optional_plain_str(build_data.get("prompts_dir"), "build.prompts_dir") + agents_dir = _parse_optional_plain_str(build_data.get("agents_dir"), "build.agents_dir") proxy = _parse_proxy(build_data.get("proxy"), "build") hosts = _parse_hosts(build_data.get("hosts"), "build") + review = _parse_review(build_data) return BuildConfig( - task_executor=task_executor, - worktree=build_data.get("worktree"), - skip_finalize=build_data.get("skip_finalize"), - session_timeout=build_data.get("session_timeout"), - idle_timeout=build_data.get("idle_timeout"), - wait=build_data.get("wait"), - max_iterations=build_data.get("max_iterations"), - prompts_dir=build_data.get("prompts_dir"), - agents_dir=build_data.get("agents_dir"), - codex_review=build_data.get("codex_review"), - review_executor=review_executor, + agent=agent, + env=env, + max_iterations=max_iterations, + session_timeout=session_timeout, + idle_timeout=idle_timeout, + wait=wait, + prompts_dir=prompts_dir, + agents_dir=agents_dir, proxy=proxy, hosts=hosts, + review=review, ) @@ -635,10 +733,8 @@ def load_project_config() -> ProjectConfig: OSError: if .goga/config.yml exists but cannot be read (e.g. it is a directory, or the file is unreadable due to permissions). These are raised by ``config_path.open()``. - ValueError: if .goga/config.yml is not a YAML mapping or invalid field values, - or when the deprecated build.image field is present. - KeyError: if required sections are missing (language, or build.task_executor - when build is present). + ValueError: if .goga/config.yml is not a YAML mapping or invalid field values. + KeyError: if required sections are missing (language). yaml.YAMLError: if YAML parsing fails. """ config_path = Path("./.goga/config.yml") diff --git a/tests/config/test_config.py b/tests/config/test_config.py index c551a102..dc0f4bca 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -6,25 +6,28 @@ import goga.config as goga_config_mod import pytest from goga.config import ( + AdditionalReviewConfig, BuildConfig, CodemanifestConfig, LintConfig, PipelineConfig, ProjectConfig, - ReviewExecutorConfig, - TaskExecutorConfig, + ReviewConfig, ) from goga.config.project.config import DepConfig +from tests.conftest import is_kw_only_dataclass + # --- Contract tests --- class TestFacadeAvailability: def test_import_from_facade(self): - """ProjectConfig, BuildConfig, TaskExecutorConfig are importable from goga.config.""" + """ProjectConfig, BuildConfig, ReviewConfig are importable from goga.config.""" assert hasattr(goga_config_mod, "ProjectConfig") assert hasattr(goga_config_mod, "BuildConfig") - assert hasattr(goga_config_mod, "TaskExecutorConfig") + assert hasattr(goga_config_mod, "ReviewConfig") + assert hasattr(goga_config_mod, "AdditionalReviewConfig") def test_pipeline_config_importable(self): """PipelineConfig is importable from goga.config and in __all__.""" @@ -36,10 +39,20 @@ def test_codemanifest_config_importable(self): assert hasattr(goga_config_mod, "CodemanifestConfig") assert "CodemanifestConfig" in goga_config_mod.__all__ - def test_review_executor_config_importable(self): - """ReviewExecutorConfig is importable from goga.config and in __all__.""" - assert hasattr(goga_config_mod, "ReviewExecutorConfig") - assert "ReviewExecutorConfig" in goga_config_mod.__all__ + def test_review_configs_importable(self): + """ReviewConfig and AdditionalReviewConfig are importable from goga.config and in __all__.""" + assert hasattr(goga_config_mod, "ReviewConfig") + assert hasattr(goga_config_mod, "AdditionalReviewConfig") + assert "ReviewConfig" in goga_config_mod.__all__ + assert "AdditionalReviewConfig" in goga_config_mod.__all__ + + def test_review_configs_importable_from_project_cell(self): + """ReviewConfig and AdditionalReviewConfig are importable from goga.config.project.""" + from goga.config.project import AdditionalReviewConfig as ProjectAdditional + from goga.config.project import ReviewConfig as ProjectReview + + assert ProjectReview is ReviewConfig + assert ProjectAdditional is AdditionalReviewConfig def test_load_config_importable(self): """load_project_config is importable from goga.config.""" @@ -52,6 +65,13 @@ def test_old_names_not_importable(self): assert "TaskExecutor" not in goga_config_mod.__all__ assert "CodemenifestConfig" not in goga_config_mod.__all__ + def test_retired_names_not_importable(self): + """TaskExecutorConfig and ReviewExecutorConfig are gone from the facade.""" + assert not hasattr(goga_config_mod, "TaskExecutorConfig") + assert not hasattr(goga_config_mod, "ReviewExecutorConfig") + assert "TaskExecutorConfig" not in goga_config_mod.__all__ + assert "ReviewExecutorConfig" not in goga_config_mod.__all__ + def test_old_names_raise_import_error(self): """Importing the renamed/typo classes raises ImportError.""" with pytest.raises(ImportError): @@ -60,25 +80,13 @@ def test_old_names_raise_import_error(self): with pytest.raises(ImportError): from goga.config import CodemenifestConfig # noqa: F401 + def test_retired_names_raise_import_error(self): + """Importing the retired executor configs raises ImportError.""" + with pytest.raises(ImportError): + from goga.config import TaskExecutorConfig # noqa: F401 -class TestTaskExecutorConfigAPIShape: - def test_has_agent_field(self): - assert "agent" in TaskExecutorConfig.__dataclass_fields__ - - def test_has_env_field(self): - assert "env" in TaskExecutorConfig.__dataclass_fields__ - - def test_agent_type_is_str_or_none(self): - assert TaskExecutorConfig.__dataclass_fields__["agent"].type == str | None - - def test_agent_has_default_none(self): - assert TaskExecutorConfig.__dataclass_fields__["agent"].default is None - - def test_env_type_is_dict(self): - assert TaskExecutorConfig.__dataclass_fields__["env"].type is dict - - def test_env_has_default(self): - assert TaskExecutorConfig.__dataclass_fields__["env"].default_factory is not dataclasses.MISSING + with pytest.raises(ImportError): + from goga.config import ReviewExecutorConfig # noqa: F401 class TestPipelineConfigAPIShape: @@ -120,38 +128,138 @@ def test_hosts_has_default_factory(self): assert PipelineConfig.__dataclass_fields__["hosts"].default_factory is not dataclasses.MISSING -class TestBuildConfigAPIShape: - def test_has_task_executor_field(self): - assert "task_executor" in BuildConfig.__dataclass_fields__ - - def test_review_executor_declared_fields(self): - """ReviewExecutorConfig declares exactly skip, agent, roles, env (in - order), env annotated dict[str, str] with a dict factory default. +class TestReviewConfigAPIShape: + def test_review_config_is_kw_only_dataclass(self): + """ReviewConfig passes the shared is_kw_only_dataclass helper.""" + assert dataclasses.is_dataclass(ReviewConfig) + assert is_kw_only_dataclass(ReviewConfig) - The full shape pin (names, annotation, MISSING default, dict factory, - `{}` for an unset env) lives in - tests/config/test_loader.py::test_review_executor_config_declared_fields_include_env; - this facade-side check keeps one presence assertion per fact.""" - from goga.config import ReviewExecutorConfig + def test_review_config_is_frozen(self): + """ReviewConfig is frozen — field reassignment raises FrozenInstanceError.""" + review = ReviewConfig() + with pytest.raises(dataclasses.FrozenInstanceError): + review.agent = "codex" # type: ignore[misc] + + def test_review_config_declared_fields(self): + """ReviewConfig declares exactly the eleven contract fields.""" + names = [f.name for f in dataclasses.fields(ReviewConfig)] + assert names == [ + "skip", + "agent", + "env", + "roles", + "base_ref", + "strategy", + "finalize", + "additional", + "session_timeout", + "idle_timeout", + "wait", + ] + + def test_review_config_env_factory_default(self): + """env defaults to an empty dict via a factory.""" + env_field = ReviewConfig.__dataclass_fields__["env"] + assert env_field.type == dict[str, str] + assert env_field.default is dataclasses.MISSING + assert env_field.default_factory is dict + assert ReviewConfig().env == {} + + def test_review_config_all_fields_default_none(self): + """Every field except env defaults to None (unset = inherit at the consumer).""" + params = {f.name: f for f in dataclasses.fields(ReviewConfig)} + for name in ("skip", "agent", "roles", "base_ref", "strategy", "finalize", "additional", + "session_timeout", "idle_timeout", "wait"): + assert params[name].default is None, name + + def test_review_config_stores_values_verbatim(self): + """Pure construction stores every value verbatim — no normalization here.""" + additional = AdditionalReviewConfig(agent="cursor", patience=2, max_iterations=4) + review = ReviewConfig( + skip=False, + agent="codex", + env={"B": "2"}, + roles=["quality"], + base_ref="main", + strategy="short", + finalize="do it", + additional=additional, + session_timeout="40m", + idle_timeout="9m", + wait="2m", + ) + assert review.skip is False + assert review.agent == "codex" + assert review.env == {"B": "2"} + assert review.roles == ["quality"] + assert review.base_ref == "main" + assert review.strategy == "short" + assert review.finalize == "do it" + assert review.additional is additional + assert review.session_timeout == "40m" + assert review.idle_timeout == "9m" + assert review.wait == "2m" + + def test_review_config_empty_roles_stay_empty(self): + """roles=[] stays an empty list — NOT coerced to None.""" + assert ReviewConfig(roles=[]).roles == [] + + +class TestAdditionalReviewConfigAPIShape: + def test_additional_review_config_is_kw_only_dataclass(self): + """AdditionalReviewConfig passes the shared is_kw_only_dataclass helper.""" + assert dataclasses.is_dataclass(AdditionalReviewConfig) + assert is_kw_only_dataclass(AdditionalReviewConfig) + + def test_additional_review_config_is_frozen(self): + """AdditionalReviewConfig is frozen — field reassignment raises FrozenInstanceError.""" + additional = AdditionalReviewConfig() + with pytest.raises(dataclasses.FrozenInstanceError): + additional.agent = "codex" # type: ignore[misc] - names = [f.name for f in dataclasses.fields(ReviewExecutorConfig)] - assert names == ["skip", "agent", "roles", "env", "base_ref", "patience"] - assert ReviewExecutorConfig.__dataclass_fields__["env"].type == dict[str, str] - assert ReviewExecutorConfig(skip=None, agent=None, roles=None).env == {} + def test_additional_review_config_declared_fields(self): + """AdditionalReviewConfig declares exactly agent, patience, max_iterations.""" + names = [f.name for f in dataclasses.fields(AdditionalReviewConfig)] + assert names == ["agent", "patience", "max_iterations"] - def test_review_executor_config_declares_base_ref_and_patience_fields(self): - """ReviewExecutorConfig carries the review-scoped base_ref/patience fields and - BuildConfig no longer declares the relocated review_patience.""" - from goga.config import ReviewExecutorConfig + def test_additional_review_config_defaults_none(self): + """All three fields default to None.""" + params = {f.name: f for f in dataclasses.fields(AdditionalReviewConfig)} + for name in ("agent", "patience", "max_iterations"): + assert params[name].default is None, name - assert {"base_ref", "patience"} <= set(ReviewExecutorConfig.__dataclass_fields__) - assert "review_patience" not in BuildConfig.__dataclass_fields__ + def test_additional_review_config_zero_is_meaningful(self): + """0 is a meaningful value, not an unset marker — stored verbatim.""" + additional = AdditionalReviewConfig(agent="codex", patience=0, max_iterations=0) + assert additional.patience == 0 + assert additional.max_iterations == 0 - def test_has_worktree_field(self): - assert "worktree" in BuildConfig.__dataclass_fields__ - def test_has_skip_finalize_field(self): - assert "skip_finalize" in BuildConfig.__dataclass_fields__ +class TestBuildConfigAPIShape: + def test_build_config_declared_fields(self): + """BuildConfig exposes exactly the new two-part field set.""" + names = [f.name for f in dataclasses.fields(BuildConfig)] + assert names == [ + "agent", + "env", + "max_iterations", + "session_timeout", + "idle_timeout", + "wait", + "prompts_dir", + "agents_dir", + "proxy", + "hosts", + "review", + ] + + def test_build_config_has_review_field(self): + assert "review" in BuildConfig.__dataclass_fields__ + + def test_build_config_retired_fields_absent(self): + """The retired fields no longer exist on BuildConfig.""" + for name in ("task_executor", "worktree", "skip_finalize", "codex_review", "review_executor"): + assert name not in BuildConfig.__dataclass_fields__, name def test_has_session_timeout_field(self): assert "session_timeout" in BuildConfig.__dataclass_fields__ @@ -165,19 +273,12 @@ def test_has_wait_field(self): def test_has_max_iterations_field(self): assert "max_iterations" in BuildConfig.__dataclass_fields__ - def test_review_patience_field_removed(self): - """The relocated review_patience is gone from BuildConfig.""" - assert "review_patience" not in BuildConfig.__dataclass_fields__ - def test_has_prompts_dir_field(self): assert "prompts_dir" in BuildConfig.__dataclass_fields__ def test_has_agents_dir_field(self): assert "agents_dir" in BuildConfig.__dataclass_fields__ - def test_has_codex_review_field(self): - assert "codex_review" in BuildConfig.__dataclass_fields__ - def test_has_proxy_field(self): assert "proxy" in BuildConfig.__dataclass_fields__ @@ -309,38 +410,38 @@ def test_config_pipeline_annotation_is_optional_pipelineconfig(self): def test_lang_is_required(self): """ProjectConfig without lang raises TypeError (missing required argument).""" - te = TaskExecutorConfig(agent="claude") - bc = BuildConfig(task_executor=te) + bc = BuildConfig(agent="claude") pc = PipelineConfig(agent="claude") with pytest.raises(TypeError, match="lang"): ProjectConfig(image=None, build=bc, pipeline=pc) def test_image_is_required(self): """ProjectConfig without image raises TypeError (image has no default).""" - te = TaskExecutorConfig(agent="claude") - bc = BuildConfig(task_executor=te) + bc = BuildConfig(agent="claude") pc = PipelineConfig(agent="claude") with pytest.raises(TypeError, match="image"): ProjectConfig(lang="python", build=bc, pipeline=pc) def test_pipeline_is_required(self): """ProjectConfig without pipeline raises TypeError.""" - te = TaskExecutorConfig(agent="claude") - bc = BuildConfig(task_executor=te) + bc = BuildConfig(agent="claude") with pytest.raises(TypeError, match="pipeline"): ProjectConfig(lang="python", image=None, build=bc) class TestKwOnlyEnforced: - def test_task_executor_kw_only(self): - assert all(f.kw_only for f in dataclasses.fields(TaskExecutorConfig)) - def test_pipeline_kw_only(self): assert all(f.kw_only for f in dataclasses.fields(PipelineConfig)) def test_build_config_kw_only(self): assert all(f.kw_only for f in dataclasses.fields(BuildConfig)) + def test_review_config_kw_only(self): + assert all(f.kw_only for f in dataclasses.fields(ReviewConfig)) + + def test_additional_review_config_kw_only(self): + assert all(f.kw_only for f in dataclasses.fields(AdditionalReviewConfig)) + def test_config_kw_only(self): assert all(f.kw_only for f in dataclasses.fields(ProjectConfig)) @@ -351,18 +452,20 @@ def test_codemanifest_config_positional_args_rejected(self): with pytest.raises(TypeError): CodemanifestConfig({"lib": ".specs/lib.md"}, "annotations") - def test_task_executor_positional_args_rejected(self): + def test_build_config_positional_args_rejected(self): with pytest.raises(TypeError): - TaskExecutorConfig("claude") + BuildConfig("claude") # type: ignore[call-arg] - def test_build_config_positional_args_rejected(self): - te = TaskExecutorConfig(agent="claude") + def test_review_config_positional_args_rejected(self): + with pytest.raises(TypeError): + ReviewConfig(True) # type: ignore[call-arg] + + def test_additional_review_config_positional_args_rejected(self): with pytest.raises(TypeError): - BuildConfig(te) + AdditionalReviewConfig("codex") # type: ignore[call-arg] def test_config_positional_args_rejected(self): - te = TaskExecutorConfig(agent="claude") - bc = BuildConfig(task_executor=te) + bc = BuildConfig(agent="claude") with pytest.raises(TypeError): ProjectConfig(bc) @@ -370,21 +473,6 @@ def test_config_positional_args_rejected(self): # --- Logic tests --- -class TestTaskExecutorConfigCreation: - def test_valid_agent_and_env(self): - te = TaskExecutorConfig(agent="claude", env={"KEY": "value"}) - assert te.agent == "claude" - assert te.env == {"KEY": "value"} - - def test_empty_env_dict(self): - te = TaskExecutorConfig(agent="codex") - assert te.env == {} - - def test_custom_agent_path(self): - te = TaskExecutorConfig(agent="custom:/path/to/script") - assert te.agent == "custom:/path/to/script" - - class TestPipelineConfigCreation: def test_valid_agent_and_env(self): pc = PipelineConfig(agent="claude", env={"KEY": "value"}) @@ -395,12 +483,12 @@ def test_empty_env_dict(self): pc = PipelineConfig(agent="codex") assert pc.env == {} - def test_distinct_from_task_executor(self): - """PipelineConfig and TaskExecutorConfig are separate types.""" + def test_distinct_from_build(self): + """PipelineConfig and BuildConfig are separate types.""" pc = PipelineConfig(agent="claude") - te = TaskExecutorConfig(agent="claude") - assert not isinstance(pc, TaskExecutorConfig) - assert not isinstance(te, PipelineConfig) + bc = BuildConfig(agent="claude") + assert not isinstance(pc, BuildConfig) + assert not isinstance(bc, PipelineConfig) def test_proxy_defaults_none(self): pc = PipelineConfig(agent="claude") @@ -418,74 +506,83 @@ def test_explicit_proxy_and_hosts(self): class TestBuildConfigCreation: def test_all_none_optional_fields(self): - te = TaskExecutorConfig(agent="claude") - bc = BuildConfig(task_executor=te) - assert bc.task_executor is te - assert bc.worktree is None - assert bc.skip_finalize is None + bc = BuildConfig() + assert bc.agent is None + assert bc.env == {} + assert bc.max_iterations is None assert bc.session_timeout is None assert bc.idle_timeout is None assert bc.wait is None - assert bc.max_iterations is None - assert not hasattr(bc, "review_patience") assert bc.prompts_dir is None assert bc.agents_dir is None - assert bc.codex_review is None assert bc.proxy is None assert bc.hosts == {} + assert bc.review is None + assert not hasattr(bc, "task_executor") + assert not hasattr(bc, "worktree") assert not hasattr(bc, "image") def test_all_fields_populated(self): - te = TaskExecutorConfig(agent="gemini", env={"X": "1"}) - review = ReviewExecutorConfig(agent="codex", base_ref="origin/1.2.x", patience=3) + review = ReviewConfig( + skip=False, + agent="codex", + env={"B": "2"}, + roles=["quality"], + base_ref="origin/1.2.x", + strategy="full", + finalize="final pass", + additional=AdditionalReviewConfig(agent="cursor", patience=3, max_iterations=4), + ) bc = BuildConfig( - task_executor=te, - worktree=True, - skip_finalize=False, + agent="gemini", + env={"X": "1"}, + max_iterations=10, session_timeout="30m", idle_timeout="1h", wait="5m", - max_iterations=10, prompts_dir="/custom/prompts", agents_dir="/custom/agents", - codex_review=True, - review_executor=review, + proxy="http://x:1", + hosts={"a": "1"}, + review=review, ) - assert bc.task_executor.agent == "gemini" - assert bc.task_executor.env == {"X": "1"} - assert bc.worktree is True - assert bc.skip_finalize is False + assert bc.agent == "gemini" + assert bc.env == {"X": "1"} + assert bc.max_iterations == 10 assert bc.session_timeout == "30m" assert bc.idle_timeout == "1h" assert bc.wait == "5m" - assert bc.max_iterations == 10 - assert bc.review_executor.patience == 3 - assert bc.review_executor.base_ref == "origin/1.2.x" assert bc.prompts_dir == "/custom/prompts" assert bc.agents_dir == "/custom/agents" - assert bc.codex_review is True + assert bc.proxy == "http://x:1" + assert bc.hosts == {"a": "1"} + assert bc.review is review + assert bc.review.additional.patience == 3 + assert bc.review.additional.max_iterations == 4 + assert bc.review.strategy == "full" def test_proxy_defaults_none(self): - te = TaskExecutorConfig(agent="claude") - bc = BuildConfig(task_executor=te) + bc = BuildConfig(agent="claude") assert bc.proxy is None def test_hosts_defaults_empty_dict(self): - te = TaskExecutorConfig(agent="claude") - bc = BuildConfig(task_executor=te) + bc = BuildConfig(agent="claude") assert bc.hosts == {} def test_explicit_proxy_and_hosts(self): - te = TaskExecutorConfig(agent="claude") - bc = BuildConfig(task_executor=te, proxy="http://x:1", hosts={"a": "1"}) + bc = BuildConfig(agent="claude", proxy="http://x:1", hosts={"a": "1"}) assert bc.proxy == "http://x:1" assert bc.hosts == {"a": "1"} + def test_build_config_is_frozen(self): + bc = BuildConfig(agent="claude") + with pytest.raises(dataclasses.FrozenInstanceError): + bc.agent = "codex" # type: ignore[misc] + class TestConfigCreation: def test_default_commands_dict(self): - te = TaskExecutorConfig(agent="claude") - bc = BuildConfig(task_executor=te) + bc = BuildConfig(agent="claude") pc = PipelineConfig(agent="claude") cfg = ProjectConfig(lang="python", image=None, dockerfile=None, build=bc, pipeline=pc) assert cfg.lang == "python" @@ -494,8 +591,7 @@ def test_default_commands_dict(self): assert cfg.commands == {} def test_full_config(self): - te = TaskExecutorConfig(agent="claude", env={"K": "v"}) - bc = BuildConfig(task_executor=te, worktree=True) + bc = BuildConfig(agent="claude", env={"K": "v"}, review=ReviewConfig(agent="codex")) pc = PipelineConfig(agent="codex", env={"P": "1"}) cfg = ProjectConfig( lang="python", @@ -509,19 +605,21 @@ def test_full_config(self): assert cfg.image == "qarium/foo:1.0" assert cfg.dockerfile == "Dockerfile" assert cfg.build is bc - assert cfg.build.task_executor is te + assert cfg.build.agent == "claude" + assert cfg.build.review.agent == "codex" assert cfg.pipeline is pc assert cfg.pipeline.agent == "codex" assert cfg.commands == {"foo": "bar"} - def test_nested_task_executor_access(self): - te = TaskExecutorConfig(agent="copilot", env={"A": "1", "B": "2"}) - bc = BuildConfig(task_executor=te) + def test_nested_review_access(self): + bc = BuildConfig(agent="copilot", env={"A": "1", "B": "2"}, review=ReviewConfig(base_ref="main")) pc = PipelineConfig(agent="claude") cfg = ProjectConfig(lang="python", image=None, dockerfile=None, build=bc, pipeline=pc) - assert isinstance(cfg.build.task_executor, TaskExecutorConfig) - assert cfg.build.task_executor.agent == "copilot" - assert cfg.build.task_executor.env == {"A": "1", "B": "2"} + assert isinstance(cfg.build, BuildConfig) + assert cfg.build.agent == "copilot" + assert cfg.build.env == {"A": "1", "B": "2"} + assert isinstance(cfg.build.review, ReviewConfig) + assert cfg.build.review.base_ref == "main" class TestCodemanifestConfigCreation: @@ -545,15 +643,13 @@ def test_frozen(self): class TestConfigCodemanifestField: def test_codemanifest_field_defaults_none(self): - te = TaskExecutorConfig(agent="claude") - bc = BuildConfig(task_executor=te) + bc = BuildConfig(agent="claude") pc = PipelineConfig(agent="claude") cfg = ProjectConfig(lang="python", image=None, dockerfile=None, build=bc, pipeline=pc) assert cfg.codemanifest is None def test_config_with_codemanifest(self): - te = TaskExecutorConfig(agent="claude") - bc = BuildConfig(task_executor=te) + bc = BuildConfig(agent="claude") pc = PipelineConfig(agent="claude") cc = CodemanifestConfig(usages={"lib": ".specs/lib.md"}, annotations="Use lib") cfg = ProjectConfig( @@ -670,8 +766,7 @@ def test_projectconfig_has_lint_field_default_none(self): assert field_names[-1] == "topics" def test_projectconfig_lint_accepts_lintconfig(self): - te = TaskExecutorConfig(agent="claude") - bc = BuildConfig(task_executor=te) + bc = BuildConfig(agent="claude") pc = PipelineConfig(agent="claude") lc = LintConfig(ignore=[".venv/"]) cfg = ProjectConfig( diff --git a/tests/config/test_integration.py b/tests/config/test_integration.py index ff4753b4..2acbb364 100644 --- a/tests/config/test_integration.py +++ b/tests/config/test_integration.py @@ -4,11 +4,12 @@ import pytest from goga.config import ( + AdditionalReviewConfig, BuildConfig, CodemanifestConfig, PipelineConfig, ProjectConfig, - TaskExecutorConfig, + ReviewConfig, load_project_config, ) @@ -18,23 +19,31 @@ pipeline: agent: claude build: - task_executor: - agent: gemini - env: - RUST_BACKTRACE: "1" - CARGO_HOME: /opt/cargo - worktree: true - skip_finalize: false + agent: gemini + env: + RUST_BACKTRACE: "1" + CARGO_HOME: /opt/cargo session_timeout: "45m" idle_timeout: "2h" wait: "10m" max_iterations: 20 prompts_dir: "/etc/goga/prompts" agents_dir: "/etc/goga/agents" - codex_review: false - review_executor: + review: + skip: false + agent: codex + env: + REVIEW_STRICT: "2" + roles: + - quality base_ref: origin/1.2.x - patience: 5 + strategy: full + finalize: "Final review pass." + session_timeout: "50m" + additional: + agent: cursor + patience: 5 + max_iterations: 12 commands: build: cargo build --release test: cargo test @@ -45,8 +54,7 @@ pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude """ AGENT_PYTHON_YAML = """\ @@ -55,12 +63,29 @@ pipeline: agent: codex build: + agent: codex + env: + PYTHONPATH: /src + max_iterations: 15 +""" + +# Retired keys still present in a migrated-late config — silently ignored. +RETIRED_KEYS_YAML = """\ +language: python +pipeline: + agent: claude +build: + worktree: true + skip_finalize: false + codex_review: true task_executor: - agent: codex + agent: gemini env: - PYTHONPATH: /src - worktree: false - max_iterations: 15 + FOO: bar + review_executor: + agent: codex + base_ref: origin/1.2.x + agent: claude """ @@ -84,28 +109,38 @@ def test_full_object_graph_from_yaml(self, tmp_path, monkeypatch): "test": "cargo test", } - # BuildConfig level + # BuildConfig level (two-part root) assert isinstance(config.build, BuildConfig) assert not hasattr(config.build, "image") - assert config.build.worktree is True - assert config.build.skip_finalize is False + assert not hasattr(config.build, "worktree") + assert not hasattr(config.build, "task_executor") + assert config.build.agent == "gemini" + assert config.build.env == { + "RUST_BACKTRACE": "1", + "CARGO_HOME": "/opt/cargo", + } assert config.build.session_timeout == "45m" assert config.build.idle_timeout == "2h" assert config.build.wait == "10m" assert config.build.max_iterations == 20 - assert config.build.review_executor.patience == 5 - assert config.build.review_executor.base_ref == "origin/1.2.x" assert config.build.prompts_dir == "/etc/goga/prompts" assert config.build.agents_dir == "/etc/goga/agents" - assert config.build.codex_review is False - # TaskExecutorConfig level - assert isinstance(config.build.task_executor, TaskExecutorConfig) - assert config.build.task_executor.agent == "gemini" - assert config.build.task_executor.env == { - "RUST_BACKTRACE": "1", - "CARGO_HOME": "/opt/cargo", - } + # ReviewConfig level (the review part) + assert isinstance(config.build.review, ReviewConfig) + assert config.build.review.skip is False + assert config.build.review.agent == "codex" + assert config.build.review.env == {"REVIEW_STRICT": "2"} + assert config.build.review.roles == ["quality"] + assert config.build.review.base_ref == "origin/1.2.x" + assert config.build.review.strategy == "full" + assert config.build.review.finalize == "Final review pass." + assert config.build.review.session_timeout == "50m" + assert config.build.review.additional == AdditionalReviewConfig( + agent="cursor", + patience=5, + max_iterations=12, + ) # PipelineConfig level assert isinstance(config.pipeline, PipelineConfig) @@ -123,19 +158,16 @@ def test_minimal_yaml_produces_defaults(self, tmp_path, monkeypatch): assert config.image is None assert config.commands == {} assert config.codemanifest is None - assert config.build.worktree is None + assert config.build.agent == "claude" + assert config.build.env == {} assert not hasattr(config.build, "image") - assert config.build.skip_finalize is None + assert config.build.max_iterations is None assert config.build.session_timeout is None assert config.build.idle_timeout is None assert config.build.wait is None - assert config.build.max_iterations is None - assert config.build.review_executor is None assert config.build.prompts_dir is None assert config.build.agents_dir is None - assert config.build.codex_review is None - assert config.build.task_executor.agent == "claude" - assert config.build.task_executor.env == {} + assert config.build.review is None assert config.pipeline.agent == "claude" assert config.pipeline.env == {} @@ -148,23 +180,38 @@ def test_partial_build_config(self, tmp_path, monkeypatch): assert config.lang == "python" assert config.image == "qarium/foo:1.0" - assert config.build.task_executor.agent == "codex" - assert config.build.task_executor.env == {"PYTHONPATH": "/src"} + assert config.build.agent == "codex" + assert config.build.env == {"PYTHONPATH": "/src"} assert config.codemanifest is None - assert config.build.worktree is False assert config.build.max_iterations == 15 - assert config.build.skip_finalize is None assert config.build.session_timeout is None assert config.pipeline.agent == "codex" + def test_retired_keys_silently_ignored(self, tmp_path, monkeypatch): + """A config still carrying the retired keys loads without error; they are not extracted. -class TestConfigImmutability: - """ProjectConfig, BuildConfig, TaskExecutorConfig are frozen dataclasses — fields cannot be reassigned.""" + The retired-key silence semantics are covered in detail by the loader + tests (test_load_project_config_ignores_retired_keys); this pins the + same behavior at the full-flow level. + """ + monkeypatch.chdir(tmp_path) + (tmp_path / ".goga").mkdir(exist_ok=True) + (tmp_path / ".goga" / "config.yml").write_text(RETIRED_KEYS_YAML) + + config = load_project_config() + + assert config.build.agent == "claude" + assert config.build.env == {} + assert config.build.review is None + assert not hasattr(config.build, "task_executor") + assert not hasattr(config.build, "review_executor") + assert not hasattr(config.build, "worktree") + assert not hasattr(config.build, "skip_finalize") + assert not hasattr(config.build, "codex_review") - def test_task_executor_is_frozen(self): - te = TaskExecutorConfig(agent="claude") - with pytest.raises(dataclasses.FrozenInstanceError): # type: ignore[attr-defined] - te.agent = "codex" + +class TestConfigImmutability: + """ProjectConfig, BuildConfig, ReviewConfig are frozen dataclasses — fields cannot be reassigned.""" def test_pipeline_is_frozen(self): pc = PipelineConfig(agent="claude") @@ -172,14 +219,22 @@ def test_pipeline_is_frozen(self): pc.agent = "codex" def test_build_config_is_frozen(self): - te = TaskExecutorConfig(agent="claude") - bc = BuildConfig(task_executor=te) + bc = BuildConfig(agent="claude") + with pytest.raises(dataclasses.FrozenInstanceError): # type: ignore[attr-defined] + bc.agent = "codex" + + def test_review_config_is_frozen(self): + review = ReviewConfig(agent="claude") + with pytest.raises(dataclasses.FrozenInstanceError): # type: ignore[attr-defined] + review.agent = "codex" + + def test_additional_review_config_is_frozen(self): + additional = AdditionalReviewConfig(agent="claude") with pytest.raises(dataclasses.FrozenInstanceError): # type: ignore[attr-defined] - bc.worktree = True + additional.agent = "codex" def test_config_is_frozen(self): - te = TaskExecutorConfig(agent="claude") - bc = BuildConfig(task_executor=te) + bc = BuildConfig(agent="claude") pc = PipelineConfig(agent="claude") cfg = ProjectConfig(image=None, dockerfile=None, build=bc, pipeline=pc, lang="python") with pytest.raises(dataclasses.FrozenInstanceError): # type: ignore[attr-defined] @@ -192,13 +247,12 @@ def test_codemanifest_config_is_frozen(self): def test_env_dict_mutation_does_not_raise(self): """Frozen only prevents attribute reassignment, not inner-mutable dict mutation.""" - te = TaskExecutorConfig(agent="claude", env={"K": "v"}) - te.env["NEW"] = "val" # dict content is mutable - assert te.env == {"K": "v", "NEW": "val"} + bc = BuildConfig(agent="claude", env={"K": "v"}) + bc.env["NEW"] = "val" # dict content is mutable + assert bc.env == {"K": "v", "NEW": "val"} def test_commands_dict_mutation_does_not_raise(self): - te = TaskExecutorConfig(agent="claude") - bc = BuildConfig(task_executor=te) + bc = BuildConfig(agent="claude") pc = PipelineConfig(agent="claude") cfg = ProjectConfig(image=None, dockerfile=None, build=bc, pipeline=pc, commands={"a": "1"}, lang="python") cfg.commands["b"] = "2" # dict content is mutable @@ -216,20 +270,20 @@ def test_sequential_calls_produce_independent_configs(self, tmp_path, monkeypatc (tmp_path / ".goga" / "config.yml").write_text(MINIMAL_YAML) config1 = load_project_config() assert config1.lang == "python" - assert config1.build.task_executor.agent == "claude" + assert config1.build.agent == "claude" # Second call — different config (tmp_path / ".goga").mkdir(exist_ok=True) (tmp_path / ".goga" / "config.yml").write_text(AGENT_PYTHON_YAML) config2 = load_project_config() assert config2.lang == "python" - assert config2.build.task_executor.agent == "codex" + assert config2.build.agent == "codex" # Verify independence: config1 is unaffected assert config1.lang == "python" - assert config1.build.task_executor.agent == "claude" + assert config1.build.agent == "claude" assert config2.lang == "python" - assert config2.build.task_executor.agent == "codex" + assert config2.build.agent == "codex" def test_load_after_missing_file_returns_new_config(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) @@ -238,7 +292,7 @@ def test_load_after_missing_file_returns_new_config(self, tmp_path, monkeypatch) (tmp_path / ".goga").mkdir(exist_ok=True) (tmp_path / ".goga" / "config.yml").write_text(MINIMAL_YAML) config1 = load_project_config() - assert config1.build.task_executor.agent == "claude" + assert config1.build.agent == "claude" # Remove file, second call should fail (tmp_path / ".goga" / "config.yml").unlink() diff --git a/tests/config/test_loader.py b/tests/config/test_loader.py index eca715e6..3f1ddeeb 100644 --- a/tests/config/test_loader.py +++ b/tests/config/test_loader.py @@ -8,11 +8,13 @@ import pytest import yaml from goga.config import ( + AdditionalReviewConfig, + BuildConfig, CodemanifestConfig, LintConfig, PipelineConfig, ProjectConfig, - TaskExecutorConfig, + ReviewConfig, TopicsConfig, load_project_config, ) @@ -53,8 +55,7 @@ def _write_goga_yml(path, content: str): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude """ FULL_YAML = """\ @@ -68,23 +69,31 @@ def _write_goga_yml(path, content: str): env: PIPELINE_OPT: "1" build: - task_executor: - agent: gemini - env: - FOO: bar - BAZ: qux - worktree: false - skip_finalize: true + agent: gemini + env: + FOO: bar + BAZ: qux session_timeout: "30m" idle_timeout: "1h" wait: "5m" max_iterations: 10 prompts_dir: "/custom/prompts" agents_dir: "/custom/agents" - codex_review: true - review_executor: + review: + skip: false + agent: codex + env: + REVIEW_MODEL: strict + roles: + - quality base_ref: origin/1.2.x - patience: 3 + strategy: full + finalize: "Final pass." + session_timeout: "40m" + additional: + agent: cursor + patience: 3 + max_iterations: 6 """ HAPPY_YAML = """\ @@ -93,11 +102,9 @@ def _write_goga_yml(path, content: str): pipeline: agent: claude build: - task_executor: - agent: claude - env: - KEY: value - worktree: true + agent: claude + env: + KEY: value commands: foo: bar """ @@ -133,15 +140,15 @@ def test_load_config_returns_config_instance(self, goga_project): class TestLoadConfigPositive: def test_load_config_minimal_valid_yaml(self, goga_project): - """Minimal .goga/config.yml with language+image+pipeline+build.task_executor.agent.""" + """Minimal .goga/config.yml with language+image+pipeline+build.agent.""" _write_goga_yml(goga_project, MINIMAL_YAML) config = load_project_config() assert config.lang == "python" assert config.image == "qarium/foo:1.0" - assert config.build.task_executor.agent == "claude" - assert config.build.task_executor.env == {} + assert config.build.agent == "claude" + assert config.build.env == {} assert config.commands == {} - assert config.build.worktree is None + assert config.build.review is None def test_load_config_pipeline_defaults(self, goga_project): """pipeline.env defaults to empty when not specified.""" @@ -159,19 +166,30 @@ def test_load_config_full_yaml(self, goga_project): assert config.commands == {"test": "go test ./...", "build": "go build ./..."} assert config.pipeline.agent == "codex" assert config.pipeline.env == {"PIPELINE_OPT": "1"} - assert config.build.task_executor.agent == "gemini" - assert config.build.task_executor.env == {"FOO": "bar", "BAZ": "qux"} - assert config.build.worktree is False - assert config.build.skip_finalize is True + assert config.build.agent == "gemini" + assert config.build.env == {"FOO": "bar", "BAZ": "qux"} assert config.build.session_timeout == "30m" assert config.build.idle_timeout == "1h" assert config.build.wait == "5m" assert config.build.max_iterations == 10 - assert config.build.review_executor.base_ref == "origin/1.2.x" - assert config.build.review_executor.patience == 3 assert config.build.prompts_dir == "/custom/prompts" assert config.build.agents_dir == "/custom/agents" - assert config.build.codex_review is True + assert config.build.review is not None + assert config.build.review.skip is False + assert config.build.review.agent == "codex" + assert config.build.review.env == {"REVIEW_MODEL": "strict"} + assert config.build.review.roles == ["quality"] + assert config.build.review.base_ref == "origin/1.2.x" + assert config.build.review.strategy == "full" + assert config.build.review.finalize == "Final pass." + assert config.build.review.session_timeout == "40m" + assert config.build.review.additional == AdditionalReviewConfig( + agent="cursor", + patience=3, + max_iterations=6, + ) + assert not hasattr(config.build, "worktree") + assert not hasattr(config.build, "codex_review") def test_load_config_custom_agent_path(self, goga_project): """agent: custom:/path/to/script with env.""" @@ -183,28 +201,26 @@ def test_load_config_custom_agent_path(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: custom:/path/to/script - env: - K: v + agent: custom:/path/to/script + env: + K: v """, ) config = load_project_config() - assert config.build.task_executor.agent == "custom:/path/to/script" - assert config.build.task_executor.env == {"K": "v"} + assert config.build.agent == "custom:/path/to/script" + assert config.build.env == {"K": "v"} def test_load_config_happy_path(self, goga_project): - """Happy path with language, env, worktree, commands.""" + """Happy path with language, env, commands.""" _write_goga_yml(goga_project, HAPPY_YAML) config = load_project_config() assert config.lang == "python" assert config.image == "qarium/foo:1.0" - assert config.build.task_executor.agent == "claude" - assert config.build.task_executor.env == {"KEY": "value"} - assert config.build.worktree is True + assert config.build.agent == "claude" + assert config.build.env == {"KEY": "value"} assert config.commands == {"foo": "bar"} - def test_task_executor_env_with_multiple_vars(self, goga_project): + def test_build_env_with_multiple_vars(self, goga_project): """Multiple env vars.""" _write_goga_yml( goga_project, @@ -214,23 +230,22 @@ def test_task_executor_env_with_multiple_vars(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: codex - env: - VAR1: value1 - VAR2: value2 - VAR3: value3 + agent: codex + env: + VAR1: value1 + VAR2: value2 + VAR3: value3 """, ) config = load_project_config() - assert config.build.task_executor.env == { + assert config.build.env == { "VAR1": "value1", "VAR2": "value2", "VAR3": "value3", } def test_load_config_extra_build_fields_ignored(self, goga_project): - """Unknown build fields are silently ignored (except image, which is rejected).""" + """Unknown build fields are silently ignored.""" _write_goga_yml( goga_project, """\ @@ -239,13 +254,12 @@ def test_load_config_extra_build_fields_ignored(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude unknown_field: value """, ) config = load_project_config() - assert config.build.task_executor.agent == "claude" + assert config.build.agent == "claude" # --- Proxy and hosts tests --- @@ -266,8 +280,7 @@ def test_load_config_pipeline_proxy_and_hosts_populated(self, goga_project): foo.local: 127.0.0.1 bar.local: 10.0.0.2 build: - task_executor: - agent: claude + agent: claude """, ) config = load_project_config() @@ -291,8 +304,7 @@ def test_load_config_build_proxy_and_hosts_populated(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude proxy: "http://build-proxy:8080" hosts: svc.local: 192.168.1.1 @@ -320,8 +332,7 @@ def test_load_config_hosts_null_treated_as_empty(self, goga_project): agent: claude hosts: build: - task_executor: - agent: claude + agent: claude """, ) config = load_project_config() @@ -338,8 +349,7 @@ def test_load_config_proxy_null_treated_as_none(self, goga_project): agent: claude proxy: build: - task_executor: - agent: claude + agent: claude """, ) config = load_project_config() @@ -358,8 +368,7 @@ def test_load_config_pipeline_proxy_non_string_raises(self, goga_project): agent: claude proxy: 3128 build: - task_executor: - agent: claude + agent: claude """, ) with pytest.raises(ValueError, match=r"pipeline\.proxy must be a string"): @@ -375,8 +384,7 @@ def test_load_config_build_proxy_non_string_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude proxy: 3128 """, ) @@ -394,8 +402,7 @@ def test_load_config_pipeline_hosts_not_mapping_raises(self, goga_project): agent: claude hosts: not-a-mapping build: - task_executor: - agent: claude + agent: claude """, ) with pytest.raises(ValueError, match=r"pipeline\.hosts must be a mapping"): @@ -411,8 +418,7 @@ def test_load_config_build_hosts_not_mapping_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude hosts: not-a-mapping """, ) @@ -432,8 +438,7 @@ def test_load_config_pipeline_hosts_non_string_value_raises(self, goga_project): foo.local: 127.0.0.1 bar.local: 10 build: - task_executor: - agent: claude + agent: claude """, ) with pytest.raises(ValueError, match=r"pipeline\.hosts must have string keys and values"): @@ -449,8 +454,7 @@ def test_load_config_build_hosts_non_string_key_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude hosts: 123: 10.0.0.1 """, @@ -464,22 +468,22 @@ def test_load_config_build_hosts_non_string_key_raises(self, goga_project): class TestLoadConfigSchemaBreak: def test_load_config_minimal_valid_returns_config_with_image_and_pipeline(self, goga_project): - """Minimal valid config exposes top-level image + pipeline + build.task_executor.""" + """Minimal valid config exposes top-level image + pipeline + build.agent.""" _write_goga_yml(goga_project, MINIMAL_YAML) config = load_project_config() assert config.lang == "python" assert config.image == "qarium/foo:1.0" assert config.pipeline.agent == "claude" assert isinstance(config.pipeline, PipelineConfig) - assert config.build.task_executor.agent == "claude" - assert isinstance(config.build.task_executor, TaskExecutorConfig) + assert config.build.agent == "claude" + assert isinstance(config.build, BuildConfig) # BuildConfig.image was removed assert not hasattr(config.build, "image") # codemanifest absent -> None assert config.codemanifest is None - def test_load_config_rejects_build_image(self, goga_project): - """The deprecated build.image field is hard-rejected.""" + def test_load_config_ignores_build_image(self, goga_project): + """A stale build.image key is an unknown key — silently ignored, not rejected.""" _write_goga_yml( goga_project, """\ @@ -488,13 +492,13 @@ def test_load_config_rejects_build_image(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude image: goga:latest """, ) - with pytest.raises(ValueError, match=r"build\.image"): - load_project_config() + config = load_project_config() + assert config.build.agent == "claude" + assert not hasattr(config.build, "image") def test_load_config_pipeline_absent_returns_none(self, goga_project): """YAML without the pipeline block yields config.pipeline is None.""" @@ -504,8 +508,7 @@ def test_load_config_pipeline_absent_returns_none(self, goga_project): language: python image: qarium/foo:1.0 build: - task_executor: - agent: claude + agent: claude """, ) config = load_project_config() @@ -520,8 +523,7 @@ def test_load_config_image_none_is_valid(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude """, ) config = load_project_config() @@ -537,8 +539,7 @@ def test_load_config_pipeline_agent_empty_resolves_none(self, goga_project): pipeline: agent: "" build: - task_executor: - agent: claude + agent: claude """, ) config = load_project_config() @@ -554,8 +555,7 @@ def test_load_config_pipeline_agent_missing_resolves_none(self, goga_project): image: qarium/foo:1.0 pipeline: {} build: - task_executor: - agent: claude + agent: claude """, ) config = load_project_config() @@ -572,8 +572,7 @@ def test_load_config_pipeline_agent_bool_raises(self, goga_project): pipeline: agent: true build: - task_executor: - agent: claude + agent: claude """, ) with pytest.raises(ValueError, match=r"pipeline\.agent must be a string"): @@ -589,8 +588,7 @@ def test_load_config_image_non_string_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude """, ) with pytest.raises(ValueError, match="image must be a string"): @@ -627,8 +625,7 @@ def test_load_config_pipeline_null_treated_as_absent(self, goga_project): image: qarium/foo:1.0 pipeline: null build: - task_executor: - agent: claude + agent: claude """, ) config = load_project_config() @@ -658,8 +655,7 @@ def test_load_config_empty_pipeline_mapping_parses_none_agent(self, goga_project image: qarium/foo:1.0 pipeline: {} build: - task_executor: - agent: claude + agent: claude """, ) config = load_project_config() @@ -667,8 +663,8 @@ def test_load_config_empty_pipeline_mapping_parses_none_agent(self, goga_project assert config.pipeline.agent is None assert config.pipeline.env == {} - def test_load_config_empty_build_mapping_raises_inner_error(self, goga_project): - """build: {} → inner validation preserved (KeyError on missing task_executor).""" + def test_load_config_empty_build_mapping_parses_defaults(self, goga_project): + """build: {} → BuildConfig with every field unset (agent is optional).""" _write_goga_yml( goga_project, """\ @@ -679,8 +675,11 @@ def test_load_config_empty_build_mapping_raises_inner_error(self, goga_project): build: {} """, ) - with pytest.raises(KeyError, match=r"build\.task_executor is required"): - load_project_config() + config = load_project_config() + assert isinstance(config.build, BuildConfig) + assert config.build.agent is None + assert config.build.env == {} + assert config.build.review is None # --- Negative tests --- @@ -713,8 +712,7 @@ def test_load_config_missing_language(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude """, ) with pytest.raises(KeyError, match="language is required"): @@ -730,8 +728,7 @@ def test_load_config_language_null_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude """, ) with pytest.raises(ValueError, match="language must be a non-empty string"): @@ -747,8 +744,7 @@ def test_load_config_language_empty_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude """, ) with pytest.raises(ValueError, match="language must be a non-empty string"): @@ -764,8 +760,7 @@ def test_load_config_language_bool_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude """, ) with pytest.raises(ValueError, match="language must be a non-empty string"): @@ -781,15 +776,14 @@ def test_load_config_language_whitespace_only_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude """, ) with pytest.raises(ValueError, match="language must be a non-empty string"): load_project_config() - def test_load_config_task_executor_env_non_string_keys(self, goga_project): - """task_executor env: {123: value} (int key).""" + def test_load_config_build_env_non_string_keys(self, goga_project): + """build env: {123: value} (int key).""" _write_goga_yml( goga_project, """\ @@ -798,10 +792,9 @@ def test_load_config_task_executor_env_non_string_keys(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude - env: - 123: value + agent: claude + env: + 123: value """, ) with pytest.raises(ValueError, match="env must have string"): @@ -819,8 +812,7 @@ def test_load_config_pipeline_env_non_string_keys(self, goga_project): env: 123: value build: - task_executor: - agent: claude + agent: claude """, ) with pytest.raises(ValueError, match="env must have string"): @@ -840,39 +832,8 @@ def test_load_config_build_absent_returns_none(self, goga_project): config = load_project_config() assert config.build is None - def test_load_config_missing_task_executor(self, goga_project): - """build section without task_executor.""" - _write_goga_yml( - goga_project, - """\ -language: python -image: qarium/foo:1.0 -pipeline: - agent: claude -build: - worktree: true -""", - ) - with pytest.raises(KeyError, match=r"build\.task_executor is required"): - load_project_config() - - def test_load_config_empty_build_raises(self, goga_project): - """build: {} (no task_executor).""" - _write_goga_yml( - goga_project, - """\ -language: python -image: qarium/foo:1.0 -pipeline: - agent: claude -build: {} -""", - ) - with pytest.raises(KeyError, match=r"build\.task_executor is required"): - load_project_config() - def test_load_config_missing_agent_resolves_none(self, goga_project): - """task_executor: {} (no agent key) → agent resolves to None (optional).""" + """build: {env: ...} (no agent key) → agent resolves to None (optional).""" _write_goga_yml( goga_project, """\ @@ -881,12 +842,12 @@ def test_load_config_missing_agent_resolves_none(self, goga_project): pipeline: agent: claude build: - task_executor: {} + max_iterations: 3 """, ) config = load_project_config() assert config.build is not None - assert config.build.task_executor.agent is None + assert config.build.agent is None def test_load_config_empty_agent_resolves_none(self, goga_project): """agent: '' (empty string) → resolves to None (optional).""" @@ -898,13 +859,12 @@ def test_load_config_empty_agent_resolves_none(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: "" + agent: "" """, ) config = load_project_config() assert config.build is not None - assert config.build.task_executor.agent is None + assert config.build.agent is None def test_load_config_whitespace_agent_resolves_none(self, goga_project): """agent: ' ' (whitespace-only string) → resolves to None (optional).""" @@ -916,15 +876,14 @@ def test_load_config_whitespace_agent_resolves_none(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: " " + agent: " " """, ) config = load_project_config() assert config.build is not None - assert config.build.task_executor.agent is None + assert config.build.agent is None - def test_load_config_task_executor_env_not_mapping(self, goga_project): + def test_load_config_build_env_not_mapping(self, goga_project): """env: "not-a-dict".""" _write_goga_yml( goga_project, @@ -934,15 +893,14 @@ def test_load_config_task_executor_env_not_mapping(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude - env: not-a-dict + agent: claude + env: not-a-dict """, ) with pytest.raises(ValueError, match="env must be a mapping"): load_project_config() - def test_load_config_task_executor_env_non_string_values(self, goga_project): + def test_load_config_build_env_non_string_values(self, goga_project): """env: {KEY: 123}.""" _write_goga_yml( goga_project, @@ -952,10 +910,9 @@ def test_load_config_task_executor_env_non_string_values(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude - env: - KEY: 123 + agent: claude + env: + KEY: 123 """, ) with pytest.raises(ValueError, match="env must have string"): @@ -971,43 +928,10 @@ def test_load_config_agent_bool_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: true -""", - ) - with pytest.raises(ValueError, match=r"build\.task_executor\.agent must be a string"): - load_project_config() - - def test_load_config_task_executor_scalar_raises(self, goga_project): - """task_executor: claude (scalar, not mapping).""" - _write_goga_yml( - goga_project, - """\ -language: python -image: qarium/foo:1.0 -pipeline: - agent: claude -build: - task_executor: claude -""", - ) - with pytest.raises(ValueError, match="task_executor must be a mapping"): - load_project_config() - - def test_load_config_task_executor_null_raises(self, goga_project): - """task_executor: null (null, not mapping).""" - _write_goga_yml( - goga_project, - """\ -language: python -image: qarium/foo:1.0 -pipeline: - agent: claude -build: - task_executor: + agent: true """, ) - with pytest.raises(ValueError, match="task_executor must be a mapping"): + with pytest.raises(ValueError, match=r"build\.agent must be a string"): load_project_config() def test_load_config_commands_not_dict_raises(self, goga_project): @@ -1020,8 +944,7 @@ def test_load_config_commands_not_dict_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude commands: string """, ) @@ -1052,14 +975,13 @@ def test_load_config_pipeline_not_dict_raises(self, goga_project): image: qarium/foo:1.0 pipeline: true build: - task_executor: - agent: claude + agent: claude """, ) with pytest.raises(ValueError, match="'pipeline' must be a mapping"): load_project_config() - def test_load_config_task_executor_env_bool_value_raises(self, goga_project): + def test_load_config_build_env_bool_value_raises(self, goga_project): """env: {DEBUG: true}.""" _write_goga_yml( goga_project, @@ -1069,16 +991,15 @@ def test_load_config_task_executor_env_bool_value_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude - env: - DEBUG: true + agent: claude + env: + DEBUG: true """, ) with pytest.raises(ValueError, match="env must have string"): load_project_config() - def test_load_config_task_executor_env_null_value_raises(self, goga_project): + def test_load_config_build_env_null_value_raises(self, goga_project): """env: {EMPTY: null}.""" _write_goga_yml( goga_project, @@ -1088,10 +1009,9 @@ def test_load_config_task_executor_env_null_value_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude - env: - EMPTY: + agent: claude + env: + EMPTY: """, ) with pytest.raises(ValueError, match="env must have string"): @@ -1119,7 +1039,7 @@ def test_load_config_invalid_yaml_syntax(self, goga_project): """Bad YAML syntax.""" _write_goga_yml( goga_project, - "language: python\npipeline:\n agent: claude\nbuild:\n task_executor:\n agent: [unclosed\n", + "language: python\npipeline:\n agent: claude\nbuild:\n agent: [unclosed\n", ) with pytest.raises(yaml.YAMLError): load_project_config() @@ -1292,8 +1212,7 @@ def test_load_config_with_codemanifest_section(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude codemanifest: usages: lib: .specs/lib.md @@ -1322,8 +1241,7 @@ def test_load_config_codemanifest_empty_section(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude codemanifest: {} """, ) @@ -1342,8 +1260,7 @@ def test_load_config_codemanifest_annotations_only(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude codemanifest: annotations: "Some notes" """, @@ -1363,8 +1280,7 @@ def test_load_config_codemanifest_usages_not_mapping(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude codemanifest: usages: not-a-mapping """, @@ -1382,8 +1298,7 @@ def test_load_config_codemanifest_annotations_not_string(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude codemanifest: annotations: 123 """, @@ -1401,8 +1316,7 @@ def test_load_config_codemanifest_annotations_bool_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude codemanifest: annotations: true """, @@ -1420,8 +1334,7 @@ def test_load_config_codemanifest_usages_null(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude codemanifest: usages: """, @@ -1439,8 +1352,7 @@ def test_load_config_codemanifest_annotations_empty_string(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude codemanifest: annotations: "" """, @@ -1459,8 +1371,7 @@ def test_load_config_codemanifest_scalar_string_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude codemanifest: string """, ) @@ -1477,8 +1388,7 @@ def test_load_config_codemanifest_scalar_bool_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude codemanifest: true """, ) @@ -1495,8 +1405,7 @@ def test_load_config_codemanifest_usages_non_string_key_raises(self, goga_projec pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude codemanifest: usages: 123: path.md @@ -1515,8 +1424,7 @@ def test_load_config_codemanifest_usages_non_string_value_raises(self, goga_proj pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude codemanifest: usages: lib: 123 @@ -1535,8 +1443,7 @@ def test_load_config_codemanifest_annotations_multiline(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude codemanifest: usages: lib: .specs/lib.md @@ -1562,8 +1469,7 @@ def test_load_config_codemanifest_null_returns_none(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude codemanifest: null """, ) @@ -1667,8 +1573,7 @@ def test_load_config_parses_dockerfile_field(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude """, ) config = load_project_config() @@ -1692,8 +1597,7 @@ def test_load_config_dockerfile_empty_string_valid(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude """, ) config = load_project_config() @@ -1710,8 +1614,7 @@ def test_load_config_dockerfile_non_string_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude """, ) with pytest.raises(ValueError, match="dockerfile must be a string"): @@ -1727,8 +1630,7 @@ def test_load_config_dockerfile_without_image(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude """, ) config = load_project_config() @@ -1871,8 +1773,7 @@ def test_load_config_tools_stored_verbatim(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude tools: valid: 1.0.x operator_prefixed: "==1.0" @@ -1904,8 +1805,7 @@ def test_load_config_tools_null_returns_none(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude tools: null """, ) @@ -1922,8 +1822,7 @@ def test_load_config_tools_empty_mapping_returns_empty_dict(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude tools: {} """, ) @@ -1940,8 +1839,7 @@ def test_load_config_tools_non_mapping_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude tools: 5 """, ) @@ -1958,8 +1856,7 @@ def test_load_config_tools_null_value_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude tools: viewer: """, @@ -1977,8 +1874,7 @@ def test_load_config_tools_non_string_value_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude tools: viewer: 5 """, @@ -2000,8 +1896,7 @@ def test_load_config_tools_non_string_value_float_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude tools: viewer: 1.0 """, @@ -2019,8 +1914,7 @@ def test_load_config_tools_mixed_null_and_valid_raises(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude tools: afm: 1.0.x viewer: null @@ -2039,9 +1933,8 @@ def test_load_config_backward_compatible_without_tools(self, goga_project): assert config.lang == "python" assert config.image == "qarium/foo:1.0" assert config.pipeline.agent == "claude" - assert config.build.task_executor.agent == "claude" - assert config.build.task_executor.env == {"KEY": "value"} - assert config.build.worktree is True + assert config.build.agent == "claude" + assert config.build.env == {"KEY": "value"} assert config.commands == {"foo": "bar"} def test_load_config_tools_alongside_codemanifest(self, goga_project): @@ -2054,8 +1947,7 @@ def test_load_config_tools_alongside_codemanifest(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude codemanifest: annotations: "notes" tools: @@ -2228,8 +2120,7 @@ def test_load_usages_yaml_null_returns_none(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude usages: """, ) @@ -2246,8 +2137,7 @@ def test_load_usages_present_but_empty_returns_empty_dict(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude usages: {} """, ) @@ -2264,8 +2154,7 @@ def test_load_usages_present_builds_depcfg_dict(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude usages: libs: click: @@ -2295,8 +2184,7 @@ def test_load_usages_multiple_groups_preserve_structure(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude usages: libs: click: @@ -2321,8 +2209,7 @@ def test_load_usages_non_mapping_raises_value_error(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude usages: 5 """, ) @@ -2339,8 +2226,7 @@ def test_load_usages_group_non_mapping_raises_value_error(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude usages: libs: 5 """, @@ -2358,8 +2244,7 @@ def test_load_usages_dep_non_mapping_raises_value_error(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude usages: libs: click: 5 @@ -2378,8 +2263,7 @@ def test_load_usages_traversal_group_rejected(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude usages: "..": victim: @@ -2399,8 +2283,7 @@ def test_load_usages_dep_git_missing_raises_keyerror(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude usages: libs: click: @@ -2421,8 +2304,7 @@ def test_load_usages_dep_git_invalid_raises_valueerror(self, goga_project, yaml_ pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude usages: libs: click: @@ -2442,8 +2324,7 @@ def test_load_usages_dep_ref_non_str_raises_value_error(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude usages: libs: click: @@ -2464,8 +2345,7 @@ def test_load_usages_non_string_group_key_raises_value_error(self, goga_project) pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude usages: 123: click: @@ -2485,8 +2365,7 @@ def test_load_usages_non_string_dep_key_raises_value_error(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude usages: libs: 123: @@ -2506,8 +2385,7 @@ def test_load_usages_alongside_tools(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude tools: afm: 1.0.x usages: @@ -2529,9 +2407,8 @@ def test_load_usages_backward_compatible_without_usages(self, goga_project): assert config.lang == "python" assert config.image == "qarium/foo:1.0" assert config.pipeline.agent == "claude" - assert config.build.task_executor.agent == "claude" - assert config.build.task_executor.env == {"KEY": "value"} - assert config.build.worktree is True + assert config.build.agent == "claude" + assert config.build.env == {"KEY": "value"} assert config.commands == {"foo": "bar"} @@ -2745,8 +2622,7 @@ def test_load_usages_with_root_end_to_end(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude usages: libs: click: @@ -2768,8 +2644,7 @@ def test_load_usages_root_empty_normalized_to_none(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude usages: libs: click: @@ -2791,8 +2666,7 @@ def test_load_usages_root_multi_segment_end_to_end(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude usages: libs: click: @@ -2826,8 +2700,7 @@ def test_load_usages_invalid_root_raises_value_error(self, goga_project, root_ya pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude usages: libs: click: @@ -2973,8 +2846,7 @@ def test_parse_lint_builds_lintconfig_with_ignore(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude lint: ignore: - .venv/ @@ -2996,8 +2868,7 @@ def test_parse_lint_empty_ignore_section(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude lint: ignore: [] """, @@ -3016,8 +2887,7 @@ def test_parse_lint_rejects_non_mapping_section(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude lint: not-a-mapping """, ) @@ -3034,8 +2904,7 @@ def test_parse_lint_rejects_non_list_ignore(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude lint: ignore: not-a-list """, @@ -3053,8 +2922,7 @@ def test_parse_lint_rejects_non_string_element(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude lint: ignore: - .venv/ @@ -3074,8 +2942,7 @@ def test_parse_lint_null_section_returns_none(self, goga_project): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude lint: null """, ) @@ -3090,57 +2957,52 @@ def test_parse_lint_backward_compatible_without_section(self, goga_project): assert config.lang == "python" assert config.image == "qarium/foo:1.0" assert config.pipeline.agent == "claude" - assert config.build.task_executor.agent == "claude" - assert config.build.task_executor.env == {"KEY": "value"} - assert config.build.worktree is True + assert config.build.agent == "claude" + assert config.build.env == {"KEY": "value"} assert config.commands == {"foo": "bar"} -# --- Contract tests for ReviewExecutorConfig + build.review_executor (step 6.5) --- +# --- Contract tests for the two-part build model (loader steps 6-7) --- -class TestReviewExecutorConfigContract: - def test_review_executor_config_importable_from_project_cell(self): - """ReviewExecutorConfig is importable from goga.config.project and in __all__.""" +class TestTwoPartBuildContract: + def test_review_configs_importable_from_project_cell(self): + """ReviewConfig and AdditionalReviewConfig are importable from goga.config.project.""" import goga.config.project as project_mod - from goga.config.project.config import ReviewExecutorConfig - - assert hasattr(project_mod, "ReviewExecutorConfig") - assert "ReviewExecutorConfig" in project_mod.__all__ - assert project_mod.ReviewExecutorConfig is ReviewExecutorConfig - - def test_review_executor_config_is_frozen_kw_only_dataclass(self): - """ReviewExecutorConfig is a frozen kw_only dataclass with six fields.""" - from goga.config.project.config import ReviewExecutorConfig - - assert dataclasses.is_dataclass(ReviewExecutorConfig) - params = {f.name: f for f in dataclasses.fields(ReviewExecutorConfig)} - assert set(params) == {"skip", "agent", "roles", "env", "base_ref", "patience"} - assert params["skip"].default is None - assert params["agent"].default is None - assert params["roles"].default is None - assert params["env"].default is dataclasses.MISSING - assert params["env"].default_factory is dict - assert params["base_ref"].default is None - assert params["patience"].default is None - - def test_review_executor_config_reexport_from_facade_alive(self): - """goga.config re-exports the same class object as the project cell.""" + from goga.config.project.config import AdditionalReviewConfig, ReviewConfig + + assert hasattr(project_mod, "ReviewConfig") + assert hasattr(project_mod, "AdditionalReviewConfig") + assert "ReviewConfig" in project_mod.__all__ + assert "AdditionalReviewConfig" in project_mod.__all__ + assert project_mod.ReviewConfig is ReviewConfig + assert project_mod.AdditionalReviewConfig is AdditionalReviewConfig + + def test_review_configs_reexport_from_facade_alive(self): + """goga.config re-exports the same class objects as the project cell.""" import goga.config as facade - from goga.config.project.config import ReviewExecutorConfig + from goga.config.project.config import AdditionalReviewConfig, ReviewConfig + + assert facade.ReviewConfig is ReviewConfig + assert facade.AdditionalReviewConfig is AdditionalReviewConfig - assert facade.ReviewExecutorConfig is ReviewExecutorConfig + def test_retired_executor_names_absent_from_project_cell(self): + """The retired executor configs are gone from goga.config.project.""" + import goga.config.project as project_mod - def test_build_config_accepts_review_executor_kwarg(self): - """BuildConfig accepts the review_executor kw-arg and defaults it to None.""" - from goga.config.project.config import BuildConfig, ReviewExecutorConfig + assert not hasattr(project_mod, "TaskExecutorConfig") + assert not hasattr(project_mod, "ReviewExecutorConfig") + assert "TaskExecutorConfig" not in project_mod.__all__ + assert "ReviewExecutorConfig" not in project_mod.__all__ - defaults = BuildConfig(task_executor=TaskExecutorConfig(agent="claude")) - assert defaults.review_executor is None + def test_build_config_accepts_review_kwarg(self): + """BuildConfig accepts the review kw-arg and defaults it to None.""" + defaults = BuildConfig(agent="claude") + assert defaults.review is None - review = ReviewExecutorConfig(skip=True, agent="codex", roles=["quality"]) - configured = BuildConfig(task_executor=TaskExecutorConfig(agent="claude"), review_executor=review) - assert configured.review_executor is review + review = ReviewConfig(skip=True, agent="codex", roles=["quality"]) + configured = BuildConfig(agent="claude", review=review) + assert configured.review is review def test_load_project_config_signature_unchanged(self): """load_project_config still takes no arguments (signature unchanged).""" @@ -3149,23 +3011,124 @@ def test_load_project_config_signature_unchanged(self): assert sig.return_annotation is ProjectConfig -# --- Logic tests for build.review_executor parsing (loader step 6.5) --- +# --- Logic tests for the two-part build parsing (loader steps 6-7) --- -class TestLoadConfigReviewExecutor: - def test_loader_parses_review_executor_full_section(self, goga_project): - """build.review_executor with all fields → ReviewExecutorConfig verbatim.""" - from goga.config.project.config import ReviewExecutorConfig +class TestLoadConfigTwoPartBuild: + def test_load_project_config_parses_two_part_build(self, goga_project): + """The two-part build section parses into BuildConfig + ReviewConfig + AdditionalReviewConfig.""" + _write_goga_yml( + goga_project, + """\ +language: python +build: + agent: claude + env: + A: "1" + max_iterations: 7 + session_timeout: 30m + review: + agent: codex + env: + B: "2" + roles: + - quality + base_ref: main + strategy: short + finalize: "do it" + additional: + agent: cursor + patience: 2 + max_iterations: 4 +""", + ) + config = load_project_config() + assert config.build.agent == "claude" + assert config.build.env == {"A": "1"} + assert config.build.max_iterations == 7 + assert config.build.session_timeout == "30m" + assert config.build.review is not None + assert config.build.review.agent == "codex" + assert config.build.review.env == {"B": "2"} + assert config.build.review.roles == ["quality"] + assert config.build.review.base_ref == "main" + assert config.build.review.strategy == "short" + assert config.build.review.finalize == "do it" + assert config.build.review.additional is not None + assert config.build.review.additional.agent == "cursor" + assert config.build.review.additional.patience == 2 + assert config.build.review.additional.max_iterations == 4 + assert not hasattr(config.build, "task_executor") + assert not hasattr(config.build, "worktree") + def test_load_project_config_ignores_retired_keys(self, goga_project): + """Retired keys (worktree, skip_finalize, codex_review, task_executor, review_executor) are silently ignored.""" _write_goga_yml( goga_project, """\ language: python -image: qarium/foo:1.0 build: + worktree: true + skip_finalize: true + codex_review: false task_executor: agent: claude review_executor: + agent: codex + agent: claude +""", + ) + config = load_project_config() + assert config.build.agent == "claude" + assert config.build.review is None + assert not hasattr(config.build, "task_executor") + assert not hasattr(config.build, "review_executor") + assert not hasattr(config.build, "worktree") + assert not hasattr(config.build, "skip_finalize") + assert not hasattr(config.build, "codex_review") + + @pytest.mark.parametrize( + ("build_snippet", "match"), + [ + (" review: \"x\"\n", r"build\.review must be a mapping"), + (" review:\n skip: \"yes\"\n", r"build\.review\.skip must be a bool"), + (" review:\n roles:\n - 1\n", r"build\.review\.roles must be a list of strings"), + (" review:\n strategy: 5\n", r"build\.review\.strategy must be a string"), + (" review:\n additional:\n patience: true\n", r"patience must be an int"), + (" max_iterations: true\n", r"build\.max_iterations must be an int"), + (" agent: 7\n", r"build\.agent must be a string"), + ], + ids=[ + "review-scalar", + "review-skip-string", + "review-roles-int-element", + "review-strategy-int", + "review-additional-patience-bool", + "root-max-iterations-bool", + "root-agent-int", + ], + ) + def test_load_project_config_rejects_malformed_review(self, goga_project, build_snippet, match): + """Each structurally invalid build/review value raises ValueError naming the key.""" + _write_goga_yml( + goga_project, + f"""\ +language: python +build: +{build_snippet}""", + ) + with pytest.raises(ValueError, match=match): + load_project_config() + + def test_loader_parses_review_full_section(self, goga_project): + """build.review with all fields parses into ReviewConfig verbatim.""" + _write_goga_yml( + goga_project, + """\ +language: python +build: + agent: claude + review: skip: false agent: codex roles: @@ -3174,52 +3137,78 @@ def test_loader_parses_review_executor_full_section(self, goga_project): env: ANTHROPIC_MODEL: reviewer-model REVIEW_STRICT: "2" + session_timeout: "40m" + idle_timeout: "11m" + wait: "3m" """, ) config = load_project_config() - assert config.build.review_executor == ReviewExecutorConfig( + assert config.build.review == ReviewConfig( skip=False, agent="codex", roles=["quality", "testing"], env={"ANTHROPIC_MODEL": "reviewer-model", "REVIEW_STRICT": "2"}, + session_timeout="40m", + idle_timeout="11m", + wait="3m", ) - def test_loader_review_executor_not_mapping_raises(self, goga_project): - """review_executor: 5 → ValueError mentioning 'must be a mapping'.""" + def test_loader_review_not_mapping_raises(self, goga_project): + """review: 5 → ValueError mentioning 'must be a mapping'.""" _write_goga_yml( goga_project, """\ language: python build: - task_executor: - agent: claude - review_executor: 5 + agent: claude + review: 5 """, ) - with pytest.raises(ValueError, match=r"review_executor must be a mapping"): + with pytest.raises(ValueError, match=r"build\.review must be a mapping"): load_project_config() @pytest.mark.parametrize( ("yaml_snippet", "match"), [ - ('skip: "yes"', r"review_executor\.skip must be a bool"), - ("skip: 1", r"review_executor\.skip must be a bool"), - ("agent: 7", r"review_executor\.agent must be a string"), - ("roles: quality", r"review_executor\.roles must be a list of strings"), - ("roles:\n - 1", r"review_executor\.roles must be a list of strings"), + ('skip: "yes"', r"build\.review\.skip must be a bool"), + ("skip: 1", r"build\.review\.skip must be a bool"), + ("agent: 7", r"build\.review\.agent must be a string"), + ("roles: quality", r"build\.review\.roles must be a list of strings"), + ("roles:\n - 1", r"build\.review\.roles must be a list of strings"), + ("env: 5", r"build\.review\.env must be a mapping"), + ("base_ref: 12", r"build\.review\.base_ref must be a string"), + ("strategy: 5", r"build\.review\.strategy must be a string"), + ("finalize: []", r"build\.review\.finalize must be a string"), + ("additional: 5", r"build\.review\.additional must be a mapping"), + ("additional:\n agent: 7", r"build\.review\.additional\.agent must be a string"), + ("additional:\n patience: \"3\"", r"patience must be an int"), + ("additional:\n max_iterations: true", r"max_iterations must be an int"), + ], + ids=[ + "skip-string", + "skip-yaml-int", + "agent-int", + "roles-string", + "roles-int-element", + "env-not-mapping", + "base-ref-int", + "strategy-int", + "finalize-list", + "additional-scalar", + "additional-agent-int", + "additional-patience-string", + "additional-max-iterations-bool", ], - ids=["skip-string", "skip-yaml-int", "agent-int", "roles-string", "roles-int-element"], ) - def test_loader_review_executor_field_type_errors(self, goga_project, yaml_snippet, match): - """Each structurally invalid field value raises ValueError naming the field.""" + def test_loader_review_field_type_errors(self, goga_project, yaml_snippet, match): + """Each structurally invalid review field raises ValueError naming the field.""" _write_goga_yml( goga_project, f"""\ language: python build: - task_executor: - agent: claude - review_executor: + agent: claude + review: {yaml_snippet} """, ) @@ -3230,111 +3219,67 @@ def test_loader_review_executor_field_type_errors(self, goga_project, yaml_snipp ("yaml_snippet", "expected"), [ ("", None), - ("review_executor:\n", None), - ("review_executor: {}\n", "empty-instance"), + ("review:\n", None), + ("review: {}\n", "empty-instance"), ], ids=["absent", "yaml-null", "empty-mapping"], ) - def test_loader_review_executor_absent_and_null(self, goga_project, yaml_snippet, expected): - """Absent/null section → None; empty mapping → all-fields-None instance.""" - from goga.config.project.config import ReviewExecutorConfig - - section = yaml_snippet + def test_loader_review_absent_and_null(self, goga_project, yaml_snippet, expected): + """Absent/null review section → None; empty mapping → all-fields-None instance.""" _write_goga_yml( goga_project, f"""\ language: python build: - task_executor: - agent: claude - {section}""", + agent: claude + {yaml_snippet}""", ) config = load_project_config() if expected is None: - assert config.build.review_executor is None + assert config.build.review is None else: - assert config.build.review_executor == ReviewExecutorConfig(skip=None, agent=None, roles=None) + assert config.build.review == ReviewConfig() def test_loader_empty_roles_passthrough(self, goga_project): """roles: [] → .roles == [] (empty list, NOT normalized to None).""" - from goga.config.project.config import ReviewExecutorConfig - _write_goga_yml( goga_project, """\ language: python build: - task_executor: - agent: claude - review_executor: + agent: claude + review: roles: [] """, ) config = load_project_config() - assert config.build.review_executor == ReviewExecutorConfig(skip=None, agent=None, roles=[]) - assert config.build.review_executor.roles == [] - - def test_loader_parses_review_executor_env_mapping(self, goga_project): - """review_executor.env str:str mapping → stored verbatim as dict[str, str].""" - from goga.config.project.config import ReviewExecutorConfig + assert config.build.review == ReviewConfig(roles=[]) + assert config.build.review.roles == [] + @pytest.mark.parametrize( + ("env_snippet", "env_id"), + [ + ("", "absent"), + ("env:\n", "yaml-null"), + ("env: {}\n", "empty-mapping"), + ], + ) + def test_loader_review_env_absent_null_empty_all_empty_dict(self, goga_project, env_snippet, env_id): + """Absent, YAML-null and empty-mapping review env all resolve to {} with no error.""" _write_goga_yml( goga_project, - """\ + f"""\ language: python -image: qarium/foo:1.0 build: - task_executor: - agent: claude - review_executor: - skip: false - agent: codex - roles: - - quality - env: - ANTHROPIC_MODEL: reviewer-model - REVIEW_STRICT: "2" -""", + agent: claude + review: + skip: null + {env_snippet}""", ) config = load_project_config() - assert config.build.review_executor.env == {"ANTHROPIC_MODEL": "reviewer-model", "REVIEW_STRICT": "2"} - assert all(isinstance(k, str) and isinstance(v, str) for k, v in config.build.review_executor.env.items()) - assert config.build.review_executor == ReviewExecutorConfig( - skip=False, - agent="codex", - roles=["quality"], - env={"ANTHROPIC_MODEL": "reviewer-model", "REVIEW_STRICT": "2"}, - ) - - def test_review_executor_config_declared_fields_include_env(self): - """Declared fields are skip, agent, roles, env, base_ref, patience; env is - a factory-defaulted dict[str, str].""" - from goga.config.project.config import ReviewExecutorConfig - - names = [f.name for f in dataclasses.fields(ReviewExecutorConfig)] - assert names == ["skip", "agent", "roles", "env", "base_ref", "patience"] - assert ReviewExecutorConfig.__dataclass_fields__["env"].type == dict[str, str] - env_field = {f.name: f for f in dataclasses.fields(ReviewExecutorConfig)}["env"] - assert env_field.default is dataclasses.MISSING - assert env_field.default_factory is dict - assert ReviewExecutorConfig(skip=None, agent=None, roles=None).env == {} - - def test_loader_review_executor_env_not_mapping_raises(self, goga_project): - """review_executor.env: 5 → ValueError mentioning 'must be a mapping'.""" - _write_goga_yml( - goga_project, - """\ -language: python -build: - task_executor: - agent: claude - review_executor: - env: 5 -""", - ) - with pytest.raises(ValueError, match=r"review_executor\.env must be a mapping"): - load_project_config() + assert config.build.review is not None, env_id + assert config.build.review.env == {}, env_id @pytest.mark.parametrize( "env_snippet", @@ -3345,157 +3290,132 @@ def test_loader_review_executor_env_not_mapping_raises(self, goga_project): ], ids=["int-key", "int-value", "bool-value"], ) - def test_loader_review_executor_env_non_string_key_or_value_raises(self, goga_project, env_snippet): - """Non-string env keys/values → ValueError 'must have string keys and values'.""" + def test_loader_review_env_non_string_key_or_value_raises(self, goga_project, env_snippet): + """Non-string review env keys/values → ValueError 'must have string keys and values'.""" _write_goga_yml( goga_project, f"""\ language: python build: - task_executor: - agent: claude - review_executor: + agent: claude + review: {env_snippet} """, ) - with pytest.raises(ValueError, match=r"review_executor\.env must have string keys and values"): + with pytest.raises(ValueError, match=r"build\.review\.env must have string keys and values"): load_project_config() - @pytest.mark.parametrize( - ("env_snippet", "env_id"), - [ - ("", "absent"), - ("env:\n", "yaml-null"), - ("env: {}\n", "empty-mapping"), - ], - ) - def test_loader_review_executor_env_absent_null_empty_all_empty_dict(self, goga_project, env_snippet, env_id): - """Absent, YAML-null and empty-mapping env all resolve to {} with no error.""" - _write_goga_yml( - goga_project, - f"""\ -language: python -build: - task_executor: - agent: claude - review_executor: - skip: null - {env_snippet}""", - ) - config = load_project_config() - assert config.build.review_executor is not None, env_id - assert config.build.review_executor.env == {}, env_id - - def test_review_executor_base_ref_parsed_verbatim(self, goga_project): - """review_executor.base_ref string is stored verbatim as a str.""" + def test_review_base_ref_parsed_verbatim(self, goga_project): + """review.base_ref string is stored verbatim (stripped) as a str.""" _write_goga_yml( goga_project, """\ language: python build: - task_executor: - agent: claude - review_executor: - agent: claude + agent: claude + review: base_ref: origin/1.2.x """, ) config = load_project_config() - assert config.build.review_executor.base_ref == "origin/1.2.x" - assert isinstance(config.build.review_executor.base_ref, str) - - def test_review_executor_base_ref_padded_stripped(self, goga_project): - """review_executor.base_ref with surrounding whitespace is stored stripped. + assert config.build.review.base_ref == "origin/1.2.x" + assert isinstance(config.build.review.base_ref, str) - Exact equality — an implementation that only nulls the whitespace-only - case without assigning the stripped value fails. - """ + @pytest.mark.parametrize( + "base_ref_snippet", + ["", "base_ref: null\n", 'base_ref: ""\n', 'base_ref: " "\n'], + ids=["absent", "yaml-null", "empty-string", "whitespace-only"], + ) + def test_review_base_ref_unset_variants_resolve_none(self, goga_project, base_ref_snippet): + """Absent, YAML-null, empty and whitespace-only review base_ref all resolve to None.""" _write_goga_yml( goga_project, - """\ + f"""\ language: python build: - task_executor: + agent: claude + review: agent: claude - review_executor: - base_ref: " origin/1.2.x " -""", + {base_ref_snippet}""", ) config = load_project_config() - assert config.build.review_executor.base_ref == "origin/1.2.x" + assert config.build.review is not None + assert config.build.review.base_ref is None - def test_review_executor_patience_int_parsed(self, goga_project): - """review_executor.patience YAML int is stored verbatim as an int.""" + def test_review_strategy_and_finalize_unset_variants_resolve_none(self, goga_project): + """Absent, YAML-null, empty and whitespace-only strategy/finalize resolve to None.""" _write_goga_yml( goga_project, """\ language: python build: - task_executor: - agent: claude - review_executor: - patience: 3 + agent: claude + review: + strategy: "" + finalize: " " """, ) config = load_project_config() - assert config.build.review_executor.patience == 3 - assert isinstance(config.build.review_executor.patience, int) + assert config.build.review.strategy is None + assert config.build.review.finalize is None - def test_review_executor_base_ref_non_string_raises(self, goga_project): - """review_executor.base_ref: 12 → ValueError with the exact contract message.""" + def test_review_session_knobs_parsed(self, goga_project): + """The review session knobs are stored verbatim alongside the root knobs.""" _write_goga_yml( goga_project, """\ language: python build: - task_executor: - agent: claude - review_executor: - base_ref: 12 + agent: claude + session_timeout: "30m" + review: + session_timeout: "40m" + idle_timeout: "11m" + wait: "3m" """, ) - - with pytest.raises(ValueError, match=r"review_executor\.base_ref must be a string"): - load_project_config() + config = load_project_config() + assert config.build.session_timeout == "30m" + assert config.build.review.session_timeout == "40m" + assert config.build.review.idle_timeout == "11m" + assert config.build.review.wait == "3m" @pytest.mark.parametrize( - "patience_snippet", - ['patience: "3"', "patience: 3.5"], - ids=["quoted-string", "float"], + ("patience_literal", "patience_id"), + [("0", "zero"), ("-1", "negative")], ) - def test_review_executor_patience_non_int_raises(self, goga_project, patience_snippet): - """A non-int patience (str, float) raises ValueError with the exact message.""" + def test_review_additional_patience_zero_and_negative_verbatim( + self, goga_project, patience_literal, patience_id + ): + """additional.patience 0 and -1 are stored verbatim — structural typing, no range check.""" _write_goga_yml( goga_project, f"""\ language: python build: - task_executor: - agent: claude - review_executor: - {patience_snippet} + agent: claude + review: + additional: + patience: {patience_literal} """, ) + config = load_project_config() + assert config.build.review.additional.patience == int(patience_literal), patience_id - with pytest.raises(ValueError, match=r"review_executor\.patience must be an int"): - load_project_config() - - def test_review_executor_patience_yaml_bool_rejected(self, goga_project): - """patience: true → ValueError — guards the bool-before-int check order.""" + def test_review_additional_block_absent_additional_none(self, goga_project): + """A review section without additional → .additional is None (block absent).""" _write_goga_yml( goga_project, """\ language: python build: - task_executor: - agent: claude - review_executor: - patience: true + agent: claude + review: + agent: codex """, ) - - with pytest.raises(ValueError, match=r"review_executor\.patience must be an int"): - load_project_config() + config = load_project_config() + assert config.build.review.additional is None def test_legacy_build_review_patience_key_not_parsed(self, goga_project): """A legacy build.review_patience key is silently ignored — no field, no error.""" @@ -3504,79 +3424,13 @@ def test_legacy_build_review_patience_key_not_parsed(self, goga_project): """\ language: python build: - task_executor: - agent: claude + agent: claude review_patience: 5 """, ) config = load_project_config() assert not hasattr(config.build, "review_patience") - @pytest.mark.parametrize( - "base_ref_snippet", - ["", "base_ref: null\n", 'base_ref: ""\n', 'base_ref: " "\n'], - ids=["absent", "yaml-null", "empty-string", "whitespace-only"], - ) - def test_review_executor_base_ref_unset_variants_resolve_none(self, goga_project, base_ref_snippet): - """Absent, YAML-null, empty and whitespace-only base_ref all resolve to None.""" - _write_goga_yml( - goga_project, - f"""\ -language: python -build: - task_executor: - agent: claude - review_executor: - agent: claude - {base_ref_snippet}""", - ) - config = load_project_config() - assert config.build.review_executor is not None - assert config.build.review_executor.base_ref is None - - @pytest.mark.parametrize( - "patience_snippet", - ["agent: claude\n", "agent: claude\n patience: null\n"], - ids=["absent", "yaml-null"], - ) - def test_review_executor_patience_unset_variants_resolve_none(self, goga_project, patience_snippet): - """Absent and YAML-null patience both resolve to None. - - The absent-section variant is pinned by test_loader_review_executor_absent_and_null.""" - _write_goga_yml( - goga_project, - f"""\ -language: python -build: - task_executor: - agent: claude - review_executor: - {patience_snippet}""", - ) - config = load_project_config() - assert config.build.review_executor is not None - assert config.build.review_executor.patience is None - - @pytest.mark.parametrize( - ("patience_literal", "patience_id"), - [("0", "zero"), ("-1", "negative")], - ) - def test_review_executor_patience_zero_and_negative_verbatim(self, goga_project, patience_literal, patience_id): - """patience 0 and -1 are stored verbatim — structural typing, no range check.""" - _write_goga_yml( - goga_project, - f"""\ -language: python -build: - task_executor: - agent: claude - review_executor: - patience: {patience_literal} -""", - ) - config = load_project_config() - assert config.build.review_executor.patience == int(patience_literal), patience_id - # --- Contract + logic tests for TopicsConfig + the topics section (loader step 10) --- @@ -3733,7 +3587,7 @@ def test_topics_section_alongside_other_sections(self, goga_project): _write_goga_yml( goga_project, "language: python\nimage: qarium/foo:1.0\npipeline:\n agent: claude\n" - "build:\n task_executor:\n agent: claude\nlint:\n ignore:\n - .venv/\n" + "build:\n agent: claude\nlint:\n ignore:\n - .venv/\n" "topics:\n base_ref: origin/main\n", ) config = load_project_config() diff --git a/tests/config/test_project_cell_contract.py b/tests/config/test_project_cell_contract.py index 3a72cc93..235e8d95 100644 --- a/tests/config/test_project_cell_contract.py +++ b/tests/config/test_project_cell_contract.py @@ -37,15 +37,15 @@ def _write_goga_yml(path, content: str): class TestProjectCellReexports: def test_public_names_importable_from_project_cell(self): - """The 7 public names are importable from goga.config.project.""" + """The public names are importable from goga.config.project.""" for name in ( "ProjectConfig", "load_project_config", "BuildConfig", - "TaskExecutorConfig", + "ReviewConfig", + "AdditionalReviewConfig", "PipelineConfig", "CodemanifestConfig", - "ReviewExecutorConfig", ): assert hasattr(project_mod, name), f"{name} missing from goga.config.project" assert name in project_mod.__all__, f"{name} missing from project __all__" @@ -57,6 +57,13 @@ def test_old_names_absent_from_project_cell(self): assert "Config" not in project_mod.__all__ assert "load_config" not in project_mod.__all__ + def test_retired_executor_names_absent_from_project_cell(self): + """The retired executor configs are NOT attributes of goga.config.project.""" + assert not hasattr(project_mod, "TaskExecutorConfig") + assert not hasattr(project_mod, "ReviewExecutorConfig") + assert "TaskExecutorConfig" not in project_mod.__all__ + assert "ReviewExecutorConfig" not in project_mod.__all__ + def test_old_names_raise_import_error(self): """Importing the old names from goga.config.project raises ImportError.""" with pytest.raises(ImportError): @@ -65,6 +72,14 @@ def test_old_names_raise_import_error(self): with pytest.raises(ImportError): from goga.config.project import load_config # noqa: F401 + def test_retired_executor_names_raise_import_error(self): + """Importing the retired executor configs raises ImportError.""" + with pytest.raises(ImportError): + from goga.config.project import TaskExecutorConfig # noqa: F401 + + with pytest.raises(ImportError): + from goga.config.project import ReviewExecutorConfig # noqa: F401 + def test_load_project_config_returns_project_config_annotation(self): """load_project_config declares ProjectConfig as its return annotation.""" ret = inspect.signature(load_project_config).return_annotation @@ -75,7 +90,7 @@ def test_load_project_config_returns_project_config_instance(self, goga_project) _write_goga_yml( goga_project, "language: python\nimage: qarium/foo:1.0\npipeline:\n agent: claude\n" - "build:\n task_executor:\n agent: claude\n", + "build:\n agent: claude\n", ) result = load_project_config() # identity — the facade-reexported ProjectConfig IS the class returned @@ -159,7 +174,7 @@ def test_minimal_parse_noneable_image_dockerfile(self, goga_project): """image absent → None (None-able), dockerfile absent → None, build/pipeline optional.""" _write_goga_yml( goga_project, - "language: python\npipeline:\n agent: claude\nbuild:\n task_executor:\n agent: claude\n", + "language: python\npipeline:\n agent: claude\nbuild:\n agent: claude\n", ) config = load_project_config() assert config.lang == "python" @@ -168,8 +183,9 @@ def test_minimal_parse_noneable_image_dockerfile(self, goga_project): assert isinstance(config.pipeline, PipelineConfig) assert config.pipeline.agent == "claude" assert isinstance(config.build, BuildConfig) - assert config.build.task_executor.agent == "claude" - assert config.build.task_executor.env == {} + assert config.build.agent == "claude" + assert config.build.env == {} + assert config.build.review is None assert config.commands == {} assert config.codemanifest is None assert config.tools is None @@ -179,7 +195,7 @@ def test_image_and_dockerfile_present(self, goga_project): _write_goga_yml( goga_project, "language: go\nimage: goga:latest\ndockerfile: ./Dockerfile\n" - "pipeline:\n agent: codex\nbuild:\n task_executor:\n agent: gemini\n", + "pipeline:\n agent: codex\nbuild:\n agent: gemini\n", ) config = load_project_config() assert config.image == "goga:latest" diff --git a/tests/config/test_tools_integration.py b/tests/config/test_tools_integration.py index e19a40ba..5423dbf4 100644 --- a/tests/config/test_tools_integration.py +++ b/tests/config/test_tools_integration.py @@ -5,7 +5,6 @@ CodemanifestConfig, PipelineConfig, ProjectConfig, - TaskExecutorConfig, load_project_config, ) @@ -27,12 +26,9 @@ def _write_config(path, content: str) -> None: env: PIPELINE_OPT: "1" build: - task_executor: - agent: gemini - env: - FOO: bar - worktree: false - skip_finalize: true + agent: gemini + env: + FOO: bar session_timeout: "30m" commands: test: go test ./... @@ -57,12 +53,9 @@ def _write_config(path, content: str) -> None: env: PIPELINE_OPT: "1" build: - task_executor: - agent: gemini - env: - FOO: bar - worktree: false - skip_finalize: true + agent: gemini + env: + FOO: bar session_timeout: "30m" commands: test: go test ./... @@ -92,8 +85,8 @@ def test_full_config_with_tools_populated(self, tmp_path, monkeypatch): assert isinstance(config.pipeline, PipelineConfig) assert config.pipeline.agent == "codex" assert isinstance(config.build, BuildConfig) - assert isinstance(config.build.task_executor, TaskExecutorConfig) - assert config.build.task_executor.agent == "gemini" + assert isinstance(config.build, BuildConfig) + assert config.build.agent == "gemini" assert config.commands == {"test": "go test ./...", "build": "go build ./..."} assert isinstance(config.codemanifest, CodemanifestConfig) assert config.codemanifest.annotations == "Use lib for core logic" @@ -122,10 +115,8 @@ def test_full_config_without_tools_is_none_other_fields_untouched(self, tmp_path assert config.dockerfile == "Dockerfile" assert config.pipeline.agent == "codex" assert config.pipeline.env == {"PIPELINE_OPT": "1"} - assert config.build.task_executor.agent == "gemini" - assert config.build.task_executor.env == {"FOO": "bar"} - assert config.build.worktree is False - assert config.build.skip_finalize is True + assert config.build.agent == "gemini" + assert config.build.env == {"FOO": "bar"} assert config.build.session_timeout == "30m" assert config.commands == {"test": "go test ./...", "build": "go build ./..."} assert config.codemanifest is not None @@ -143,8 +134,7 @@ def test_tools_as_root_level_sibling_preserves_insertion_order(self, tmp_path, m pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude tools: viewer: latest afm: 1.0.x @@ -179,8 +169,7 @@ def test_empty_tools_mapping_yields_empty_dict(self, tmp_path, monkeypatch): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude tools: {} """, ) @@ -204,8 +193,7 @@ def test_tools_alongside_codemanifest_and_commands(self, tmp_path, monkeypatch): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude commands: fmt: black . codemanifest: @@ -284,8 +272,7 @@ def test_tools_null_treated_as_absent(self, tmp_path, monkeypatch): pipeline: agent: claude build: - task_executor: - agent: claude + agent: claude tools: null """, ) From 858aadb48b1188d2ddffbe1f9243a2880947e201 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 20:24:13 +0000 Subject: [PATCH 084/205] feat: add the five build action catalog records (Task 2) --- .goga/history/2026/add-hooks-to-build/plan.md | 18 ++-- goga/hooks/catalog/catalog.py | 5 ++ tests/hooks/catalog/test_catalog.py | 86 +++++++++++++++++-- 3 files changed, 94 insertions(+), 15 deletions(-) diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index 32807ab9..3b66cd89 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -602,15 +602,15 @@ Action("build", "build_completed", "soft") Existing records (onboarding 2, pipeline 3, statuses 1, topics 7) stay byte-identical. -- [ ] **Declaration**: Task 2 — five build action catalog records -- [ ] **Contract tests**: in `tests/hooks/catalog/test_catalog.py` — `declared_actions()` returns the five `(domain="build", name=…)` records with error classes hard/soft×4 (expected to fail at this stage) -- [ ] **Code**: append the five records to the catalog list in `goga/hooks/catalog/catalog.py` -- [ ] **Interface verification**: `pytest tests/hooks/catalog/test_catalog.py -x -q` — contract tests pass -- [ ] **Logic tests**: `test_catalog_carries_the_five_build_records` — the five build records exist with error classes `validate_build` hard and the four notifications soft; pre-existing records byte-identical (compare against a frozen expected list); ordering deterministic (domain, then name) -- [ ] **Debugging**: `pytest tests/hooks/ -x -q` — fix implementation code until all tests pass -- [ ] **Contract re-verification**: `python -c "from goga.hooks import declared_actions; assert sum(1 for a in declared_actions() if a.domain == 'build') == 5"` -- [ ] **Lint**: `ruff check goga/hooks tests/hooks` — fix formatting if necessary -- [ ] **Completion**: mark all checkboxes of this task complete +- [x] **Declaration**: Task 2 — five build action catalog records +- [x] **Contract tests**: in `tests/hooks/catalog/test_catalog.py` — `declared_actions()` returns the five `(domain="build", name=…)` records with error classes hard/soft×4 (expected to fail at this stage) +- [x] **Code**: append the five records to the catalog list in `goga/hooks/catalog/catalog.py` +- [x] **Interface verification**: `pytest tests/hooks/catalog/test_catalog.py -x -q` — contract tests pass +- [x] **Logic tests**: `test_catalog_carries_the_five_build_records` — the five build records exist with error classes `validate_build` hard and the four notifications soft; pre-existing records byte-identical (compare against a frozen expected list); ordering deterministic (domain, then name) +- [x] **Debugging**: `pytest tests/hooks/ -x -q` — fix implementation code until all tests pass +- [x] **Contract re-verification**: `python -c "from goga.hooks import declared_actions; assert sum(1 for a in declared_actions() if a.domain == 'build') == 5"` +- [x] **Lint**: `ruff check goga/hooks tests/hooks` — fix formatting if necessary +- [x] **Completion**: mark all checkboxes of this task complete ### Task 3: Ralphex launcher flag table with external-review flags (TDD coding) diff --git a/goga/hooks/catalog/catalog.py b/goga/hooks/catalog/catalog.py index 923678fb..f0bd0892 100644 --- a/goga/hooks/catalog/catalog.py +++ b/goga/hooks/catalog/catalog.py @@ -51,6 +51,11 @@ class Action: Action(domain="topics", name="topic_published", error_class="soft"), Action(domain="topics", name="topic_switched", error_class="soft"), Action(domain="topics", name="topic_todo_entered", error_class="soft"), + Action(domain="build", name="validate_build", error_class="hard"), + Action(domain="build", name="build_started", error_class="soft"), + Action(domain="build", name="pass_started", error_class="soft"), + Action(domain="build", name="pass_completed", error_class="soft"), + Action(domain="build", name="build_completed", error_class="soft"), ] diff --git a/tests/hooks/catalog/test_catalog.py b/tests/hooks/catalog/test_catalog.py index 2e8430b3..b5dd18b4 100644 --- a/tests/hooks/catalog/test_catalog.py +++ b/tests/hooks/catalog/test_catalog.py @@ -69,6 +69,28 @@ def test_declared_actions_signature(self) -> None: assert list(parameters) == [] assert return_hint == list[Action] + def test_declared_actions_carries_the_five_build_records(self) -> None: + """The build domain block — one hard gate, four soft notifications. + + ``build/validate_build`` (hard) is the verdict-collecting gate of the + build cycle; ``build_started``, ``pass_started``, ``pass_completed``, + and ``build_completed`` (soft) only notify. Every address the build + zone emits must resolve here. + """ + build = { + action.name: action.error_class + for action in declared_actions() + if action.domain == "build" + } + + assert build == { + "validate_build": "hard", + "build_started": "soft", + "pass_started": "soft", + "pass_completed": "soft", + "build_completed": "soft", + } + # --- Logic tests --- @@ -102,7 +124,8 @@ def test_declared_actions_carries_the_seven_topics_records(self) -> None: checkpoint the topics zone emits resolves its address here. An address the zone emits but the catalog misses is a runtime ValueError in every flow, so the record set is pinned against - drift, together with the complete total: 3 + 7 topics + 3 pipeline. + drift, together with the complete total: 2 onboarding + 5 build + + 3 pipeline + 1 statuses + 7 topics. """ topics = [action for action in declared_actions() if action.domain == "topics"] @@ -115,16 +138,16 @@ def test_declared_actions_carries_the_seven_topics_records(self) -> None: ("topic_switched", "soft"), ("topic_todo_entered", "soft"), ] - assert len(declared_actions()) == 13 + assert len(declared_actions()) == 18 def test_catalog_carries_the_three_pipeline_records(self) -> None: """The pipeline domain block — the platform's first hard action, two soft notifications. ``pipeline/amend_workflow`` (hard) stops a run on hook failure; ``pipeline/run_created`` and ``pipeline/run_completed`` (soft) only - notify. The block orders between ``onboarding`` and ``statuses`` in - the ``(domain, name)`` sort, and the ten pre-existing records are - unchanged — the catalog grows to 13 records additively. + notify. The block orders between ``build`` and ``statuses`` in the + ``(domain, name)`` sort, and the pre-build records are unchanged — + the catalog grows to 18 records additively. """ records = declared_actions() triples = {(r.domain, r.name, r.error_class) for r in records} @@ -140,7 +163,7 @@ def test_catalog_carries_the_three_pipeline_records(self) -> None: domains = [action.domain for action in records] assert domains.index("onboarding") < domains.index("pipeline") < domains.index("statuses") - assert len(records) == 13 + assert len(records) == 18 pre_existing = [ ("onboarding", "amend_config", "soft"), @@ -157,6 +180,57 @@ def test_catalog_carries_the_three_pipeline_records(self) -> None: assert all(triple in triples for triple in pre_existing) + def test_catalog_carries_the_five_build_records(self) -> None: + """The build domain block — the gate plus the four cycle notifications. + + ``build/validate_build`` (hard) is the verdict-collecting gate that + stops the build before any pass; the four soft notifications only + observe the cycle. The pre-existing records are byte-identical — + the whole catalog is pinned against a frozen expected list, and the + ``build`` block orders first in the ``(domain, name)`` sort. + """ + records = declared_actions() + + build = [action for action in records if action.domain == "build"] + + assert [(action.name, action.error_class) for action in build] == [ + ("build_completed", "soft"), + ("build_started", "soft"), + ("pass_completed", "soft"), + ("pass_started", "soft"), + ("validate_build", "hard"), + ] + + expected = [ + ("build", "build_completed", "soft"), + ("build", "build_started", "soft"), + ("build", "pass_completed", "soft"), + ("build", "pass_started", "soft"), + ("build", "validate_build", "hard"), + ("onboarding", "amend_config", "soft"), + ("onboarding", "declare_session", "soft"), + ("pipeline", "amend_workflow", "hard"), + ("pipeline", "run_completed", "soft"), + ("pipeline", "run_created", "soft"), + ("statuses", "register_statuses", "soft"), + ("topics", "amend_creation", "soft"), + ("topics", "amend_todo_entry", "soft"), + ("topics", "topic_created", "soft"), + ("topics", "topic_deleted", "soft"), + ("topics", "topic_published", "soft"), + ("topics", "topic_switched", "soft"), + ("topics", "topic_todo_entered", "soft"), + ] + + assert [(a.domain, a.name, a.error_class) for a in records] == expected + + domains = [action.domain for action in records] + + assert domains.index("build") < domains.index("onboarding") + assert [(a.domain, a.name) for a in records] == sorted( + (a.domain, a.name) for a in records + ) + def test_declared_actions_is_deterministic_and_complete(self) -> None: """Same records in ``(domain, name)`` order on every call, unfiltered. From 927f00931aefe55ef670e23646374b53103a5635 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 23:28:13 +0300 Subject: [PATCH 085/205] fix: failed build --- .goga/config.yml | 24 ++++++++++++++----- .../add-topics-hooks/{completed => }/plan.md | 0 2 files changed, 18 insertions(+), 6 deletions(-) rename .goga/history/2026/add-topics-hooks/{completed => }/plan.md (100%) diff --git a/.goga/config.yml b/.goga/config.yml index 95f5e578..b4af2301 100644 --- a/.goga/config.yml +++ b/.goga/config.yml @@ -8,17 +8,29 @@ dockerfile: Dockerfile ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic" build: - agent: claude - env: - <<: *claude-env - ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" - review: + task_executor: agent: claude - base_ref: release/1.3.0 + env: + <<: *claude-env + ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" + review_executor: + agent: claude + base_ref: release/2.0.0 env: <<: *claude-env ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3[1m]" +# agent: claude +# env: +# <<: *claude-env +# ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" +# review: +# agent: claude +# base_ref: release/2.0.0 +# env: +# <<: *claude-env +# ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3[1m]" + pipeline: agent: claude env: diff --git a/.goga/history/2026/add-topics-hooks/completed/plan.md b/.goga/history/2026/add-topics-hooks/plan.md similarity index 100% rename from .goga/history/2026/add-topics-hooks/completed/plan.md rename to .goga/history/2026/add-topics-hooks/plan.md From 8af25cdf44b694ae00df46807396f7b88155d256 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 20:28:53 +0000 Subject: [PATCH 086/205] feat: ralphex launcher flag table with external-review flags (Task 3) --- .goga/history/2026/add-hooks-to-build/plan.md | 18 +-- goga/ralphex/run_ralphex.py | 29 +++- tests/ralphex/test_run_ralphex.py | 132 +++++++++++++++--- 3 files changed, 147 insertions(+), 32 deletions(-) diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index 3b66cd89..89ef003e 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -647,17 +647,17 @@ non-external keys additionally dropping 0 (`max_iterations`, `session_timeout`, `review_patience`/`max_external_iterations`. `worktree`/`skip_finalize` no longer exist in the table. -- [ ] **Declaration**: Task 3 — ralphex launcher flag table -- [ ] **Contract tests**: in `tests/ralphex/test_run_ralphex.py` — bool mapping `review`→`--review`, `tasks_only`→`--tasks-only`, `external_only`→`-e` (True emits, False/absent omits); scalar mapping for the seven scalar keys; `worktree`/`skip_finalize` absent from any emitted command (expected to fail at this stage) -- [ ] **Code**: replace `_BOOL_FLAGS` (run_ralphex.py:12–14) and extend `_SCALAR_FLAGS` (run_ralphex.py:18) per the tables above; implement the asymmetric zero rule -- [ ] **Interface verification**: `pytest tests/ralphex/test_run_ralphex.py -x -q` — contract tests pass -- [ ] **Logic tests**: `test_run_ralphex_external_flags_and_zero_rule` (patch `subprocess.call` recording argv; input `run_ralphex("p.md", {"external_only": True, "review_patience": 0, "max_external_iterations": 0, "max_iterations": 0, "base_ref": "main"}, False)`; assert +- [x] **Declaration**: Task 3 — ralphex launcher flag table +- [x] **Contract tests**: in `tests/ralphex/test_run_ralphex.py` — bool mapping `review`→`--review`, `tasks_only`→`--tasks-only`, `external_only`→`-e` (True emits, False/absent omits); scalar mapping for the seven scalar keys; `worktree`/`skip_finalize` absent from any emitted command (expected to fail at this stage) +- [x] **Code**: replace `_BOOL_FLAGS` (run_ralphex.py:12–14) and extend `_SCALAR_FLAGS` (run_ralphex.py:18) per the tables above; implement the asymmetric zero rule +- [x] **Interface verification**: `pytest tests/ralphex/test_run_ralphex.py -x -q` — contract tests pass +- [x] **Logic tests**: `test_run_ralphex_external_flags_and_zero_rule` (patch `subprocess.call` recording argv; input `run_ralphex("p.md", {"external_only": True, "review_patience": 0, "max_external_iterations": 0, "max_iterations": 0, "base_ref": "main"}, False)`; assert `argv == ["ralphex", "p.md", "--config-dir", ".ralphex/", "-e", "--review-patience", "0", "--max-external-iterations", "0", "--base-ref", "main"]` and `--worktree`/`--skip-finalize` never appear for any input); `test_max_iterations_zero_dropped_by_launcher` (`run_ralphex("p.md", {"tasks_only": True, "max_iterations": 0}, dry_run=True)` capture stderr — printed command has no `--max-iterations`; contrast `review_patience: 0` prints `--review-patience 0`) -- [ ] **Debugging**: `pytest tests/ralphex/ -x -q` — fix implementation code until all tests pass -- [ ] **Contract re-verification**: dry-run still prints `shlex.join(cmd)` to stderr and never the env layer; PATH-missing → clean one-line stderr + exit 1 -- [ ] **Lint**: `ruff check goga/ralphex tests/ralphex` — fix formatting if necessary -- [ ] **Completion**: mark all checkboxes of this task complete +- [x] **Debugging**: `pytest tests/ralphex/ -x -q` — fix implementation code until all tests pass +- [x] **Contract re-verification**: dry-run still prints `shlex.join(cmd)` to stderr and never the env layer; PATH-missing → clean one-line stderr + exit 1 +- [x] **Lint**: `ruff check goga/ralphex tests/ralphex` — fix formatting if necessary +- [x] **Completion**: mark all checkboxes of this task complete ### Task 4: Build hooks zone skeleton (infrastructure) diff --git a/goga/ralphex/run_ralphex.py b/goga/ralphex/run_ralphex.py index 4ec73ab7..8f1eb61d 100644 --- a/goga/ralphex/run_ralphex.py +++ b/goga/ralphex/run_ralphex.py @@ -10,10 +10,9 @@ # contract (see the `options` annotation), NOT by the ralphex practice — # changing the flag set is a CODEMANIFEST change, not an implementation one. _BOOL_FLAGS: tuple[tuple[str, str], ...] = ( - ("worktree", "--worktree"), - ("skip_finalize", "--skip-finalize"), ("review", "--review"), ("tasks_only", "--tasks-only"), + ("external_only", "-e"), ) _SCALAR_FLAGS: tuple[tuple[str, str], ...] = ( ("session_timeout", "--session-timeout"), @@ -21,8 +20,19 @@ ("wait", "--wait"), ("max_iterations", "--max-iterations"), ("review_patience", "--review-patience"), + ("max_external_iterations", "--max-external-iterations"), ("base_ref", "--base-ref"), ) +# The external-review scalars where 0 is a meaningful value, not an unset +# marker: review_patience 0 = patience disabled, max_external_iterations 0 = +# ralphex auto. They are emitted under the wider "not None and not empty" +# rule; every other scalar key keeps the historical drop of None/""/0. +_ZERO_PASSTHROUGH_KEYS: frozenset[str] = frozenset( + { + "review_patience", + "max_external_iterations", + } +) def _build_command(plan: str, options: dict[str, str | int | bool]) -> list[str]: @@ -33,7 +43,9 @@ def _build_command(plan: str, options: dict[str, str | int | bool]) -> list[str] maps each resolved option key to exactly one ralphex CLI flag per the fixed mapping in the run_ralphex contract. A bool key that is True emits a bare flag (False or absent -> omit); a scalar key emits ``-- `` - unless the value is None, an empty string, or 0. + unless the value is None, an empty string, or 0 — except the zero-valued + external flags (``review_patience``/``max_external_iterations``), whose 0 + is meaningful (disabled / ralphex auto) and IS passed. Args: plan: Path to the plan file, passed to ralphex positionally. @@ -53,7 +65,11 @@ def _build_command(plan: str, options: dict[str, str | int | bool]) -> list[str] for key, flag in _SCALAR_FLAGS: value = options.get(key) - if value not in (None, "", 0): + + if key in _ZERO_PASSTHROUGH_KEYS: + if value is not None and value != "": + cmd.extend([flag, str(value)]) + elif value not in (None, "", 0): cmd.extend([flag, str(value)]) return cmd @@ -84,7 +100,10 @@ def run_ralphex( plan: Path to the plan file (resolved by the caller). Passed verbatim to ralphex as the positional argument. options: Resolved ralphex options (precedence already applied by the - caller). Each key maps to exactly one ralphex CLI flag. + caller). Each key maps to exactly one ralphex CLI flag; a scalar + value of 0 is dropped except for the external flags + (``review_patience``/``max_external_iterations``), whose 0 is + meaningful and passed. dry_run: When True, print the assembled command to sys.stderr and return 0 without launching. The env layer is never printed. env: Optional environment layer ({str: str}) for this subprocess only. diff --git a/tests/ralphex/test_run_ralphex.py b/tests/ralphex/test_run_ralphex.py index d2a26e46..76805783 100644 --- a/tests/ralphex/test_run_ralphex.py +++ b/tests/ralphex/test_run_ralphex.py @@ -39,15 +39,15 @@ def test_build_command_basic_has_plan_and_config_dir(self) -> None: def test_bool_option_true_emits_bare_flag(self) -> None: """A True bool option emits the bare flag (no value).""" - cmd = _build_command("plan.md", {"worktree": True, "skip_finalize": True}) + cmd = _build_command("plan.md", {"review": True, "external_only": True}) - assert "--worktree" in cmd - assert "--skip-finalize" in cmd + assert "--review" in cmd + assert "-e" in cmd def test_bool_option_false_or_absent_omits_flag(self) -> None: """False or absent bool options are omitted.""" - assert "--worktree" not in _build_command("plan.md", {"worktree": False}) - assert "--worktree" not in _build_command("plan.md", {}) + assert "-e" not in _build_command("plan.md", {"external_only": False}) + assert "-e" not in _build_command("plan.md", {}) def test_scalar_option_emits_flag_with_value(self) -> None: """A scalar option emits -- .""" @@ -59,28 +59,54 @@ def test_scalar_option_emits_flag_with_value(self) -> None: assert "10" in cmd def test_scalar_option_zero_and_empty_omitted(self) -> None: - """Scalar values of None/""/0 are omitted (guards against 0==False regression). + """Historical scalar keys drop None/""/0 (guards against 0==False regression). - review_patience 0 is the documented patience-unset case: the resolver - forwards a CLI/config 0 verbatim and the launcher drops it, so ralphex - runs its own default (0 = disabled).""" - assert _build_command("plan.md", {"max_iterations": 0, "session_timeout": "", "review_patience": 0}) == [ + The external flags are the documented exception to this rule and are + covered by their own tests below.""" + assert _build_command("plan.md", {"max_iterations": 0, "session_timeout": ""}) == [ "ralphex", "plan.md", "--config-dir", ".ralphex/", ] + def test_external_scalar_zero_is_passed(self) -> None: + """review_patience 0 (disabled) and max_external_iterations 0 (ralphex + auto) are meaningful values and ARE emitted as 0.""" + cmd = _build_command("plan.md", {"review_patience": 0, "max_external_iterations": 0}) + + assert cmd == [ + "ralphex", + "plan.md", + "--config-dir", + ".ralphex/", + "--review-patience", + "0", + "--max-external-iterations", + "0", + ] + + @pytest.mark.parametrize( + "value", + [None, ""], + ids=["none", "empty_string"], + ) + def test_external_scalar_unset_omits_flag(self, value: str | int | None) -> None: + """Unset external scalars (None or empty string) add no token — only a + meaningful 0 passes the wider external emission rule.""" + cmd = _build_command("plan.md", {"review_patience": value, "max_external_iterations": value}) + + assert cmd == ["ralphex", "plan.md", "--config-dir", ".ralphex/"] + # The option -> flag mapping is a fixed hand-maintained table (the # run_ralphex contract). These pin every literal so a typo in the table # (or a key/flag desync) cannot silently drop a user-supplied option. @pytest.mark.parametrize( ("key", "flag"), [ - ("worktree", "--worktree"), - ("skip_finalize", "--skip-finalize"), ("review", "--review"), ("tasks_only", "--tasks-only"), + ("external_only", "-e"), ], ) def test_bool_flag_mapping_is_exact(self, key: str, flag: str) -> None: @@ -97,6 +123,7 @@ def test_bool_flag_mapping_is_exact(self, key: str, flag: str) -> None: ("wait", "--wait", "60s"), ("max_iterations", "--max-iterations", 10), ("review_patience", "--review-patience", 3), + ("max_external_iterations", "--max-external-iterations", 5), ("base_ref", "--base-ref", "origin/1.2.x"), ], ) @@ -120,17 +147,34 @@ def test_base_ref_unset_omits_flag(self, value: str | None) -> None: assert cmd == ["ralphex", "plan.md", "--config-dir", ".ralphex/"] assert "--base-ref" not in cmd + @pytest.mark.parametrize( + "options", + [ + {"worktree": True, "skip_finalize": True}, + {"review": True, "worktree": True, "skip_finalize": True}, + {"tasks_only": True, "external_only": True, "worktree": False, "skip_finalize": False}, + ], + ids=["retired_only", "mixed_with_live_keys", "retired_false"], + ) + def test_retired_flags_absent_from_any_command(self, options: dict[str, str | int | bool]) -> None: + """worktree/skip_finalize are retired table keys: no input produces + --worktree or --skip-finalize (keys outside the table are ignored).""" + cmd = _build_command("plan.md", options) + + assert "--worktree" not in cmd + assert "--skip-finalize" not in cmd + class TestRunRalphexLogic: def test_run_ralphex_dry_run_prints_command_and_returns_0(self, capsys: pytest.CaptureFixture[str]) -> None: """dry_run prints the assembled command to stderr and returns 0 without launching.""" - result = run_ralphex("plan.md", {"worktree": True}, dry_run=True) + result = run_ralphex("plan.md", {"review": True}, dry_run=True) assert result == 0 captured = capsys.readouterr() # The full joined argv is printed (not a stub): plan, config-dir, and # the resolved flag all appear. - assert "ralphex plan.md --config-dir .ralphex/ --worktree" in captured.err + assert "ralphex plan.md --config-dir .ralphex/ --review" in captured.err def test_run_ralphex_returns_0_on_success(self) -> None: """A successful (exit 0) ralphex invocation returns 0.""" @@ -200,7 +244,7 @@ def test_run_ralphex_dry_run_never_prints_env_values(self, capsys: pytest.Captur env layer values; no subprocess machinery is touched.""" with ( mock.patch.object(_run_ralphex_module.subprocess, "call", return_value=0) as mock_call, - mock.patch.object(_run_ralphex_module.shutil, "which", return_value="/usr/bin/ralphex") as mock_which, + mock.patch.object(_run_ralphex_module.shutil, "which", return_value="/usr/local/bin/ralphex") as mock_which, ): result = run_ralphex("plan.md", {"review": True}, True, env={"SECRET_TOKEN": "s3cr3t"}) @@ -218,7 +262,7 @@ def test_run_ralphex_env_none_and_empty_no_env_kwarg(self, env: dict[str, str] | test_run_ralphex_inherits_env_no_env_kwarg).""" with ( mock.patch.object(_run_ralphex_module.subprocess, "call", return_value=0) as mock_call, - mock.patch.object(_run_ralphex_module.shutil, "which", return_value="/usr/bin/ralphex"), + mock.patch.object(_run_ralphex_module.shutil, "which", return_value="/usr/local/bin/ralphex"), ): run_ralphex("p.md", {}, False, env=env) @@ -271,7 +315,7 @@ def test_run_ralphex_env_layer_illegal_variable_name_returns_1(self, capsys: pyt "call", side_effect=ValueError("illegal environment variable name"), ), - mock.patch.object(_run_ralphex_module.shutil, "which", return_value="/usr/bin/ralphex"), + mock.patch.object(_run_ralphex_module.shutil, "which", return_value="/usr/local/bin/ralphex"), ): result = run_ralphex("plan.md", {}, False, env={"A=B": "x"}) @@ -308,7 +352,7 @@ def test_run_ralphex_env_layer_oversized_value_returns_1(self, capsys: pytest.Ca "call", side_effect=OSError(7, "Argument list too long", "ralphex"), ), - mock.patch.object(_run_ralphex_module.shutil, "which", return_value="/usr/bin/ralphex"), + mock.patch.object(_run_ralphex_module.shutil, "which", return_value="/usr/local/bin/ralphex"), ): result = run_ralphex("plan.md", {}, False, env={"BIG": "x" * 300000}) @@ -340,3 +384,55 @@ def test_run_ralphex_maps_new_bool_flags(self) -> None: assert "--review" not in neither_argv assert "--tasks-only" not in neither_argv + + def test_run_ralphex_external_flags_and_zero_rule(self) -> None: + """The external-review surface maps exactly: external_only emits -e, + the two external scalars pass 0 verbatim, the historical + max_iterations 0 is dropped, base_ref carries its value, and the + retired flags never appear.""" + with ( + mock.patch.object(_run_ralphex_module.subprocess, "call", return_value=0) as mock_call, + mock.patch.object(_run_ralphex_module.shutil, "which", return_value="/usr/bin/ralphex"), + ): + run_ralphex( + "p.md", + { + "external_only": True, + "review_patience": 0, + "max_external_iterations": 0, + "max_iterations": 0, + "base_ref": "main", + }, + False, + ) + + argv = list(mock_call.call_args.args[0]) + assert argv == [ + "ralphex", + "p.md", + "--config-dir", + ".ralphex/", + "-e", + "--review-patience", + "0", + "--max-external-iterations", + "0", + "--base-ref", + "main", + ] + assert "--worktree" not in argv + assert "--skip-finalize" not in argv + + def test_max_iterations_zero_dropped_by_launcher(self, capsys: pytest.CaptureFixture[str]) -> None: + """dry-run print: a 0 max_iterations never reaches the printed command + while a 0 review_patience does (the asymmetric zero rule, both arms).""" + run_ralphex("p.md", {"tasks_only": True, "max_iterations": 0}, dry_run=True) + + dropped = capsys.readouterr() + assert "--max-iterations" not in dropped.err + assert "--tasks-only" in dropped.err + + run_ralphex("p.md", {"review_patience": 0}, dry_run=True) + + passed = capsys.readouterr() + assert "--review-patience 0" in passed.err From 2cfcafa75ede2740b2ba05cf575cac325923d15e Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 20:31:06 +0000 Subject: [PATCH 087/205] feat: build hooks zone skeleton with test scaffolding (Task 4) --- .goga/history/2026/add-hooks-to-build/plan.md | 10 +++++----- goga/build/hooks/__init__.py | 20 +++++++++++++++++++ tests/build/hooks/__init__.py | 0 tests/build/hooks/conftest.py | 12 +++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 goga/build/hooks/__init__.py create mode 100644 tests/build/hooks/__init__.py create mode 100644 tests/build/hooks/conftest.py diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index 89ef003e..ea35388d 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -679,11 +679,11 @@ test package with the fixture re-export. Locations: **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] Create `goga/build/hooks/__init__.py` — module docstring naming the zone (the fact vocabulary, the gate, the checkpoint surface over the platform facade; the never-stop deviation), `from __future__ import annotations`, empty `__all__: list[str]` -- [ ] Create `tests/build/hooks/__init__.py` (empty, per the each-test-directory-has-`__init__.py` rule) -- [ ] Create `tests/build/hooks/conftest.py` re-exporting the platform boundary fixtures from `tests/hooks/conftest.py` (`pin_package_environment`, `install_tool_package`) — mirror `tests/pipeline/hooks/conftest.py` -- [ ] Verify importability: `python -c "import goga.build.hooks"` (from the repo root, in `.venv`) -- [ ] Lint: `ruff check goga/build tests/build` — fix formatting if necessary +- [x] Create `goga/build/hooks/__init__.py` — module docstring naming the zone (the fact vocabulary, the gate, the checkpoint surface over the platform facade; the never-stop deviation), `from __future__ import annotations`, empty `__all__: list[str]` +- [x] Create `tests/build/hooks/__init__.py` (empty, per the each-test-directory-has-`__init__.py` rule) +- [x] Create `tests/build/hooks/conftest.py` re-exporting the platform boundary fixtures from `tests/hooks/conftest.py` (`pin_package_environment`, `install_tool_package`) — mirror `tests/pipeline/hooks/conftest.py` +- [x] Verify importability: `python -c "import goga.build.hooks"` (from the repo root, in `.venv`) +- [x] Lint: `ruff check goga/build tests/build` — fix formatting if necessary ### Task 5: Zone fact vocabulary — `facts.py` (TDD coding) diff --git a/goga/build/hooks/__init__.py b/goga/build/hooks/__init__.py new file mode 100644 index 00000000..24392228 --- /dev/null +++ b/goga/build/hooks/__init__.py @@ -0,0 +1,20 @@ +"""Hooks zone of the build domain — the checkpoint surface of build runs. + +The zone owns the fact vocabulary of the run events (work identity, moment, +stage facts), the read-only contexts of the five moments, the +verdict-collecting gate view, and the ``BuildHooks`` checkpoint surface +delivering the domain's hard gate ``build/validate_build`` and the four soft +notifications ``build/build_started`` / ``build/pass_started`` / +``build/pass_completed`` / ``build/build_completed`` over the platform +facade. The gate follows the staged per-tool walk of the platform with one +domain-local deviation: the walk never stops early — every subscribed tool's +validation hooks run to completion and the vetoes are collected into one +verdict. + +Built incrementally: each entity task adds its module's import and +``__all__`` entry. +""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/tests/build/hooks/__init__.py b/tests/build/hooks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/build/hooks/conftest.py b/tests/build/hooks/conftest.py new file mode 100644 index 00000000..72dc0b39 --- /dev/null +++ b/tests/build/hooks/conftest.py @@ -0,0 +1,12 @@ +"""Shared fixtures of the build hooks zone tests — the platform boundary. + +Re-exports the two boundary fixtures of the hooks platform tests +(``pin_package_environment`` / ``install_tool_package``) so the zone suites +pin the same two outside-world points — the ``packages_distributions`` read +and the ``sys.modules`` entry of a ``goga_tool_*`` package — with the +platform code under test running for real. +""" + +from __future__ import annotations + +from tests.hooks.conftest import install_tool_package, pin_package_environment # noqa: F401 From 4eff397e0c6e0ff8e2c4b634893d6d9e7a49270e Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 20:34:10 +0000 Subject: [PATCH 088/205] feat: zone fact vocabulary facts.py with facade exports (Task 5) --- .goga/history/2026/add-hooks-to-build/plan.md | 20 +- goga/build/hooks/__init__.py | 23 +- goga/build/hooks/facts.py | 168 ++++++++++ tests/build/hooks/test_facts.py | 287 ++++++++++++++++++ 4 files changed, 486 insertions(+), 12 deletions(-) create mode 100644 goga/build/hooks/facts.py create mode 100644 tests/build/hooks/test_facts.py diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index ea35388d..8f2a8024 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -720,16 +720,16 @@ Violation(tool: str, hook: str, reason: str) GateVerdict(violations: list[Violation]) # property approved -> bool ``` -- [ ] **Declaration**: Task 5 — zone fact vocabulary -- [ ] **Contract tests**: in `tests/build/hooks/test_facts.py` — each of the seven names importable from `goga.build.hooks`; each passes `is_kw_only_dataclass`; `GateVerdict.approved` exists as a property (expected to fail at this stage) -- [ ] **Code**: create `goga/build/hooks/facts.py` with the seven dataclasses (kw_only, non-frozen, zone-style module docstring, Google docstrings carrying the manifest property descriptions) -- [ ] **Code**: add the seven imports + `__all__` entries to `goga/build/hooks/__init__.py` -- [ ] **Interface verification**: `pytest tests/build/hooks/test_facts.py -x -q` — contract tests pass -- [ ] **Logic tests**: `GateVerdict([]).approved is True`; `GateVerdict([Violation("t", "h", "r")]).approved is False`; `WorkIdentity("feature-x")` branch-only form gives `slug is None and year is None`; `StageFacts` review-only members accept None on the tasks part; `AdditionalFacts` stores 0 verbatim (`patience=0` stays 0) -- [ ] **Debugging**: `pytest tests/build/hooks/ -x -q` — fix implementation code until all tests pass -- [ ] **Contract re-verification**: the seven names importable from the facade `goga.build.hooks` -- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary -- [ ] **Completion**: mark all checkboxes of this task complete +- [x] **Declaration**: Task 5 — zone fact vocabulary +- [x] **Contract tests**: in `tests/build/hooks/test_facts.py` — each of the seven names importable from `goga.build.hooks`; each passes `is_kw_only_dataclass`; `GateVerdict.approved` exists as a property (expected to fail at this stage) +- [x] **Code**: create `goga/build/hooks/facts.py` with the seven dataclasses (kw_only, non-frozen, zone-style module docstring, Google docstrings carrying the manifest property descriptions) +- [x] **Code**: add the seven imports + `__all__` entries to `goga/build/hooks/__init__.py` +- [x] **Interface verification**: `pytest tests/build/hooks/test_facts.py -x -q` — contract tests pass +- [x] **Logic tests**: `GateVerdict([]).approved is True`; `GateVerdict([Violation("t", "h", "r")]).approved is False`; `WorkIdentity("feature-x")` branch-only form gives `slug is None and year is None`; `StageFacts` review-only members accept None on the tasks part; `AdditionalFacts` stores 0 verbatim (`patience=0` stays 0) +- [x] **Debugging**: `pytest tests/build/hooks/ -x -q` — fix implementation code until all tests pass +- [x] **Contract re-verification**: the seven names importable from the facade `goga.build.hooks` +- [x] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [x] **Completion**: mark all checkboxes of this task complete ### Task 6: Zone read-only contexts — `contexts.py` (TDD coding) diff --git a/goga/build/hooks/__init__.py b/goga/build/hooks/__init__.py index 24392228..5ae606cc 100644 --- a/goga/build/hooks/__init__.py +++ b/goga/build/hooks/__init__.py @@ -12,9 +12,28 @@ verdict. Built incrementally: each entity task adds its module's import and -``__all__`` entry. +``__all__`` entry. With the fact vocabulary landed, the seven fact names +of the zone are re-exported here. """ from __future__ import annotations -__all__: list[str] = [] +from .facts import ( + AdditionalFacts, + BuildMoment, + GateVerdict, + RelocationOutcome, + StageFacts, + Violation, + WorkIdentity, +) + +__all__: list[str] = [ + "AdditionalFacts", + "BuildMoment", + "GateVerdict", + "RelocationOutcome", + "StageFacts", + "Violation", + "WorkIdentity", +] diff --git a/goga/build/hooks/facts.py b/goga/build/hooks/facts.py new file mode 100644 index 00000000..55540851 --- /dev/null +++ b/goga/build/hooks/facts.py @@ -0,0 +1,168 @@ +"""The fact vocabulary of the build domain run events — pure fact carriers. + +Seven dataclasses shared by every context of the zone: ``WorkIdentity`` +(the identity of the current work — the branch, with the topic slug and +year when the branch hosts a topic), ``BuildMoment`` (the uniform envelope +of every build context), ``AdditionalFacts`` (the delivered mirror of the +external-review block), ``StageFacts`` (the resolved facts of one stage +part of the run), ``RelocationOutcome`` (the outcome of the plan +relocation attempt), ``Violation`` (one collected veto of the gate walk), +and ``GateVerdict`` (the collected verdict of the gate walk — data only, +acting on it belongs to the operation). + +Nothing is read or derived here — the constructing operation passes +resolved values with inheritance already applied. Env values never appear +anywhere: ``StageFacts.env`` carries names only. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(kw_only=True) +class WorkIdentity: + """The identity of the current work — the branch and its hosted topic. + + Args: + branch: the current branch name as resolved by the operation + (``"unknown"`` when resolution failed). + slug: the normalized topic slug — present when the branch hosts a + topic; ``None`` in the branch-only form. + year: the resolved year as four digits — present when the branch + hosts a topic; ``None`` in the branch-only form. + """ + + branch: str + slug: str | None = None + year: str | None = None + + +@dataclass(kw_only=True) +class BuildMoment: + """The uniform envelope of every build context. + + Args: + plan: the plan file path of the run. + work: the current work identity. + dry_run: ``True`` when the run rehearses the cycle without + launching passes. + """ + + plan: str + work: WorkIdentity + dry_run: bool + + +@dataclass(kw_only=True) +class AdditionalFacts: + """The delivered mirror of the external-review block. + + The documented facts of ``build.review.additional`` for tool authors. + + Args: + agent: the external review agent name (after inheritance), or + ``None`` when unset. + patience: the external-review stop threshold — ``0`` means + disabled; ``None`` when unset. + max_iterations: the external review iteration cap — ``0`` means + ralphex auto; ``None`` when unset. + """ + + agent: str | None + patience: int | None + max_iterations: int | None + + +@dataclass(kw_only=True) +class StageFacts: + """The resolved facts of one stage part of the run. + + The delivered projection of the operation's resolved settings for that + stage — the review-only members are ``None`` on the tasks part. + + Args: + stage: the stage identity — exactly ``tasks`` or ``review``. + agent: the executor agent name of the stage. + env: the env presence of the stage layer as names — values never + appear; an empty list means no env layer. + max_iterations: the resolved iteration cap of the stage. + session_timeout: the resolved session timeout of the stage. + idle_timeout: the resolved idle timeout of the stage. + wait: the resolved rate-limit wait of the stage. + roles: the declared reviewer composition — review stage only; + ``None`` on the tasks part. + base_ref: the review diff base — review stage only; ``None`` on + the tasks part. + strategy: the resolved review strategy (full, medium, short) — + review stage only; ``None`` on the tasks part. + finalize: the full finalize prompt text when configured — + ``None`` when unset or on the tasks part. + additional: the external-review facts — review stage only; + ``None`` on the tasks part. + """ + + stage: str + agent: str | None + env: list[str] + max_iterations: int | None + session_timeout: str | None + idle_timeout: str | None + wait: str | None + roles: list[str] | None + base_ref: str | None + strategy: str | None + finalize: str | None + additional: AdditionalFacts | None + + +@dataclass(kw_only=True) +class RelocationOutcome: + """The outcome of the plan relocation attempt. + + Args: + moved: ``True`` when the plan was relocated into the completed + directory. + destination: the relocation destination when moved, ``None`` when + not moved. + """ + + moved: bool + destination: str | None + + +@dataclass(kw_only=True) +class Violation: + """One collected veto of the gate walk. + + Args: + tool: the tool identity assigned by the platform. + hook: the hook name that vetoed or crashed. + reason: the veto reason — a hook-authored message or the crash + reason; never a raw traceback. + """ + + tool: str + hook: str + reason: str + + +@dataclass(kw_only=True) +class GateVerdict: + """The collected verdict of the gate walk. + + Every veto of every subscribed tool, in enumeration order — the + verdict is data only; acting on it (the merged error, the exit code) + belongs to the operation. + + Args: + violations: the collected violations; an empty list means + approved. + """ + + violations: list[Violation] + + @property + def approved(self) -> bool: + """``True`` when no violation was collected — the run may proceed.""" + return not self.violations diff --git a/tests/build/hooks/test_facts.py b/tests/build/hooks/test_facts.py new file mode 100644 index 00000000..bb901859 --- /dev/null +++ b/tests/build/hooks/test_facts.py @@ -0,0 +1,287 @@ +"""Contract and logic tests for the entities declared in +``goga/build/hooks/CODEMANIFEST`` with ``location: facts.py``: + +- ``WorkIdentity(branch, slug, year)`` — the identity of the current work +- ``BuildMoment(plan, work, dry_run)`` — the uniform envelope of every + build context +- ``StageFacts(stage, agent, env, ...)`` — the resolved facts of one stage + part of the run +- ``AdditionalFacts(agent, patience, max_iterations)`` — the delivered + mirror of the external-review block +- ``RelocationOutcome(moved, destination)`` — the outcome of the plan + relocation attempt +- ``Violation(tool, hook, reason)`` — one collected veto of the gate walk +- ``GateVerdict(violations)`` — the collected verdict of the gate walk + +Supported data only — no mocks, no filesystem: the models are pure fact +carriers, every resolution happens in the constructing operation and env +values never appear (names only). +""" + +from __future__ import annotations + +import dataclasses + +import pytest +from goga.build.hooks import ( + AdditionalFacts, + BuildMoment, + GateVerdict, + RelocationOutcome, + StageFacts, + Violation, + WorkIdentity, +) + +from tests.conftest import is_kw_only_dataclass + +FACT_TYPES: tuple[type, ...] = ( + WorkIdentity, + BuildMoment, + StageFacts, + AdditionalFacts, + RelocationOutcome, + Violation, + GateVerdict, +) + + +def _field_defaults(cls: type) -> list[tuple[str, object]]: + """(name, default) per declared field — ``MISSING`` for required fields.""" + return [(field.name, field.default) for field in dataclasses.fields(cls)] + + +def _tasks_facts(**overrides: object) -> StageFacts: + """A tasks-part ``StageFacts`` — the review-only members None.""" + values: dict[str, object] = { + "stage": "tasks", + "agent": "claude", + "env": ["A", "B"], + "max_iterations": 9, + "session_timeout": "30m", + "idle_timeout": "5m", + "wait": "1m", + "roles": None, + "base_ref": None, + "strategy": None, + "finalize": None, + "additional": None, + } + values.update(overrides) + + return StageFacts(**values) # type: ignore[arg-type] + + +# --- Contract tests --- + + +class TestFactsContract: + def test_entities_are_importable_from_the_zone_facade(self) -> None: + """All seven fact types live on the zone package and its ``__all__``.""" + import goga.build.hooks as zone + + for cls in FACT_TYPES: + assert getattr(zone, cls.__name__) is cls + + for name in (cls.__name__ for cls in FACT_TYPES): + assert name in zone.__all__ + + def test_fact_types_are_kw_only_and_non_frozen(self) -> None: + """kw_only dataclasses; the delivery proxy, not frozen-ness, closes mutability.""" + for cls in FACT_TYPES: + assert dataclasses.is_dataclass(cls) + assert is_kw_only_dataclass(cls) + assert not cls.__dataclass_params__.frozen + + with pytest.raises(TypeError): + WorkIdentity("feature-x", "feature-x", "2026") # type: ignore[misc] + + with pytest.raises(TypeError): + GateVerdict([]) # type: ignore[misc] + + def test_gate_verdict_approved_is_a_property(self) -> None: + """``approved`` is a computed property, not a stored field.""" + assert isinstance(GateVerdict.approved, property) + assert "approved" not in [field.name for field in dataclasses.fields(GateVerdict)] + + def test_work_identity_carries_exactly_the_declared_fields(self) -> None: + """``branch, slug=None, year=None`` — names, order, defaults.""" + assert _field_defaults(WorkIdentity) == [ + ("branch", dataclasses.MISSING), + ("slug", None), + ("year", None), + ] + + def test_build_moment_carries_exactly_the_declared_fields(self) -> None: + """``plan, work, dry_run`` — all required, no defaults.""" + assert _field_defaults(BuildMoment) == [ + ("plan", dataclasses.MISSING), + ("work", dataclasses.MISSING), + ("dry_run", dataclasses.MISSING), + ] + + def test_stage_facts_carries_exactly_the_declared_fields(self) -> None: + """Twelve fields in the declared order — all required, no defaults.""" + assert _field_defaults(StageFacts) == [ + ("stage", dataclasses.MISSING), + ("agent", dataclasses.MISSING), + ("env", dataclasses.MISSING), + ("max_iterations", dataclasses.MISSING), + ("session_timeout", dataclasses.MISSING), + ("idle_timeout", dataclasses.MISSING), + ("wait", dataclasses.MISSING), + ("roles", dataclasses.MISSING), + ("base_ref", dataclasses.MISSING), + ("strategy", dataclasses.MISSING), + ("finalize", dataclasses.MISSING), + ("additional", dataclasses.MISSING), + ] + + def test_remaining_facts_carry_exactly_the_declared_fields(self) -> None: + """``AdditionalFacts``, ``RelocationOutcome``, ``Violation``, ``GateVerdict``.""" + assert _field_defaults(AdditionalFacts) == [ + ("agent", dataclasses.MISSING), + ("patience", dataclasses.MISSING), + ("max_iterations", dataclasses.MISSING), + ] + assert _field_defaults(RelocationOutcome) == [ + ("moved", dataclasses.MISSING), + ("destination", dataclasses.MISSING), + ] + assert _field_defaults(Violation) == [ + ("tool", dataclasses.MISSING), + ("hook", dataclasses.MISSING), + ("reason", dataclasses.MISSING), + ] + assert _field_defaults(GateVerdict) == [ + ("violations", dataclasses.MISSING), + ] + + +# --- Logic tests --- + + +class TestGateVerdict: + def test_empty_verdict_is_approved(self) -> None: + """No violations — the run may proceed.""" + verdict = GateVerdict(violations=[]) + + assert verdict.approved is True + assert verdict.violations == [] + + def test_violated_verdict_is_not_approved(self) -> None: + """Any collected violation blocks the run.""" + verdict = GateVerdict(violations=[Violation(tool="t", hook="h", reason="r")]) + + assert verdict.approved is False + assert verdict.violations == [Violation(tool="t", hook="h", reason="r")] + + +class TestWorkIdentity: + def test_branch_only_form_leaves_slug_and_year_none(self) -> None: + """``WorkIdentity(branch=...)`` alone serves a branch hosting no topic.""" + work = WorkIdentity(branch="feature-x") + + assert work.branch == "feature-x" + assert work.slug is None + assert work.year is None + + def test_hosting_form_carries_slug_and_year(self) -> None: + """The topic-hosting form carries the normalized slug and the year.""" + work = WorkIdentity(branch="feature-x", slug="feature-x", year="2026") + + assert work.branch == "feature-x" + assert work.slug == "feature-x" + assert work.year == "2026" + + +class TestBuildMoment: + def test_carries_the_envelope_values_verbatim(self) -> None: + """The plan path, the work identity, and the rehearsal fact.""" + work = WorkIdentity(branch="add-hooks-to-build") + moment = BuildMoment(plan="docs/plans/plan.md", work=work, dry_run=True) + + assert moment.plan == "docs/plans/plan.md" + assert moment.work is work + assert moment.dry_run is True + + +class TestStageFacts: + def test_tasks_part_accepts_none_review_members(self) -> None: + """The review-only members are None on the tasks part.""" + facts = _tasks_facts() + + assert facts.stage == "tasks" + assert facts.agent == "claude" + assert facts.env == ["A", "B"] + assert facts.roles is None + assert facts.base_ref is None + assert facts.strategy is None + assert facts.finalize is None + assert facts.additional is None + + def test_review_part_carries_the_full_fact_set(self) -> None: + """The review part carries the review-only members and the additional mirror.""" + additional = AdditionalFacts(agent="codex", patience=2, max_iterations=4) + facts = _tasks_facts( + stage="review", + agent="codex", + env=[], + roles=["quality", "testing"], + base_ref="main", + strategy="short", + finalize="Final pass: merge the review.", + additional=additional, + ) + + assert facts.stage == "review" + assert facts.env == [] + assert facts.roles == ["quality", "testing"] + assert facts.base_ref == "main" + assert facts.strategy == "short" + assert facts.finalize == "Final pass: merge the review." + assert facts.additional is additional + + def test_env_carries_names_only(self) -> None: + """``env`` is the presence list of names — values never appear.""" + facts = _tasks_facts(env=["API_KEY", "HTTP_PROXY"]) + + assert facts.env == ["API_KEY", "HTTP_PROXY"] + + +class TestAdditionalFacts: + def test_stores_zero_verbatim(self) -> None: + """0 is a meaningful value, not an unset marker — it stays 0.""" + facts = AdditionalFacts(agent="codex", patience=0, max_iterations=0) + + assert facts.agent == "codex" + assert facts.patience == 0 + assert facts.max_iterations == 0 + + def test_unset_members_are_none(self) -> None: + """The absent form carries None for every member.""" + facts = AdditionalFacts(agent=None, patience=None, max_iterations=None) + + assert facts.agent is None + assert facts.patience is None + assert facts.max_iterations is None + + +class TestOutcomeAndViolation: + def test_relocation_outcome_carries_both_forms(self) -> None: + """The moved form carries the destination; the not-moved form None.""" + moved = RelocationOutcome(moved=True, destination="docs/plans/completed/plan.md") + stayed = RelocationOutcome(moved=False, destination=None) + + assert moved.moved is True + assert moved.destination == "docs/plans/completed/plan.md" + assert stayed.moved is False + assert stayed.destination is None + + def test_violation_carries_the_triple_verbatim(self) -> None: + """Tool, hook, reason — the reason is a message, never a traceback.""" + violation = Violation(tool="goga_tool_a", hook="policy", reason="no deploys on friday") + + assert violation.tool == "goga_tool_a" + assert violation.hook == "policy" + assert violation.reason == "no deploys on friday" From 133828b176279f08e1c7817ab22425574bbeda34 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 20:36:31 +0000 Subject: [PATCH 089/205] feat: zone read-only contexts contexts.py with facade exports (Task 6) --- .goga/history/2026/add-hooks-to-build/plan.md | 20 +- goga/build/hooks/__init__.py | 11 +- goga/build/hooks/contexts.py | 146 +++++++++ tests/build/hooks/test_contexts.py | 278 ++++++++++++++++++ 4 files changed, 443 insertions(+), 12 deletions(-) create mode 100644 goga/build/hooks/contexts.py create mode 100644 tests/build/hooks/test_contexts.py diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index 8f2a8024..dda53e96 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -757,16 +757,16 @@ an empty or whitespace-only reason is stored as given. Constraints: no cancellation, redirect, or deferral — a veto stops the run through the collected verdict only. -- [ ] **Declaration**: Task 6 — zone read-only contexts -- [ ] **Contract tests**: in `tests/build/hooks/test_contexts.py` — the five names importable from `goga.build.hooks`; kw_only; `BuildValidation.veto` callable (expected to fail at this stage) -- [ ] **Code**: create `goga/build/hooks/contexts.py` — the five contexts + `veto()` writing `self._veto` (whole replacement) with the private `init=False` buffer field -- [ ] **Code**: add the five imports + `__all__` entries to the facade -- [ ] **Interface verification**: `pytest tests/build/hooks/test_contexts.py -x -q` — contract tests pass -- [ ] **Logic tests**: `veto("one")` then `veto("two")` → buffer holds exactly `"two"` (whole replacement); `veto(" ")` stores the whitespace verbatim; read-only fields (`moment`, `tasks`, `review`, `skip`, `facts`, `exit_code`, `stages`, `relocation`, `statuses`) carry the constructed values unchanged -- [ ] **Debugging**: `pytest tests/build/hooks/ -x -q` — fix implementation code until all tests pass -- [ ] **Contract re-verification**: the twelve fact+context names importable from the facade -- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary -- [ ] **Completion**: mark all checkboxes of this task complete +- [x] **Declaration**: Task 6 — zone read-only contexts +- [x] **Contract tests**: in `tests/build/hooks/test_contexts.py` — the five names importable from `goga.build.hooks`; kw_only; `BuildValidation.veto` callable (expected to fail at this stage) +- [x] **Code**: create `goga/build/hooks/contexts.py` — the five contexts + `veto()` writing `self._veto` (whole replacement) with the private `init=False` buffer field +- [x] **Code**: add the five imports + `__all__` entries to the facade +- [x] **Interface verification**: `pytest tests/build/hooks/test_contexts.py -x -q` — contract tests pass +- [x] **Logic tests**: `veto("one")` then `veto("two")` → buffer holds exactly `"two"` (whole replacement); `veto(" ")` stores the whitespace verbatim; read-only fields (`moment`, `tasks`, `review`, `skip`, `facts`, `exit_code`, `stages`, `relocation`, `statuses`) carry the constructed values unchanged +- [x] **Debugging**: `pytest tests/build/hooks/ -x -q` — fix implementation code until all tests pass +- [x] **Contract re-verification**: the twelve fact+context names importable from the facade +- [x] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [x] **Completion**: mark all checkboxes of this task complete ### Task 7: Checkpoint surface `BuildHooks` — `events.py` + facade completion (TDD coding) diff --git a/goga/build/hooks/__init__.py b/goga/build/hooks/__init__.py index 5ae606cc..50dec9b5 100644 --- a/goga/build/hooks/__init__.py +++ b/goga/build/hooks/__init__.py @@ -12,12 +12,14 @@ verdict. Built incrementally: each entity task adds its module's import and -``__all__`` entry. With the fact vocabulary landed, the seven fact names -of the zone are re-exported here. +``__all__`` entry. With the fact vocabulary and the run-event contexts +landed, the twelve fact and context names of the zone are re-exported +here. """ from __future__ import annotations +from .contexts import BuildCompleted, BuildStarted, BuildValidation, PassCompleted, PassStarted from .facts import ( AdditionalFacts, BuildMoment, @@ -30,8 +32,13 @@ __all__: list[str] = [ "AdditionalFacts", + "BuildCompleted", "BuildMoment", + "BuildStarted", + "BuildValidation", "GateVerdict", + "PassCompleted", + "PassStarted", "RelocationOutcome", "StageFacts", "Violation", diff --git a/goga/build/hooks/contexts.py b/goga/build/hooks/contexts.py new file mode 100644 index 00000000..67e6fce7 --- /dev/null +++ b/goga/build/hooks/contexts.py @@ -0,0 +1,146 @@ +"""The run-event contexts of the build domain — read-only fact bundles. + +Five dataclasses carrying the facts a hook observes at the build +checkpoints: ``BuildValidation`` (the gate's per-tool view — the same +read-only facts plus the veto buffer of that one tool), ``BuildStarted`` +(the resolved facts the gate saw, immediately before the first pass +launch), ``PassStarted`` / ``PassCompleted`` (the facts of a pass at its +start and completion moments — completion is a fact, not a success +claim), and ``BuildCompleted`` (the outcome facts of the started run). + +Read-only facts of the observed moment — a hook observes and cannot +alter. The single write channel is :meth:`BuildValidation.veto`, and it +buffers into the tool's private buffer alone: the call changes nothing +until the gate walk collects it, and the delivery proxy closes every +context against attribute writes. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .facts import BuildMoment, RelocationOutcome, StageFacts + + +@dataclass(kw_only=True) +class BuildValidation: + """The gate's delivered view of one tool — the facts plus the veto buffer. + + The read-only facts of the run about to start, delivered to every + validation hook of one tool, plus the veto buffer belonging to that + tool alone. + + Args: + moment: the uniform envelope of the run. + tasks: the resolved facts of the tasks stage. + review: the resolved facts of the review stage — always present, + including a skipped review; the facts describe the resolved + settings, not the execution. + skip: the resolved review skip state of the run. + """ + + moment: BuildMoment + tasks: StageFacts + review: StageFacts + skip: bool + + _veto: str | None = field(init=False, default=None, repr=False) + + def veto(self, reason: str) -> None: + """Buffer this tool's veto of the run. + + The replacement is whole — a later call replaces the earlier + reason. The view records no hook identity: the walk attributes + the veto to a hook by observing the buffer change around each + call. The call changes nothing until the walk collects it — it + does not cancel, redirect, or defer the operation; a veto stops + the run through the collected verdict only. + + Args: + reason: the human-readable violation reason — an empty or + whitespace-only reason is stored as given, and the + merged error renders it verbatim. + """ + self._veto = reason + + +@dataclass(kw_only=True) +class BuildStarted: + """The read-only context of the start notification. + + The same resolved facts the gate saw, delivered immediately before + the first pass launch — a hook observes and cannot alter. + + Args: + moment: the uniform envelope of the run. + tasks: the resolved facts of the tasks stage. + review: the resolved facts of the review stage. + skip: the resolved review skip state of the run. + """ + + moment: BuildMoment + tasks: StageFacts + review: StageFacts + skip: bool + + +@dataclass(kw_only=True) +class PassStarted: + """The read-only context of the pass-start notification. + + The facts of the pass about to launch — a hook observes and cannot + alter. + + Args: + moment: the uniform envelope of the run. + facts: the stage facts of the pass about to launch. + """ + + moment: BuildMoment + facts: StageFacts + + +@dataclass(kw_only=True) +class PassCompleted: + """The read-only context of the pass-completion notification. + + The facts of the finished pass plus its actual exit code — + completion is a fact, not a success claim. + + Args: + moment: the uniform envelope of the run. + facts: the stage facts of the finished pass. + exit_code: the actual exit code of the pass — zero, non-zero, + or a spawn-failure code. + """ + + moment: BuildMoment + facts: StageFacts + exit_code: int + + +@dataclass(kw_only=True) +class BuildCompleted: + """The read-only context of the completion notification. + + The outcome facts of the started run at the completion moment — the + artifact to history-status integration builds from these facts + alone. + + Args: + moment: the uniform envelope of the run. + exit_code: the final exit code of the run — the last executed + pass's code. + stages: the executed stage sequence in execution order — a + skipped review is absent. + relocation: the outcome of the plan relocation attempt. + statuses: the work's current history statuses at the completion + moment, recomputed after the relocation attempt; an empty + list in the branch-only form. + """ + + moment: BuildMoment + exit_code: int + stages: list[str] + relocation: RelocationOutcome + statuses: list[str] diff --git a/tests/build/hooks/test_contexts.py b/tests/build/hooks/test_contexts.py new file mode 100644 index 00000000..693145dd --- /dev/null +++ b/tests/build/hooks/test_contexts.py @@ -0,0 +1,278 @@ +"""Contract and logic tests for the entities declared in +``goga/build/hooks/CODEMANIFEST`` with ``location: contexts.py``: + +- ``BuildValidation(moment, tasks, review, skip)`` — the gate's per-tool + view with the veto buffer +- ``BuildStarted(moment, tasks, review, skip)`` — the read-only context + of the start notification +- ``PassStarted(moment, facts)`` — the read-only context of the + pass-start notification +- ``PassCompleted(moment, facts, exit_code)`` — the read-only context of + the pass-completion notification (completion is a fact, not a success + claim) +- ``BuildCompleted(moment, exit_code, stages, relocation, statuses)`` — + the read-only context of the completion notification + +Supported data only — no mocks, no filesystem: the contexts are read-only +views over caller-resolved facts; the veto buffer of ``BuildValidation`` +is the single write channel and belongs to one tool alone. +""" + +from __future__ import annotations + +import dataclasses + +import pytest +from goga.build.hooks import ( + AdditionalFacts, + BuildCompleted, + BuildMoment, + BuildStarted, + BuildValidation, + PassCompleted, + PassStarted, + RelocationOutcome, + StageFacts, + WorkIdentity, +) + +from tests.conftest import is_kw_only_dataclass + +CONTEXT_TYPES: tuple[type, ...] = ( + BuildValidation, + BuildStarted, + PassStarted, + PassCompleted, + BuildCompleted, +) + + +def _field_names(cls: type) -> list[str]: + """The declared field names — the private init=False buffer included.""" + return [field.name for field in dataclasses.fields(cls)] + + +def _tasks_facts(**overrides: object) -> StageFacts: + """A tasks-part ``StageFacts`` — the review-only members None.""" + values: dict[str, object] = { + "stage": "tasks", + "agent": "claude", + "env": ["A", "B"], + "max_iterations": 9, + "session_timeout": "30m", + "idle_timeout": "5m", + "wait": "1m", + "roles": None, + "base_ref": None, + "strategy": None, + "finalize": None, + "additional": None, + } + values.update(overrides) + + return StageFacts(**values) # type: ignore[arg-type] + + +def _review_facts(**overrides: object) -> StageFacts: + """A review-part ``StageFacts`` — the review-only members populated.""" + values: dict[str, object] = { + "stage": "review", + "agent": "codex", + "env": [], + "max_iterations": None, + "session_timeout": "30m", + "idle_timeout": "5m", + "wait": "1m", + "roles": ["quality"], + "base_ref": "main", + "strategy": "medium", + "finalize": None, + "additional": AdditionalFacts(agent="codex", patience=None, max_iterations=None), + } + values.update(overrides) + + return StageFacts(**values) # type: ignore[arg-type] + + +def _moment() -> BuildMoment: + """The uniform envelope — a branch-only work identity.""" + return BuildMoment( + plan="docs/plans/plan.md", + work=WorkIdentity(branch="add-hooks-to-build"), + dry_run=False, + ) + + +# --- Contract tests --- + + +class TestContextsContract: + def test_entities_are_importable_from_the_zone_facade(self) -> None: + """All five context types live on the zone package and its ``__all__``.""" + import goga.build.hooks as zone + + for cls in CONTEXT_TYPES: + assert getattr(zone, cls.__name__) is cls + + for name in (cls.__name__ for cls in CONTEXT_TYPES): + assert name in zone.__all__ + + def test_context_types_are_kw_only_and_non_frozen(self) -> None: + """kw_only dataclasses; the delivery proxy, not frozen-ness, closes mutability.""" + for cls in CONTEXT_TYPES: + assert dataclasses.is_dataclass(cls) + assert is_kw_only_dataclass(cls) + assert not cls.__dataclass_params__.frozen + + with pytest.raises(TypeError): + BuildValidation(_moment(), _tasks_facts(), _review_facts(), False) # type: ignore[misc] + + def test_build_validation_veto_is_callable(self) -> None: + """``veto`` is a method of ``BuildValidation`` — the single write channel.""" + assert callable(BuildValidation.veto) + + view = BuildValidation( + moment=_moment(), tasks=_tasks_facts(), review=_review_facts(), skip=False + ) + view.veto("blocked") + + def test_build_validation_carries_exactly_the_declared_fields(self) -> None: + """``moment, tasks, review, skip`` plus the private init=False buffer.""" + assert _field_names(BuildValidation) == ["moment", "tasks", "review", "skip", "_veto"] + + buffer_field = dataclasses.fields(BuildValidation)[-1] + assert buffer_field.init is False + assert buffer_field.default is None + + def test_remaining_contexts_carry_exactly_the_declared_fields(self) -> None: + """``BuildStarted``, ``PassStarted``, ``PassCompleted``, ``BuildCompleted``.""" + assert _field_names(BuildStarted) == ["moment", "tasks", "review", "skip"] + assert _field_names(PassStarted) == ["moment", "facts"] + assert _field_names(PassCompleted) == ["moment", "facts", "exit_code"] + assert _field_names(BuildCompleted) == [ + "moment", + "exit_code", + "stages", + "relocation", + "statuses", + ] + + +# --- Logic tests --- + + +class TestBuildValidation: + def test_veto_replaces_the_buffer_whole(self) -> None: + """``veto("one")`` then ``veto("two")`` — the buffer holds exactly ``"two"``.""" + view = BuildValidation( + moment=_moment(), tasks=_tasks_facts(), review=_review_facts(), skip=False + ) + + assert view._veto is None + + view.veto("one") + assert view._veto == "one" + + view.veto("two") + assert view._veto == "two" + + def test_veto_stores_whitespace_reason_verbatim(self) -> None: + """An empty or whitespace-only reason is stored as given.""" + view = BuildValidation( + moment=_moment(), tasks=_tasks_facts(), review=_review_facts(), skip=False + ) + + view.veto(" ") + + assert view._veto == " " + + def test_read_only_fields_carry_the_constructed_values(self) -> None: + """``moment``, ``tasks``, ``review``, ``skip`` — the observed facts, unchanged.""" + moment = _moment() + tasks = _tasks_facts() + review = _review_facts() + view = BuildValidation(moment=moment, tasks=tasks, review=review, skip=True) + + assert view.moment is moment + assert view.tasks is tasks + assert view.review is review + assert view.skip is True + + def test_veto_changes_no_delivered_fact(self) -> None: + """The write channel touches the buffer alone — the facts stay as constructed.""" + view = BuildValidation( + moment=_moment(), tasks=_tasks_facts(), review=_review_facts(), skip=False + ) + + view.veto("policy") + + assert view.tasks.agent == "claude" + assert view.review.strategy == "medium" + assert view.skip is False + + +class TestNotificationContexts: + def test_build_started_carries_the_constructed_values(self) -> None: + """The same resolved facts the gate saw, immediately before the first pass.""" + moment = _moment() + tasks = _tasks_facts() + review = _review_facts() + context = BuildStarted(moment=moment, tasks=tasks, review=review, skip=True) + + assert context.moment is moment + assert context.tasks is tasks + assert context.review is review + assert context.skip is True + + def test_pass_started_carries_the_constructed_values(self) -> None: + """The facts of the pass about to launch.""" + moment = _moment() + facts = _tasks_facts() + context = PassStarted(moment=moment, facts=facts) + + assert context.moment is moment + assert context.facts is facts + + def test_pass_completed_carries_the_actual_exit_code(self) -> None: + """Completion is a fact — the code travels verbatim, zero and non-zero alike.""" + for code in (0, 2, 126): + moment = _moment() + facts = _review_facts() + context = PassCompleted(moment=moment, facts=facts, exit_code=code) + + assert context.moment is moment + assert context.facts is facts + assert context.exit_code == code + + def test_build_completed_carries_the_constructed_values(self) -> None: + """The outcome facts of the started run — the status integration builds from them.""" + moment = _moment() + relocation = RelocationOutcome(moved=True, destination="docs/plans/completed/plan.md") + context = BuildCompleted( + moment=moment, + exit_code=0, + stages=["tasks", "review"], + relocation=relocation, + statuses=["backlog", "designed"], + ) + + assert context.moment is moment + assert context.exit_code == 0 + assert context.stages == ["tasks", "review"] + assert context.relocation is relocation + assert context.statuses == ["backlog", "designed"] + + def test_build_completed_accepts_the_empty_completion_forms(self) -> None: + """A skipped review leaves ``stages`` without it; branch-only delivers ``[]``.""" + context = BuildCompleted( + moment=_moment(), + exit_code=1, + stages=["tasks"], + relocation=RelocationOutcome(moved=False, destination=None), + statuses=[], + ) + + assert context.stages == ["tasks"] + assert context.relocation.moved is False + assert context.relocation.destination is None + assert context.statuses == [] From 6812f6e4ed54a5dc405b1e8bab08fc548eddc20a Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 20:44:02 +0000 Subject: [PATCH 090/205] feat: build hooks checkpoint surface events.py with facade completion (Task 7) --- .goga/history/2026/add-hooks-to-build/plan.md | 20 +- goga/build/hooks/__init__.py | 10 +- goga/build/hooks/events.py | 290 ++++++++++++ tests/build/hooks/test_events.py | 443 ++++++++++++++++++ 4 files changed, 749 insertions(+), 14 deletions(-) create mode 100644 goga/build/hooks/events.py create mode 100644 tests/build/hooks/test_events.py diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index dda53e96..422f0702 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -859,16 +859,16 @@ Emissions — each builds its context and calls returns; a failing hook warns inside the platform (soft class); the run is unaffected. -- [ ] **Declaration**: Task 7 — checkpoint surface BuildHooks -- [ ] **Contract tests**: in `tests/build/hooks/test_events.py` — `BuildHooks` importable from `goga.build.hooks`; the five methods exist with the declared signatures; construction performs no enumeration and no imports (expected to fail at this stage) -- [ ] **Code**: create `goga/build/hooks/events.py` — `BuildHooks` with `_ensure_registry()` (lazy, once per instance) and the five checkpoints per the algorithm above -- [ ] **Code**: finalize `goga/build/hooks/__init__.py` — all 13 types in `__all__`, alphabetical, zone docstring updated -- [ ] **Interface verification**: `pytest tests/build/hooks/test_events.py -x -q` — contract tests pass -- [ ] **Logic tests** (all in `tests/build/hooks/test_events.py`, using `pin_package_environment` + `install_tool_package`): `test_gate_collects_vetoes_without_early_stop` (two tools `goga_tool_a`/`goga_tool_b` subscribing `("build","validate_build","policy", hook)`; A vetoes `"no deploys on friday"`, B records `self.calls` and approves → `verdict.approved is False`; single violation naming tool+hook+reason; B's hook ran); `test_gate_attributes_veto_to_hook_and_replaces_whole` (one tool, hooks `first` vetoes "one", `second` vetoes "two" → exactly one `Violation` with `hook == "second"` and `reason == "two"`); `test_gate_crash_overrides_veto_and_walk_continues` (tool A: hook `broken` raises `RuntimeError("boom")` after hook `vetoer` buffered "blocked"; tool B approves → violations == `[Violation(A, "broken", "boom")]` only; B still ran; no exception escapes); `test_gate_empty_verdict_when_no_subscriptions` (`pin_package_environment({})` → `approved is True; violations == []`); `test_gate_veto_empty_reason_rendered_verbatim` (hook calls `context.veto(" ")` → `Violation.reason == " "`; approved False); plus emission tests — each `emit_*` delegates to `emit_hook_event` with the right action name and the same context instance for every tool (one tool subscribing all four soft actions, recording `context` via `self`) -- [ ] **Debugging**: `pytest tests/build/hooks/ tests/hooks/ -x -q` — fix implementation code until all tests pass -- [ ] **Contract re-verification**: facade check — `python -c "from goga.build.hooks import BuildHooks, BuildMoment, StageFacts, WorkIdentity, AdditionalFacts, RelocationOutcome, Violation, GateVerdict, BuildValidation, BuildStarted, PassStarted, PassCompleted, BuildCompleted"`; `goga schema` shows `goga/build/hooks` with exactly 13 types and a single dependency on `goga/hooks` -- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting, apply decomposition if necessary -- [ ] **Completion**: mark all checkboxes of this task complete +- [x] **Declaration**: Task 7 — checkpoint surface BuildHooks +- [x] **Contract tests**: in `tests/build/hooks/test_events.py` — `BuildHooks` importable from `goga.build.hooks`; the five methods exist with the declared signatures; construction performs no enumeration and no imports (expected to fail at this stage) +- [x] **Code**: create `goga/build/hooks/events.py` — `BuildHooks` with `_ensure_registry()` (lazy, once per instance) and the five checkpoints per the algorithm above +- [x] **Code**: finalize `goga/build/hooks/__init__.py` — all 13 types in `__all__`, alphabetical, zone docstring updated +- [x] **Interface verification**: `pytest tests/build/hooks/test_events.py -x -q` — contract tests pass +- [x] **Logic tests** (all in `tests/build/hooks/test_events.py`, using `pin_package_environment` + `install_tool_package`): `test_gate_collects_vetoes_without_early_stop` (two tools `goga_tool_a`/`goga_tool_b` subscribing `("build","validate_build","policy", hook)`; A vetoes `"no deploys on friday"`, B records `self.calls` and approves → `verdict.approved is False`; single violation naming tool+hook+reason; B's hook ran); `test_gate_attributes_veto_to_hook_and_replaces_whole` (one tool, hooks `first` vetoes "one", `second` vetoes "two" → exactly one `Violation` with `hook == "second"` and `reason == "two"`); `test_gate_crash_overrides_veto_and_walk_continues` (tool A: hook `broken` raises `RuntimeError("boom")` after hook `vetoer` buffered "blocked"; tool B approves → violations == `[Violation(A, "broken", "boom")]` only; B still ran; no exception escapes); `test_gate_empty_verdict_when_no_subscriptions` (`pin_package_environment({})` → `approved is True; violations == []`); `test_gate_veto_empty_reason_rendered_verbatim` (hook calls `context.veto(" ")` → `Violation.reason == " "`; approved False); plus emission tests — each `emit_*` delegates to `emit_hook_event` with the right action name and the same context instance for every tool (one tool subscribing all four soft actions, recording `context` via `self`) +- [x] **Debugging**: `pytest tests/build/hooks/ tests/hooks/ -x -q` — fix implementation code until all tests pass +- [x] **Contract re-verification**: facade check — `python -c "from goga.build.hooks import BuildHooks, BuildMoment, StageFacts, WorkIdentity, AdditionalFacts, RelocationOutcome, Violation, GateVerdict, BuildValidation, BuildStarted, PassStarted, PassCompleted, BuildCompleted"`; `goga schema` shows `goga/build/hooks` with exactly 13 types and a single dependency on `goga/hooks` +- [x] **Lint**: `ruff check goga/build tests/build` — fix formatting, apply decomposition if necessary +- [x] **Completion**: mark all checkboxes of this task complete ### Task 8: Run settings resolution — `run_settings.py` (TDD coding) diff --git a/goga/build/hooks/__init__.py b/goga/build/hooks/__init__.py index 50dec9b5..8a96fb41 100644 --- a/goga/build/hooks/__init__.py +++ b/goga/build/hooks/__init__.py @@ -11,15 +11,16 @@ validation hooks run to completion and the vetoes are collected into one verdict. -Built incrementally: each entity task adds its module's import and -``__all__`` entry. With the fact vocabulary and the run-event contexts -landed, the twelve fact and context names of the zone are re-exported -here. +Built incrementally: each entity task added its module's import and +``__all__`` entry. With the fact vocabulary, the run-event contexts, and +the checkpoint surface landed, the thirteen contract names of the zone +are re-exported here. """ from __future__ import annotations from .contexts import BuildCompleted, BuildStarted, BuildValidation, PassCompleted, PassStarted +from .events import BuildHooks from .facts import ( AdditionalFacts, BuildMoment, @@ -33,6 +34,7 @@ __all__: list[str] = [ "AdditionalFacts", "BuildCompleted", + "BuildHooks", "BuildMoment", "BuildStarted", "BuildValidation", diff --git a/goga/build/hooks/events.py b/goga/build/hooks/events.py new file mode 100644 index 00000000..b1376510 --- /dev/null +++ b/goga/build/hooks/events.py @@ -0,0 +1,290 @@ +"""The checkpoint surface of the build domain — the events cell of the zone. + +The entity declared in the cell CODEMANIFEST with ``location: events.py``: +``BuildHooks`` — the verdict-collecting gate delivery and the four +notification emissions of the build cycle over the platform facade. +Construction is cheap and every context is built from the values the +caller passes; one lazily-built run registry carries every checkpoint of +a run, so the package enumeration happens once whatever the number of +checkpoints. The gate is the domain's hard action with one domain-local +deviation: the staged per-tool walk never stops early — every subscribed +tool's validation hooks run to completion and the vetoes are collected +into one verdict — while the four notifications are soft +fire-and-forget emissions: a failing hook warns inside the platform and +never affects the run. +""" + +from __future__ import annotations + +from ...hooks import ( + HookRegistry, + build_hook_arguments, + declared_actions, + emit_hook_event, + wrap_context, +) +from .contexts import BuildCompleted, BuildStarted, BuildValidation, PassCompleted, PassStarted +from .facts import BuildMoment, GateVerdict, RelocationOutcome, StageFacts, Violation + + +class BuildHooks: + """The checkpoint surface of the build domain. + + Owns the single run registry shared by the validation gate and the + four notifications, and drives the gate's staged walk over the public + primitives of the hooks platform. Tools are mutually blind — every + tool's validation view is a fresh read-only bundle over the same + delivered facts with a veto buffer of that tool alone — and the walk + collects the vetoes instead of stopping at the first failure. + + Requirements: + - Cheap construction — no enumeration and no imports happen at + construction + - One ``HookRegistry`` per run carries every checkpoint of a + command — the assembly runs once per run whatever the number of + checkpoints + - Every context is built from the values the caller passes — no + repository reads happen at a checkpoint + """ + + def __init__(self) -> None: + """Create the checkpoint surface of one build run. + + Nothing is enumerated and nothing is imported: the run registry + builds lazily on the first checkpoint that needs it. + """ + self._registry: HookRegistry | None = None + + def _ensure_registry(self) -> HookRegistry: + """Build the run registry once — the shared state of every checkpoint. + + Returns: + The assembled registry of the run — built on the first call and + reused by every checkpoint; never rebuilt on the same surface. + + Raises: + ImportError: A tool package exists but its facade fails to + import — the single fatal case; the message names the + package. + """ + if self._registry is None: + registry = HookRegistry() + registry.build_once() + self._registry = registry + + return self._registry + + def validate_build( + self, + moment: BuildMoment, + tasks: StageFacts, + review: StageFacts, + skip: bool, + ) -> GateVerdict: + """Deliver the validation gate and return the collected verdict. + + Algorithm: + 1. Resolve the address ``build.validate_build`` against + ``declared_actions`` — an unknown address is a clean error + of the emitting side + 2. Walk the subscriptions of the address per tool in + enumeration order: build the tool's ``BuildValidation`` + view over the delivered facts — every tool reads the same + facts through a fresh view — wrap it via ``wrap_context``, + project the call arguments via ``build_hook_arguments`` + with the tool's own context, and call each hook of the + tool; the veto buffer is snapshotted before each call — a + buffer change during a call attributes the veto to that + hook's subscription name (a later veto replaces the + earlier attribution whole, mirroring the buffer rule) + 3. A raising hook is the tool's single crash violation — the + crash reason, never a raw traceback — and stops that + tool's remaining hooks; the crash overrides the tool's + buffered veto; the walk continues with the next tool and + never stops between tools whatever a tool returned or + raised + 4. A tool whose every hook returned and whose buffer carries + a veto contributes exactly one ``Violation`` with the + attributed hook; a tool whose buffer stayed empty approves + silently — no record + 5. Return the ``GateVerdict`` with the violations in + enumeration order — an address without subscriptions + returns the empty, approved verdict + + Args: + moment: The uniform envelope of the run. + tasks: The resolved facts of the tasks stage. + review: The resolved facts of the review stage — always + present, including a skipped review. + skip: The resolved review skip state of the run. + + Returns: + The :class:`~goga.build.hooks.GateVerdict` — approved when no + tool vetoed or crashed. + + Raises: + ValueError: The address is not declared. + """ + registry = self._ensure_registry() + + record = next( + (entry for entry in declared_actions() if entry.domain == "build" and entry.name == "validate_build"), + None, + ) + if record is None: + raise ValueError("unknown hook action: build.validate_build") + + groups: dict[str, list] = {} + for subscription in registry.subscriptions_for("build", "validate_build"): + groups.setdefault(subscription.tool, []).append(subscription) + + violations: list[Violation] = [] + + for tool, subscriptions in groups.items(): + # A fresh view per tool — the same delivered facts, a veto + # buffer belonging to this tool alone. + view = BuildValidation(moment=moment, tasks=tasks, review=review, skip=skip) + proxy = wrap_context(view) + + attributed_hook = "" + crash: tuple[str, str] | None = None + + for subscription in subscriptions: + before = view._veto + try: + subscription.hook( + **build_hook_arguments(subscription.hook, proxy, registry.self_context(tool)) + ) + except Exception as reason: + # One crash violation for the tool — the crash reason + # overrides any buffered veto; the tool's remaining + # hooks stop, the walk does not. + crash = (subscription.name, str(reason)) + break + + if view._veto != before: + attributed_hook = subscription.name + + if crash is not None: + violations.append(Violation(tool=tool, hook=crash[0], reason=crash[1])) + elif view._veto is not None: + # The buffer only changes through veto(), so a non-empty + # buffer always carries an observed attribution. + violations.append(Violation(tool=tool, hook=attributed_hook, reason=view._veto)) + + return GateVerdict(violations=violations) + + def emit_build_started( + self, + moment: BuildMoment, + tasks: StageFacts, + review: StageFacts, + skip: bool, + ) -> None: + """Emit the start notification — the resolved facts the gate saw. + + Fire-and-forget: nothing is collected and no value returns. The + same context instance is delivered to every subscribed tool — the + notification contexts carry no buffer — and a failing hook is + skipped with a warning under the soft error class of the action: + the run proceeds. + + Args: + moment: The uniform envelope of the run. + tasks: The resolved facts of the tasks stage. + review: The resolved facts of the review stage. + skip: The resolved review skip state of the run. + """ + context = BuildStarted(moment=moment, tasks=tasks, review=review, skip=skip) + + emit_hook_event( + self._ensure_registry(), + "build", + "build_started", + context_for=lambda _tool: context, + ) + + def emit_pass_started(self, moment: BuildMoment, facts: StageFacts) -> None: + """Emit the pass-start notification — the facts of the launching pass. + + Fire-and-forget: nothing is collected and no value returns. A + failing hook is skipped with a warning under the soft error class + of the action — the pass launches. + + Args: + moment: The uniform envelope of the run. + facts: The stage facts of the pass about to launch. + """ + context = PassStarted(moment=moment, facts=facts) + + emit_hook_event( + self._ensure_registry(), + "build", + "pass_started", + context_for=lambda _tool: context, + ) + + def emit_pass_completed(self, moment: BuildMoment, facts: StageFacts, exit_code: int) -> None: + """Emit the pass-completion notification — the finished pass's facts. + + Fire-and-forget: nothing is collected and no value returns. The + emission happens on every pass return path — zero, non-zero, and + spawn failures alike; completion is a fact, not a success claim — + and a failing hook warns under the soft error class: the exit code + of the pass is never affected. + + Args: + moment: The uniform envelope of the run. + facts: The stage facts of the finished pass. + exit_code: The actual exit code of the pass return. + """ + context = PassCompleted(moment=moment, facts=facts, exit_code=exit_code) + + emit_hook_event( + self._ensure_registry(), + "build", + "pass_completed", + context_for=lambda _tool: context, + ) + + # The parameter list is fixed by the cell contract — the CODEMANIFEST + # declares every fact the context carries. + def emit_build_completed( + self, + moment: BuildMoment, + exit_code: int, + stages: list[str], + relocation: RelocationOutcome, + statuses: list[str], + ) -> None: + """Emit the completion notification — the outcome of the started run. + + Fire-and-forget: nothing is collected and no value returns. The + emission happens on every return path of a started run — zero, + non-zero, and spawn failures alike — and a failing hook warns + under the soft error class: the exit code of the run is never + affected. + + Args: + moment: The uniform envelope of the run. + exit_code: The final exit code of the run — the last executed + pass's code. + stages: The executed stage sequence in execution order. + relocation: The outcome of the plan relocation attempt. + statuses: The work's history statuses recomputed at the + completion moment. + """ + context = BuildCompleted( + moment=moment, + exit_code=exit_code, + stages=stages, + relocation=relocation, + statuses=statuses, + ) + + emit_hook_event( + self._ensure_registry(), + "build", + "build_completed", + context_for=lambda _tool: context, + ) diff --git a/tests/build/hooks/test_events.py b/tests/build/hooks/test_events.py new file mode 100644 index 00000000..aca62978 --- /dev/null +++ b/tests/build/hooks/test_events.py @@ -0,0 +1,443 @@ +"""Contract and logic tests for the entity declared in +``goga/build/hooks/CODEMANIFEST`` with ``location: events.py``: + +- ``BuildHooks()`` — the checkpoint surface delivering the hard validation + gate and emitting the four soft notifications of the build cycle + +The checkpoint surface runs for real over the platform boundary fixtures of +``tests/hooks/conftest.py`` (re-exported by the zone test package) — the +registry, the registrars, and the delivery execute the actual platform +code. +""" + +from __future__ import annotations + +import inspect + +import pytest +from goga.build.hooks import ( + AdditionalFacts, + BuildHooks, + BuildMoment, + RelocationOutcome, + StageFacts, + Violation, + WorkIdentity, +) + +_ZONE_ALL: list[str] = [ + "AdditionalFacts", + "BuildCompleted", + "BuildHooks", + "BuildMoment", + "BuildStarted", + "BuildValidation", + "GateVerdict", + "PassCompleted", + "PassStarted", + "RelocationOutcome", + "StageFacts", + "Violation", + "WorkIdentity", +] +"""The completed zone facade — exactly the thirteen contract names.""" + + +def _tasks_facts(**overrides: object) -> StageFacts: + """A tasks-part ``StageFacts`` — the review-only members None.""" + values: dict[str, object] = { + "stage": "tasks", + "agent": "claude", + "env": ["A", "B"], + "max_iterations": 9, + "session_timeout": "30m", + "idle_timeout": "5m", + "wait": "1m", + "roles": None, + "base_ref": None, + "strategy": None, + "finalize": None, + "additional": None, + } + values.update(overrides) + + return StageFacts(**values) # type: ignore[arg-type] + + +def _review_facts(**overrides: object) -> StageFacts: + """A review-part ``StageFacts`` — the review-only members populated.""" + values: dict[str, object] = { + "stage": "review", + "agent": "codex", + "env": [], + "max_iterations": None, + "session_timeout": "30m", + "idle_timeout": "5m", + "wait": "1m", + "roles": ["quality"], + "base_ref": "main", + "strategy": "medium", + "finalize": None, + "additional": AdditionalFacts(agent="codex", patience=None, max_iterations=None), + } + values.update(overrides) + + return StageFacts(**values) # type: ignore[arg-type] + + +def _moment() -> BuildMoment: + """The uniform envelope — a branch-only work identity.""" + return BuildMoment( + plan="docs/plans/plan.md", + work=WorkIdentity(branch="add-hooks-to-build"), + dry_run=False, + ) + + +# --- Contract tests --- + + +class TestCheckpointContract: + def test_zone_facade_exports_exactly_the_contract(self) -> None: + """The facade IS the contract surface — the thirteen names, importable.""" + import goga.build.hooks as zone + + assert zone.BuildHooks is BuildHooks + assert zone.__all__ == _ZONE_ALL # alphabetical, complete + + for name in zone.__all__: + assert getattr(zone, name, None) is not None, name + + def test_surface_carries_the_declared_method_signatures(self) -> None: + """Every checkpoint takes exactly the declared parameters.""" + assert list(inspect.signature(BuildHooks.validate_build).parameters) == [ + "self", + "moment", + "tasks", + "review", + "skip", + ] + assert list(inspect.signature(BuildHooks.emit_build_started).parameters) == [ + "self", + "moment", + "tasks", + "review", + "skip", + ] + assert list(inspect.signature(BuildHooks.emit_pass_started).parameters) == [ + "self", + "moment", + "facts", + ] + assert list(inspect.signature(BuildHooks.emit_pass_completed).parameters) == [ + "self", + "moment", + "facts", + "exit_code", + ] + assert list(inspect.signature(BuildHooks.emit_build_completed).parameters) == [ + "self", + "moment", + "exit_code", + "stages", + "relocation", + "statuses", + ] + + def test_validate_build_returns_the_gate_verdict(self) -> None: + """The gate's return annotation is the collected verdict.""" + assert inspect.signature(BuildHooks.validate_build).return_annotation == "GateVerdict" + + def test_construction_enumerates_nothing(self, pin_package_environment) -> None: + """Cheap construction — the package environment stays unread.""" + boundary = pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + BuildHooks() + + assert boundary.call_count == 0 + + +# --- Logic tests (real platform) --- + + +class TestValidationGate: + def test_gate_collects_vetoes_without_early_stop( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """One vetoing tool never stops the walk — the approving tool still runs.""" + boundary = pin_package_environment({"goga_tool_a": ["a-dist"], "goga_tool_b": ["b-dist"]}) + + def register_a(hooks: object) -> None: + def policy(self: object, context: object) -> None: + context.veto("no deploys on friday") + + hooks.subscribe("build", "validate_build", "policy", policy) # type: ignore[attr-defined] + + def register_b(hooks: object) -> None: + def observer(self: object, context: object) -> None: + self.calls = getattr(self, "calls", 0) + 1 + + hooks.subscribe("build", "validate_build", "observer", observer) # type: ignore[attr-defined] + + install_tool_package("goga_tool_a", register_hooks=register_a) + install_tool_package("goga_tool_b", register_hooks=register_b) + + surface = BuildHooks() + verdict = surface.validate_build( + moment=_moment(), tasks=_tasks_facts(), review=_review_facts(), skip=False + ) + + assert verdict.approved is False + assert len(verdict.violations) == 1 + + violation = verdict.violations[0] + assert (violation.tool, violation.hook, violation.reason) == ("a", "policy", "no deploys on friday") + + # The walk ran to completion: B's hook ran although A had vetoed. + assert surface._registry.self_context("b").calls == 1 + assert boundary.call_count == 1 + + def test_gate_attributes_veto_to_hook_and_replaces_whole( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """Two vetoing hooks of one tool — the later veto wins whole, once.""" + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + def register(hooks: object) -> None: + def first(self: object, context: object) -> None: + context.veto("one") + + def second(self: object, context: object) -> None: + context.veto("two") + + hooks.subscribe("build", "validate_build", "first", first) # type: ignore[attr-defined] + hooks.subscribe("build", "validate_build", "second", second) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register) + + verdict = BuildHooks().validate_build( + moment=_moment(), tasks=_tasks_facts(), review=_review_facts(), skip=False + ) + + assert verdict.violations == [Violation(tool="demo", hook="second", reason="two")] + + def test_gate_crash_overrides_veto_and_walk_continues( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """A crash is the tool's single violation; the walk continues, nothing escapes.""" + pin_package_environment({"goga_tool_a": ["a-dist"], "goga_tool_b": ["b-dist"]}) + + def register_a(hooks: object) -> None: + def vetoer(self: object, context: object) -> None: + context.veto("blocked") + + def broken(self: object, context: object) -> None: + raise RuntimeError("boom") + + hooks.subscribe("build", "validate_build", "vetoer", vetoer) # type: ignore[attr-defined] + hooks.subscribe("build", "validate_build", "broken", broken) # type: ignore[attr-defined] + + def register_b(hooks: object) -> None: + def observer(self: object, context: object) -> None: + self.calls = getattr(self, "calls", 0) + 1 + + hooks.subscribe("build", "validate_build", "observer", observer) # type: ignore[attr-defined] + + install_tool_package("goga_tool_a", register_hooks=register_a) + install_tool_package("goga_tool_b", register_hooks=register_b) + + surface = BuildHooks() + verdict = surface.validate_build( + moment=_moment(), tasks=_tasks_facts(), review=_review_facts(), skip=False + ) + + assert verdict.violations == [Violation(tool="a", hook="broken", reason="boom")] + assert verdict.approved is False + + # The walk continued past the crashing tool — B ran and approved. + assert surface._registry.self_context("b").calls == 1 + + def test_gate_empty_verdict_when_no_subscriptions(self, pin_package_environment) -> None: + """No tool packages installed — the gate is inert and approves.""" + pin_package_environment({}) + + verdict = BuildHooks().validate_build( + moment=_moment(), tasks=_tasks_facts(), review=_review_facts(), skip=False + ) + + assert verdict.approved is True + assert verdict.violations == [] + + def test_gate_veto_empty_reason_rendered_verbatim( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """A whitespace-only reason is stored and delivered as given.""" + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + def register(hooks: object) -> None: + def policy(self: object, context: object) -> None: + context.veto(" ") + + hooks.subscribe("build", "validate_build", "policy", policy) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register) + + verdict = BuildHooks().validate_build( + moment=_moment(), tasks=_tasks_facts(), review=_review_facts(), skip=False + ) + + assert verdict.approved is False + assert verdict.violations[0].reason == " " + + +class TestNotificationEmissions: + def test_each_emission_addresses_its_action_with_one_shared_context( + self, + monkeypatch: pytest.MonkeyPatch, + pin_package_environment, + install_tool_package, + ) -> None: + """Each emit_* delegates with its action name; one context instance for every tool.""" + from goga.build.hooks import events as events_module + + boundary = pin_package_environment( + {"goga_tool_demo": ["demo-dist"], "goga_tool_second": ["second-dist"]} + ) + + def register_all(hooks: object) -> None: + def make(action: str): + def hook(self: object, context: object) -> None: + setattr(self, action, context) + + return hook + + for action in ("build_started", "pass_started", "pass_completed", "build_completed"): + hooks.subscribe("build", action, action, make(action)) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register_all) + install_tool_package("goga_tool_second", register_hooks=register_all) + + # A delegating spy over the platform emission — the real delivery + # still runs; the spy only records the delegated addresses and + # their context builders. + real_emit = events_module.emit_hook_event + delegated: list[tuple[str, str, object]] = [] + + def spy(registry: object, domain: str, action: str, context_for: object) -> None: + delegated.append((domain, action, context_for)) + real_emit(registry, domain, action, context_for) # type: ignore[arg-type] + + monkeypatch.setattr(events_module, "emit_hook_event", spy) + + moment = _moment() + tasks = _tasks_facts() + review = _review_facts() + relocation = RelocationOutcome(moved=True, destination="docs/plans/completed/plan.md") + surface = BuildHooks() + + # Nothing returns — fire-and-forget on every checkpoint. + assert surface.emit_build_started(moment=moment, tasks=tasks, review=review, skip=False) is None + assert surface.emit_pass_started(moment=moment, facts=tasks) is None + assert surface.emit_pass_completed(moment=moment, facts=review, exit_code=2) is None + assert ( + surface.emit_build_completed( + moment=moment, + exit_code=2, + stages=["tasks", "review"], + relocation=relocation, + statuses=["backlog", "designed"], + ) + is None + ) + + # Each emit_* delegated to emit_hook_event on the build domain + # with exactly its own action name. + assert [(domain, action) for domain, action, _ in delegated] == [ + ("build", "build_started"), + ("build", "pass_started"), + ("build", "pass_completed"), + ("build", "build_completed"), + ] + + # One context instance for every tool of each emission — the + # read-only notification contexts carry no per-tool buffer. + for _, _, context_for in delegated: + builder = context_for # type: ignore[operator] + assert builder("demo") is builder("second") + + # The delivered views carried the caller's facts — and the + # distinguishing members pin each action's context: a wrong + # address would leave another action's shape in the slot. + demo = surface._registry.self_context("demo") + second = surface._registry.self_context("second") + + assert demo.build_started.tasks is tasks + assert demo.build_started.review is review + assert demo.build_started.skip is False + assert second.build_started.tasks is tasks + + assert demo.pass_started.facts is tasks + assert second.pass_started.facts is tasks + + assert demo.pass_completed.facts is review + assert demo.pass_completed.exit_code == 2 + assert second.pass_completed.exit_code == 2 + + assert demo.build_completed.exit_code == 2 + assert demo.build_completed.stages == ["tasks", "review"] + assert demo.build_completed.relocation is relocation + assert demo.build_completed.statuses == ["backlog", "designed"] + assert second.build_completed.stages == ["tasks", "review"] + + # One registry build carried every checkpoint of the surface. + assert boundary.call_count == 1 + + def test_gate_and_emissions_share_the_run_registry( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """The gate and every emission of one surface share one registry build.""" + boundary = pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + def register(hooks: object) -> None: + def policy(self: object, context: object) -> None: + return None + + def started(self: object, context: object) -> None: + return None + + def completed(self: object, context: object) -> None: + return None + + hooks.subscribe("build", "validate_build", "policy", policy) # type: ignore[attr-defined] + hooks.subscribe("build", "build_started", "started", started) # type: ignore[attr-defined] + hooks.subscribe("build", "build_completed", "completed", completed) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register) + + moment = _moment() + tasks = _tasks_facts() + review = _review_facts() + surface = BuildHooks() + + verdict = surface.validate_build(moment=moment, tasks=tasks, review=review, skip=False) + surface.emit_build_started(moment=moment, tasks=tasks, review=review, skip=False) + surface.emit_build_completed( + moment=moment, + exit_code=0, + stages=["tasks"], + relocation=RelocationOutcome(moved=False, destination=None), + statuses=[], + ) + + assert verdict.approved is True + assert boundary.call_count == 1 From c0e0e998a1bffa7f7b1a4c88c6f2248e198415fc Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 20:48:25 +0000 Subject: [PATCH 091/205] feat: run settings resolution run_settings.py with pure resolver (Task 8) --- .goga/history/2026/add-hooks-to-build/plan.md | 18 +- goga/build/run_settings.py | 223 +++++++++++++++++ tests/build/test_run_settings.py | 224 ++++++++++++++++++ 3 files changed, 456 insertions(+), 9 deletions(-) create mode 100644 goga/build/run_settings.py create mode 100644 tests/build/test_run_settings.py diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index 422f0702..761135c0 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -943,15 +943,15 @@ Algorithm listing: additional=additional, session_timeout=…, idle_timeout=…, wait=…)) ``` -- [ ] **Declaration**: Task 8 — run settings resolution -- [ ] **Contract tests**: in `tests/build/test_run_settings.py` — `resolve_run_settings`, `RunSettings`, `PassSettings`, `ReviewPassSettings` importable from `goga.build.run_settings`; the three dataclasses pass `is_kw_only_dataclass` and are frozen; `ReviewPassSettings` is a `PassSettings` subclass (expected to fail at this stage) -- [ ] **Code**: create `goga/build/run_settings.py` per the algorithm above (Google docstrings, `from __future__ import annotations`, relative imports) -- [ ] **Interface verification**: `pytest tests/build/test_run_settings.py -x -q` — contract tests pass -- [ ] **Logic tests**: `test_resolve_run_settings_full_inheritance` (setup: `BuildConfig(agent="claude", env={"A":"1"}, max_iterations=9, session_timeout="30m", idle_timeout="5m", wait="1m", review=ReviewConfig(skip=None, agent=None, env={}, roles=["quality"], base_ref="main", strategy=None, finalize=None, additional=AdditionalReviewConfig(agent=None, patience=3, max_iterations=None), session_timeout=None, idle_timeout=None, wait=None))`, `cli_options={}` → assert `settings.review.agent == "claude"`, `settings.review.strategy == "medium"`, `settings.review.additional.agent == "claude"`, `settings.review.additional.patience == 3`, `settings.review.env == {}`, `settings.review.base_ref == "main"`); `test_resolve_run_settings_cli_overrides_and_tri_state` (config with `review=ReviewConfig(skip=True, session_timeout="10m", …)`, `cli_options={"skip_review": False, "session_timeout": "99m"}` → `settings.skip is False`; `settings.review.session_timeout == "99m"`); `test_resolve_run_settings_review_absent` (`BuildConfig(agent="claude", env={}, max_iterations=5, …, review=None)`, `cli_options={}` → `settings.review.additional.agent == "claude"`, `settings.review.additional.patience is None`, `settings.review.roles is None`, `settings.skip is False`; repeat with `review=ReviewConfig(roles=[])` → `settings.review.roles == []` — the empty list travels verbatim, never coerced to None); `test_resolve_run_settings_base_ref_normalization` (`cli_options={"base_ref": " release/1.3.0 "}`, config review `base_ref="main"` → `settings.review.base_ref == "release/1.3.0"`; repeat `cli_options={"base_ref": " "}` → `base_ref == "main"` — empty CLI counts as unset) -- [ ] **Debugging**: `pytest tests/build/test_run_settings.py -x -q` — fix implementation code until all tests pass -- [ ] **Contract re-verification**: `python -c "from goga.build.run_settings import RunSettings, PassSettings, ReviewPassSettings, resolve_run_settings"`; `from goga.config import BuildConfig, ReviewConfig, AdditionalReviewConfig` still resolves -- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary -- [ ] **Completion**: mark all checkboxes of this task complete +- [x] **Declaration**: Task 8 — run settings resolution +- [x] **Contract tests**: in `tests/build/test_run_settings.py` — `resolve_run_settings`, `RunSettings`, `PassSettings`, `ReviewPassSettings` importable from `goga.build.run_settings`; the three dataclasses pass `is_kw_only_dataclass` and are frozen; `ReviewPassSettings` is a `PassSettings` subclass (expected to fail at this stage) +- [x] **Code**: create `goga/build/run_settings.py` per the algorithm above (Google docstrings, `from __future__ import annotations`, relative imports) +- [x] **Interface verification**: `pytest tests/build/test_run_settings.py -x -q` — contract tests pass +- [x] **Logic tests**: `test_resolve_run_settings_full_inheritance` (setup: `BuildConfig(agent="claude", env={"A":"1"}, max_iterations=9, session_timeout="30m", idle_timeout="5m", wait="1m", review=ReviewConfig(skip=None, agent=None, env={}, roles=["quality"], base_ref="main", strategy=None, finalize=None, additional=AdditionalReviewConfig(agent=None, patience=3, max_iterations=None), session_timeout=None, idle_timeout=None, wait=None))`, `cli_options={}` → assert `settings.review.agent == "claude"`, `settings.review.strategy == "medium"`, `settings.review.additional.agent == "claude"`, `settings.review.additional.patience == 3`, `settings.review.env == {}`, `settings.review.base_ref == "main"`); `test_resolve_run_settings_cli_overrides_and_tri_state` (config with `review=ReviewConfig(skip=True, session_timeout="10m", …)`, `cli_options={"skip_review": False, "session_timeout": "99m"}` → `settings.skip is False`; `settings.review.session_timeout == "99m"`); `test_resolve_run_settings_review_absent` (`BuildConfig(agent="claude", env={}, max_iterations=5, …, review=None)`, `cli_options={}` → `settings.review.additional.agent == "claude"`, `settings.review.additional.patience is None`, `settings.review.roles is None`, `settings.skip is False`; repeat with `review=ReviewConfig(roles=[])` → `settings.review.roles == []` — the empty list travels verbatim, never coerced to None); `test_resolve_run_settings_base_ref_normalization` (`cli_options={"base_ref": " release/1.3.0 "}`, config review `base_ref="main"` → `settings.review.base_ref == "release/1.3.0"`; repeat `cli_options={"base_ref": " "}` → `base_ref == "main"` — empty CLI counts as unset) +- [x] **Debugging**: `pytest tests/build/test_run_settings.py -x -q` — fix implementation code until all tests pass +- [x] **Contract re-verification**: `python -c "from goga.build.run_settings import RunSettings, PassSettings, ReviewPassSettings, resolve_run_settings"`; `from goga.config import BuildConfig, ReviewConfig, AdditionalReviewConfig` still resolves +- [x] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [x] **Completion**: mark all checkboxes of this task complete ### Task 9: Pass options composition — `pass_options.py` (TDD coding) diff --git a/goga/build/run_settings.py b/goga/build/run_settings.py new file mode 100644 index 00000000..de9ae5d0 --- /dev/null +++ b/goga/build/run_settings.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from ..config import AdditionalReviewConfig, BuildConfig, ReviewConfig + + +@dataclass(kw_only=True, frozen=True) +class PassSettings: + """The resolved tasks-pass part of the run plan. + + ``agent``: the tasks-pass executor agent name, None when unset. + + ``env``: the tasks-pass env layer, verbatim; the review pass never + receives it. + + ``max_iterations``: the tasks-pass iteration cap; None when unset. + + ``session_timeout``: the tasks-pass session timeout; None when unset. + + ``idle_timeout``: the tasks-pass idle timeout; None when unset. + + ``wait``: the tasks-pass rate-limit wait; None when unset. + """ + + agent: str | None = None + env: dict[str, str] = field(default_factory=dict) + max_iterations: int | None = None + session_timeout: str | None = None + idle_timeout: str | None = None + wait: str | None = None + + +@dataclass(kw_only=True, frozen=True) +class ReviewPassSettings(PassSettings): + """The resolved review-pass part — a concretization of the base pass part. + + Carries the inherited agent and session-knob fields of ``PassSettings``, + the verbatim review env layer (never inherited from the root env — the + root env is the tasks-pass layer only), plus the review-only members. + + ``roles``: the declared reviewer composition, verbatim; None or an empty + list mean the full default set to the consumer. + + ``base_ref``: the resolved review diff base; None when unset. + + ``strategy``: the resolved review strategy — full, medium, or short. + + ``finalize``: the finalize prompt; None leaves the step at the ralphex + default (off). + + ``additional``: the resolved external-review block; its agent field + carries the inherited review agent when unset in config. + """ + + roles: list[str] | None = None + base_ref: str | None = None + strategy: str + finalize: str | None = None + additional: AdditionalReviewConfig + + +@dataclass(kw_only=True, frozen=True) +class RunSettings: + """The resolved run plan of a single build. + + Immutable value-object computed by ``resolve_run_settings`` — never loaded + from YAML directly. ``skip`` is the final skip decision of the tri-state + resolution (False when neither the CLI nor the config set it). ``tasks`` + is the resolved tasks-pass part. ``review`` is the resolved review-pass + part with root inheritance applied — always present, so a skipped run + still carries the resolved review facts. + + ``skip``: the final skip decision (False when no source set it). + + ``tasks``: the resolved tasks-pass part. + + ``review``: the resolved review-pass part with root inheritance applied. + """ + + skip: bool = False + tasks: PassSettings = field(default_factory=PassSettings) + review: ReviewPassSettings + + +def resolve_run_settings(config: BuildConfig, cli_options: dict) -> RunSettings: + """Resolve the run settings of one build from the two-part configuration and the CLI options. + + Pure function — no side effects, no validation of values (the semantic + checks belong to ``validate_review_config``), no wrapper resolution (that + belongs to the orchestrator and the validation routine). Every knob + resolves with the precedence CLI > config > default > omit; unset at both + levels stays None, so the key stays absent from the ralphex options. + + An absent ``build.review`` (``config.review`` is None) treats every review + field as unset: skip resolves False, the agent and session knobs inherit + the root values, base_ref/roles/finalize stay None, and the additional + part resolves with the inherited review agent and unset counters — the + additional block is always constructed (``ReviewPassSettings.additional`` + is non-optional). + + The env dicts pass verbatim: the review env never inherits the root env + (secret-safe — the root env is the tasks-pass layer); an empty review env + means no review layer. Review ``max_iterations`` is root-only and never + resolves onto the review part. A non-None ``cli_options`` base_ref wins + over the config value — padded values strip, an empty or whitespace-only + CLI value counts as unset. + + Args: + config: Build configuration in the two-part form; the review part may + be None. + cli_options: In-container CLI options; the keys read here are + `skip_review` (bool | None), `base_ref` (str | None), and + `review_patience`, `session_timeout`, `idle_timeout`, `wait`, + `max_iterations` (each None when the flag was not given). + + Returns: + The resolved run plan: the skip decision, the tasks part, and the + review part with root inheritance applied. + """ + review = config.review + + tasks = PassSettings( + agent=config.agent, + env=config.env, + max_iterations=_cli_or_value(cli_options, "max_iterations", config.max_iterations), + session_timeout=_cli_or_value(cli_options, "session_timeout", config.session_timeout), + idle_timeout=_cli_or_value(cli_options, "idle_timeout", config.idle_timeout), + wait=_cli_or_value(cli_options, "wait", config.wait), + ) + + review_agent = _review_value(review, "agent") or config.agent + + return RunSettings( + skip=_resolve_skip(cli_options, review), + tasks=tasks, + review=ReviewPassSettings( + agent=review_agent, + env=review.env if review is not None else {}, + session_timeout=_resolve_review_knob(cli_options, "session_timeout", review, config), + idle_timeout=_resolve_review_knob(cli_options, "idle_timeout", review, config), + wait=_resolve_review_knob(cli_options, "wait", review, config), + roles=_review_value(review, "roles"), + base_ref=_resolve_base_ref(cli_options, review), + strategy=_review_value(review, "strategy") or "medium", + finalize=_review_value(review, "finalize"), + additional=_resolve_additional(cli_options, review, review_agent), + ), + ) + + +def _resolve_skip(cli_options: dict, review: ReviewConfig | None) -> bool: + """Tri-state skip resolution: CLI, else config, else False.""" + cli_skip = cli_options.get("skip_review") + + if cli_skip is not None: + return cli_skip + + config_skip = _review_value(review, "skip") + + return config_skip if config_skip is not None else False + + +def _resolve_review_knob( + cli_options: dict, + key: str, + review: ReviewConfig | None, + config: BuildConfig, +) -> str | None: + """Session-knob resolution for the review part: CLI, else review, else root.""" + cli_value = cli_options.get(key) + + if cli_value is not None: + return cli_value + + review_value = _review_value(review, key) + + return review_value if review_value is not None else getattr(config, key) + + +def _resolve_additional( + cli_options: dict, + review: ReviewConfig | None, + review_agent: str | None, +) -> AdditionalReviewConfig: + """Additional-block resolution: agent inherits the review agent, patience is CLI > config.""" + block = review.additional if review is not None else None + + cli_patience = cli_options.get("review_patience") + + if cli_patience is None and block is not None: + cli_patience = block.patience + + return AdditionalReviewConfig( + agent=(block.agent if block is not None else None) or review_agent, + patience=cli_patience, + max_iterations=block.max_iterations if block is not None else None, + ) + + +def _resolve_base_ref(cli_options: dict, review: ReviewConfig | None) -> str | None: + """Diff-base resolution: a non-empty stripped CLI value, else the config value.""" + cli_base_ref = cli_options.get("base_ref") + + if cli_base_ref is not None: + stripped = cli_base_ref.strip() + + if stripped: + return stripped + + return _review_value(review, "base_ref") + + +def _cli_or_value(cli_options: dict, key: str, value: str | int | None) -> str | int | None: + """Root-level knob resolution for the tasks part: the CLI value when given, else the root value.""" + cli_value = cli_options.get(key) + + return cli_value if cli_value is not None else value + + +def _review_value(review: ReviewConfig | None, key: str): + """The verbatim review-part value of ``key``; None when the part is absent.""" + return getattr(review, key) if review is not None else None diff --git a/tests/build/test_run_settings.py b/tests/build/test_run_settings.py new file mode 100644 index 00000000..5734ee43 --- /dev/null +++ b/tests/build/test_run_settings.py @@ -0,0 +1,224 @@ +"""Contract and logic tests for the entities declared in +``goga/build/CODEMANIFEST`` with ``location: run_settings.py``: + +- ``RunSettings(skip, tasks, review)`` — the resolved run plan of a single + build +- ``PassSettings(agent, env, ...)`` — the resolved tasks-pass part +- ``PassSettings::ReviewPassSettings(...)`` — the resolved review-pass part + (a concretization of the base pass part) +- ``resolve_run_settings(config, cli_options)`` — the pure resolver applying + CLI > config > default > omit precedence and root→review inheritance + +Supported data only — no mocks, no filesystem: the resolver is a pure +function of the two-part configuration and the CLI options dictionary. +""" + +from __future__ import annotations + +import dataclasses + +from goga.build.run_settings import ( + PassSettings, + ReviewPassSettings, + RunSettings, + resolve_run_settings, +) +from goga.config import AdditionalReviewConfig, BuildConfig, ReviewConfig + +from tests.conftest import is_kw_only_dataclass + +SETTINGS_TYPES: tuple[type, ...] = (RunSettings, PassSettings, ReviewPassSettings) + + +def _field_names(cls: type) -> list[str]: + """Declared field names of ``cls``, inheritance order included.""" + return [field.name for field in dataclasses.fields(cls)] + + +# --- Contract tests --- + + +class TestRunSettingsContract: + def test_resolver_and_value_objects_are_importable(self) -> None: + """The four names live on the ``goga.build.run_settings`` module.""" + import goga.build.run_settings as module + + for name in ("resolve_run_settings", "RunSettings", "PassSettings", "ReviewPassSettings"): + assert hasattr(module, name) + + def test_value_objects_are_kw_only_and_frozen(self) -> None: + """Immutable kw_only value objects per the ``conventions`` practice.""" + for cls in SETTINGS_TYPES: + assert dataclasses.is_dataclass(cls) + assert is_kw_only_dataclass(cls) + assert cls.__dataclass_params__.frozen + + def test_review_pass_settings_is_a_pass_settings_concretization(self) -> None: + """``ReviewPassSettings`` extends the base pass part, frozen over frozen.""" + assert issubclass(ReviewPassSettings, PassSettings) + + def test_field_sets_match_the_contract(self) -> None: + """Exact field sets: the run plan triple, the base pass part, the review concretization.""" + assert _field_names(RunSettings) == ["skip", "tasks", "review"] + assert _field_names(PassSettings) == [ + "agent", + "env", + "max_iterations", + "session_timeout", + "idle_timeout", + "wait", + ] + assert _field_names(ReviewPassSettings) == [ + "agent", + "env", + "max_iterations", + "session_timeout", + "idle_timeout", + "wait", + "roles", + "base_ref", + "strategy", + "finalize", + "additional", + ] + + +# --- Logic tests --- + + +class TestResolveRunSettings: + def test_resolve_run_settings_full_inheritance(self) -> None: + """Root values flow into the review part; strategy defaults to medium.""" + config = BuildConfig( + agent="claude", + env={"A": "1"}, + max_iterations=9, + session_timeout="30m", + idle_timeout="5m", + wait="1m", + review=ReviewConfig( + skip=None, + agent=None, + env={}, + roles=["quality"], + base_ref="main", + strategy=None, + finalize=None, + additional=AdditionalReviewConfig(agent=None, patience=3, max_iterations=None), + session_timeout=None, + idle_timeout=None, + wait=None, + ), + ) + + settings = resolve_run_settings(config, {}) + + assert settings.review.agent == "claude" + assert settings.review.strategy == "medium" + assert settings.review.additional.agent == "claude" + assert settings.review.additional.patience == 3 + assert settings.review.env == {} + assert settings.review.base_ref == "main" + + assert settings.skip is False + assert settings.tasks.agent == "claude" + assert settings.tasks.env == {"A": "1"} + assert settings.tasks.max_iterations == 9 + assert settings.tasks.session_timeout == "30m" + assert settings.tasks.idle_timeout == "5m" + assert settings.tasks.wait == "1m" + assert settings.review.session_timeout == "30m" + assert settings.review.idle_timeout == "5m" + assert settings.review.wait == "1m" + assert settings.review.max_iterations is None + assert settings.review.roles == ["quality"] + assert settings.review.finalize is None + + def test_resolve_run_settings_cli_overrides_and_tri_state(self) -> None: + """CLI wins over config on every knob; the tri-state skip resolves False.""" + config = BuildConfig( + agent="claude", + env={}, + max_iterations=5, + session_timeout="30m", + review=ReviewConfig( + skip=True, + agent="codex", + session_timeout="10m", + ), + ) + + settings = resolve_run_settings(config, {"skip_review": False, "session_timeout": "99m"}) + + assert settings.skip is False + assert settings.review.session_timeout == "99m" + assert settings.review.agent == "codex" + assert settings.tasks.session_timeout == "99m" + assert settings.tasks.max_iterations == 5 + + unoverridden = resolve_run_settings(config, {}) + + assert unoverridden.skip is True + assert unoverridden.review.session_timeout == "10m" + assert unoverridden.tasks.session_timeout == "30m" + + def test_resolve_run_settings_review_absent(self) -> None: + """An absent review part resolves with step-0 semantics; [] travels verbatim.""" + config = BuildConfig(agent="claude", env={}, max_iterations=5, review=None) + + settings = resolve_run_settings(config, {}) + + assert settings.review.additional.agent == "claude" + assert settings.review.additional.patience is None + assert settings.review.additional.max_iterations is None + assert settings.review.roles is None + assert settings.skip is False + assert settings.review.agent == "claude" + assert settings.review.env == {} + assert settings.review.strategy == "medium" + + with_empty_roles = resolve_run_settings( + BuildConfig(agent="claude", review=ReviewConfig(roles=[])), {} + ) + + assert with_empty_roles.review.roles == [] + assert with_empty_roles.review.additional.agent == "claude" + + def test_resolve_run_settings_base_ref_normalization(self) -> None: + """A padded CLI base_ref strips; whitespace-only CLI counts as unset.""" + config = BuildConfig(agent="claude", review=ReviewConfig(base_ref="main")) + + padded = resolve_run_settings(config, {"base_ref": " release/1.3.0 "}) + + assert padded.review.base_ref == "release/1.3.0" + + blank = resolve_run_settings(config, {"base_ref": " "}) + + assert blank.review.base_ref == "main" + + absent = resolve_run_settings(config, {}) + + assert absent.review.base_ref == "main" + + def test_resolve_run_settings_env_and_patience_precedence(self) -> None: + """The review env never inherits the root env; patience is CLI > config verbatim.""" + config = BuildConfig( + agent="claude", + env={"ROOT": "secret"}, + review=ReviewConfig( + env={"REVIEW": "layer"}, + additional=AdditionalReviewConfig(agent="cursor", patience=1, max_iterations=4), + ), + ) + + settings = resolve_run_settings(config, {"review_patience": 0}) + + assert settings.tasks.env == {"ROOT": "secret"} + assert settings.review.env == {"REVIEW": "layer"} + assert settings.review.additional.agent == "cursor" + assert settings.review.additional.patience == 0 + assert settings.review.additional.max_iterations == 4 + + from_config = resolve_run_settings(config, {}) + + assert from_config.review.additional.patience == 1 From 7bb78ed3475f525e3644ddeb6080c33ce2e840e1 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 20:51:43 +0000 Subject: [PATCH 092/205] feat: pass options composition pass_options.py with pure composer (Task 9) --- .goga/history/2026/add-hooks-to-build/plan.md | 18 +- goga/build/pass_options.py | 88 +++++++++ tests/build/test_pass_options.py | 184 ++++++++++++++++++ 3 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 goga/build/pass_options.py create mode 100644 tests/build/test_pass_options.py diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index 761135c0..8e46c2fc 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -983,15 +983,15 @@ Verified design trace: pass-mode flag. ``` -- [ ] **Declaration**: Task 9 — pass options composition -- [ ] **Contract tests**: in `tests/build/test_pass_options.py` — `compose_pass_options` importable from `goga.build.pass_options`; returns a plain dict (expected to fail at this stage) -- [ ] **Code**: create `goga/build/pass_options.py` per the trace above -- [ ] **Interface verification**: `pytest tests/build/test_pass_options.py -x -q` — contract tests pass -- [ ] **Logic tests**: `test_compose_pass_options_tasks` (settings with tasks knobs `session_timeout="30m"`, `max_iterations=9`, review part carrying `base_ref="main"`, `additional.patience=0` → `{"tasks_only": True, "session_timeout": "30m", "max_iterations": 9}` exactly; no `review`/`external_only`/`base_ref`/`review_patience`); `test_compose_pass_options_review_medium_and_short` (same settings, `strategy="medium"` → `options["review"] is True` and `"external_only" not in options`; `options["base_ref"] == "main"`; `options["review_patience"] == 0` — zero kept; `"max_external_iterations" in options` iff `additional.max_iterations is not None`; rebuild with `strategy="short"` → `options["external_only"] is True and "review" not in options`) -- [ ] **Debugging**: `pytest tests/build/test_pass_options.py -x -q` — fix implementation code until all tests pass -- [ ] **Contract re-verification**: every emitted key maps 1:1 to a `run_ralphex` flag (cross-check against the Task 3 table) -- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary -- [ ] **Completion**: mark all checkboxes of this task complete +- [x] **Declaration**: Task 9 — pass options composition +- [x] **Contract tests**: in `tests/build/test_pass_options.py` — `compose_pass_options` importable from `goga.build.pass_options`; returns a plain dict (expected to fail at this stage) +- [x] **Code**: create `goga/build/pass_options.py` per the trace above +- [x] **Interface verification**: `pytest tests/build/test_pass_options.py -x -q` — contract tests pass +- [x] **Logic tests**: `test_compose_pass_options_tasks` (settings with tasks knobs `session_timeout="30m"`, `max_iterations=9`, review part carrying `base_ref="main"`, `additional.patience=0` → `{"tasks_only": True, "session_timeout": "30m", "max_iterations": 9}` exactly; no `review`/`external_only`/`base_ref`/`review_patience`); `test_compose_pass_options_review_medium_and_short` (same settings, `strategy="medium"` → `options["review"] is True` and `"external_only" not in options`; `options["base_ref"] == "main"`; `options["review_patience"] == 0` — zero kept; `"max_external_iterations" in options` iff `additional.max_iterations is not None`; rebuild with `strategy="short"` → `options["external_only"] is True and "review" not in options`) +- [x] **Debugging**: `pytest tests/build/test_pass_options.py -x -q` — fix implementation code until all tests pass +- [x] **Contract re-verification**: every emitted key maps 1:1 to a `run_ralphex` flag (cross-check against the Task 3 table) +- [x] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [x] **Completion**: mark all checkboxes of this task complete ### Task 10: Review config semantic validation — `review_config.py` re-signature (TDD coding) diff --git a/goga/build/pass_options.py b/goga/build/pass_options.py new file mode 100644 index 00000000..92be43e8 --- /dev/null +++ b/goga/build/pass_options.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from .run_settings import RunSettings + +# Resolved knob keys composed per stage. The tasks stage carries the +# tasks-pass knobs; the review stage carries the session knobs only — +# review max_iterations is root-only (never resolves onto the review part) +# and reaches the external surface as max_external_iterations instead. +_TASKS_KNOB_KEYS: tuple[str, ...] = ("session_timeout", "idle_timeout", "wait", "max_iterations") +_REVIEW_KNOB_KEYS: tuple[str, ...] = ("session_timeout", "idle_timeout", "wait") + + +def compose_pass_options(settings: RunSettings, stage: str) -> dict[str, str | int | bool]: + """Compose the ralphex options of one pass from the resolved run settings. + + Pure mapping — no side effects, no reads beyond ``settings``: the + resolution precedence (CLI > config > default > omit) and the root + inheritance were applied by ``resolve_run_settings``, so every knob read + here is final and an unset one (None) stays absent from the dict — the + assembled ralphex command carries no flag for it. The agent never appears + in the composition (it reaches the pass as the executor wrapper) and the + env never appears either (it reaches the pass as the subprocess env + layer — secret-safe, values never travel through options). + + The tasks stage emits the ``tasks_only`` mode flag plus the resolved + tasks knobs. The review stage emits exactly one mode flag bound to the + strategy — ``review``, or ``external_only`` under short — plus the + resolved review session knobs, ``base_ref``, and the external-review + counters of the additional block, whose 0 values are meaningful (patience + disabled / ralphex auto) and pass verbatim. + + Args: + settings: The resolved run plan of the build. + stage: The pass stage — exactly ``tasks`` or ``review``. + + Returns: + The ralphex options of the pass, consumed by ``run_build_pass``; + carries exactly one pass-mode flag. + + Raises: + ValueError: When ``stage`` is neither ``tasks`` nor ``review``. + """ + if stage == "tasks": + return _compose_tasks(settings) + + if stage == "review": + return _compose_review(settings) + + raise ValueError(f"unknown build pass stage: {stage}") + + +def _compose_tasks(settings: RunSettings) -> dict[str, str | int | bool]: + """Compose the tasks-pass options: the mode flag plus the resolved tasks knobs.""" + options: dict[str, str | int | bool] = {"tasks_only": True} + + for key in _TASKS_KNOB_KEYS: + value = getattr(settings.tasks, key) + + if value is not None: + options[key] = value + + return options + + +def _compose_review(settings: RunSettings) -> dict[str, str | int | bool]: + """Compose the review-pass options: the strategy-bound mode flag plus the review surface.""" + review = settings.review + mode_flag = "external_only" if review.strategy == "short" else "review" + options: dict[str, str | int | bool] = {mode_flag: True} + + for key in _REVIEW_KNOB_KEYS: + value = getattr(review, key) + + if value is not None: + options[key] = value + + if review.base_ref is not None: + options["base_ref"] = review.base_ref + + # The two external-review counters keep 0 verbatim — patience 0 means + # disabled and max_iterations 0 means ralphex auto; only None is unset. + if review.additional.patience is not None: + options["review_patience"] = review.additional.patience + + if review.additional.max_iterations is not None: + options["max_external_iterations"] = review.additional.max_iterations + + return options diff --git a/tests/build/test_pass_options.py b/tests/build/test_pass_options.py new file mode 100644 index 00000000..92257ca4 --- /dev/null +++ b/tests/build/test_pass_options.py @@ -0,0 +1,184 @@ +"""Contract and logic tests for the entity declared in +``goga/build/CODEMANIFEST`` with ``location: pass_options.py``: + +- ``compose_pass_options(settings, stage)`` — the pure composer mapping the + resolved run plan onto the ralphex options of one pass + +Supported data only — no mocks, no filesystem: the composer is a pure +function of the resolved ``RunSettings`` and the stage name. +""" + +from __future__ import annotations + +import dataclasses +import sys + +import goga.ralphex # noqa: F401 — imported for the side effect registering the submodule +import pytest +from goga.build.pass_options import compose_pass_options +from goga.build.run_settings import PassSettings, ReviewPassSettings, RunSettings +from goga.config import AdditionalReviewConfig + +# goga.ralphex.run_ralphex is shadowed in the package __init__ by the +# run_ralphex function, so `import goga.ralphex.run_ralphex as ...` returns +# the function, not the module. Resolve the real module via sys.modules — +# the mirror of tests/ralphex/test_run_ralphex.py. +_launcher = sys.modules["goga.ralphex.run_ralphex"] + + +def _run_settings( + strategy: str = "medium", + tasks: PassSettings | None = None, + base_ref: str | None = None, + patience: int | None = None, + additional_max_iterations: int | None = None, +) -> RunSettings: + """A resolved run plan with the knobs one composition assertion varies.""" + return RunSettings( + tasks=tasks if tasks is not None else PassSettings(), + review=ReviewPassSettings( + strategy=strategy, + base_ref=base_ref, + additional=AdditionalReviewConfig( + agent="claude", + patience=patience, + max_iterations=additional_max_iterations, + ), + ), + ) + + +# --- Contract tests --- + + +class TestComposePassOptionsContract: + def test_composer_is_importable_from_its_module(self) -> None: + """``compose_pass_options`` lives on the ``goga.build.pass_options`` module.""" + import goga.build.pass_options as module + + assert hasattr(module, "compose_pass_options") + + def test_composition_returns_a_plain_dict(self) -> None: + """The composition is a plain ``dict``, not a mapping proxy or subclass.""" + settings = _run_settings() + + assert type(compose_pass_options(settings, "tasks")) is dict + assert type(compose_pass_options(settings, "review")) is dict + + def test_every_emitted_key_maps_to_a_launcher_flag(self) -> None: + """The emitted key universe is exactly the ``run_ralphex`` option-table key set.""" + launcher_keys = {key for key, _flag in _launcher._BOOL_FLAGS + _launcher._SCALAR_FLAGS} + emitted = set() + + for stage in ("tasks", "review"): + for strategy in ("full", "medium", "short"): + settings = dataclasses.replace( + _run_settings( + strategy=strategy, + tasks=PassSettings( + session_timeout="30m", + idle_timeout="5m", + wait="1m", + max_iterations=9, + ), + base_ref="main", + patience=0, + additional_max_iterations=0, + ), + review=ReviewPassSettings( + strategy=strategy, + base_ref="main", + session_timeout="30m", + idle_timeout="5m", + wait="1m", + additional=AdditionalReviewConfig(agent="claude", patience=0, max_iterations=0), + ), + ) + + options = compose_pass_options(settings, stage) + + assert set(options) <= launcher_keys + emitted |= set(options) + + assert emitted == launcher_keys + + +# --- Logic tests --- + + +class TestComposePassOptions: + def test_compose_pass_options_tasks(self) -> None: + """The tasks stage carries the mode flag and the resolved tasks knobs only.""" + settings = _run_settings( + tasks=PassSettings(session_timeout="30m", max_iterations=9), + base_ref="main", + patience=0, + ) + + options = compose_pass_options(settings, "tasks") + + assert options == {"tasks_only": True, "session_timeout": "30m", "max_iterations": 9} + + def test_compose_pass_options_review_medium_and_short(self) -> None: + """The review stage binds its mode flag to the strategy and keeps zero values.""" + settings = dataclasses.replace( + _run_settings( + strategy="medium", + tasks=PassSettings(session_timeout="30m", max_iterations=9), + base_ref="main", + patience=0, + ), + review=ReviewPassSettings( + strategy="medium", + base_ref="main", + session_timeout="30m", + additional=AdditionalReviewConfig(agent="claude", patience=0, max_iterations=None), + ), + ) + + options = compose_pass_options(settings, "review") + + assert options["review"] is True + assert "external_only" not in options + assert options["base_ref"] == "main" + assert options["session_timeout"] == "30m" + assert options["review_patience"] == 0 + assert "max_external_iterations" not in options + assert "max_iterations" not in options + + with_external_cap = _run_settings( + strategy="medium", + base_ref="main", + patience=0, + additional_max_iterations=4, + ) + + assert compose_pass_options(with_external_cap, "review")["max_external_iterations"] == 4 + + short = compose_pass_options(_run_settings(strategy="short", base_ref="main", patience=0), "review") + + assert short["external_only"] is True + assert "review" not in short + + def test_unset_knobs_stay_absent(self) -> None: + """An all-unset plan composes to exactly one mode flag per stage.""" + settings = _run_settings() + + assert compose_pass_options(settings, "tasks") == {"tasks_only": True} + assert compose_pass_options(settings, "review") == {"review": True} + + def test_external_zero_values_travel_verbatim(self) -> None: + """patience 0 and max_external_iterations 0 are meaningful and kept.""" + settings = _run_settings(strategy="short", patience=0, additional_max_iterations=0) + + options = compose_pass_options(settings, "review") + + assert options["review_patience"] == 0 + assert options["max_external_iterations"] == 0 + + def test_unknown_stage_raises(self) -> None: + """A stage outside {tasks, review} is rejected naming the stage.""" + settings = _run_settings() + + with pytest.raises(ValueError, match="unknown build pass stage: combined"): + compose_pass_options(settings, "combined") From 5439944869de276b2eb4cbc37fbb39de4d05e7e6 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 20:57:29 +0000 Subject: [PATCH 093/205] feat: review config semantic validation review_config.py with settings signature (Task 10) --- .goga/history/2026/add-hooks-to-build/plan.md | 18 +- goga/build/review_config.py | 76 +++-- tests/build/test_review_config.py | 295 +++++++++++------- 3 files changed, 236 insertions(+), 153 deletions(-) diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index 8e46c2fc..d549062d 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -1038,15 +1038,15 @@ review/additional wrappers. Tests monkeypatch `resolve_wrapper_path` at its import point (`goga.build.review_config.resolve_wrapper_path`) to a real `tmp_path` file — the established pattern of `tests/build/test_review_config.py`. -- [ ] **Declaration**: Task 10 — review config semantic validation -- [ ] **Contract tests**: in `tests/build/test_review_config.py` — `validate_review_config(settings)` accepts exactly one positional argument of type `RunSettings` (expected to fail at this stage) -- [ ] **Code**: rewrite `goga/build/review_config.py` per the fixed order above (ROLE_WHITELIST constant; relative import of `resolve_wrapper_path` from `..agents`) -- [ ] **Interface verification**: `pytest tests/build/test_review_config.py -x -q` — contract tests pass -- [ ] **Logic tests**: `test_validate_review_config_accepts_clean_settings` (tmp wrapper file; monkeypatched `goga.build.review_config.resolve_wrapper_path` → `str(wrapper)`; `RunSettings(skip=False, review=ReviewPassSettings(agent="claude", env={"X":"1"}, roles=["quality"], strategy="medium", additional=AdditionalReviewConfig(agent="claude", patience=None, max_iterations=None), …)` → returns None, no exception); `test_validate_review_config_rejects_bad_fields` (clean baseline; wrapper monkeypatched to an existing tmp file; parametrize mutations: role `"auditor"`; review env non-empty + agent None; wrapper path to a missing file (`/home/goga/bin/ghost-as-claude.sh` via the patch); strategy `"fast"` → `pytest.raises(ValueError, match=…)` naming the role / the env-requires-agent problem / the agent+path / the strategy value; a `skip=True` variant of every mutation returns None); `test_validate_review_config_rejects_missing_additional_wrapper` (baseline `strategy="full"`, `additional.agent="codex"`; monkeypatch so the review agent resolves to an existing `tmp_path` file and the additional agent to a missing path → `pytest.raises(ValueError, match="ghost-as-claude.sh")` naming the additional agent and its path; a `skip=True` variant of the same settings returns None) -- [ ] **Debugging**: `pytest tests/build/test_review_config.py -x -q` — fix implementation code until all tests pass -- [ ] **Contract re-verification**: check order observable via the negative tests (roles → env gate → review wrapper → additional wrapper → strategy) -- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary -- [ ] **Completion**: mark all checkboxes of this task complete +- [x] **Declaration**: Task 10 — review config semantic validation +- [x] **Contract tests**: in `tests/build/test_review_config.py` — `validate_review_config(settings)` accepts exactly one positional argument of type `RunSettings` (expected to fail at this stage) +- [x] **Code**: rewrite `goga/build/review_config.py` per the fixed order above (ROLE_WHITELIST constant; relative import of `resolve_wrapper_path` from `..agents`) +- [x] **Interface verification**: `pytest tests/build/test_review_config.py -x -q` — contract tests pass +- [x] **Logic tests**: `test_validate_review_config_accepts_clean_settings` (tmp wrapper file; monkeypatched `goga.build.review_config.resolve_wrapper_path` → `str(wrapper)`; `RunSettings(skip=False, review=ReviewPassSettings(agent="claude", env={"X":"1"}, roles=["quality"], strategy="medium", additional=AdditionalReviewConfig(agent="claude", patience=None, max_iterations=None), …)` → returns None, no exception); `test_validate_review_config_rejects_bad_fields` (clean baseline; wrapper monkeypatched to an existing tmp file; parametrize mutations: role `"auditor"`; review env non-empty + agent None; wrapper path to a missing file (`/home/goga/bin/ghost-as-claude.sh` via the patch); strategy `"fast"` → `pytest.raises(ValueError, match=…)` naming the role / the env-requires-agent problem / the agent+path / the strategy value; a `skip=True` variant of every mutation returns None); `test_validate_review_config_rejects_missing_additional_wrapper` (baseline `strategy="full"`, `additional.agent="codex"`; monkeypatch so the review agent resolves to an existing `tmp_path` file and the additional agent to a missing path → `pytest.raises(ValueError, match="ghost-as-claude.sh")` naming the additional agent and its path; a `skip=True` variant of the same settings returns None) +- [x] **Debugging**: `pytest tests/build/test_review_config.py -x -q` — fix implementation code until all tests pass +- [x] **Contract re-verification**: check order observable via the negative tests (roles → env gate → review wrapper → additional wrapper → strategy) +- [x] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [x] **Completion**: mark all checkboxes of this task complete ### Task 11: Ralphex defaults sync with finalize materialization — `ralphex_runtime.py` (TDD coding) diff --git a/goga/build/review_config.py b/goga/build/review_config.py index e920ab47..afe7c159 100644 --- a/goga/build/review_config.py +++ b/goga/build/review_config.py @@ -3,54 +3,76 @@ from pathlib import Path from ..agents import resolve_wrapper_path -from ..config import BuildConfig -from .review_options import ReviewOptions +from .run_settings import RunSettings ROLE_WHITELIST: frozenset[str] = frozenset( {"quality", "implementation", "testing", "simplification", "documentation"}, ) +STRATEGY_WHITELIST: frozenset[str] = frozenset({"full", "medium", "short"}) -def validate_review_config( - config: BuildConfig, # noqa: ARG001 — part of the CODEMANIFEST signature - review: ReviewOptions, -) -> None: - """Semantically validate the review configuration of a run whose review phase will run. + +def validate_review_config(settings: RunSettings) -> None: + """Semantically validate the review configuration of a run whose review pass will execute. Raises ValueError naming the invalid value: an unknown reviewer role (the whitelist is synchronized with the default ralphex review agents), a - non-empty review env declared without a review agent (the env-requires- - agent gate), or, in a two-pass run, a review executor whose wrapper script - does not exist. A skipped run returns without any checks — no review phase - of it will execute, so no review field of it is validated. + non-empty review env declared without a review agent (the + env-requires-agent gate), a review agent that resolved to None at all + (reachable only on direct in-container invocation — the host launcher + requires build.agent up front), a review-agent wrapper script that does + not exist, an additional-agent wrapper that does not exist when the + strategy engages the external review (short always; full with an + additional agent), or a strategy outside the full | medium | short + whitelist. A skipped run returns without any checks — no review pass of + it will execute, so no review field of it is validated. The checks run in a fixed order — roles, then the env gate, then the - wrapper existence check — so the first violated rule is the one reported. - All of them live here by design: this routine is what must fail before any - side effect — before .ralphex/ is written and before ralphex is launched. - `resolve_wrapper_path` stays a pure string builder (the boundary owned by - goga/agents); the task executor wrapper is deliberately not validated, its - absence surfaces at ralphex time. + review-agent wrapper, then the additional-agent wrapper, then the + strategy — so the first violated rule is the one reported. All of them + live here by design: this routine is what must fail before any side + effect — before .ralphex/ is written and before the first checkpoint + fires. `resolve_wrapper_path` stays a pure string builder (the boundary + owned by goga/agents); the tasks-pass agent wrapper is deliberately not + validated, its absence surfaces at ralphex time. Args: - config: Build configuration of the run (context of the review decision). - review: Resolved review options; skip, roles, review_env, review_agent - and two_pass are read. + settings: Resolved run plan of the build; skip and every review fact + the checks read (roles, env, agent, additional, strategy) come + from its review part. """ - if review.skip: + if settings.skip: return + review = settings.review + for role in review.roles or []: if role not in ROLE_WHITELIST: raise ValueError(f"unknown review role: {role!r}; expected one of {sorted(ROLE_WHITELIST)}") - if review.review_env and review.review_agent is None: - raise ValueError("review env requires a review agent: set build.review_executor.agent") + if review.env and review.agent is None: + raise ValueError("review env requires a review agent: set build.review.agent") + + if review.agent is None: + raise ValueError("no review agent resolved: set build.agent or build.review.agent") + + wrapper = resolve_wrapper_path(review.agent) - if review.two_pass: - wrapper = resolve_wrapper_path(review.review_agent) + if not Path(wrapper).is_file(): + raise ValueError(f"review agent wrapper not found: {wrapper} (agent {review.agent!r})") - if not Path(wrapper).is_file(): + additional_agent = review.additional.agent if review.additional is not None else None + + if review.strategy == "short" or (review.strategy == "full" and additional_agent is not None): + additional_wrapper = resolve_wrapper_path(additional_agent) + + if not Path(additional_wrapper).is_file(): raise ValueError( - f"review executor wrapper not found: {wrapper} (agent {review.review_agent!r})", + f"additional review agent wrapper not found: {additional_wrapper} " + f"(agent {additional_agent!r})", ) + + if review.strategy not in STRATEGY_WHITELIST: + raise ValueError( + f"unknown review strategy: {review.strategy!r}; expected one of {sorted(STRATEGY_WHITELIST)}", + ) diff --git a/tests/build/test_review_config.py b/tests/build/test_review_config.py index 2c9f3a99..f298e360 100644 --- a/tests/build/test_review_config.py +++ b/tests/build/test_review_config.py @@ -1,18 +1,51 @@ from __future__ import annotations +import dataclasses import inspect import typing from pathlib import Path import pytest from goga.build.review_config import ROLE_WHITELIST, validate_review_config -from goga.build.review_options import ReviewOptions -from goga.config import BuildConfig, TaskExecutorConfig +from goga.build.run_settings import PassSettings, ReviewPassSettings, RunSettings +from goga.config import AdditionalReviewConfig +WRAPPER_PATCH_TARGET = "goga.build.review_config.resolve_wrapper_path" -def _make_build_config(task_agent: str = "claude", **kwargs) -> BuildConfig: - task_executor = TaskExecutorConfig(agent=task_agent, env={}) - return BuildConfig(task_executor=task_executor, **kwargs) + +def _make_settings( + agent: str | None = "claude", + env: dict[str, str] | None = None, + roles: list[str] | None = None, + strategy: str = "medium", + additional_agent: str | None = None, +) -> RunSettings: + """Clean baseline settings (skip False); each mutation below changes exactly one fact.""" + return RunSettings( + skip=False, + tasks=PassSettings(agent="claude", env={}), + review=ReviewPassSettings( + agent=agent, + env=env if env is not None else {}, + roles=roles, + strategy=strategy, + additional=AdditionalReviewConfig(agent=additional_agent, patience=None, max_iterations=None), + ), + ) + + +MISSING_WRAPPER = "/home/goga/bin/ghost-as-claude.sh" + + +def _patch_wrapper(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, existing: str) -> None: + """Patch wrapper resolution: ``existing`` maps to a real tmp file, others to a missing path.""" + wrapper = tmp_path / f"{existing}-as-claude.sh" + wrapper.write_text("#!/bin/sh\n") + + def fake_resolve(agent: str) -> str: + return str(wrapper) if agent == existing else MISSING_WRAPPER + + monkeypatch.setattr(WRAPPER_PATCH_TARGET, fake_resolve) class TestValidateReviewConfigContract: @@ -22,15 +55,11 @@ def test_validate_review_config_importable_from_module(self) -> None: def test_validate_review_config_has_correct_signature(self) -> None: sig = inspect.signature(validate_review_config) params = list(sig.parameters.keys()) - assert params == ["config", "review"] - - def test_validate_review_config_config_param_type(self) -> None: - hints = typing.get_type_hints(validate_review_config) - assert hints["config"] is BuildConfig + assert params == ["settings"] - def test_validate_review_config_review_param_type(self) -> None: + def test_validate_review_config_settings_param_type(self) -> None: hints = typing.get_type_hints(validate_review_config) - assert hints["review"] is ReviewOptions + assert hints["settings"] is RunSettings def test_validate_review_config_returns_none(self) -> None: hints = typing.get_type_hints(validate_review_config) @@ -48,143 +77,175 @@ def test_role_whitelist_is_frozenset_of_five_names(self) -> None: class TestValidateReviewConfigLogic: - def test_validate_review_config_passes_whitelist_roles(self) -> None: - config = _make_build_config() - review = ReviewOptions( - skip=False, review_agent=None, roles=["quality", "testing"], two_pass=False, review_env={} - ) - - validate_review_config(config, review) + def test_validate_review_config_accepts_clean_settings( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A clean resolved plan passes every check — the review wrapper exists via the patch.""" + _patch_wrapper(tmp_path, monkeypatch, existing="claude") + + settings = _make_settings(env={"X": "1"}, roles=["quality"]) + + assert validate_review_config(settings) is None + + @pytest.mark.parametrize( + ("settings", "match"), + [ + pytest.param(_make_settings(roles=["auditor"]), "auditor", id="unknown-role"), + pytest.param( + _make_settings(env={"X": "1"}, agent=None), + r"env requires a review agent", + id="env-without-agent", + ), + pytest.param( + _make_settings(agent="ghost"), + r"ghost-as-claude\.sh \(agent 'ghost'\)", + id="missing-review-wrapper", + ), + pytest.param(_make_settings(strategy="fast"), "fast", id="unknown-strategy"), + ], + ) + def test_validate_review_config_rejects_bad_fields( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + settings: RunSettings, + match: str, + ) -> None: + """Each mutation raises ValueError naming the invalid value.""" + _patch_wrapper(tmp_path, monkeypatch, existing="claude") + + with pytest.raises(ValueError, match=match): + validate_review_config(settings) + + @pytest.mark.parametrize( + "settings", + [ + pytest.param(_make_settings(roles=["auditor"]), id="unknown-role"), + pytest.param(_make_settings(env={"X": "1"}, agent=None), id="env-without-agent"), + pytest.param(_make_settings(agent="ghost"), id="missing-review-wrapper"), + pytest.param(_make_settings(strategy="fast"), id="unknown-strategy"), + ], + ) + def test_validate_review_config_skipped_variant_of_every_mutation_returns_none( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + settings: RunSettings, + ) -> None: + """A skipped run validates nothing — every mutation returns None under skip=True.""" + _patch_wrapper(tmp_path, monkeypatch, existing="claude") - def test_validate_review_config_unknown_role_raises(self) -> None: - config = _make_build_config() - review = ReviewOptions(skip=False, review_agent=None, roles=["bogus"], two_pass=False, review_env={}) + assert validate_review_config(dataclasses.replace(settings, skip=True)) is None - with pytest.raises(ValueError, match="bogus"): - validate_review_config(config, review) + def test_validate_review_config_rejects_missing_additional_wrapper( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Under full with an additional agent, its wrapper is resolved and existence-checked + the same way — the error names the additional agent and its path.""" + _patch_wrapper(tmp_path, monkeypatch, existing="claude") - def test_validate_review_config_unknown_role_message_lists_whitelist(self) -> None: - config = _make_build_config() - review = ReviewOptions(skip=False, review_agent=None, roles=["bogus"], two_pass=False, review_env={}) + settings = _make_settings(strategy="full", additional_agent="codex") - with pytest.raises(ValueError, match=r"quality.*simplification"): - validate_review_config(config, review) + with pytest.raises(ValueError, match=r"ghost-as-claude\.sh \(agent 'codex'\)"): + validate_review_config(settings) - def test_validate_review_config_missing_review_wrapper_raises(self) -> None: - config = _make_build_config() - review = ReviewOptions(skip=False, review_agent="ghost", roles=None, two_pass=True, review_env={}) + assert validate_review_config(dataclasses.replace(settings, skip=True)) is None - with pytest.raises(ValueError, match=r"ghost-as-claude\.sh"): - validate_review_config(config, review) + def test_validate_review_config_no_resolved_agent_raises( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The degenerate in-container case: no agent at all resolved (empty env, so the env + gate stays silent) — resolve_wrapper_path(None) must never build a nonsense path.""" + _patch_wrapper(tmp_path, monkeypatch, existing="claude") - def test_validate_review_config_missing_review_wrapper_message_names_agent(self) -> None: - config = _make_build_config() - review = ReviewOptions(skip=False, review_agent="ghost", roles=None, two_pass=True, review_env={}) + settings = _make_settings(agent=None, env={}) - with pytest.raises(ValueError, match="ghost"): - validate_review_config(config, review) + with pytest.raises(ValueError, match=r"no review agent resolved: set build\.agent or build\.review\.agent"): + validate_review_config(settings) - def test_validate_review_config_existing_wrapper_passes( + def test_validate_review_config_env_gate_names_new_key( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - wrapper = tmp_path / "codex-as-claude.sh" - wrapper.write_text("#!/bin/sh\n") - monkeypatch.setattr("goga.build.review_config.resolve_wrapper_path", lambda _agent: str(wrapper)) - - config = _make_build_config() - review = ReviewOptions(skip=False, review_agent="codex", roles=None, two_pass=True, review_env={}) - - validate_review_config(config, review) + """The env gate message points at the live config key build.review.agent.""" + _patch_wrapper(tmp_path, monkeypatch, existing="claude") - def test_validate_review_config_skipped_run_no_checks(self) -> None: - config = _make_build_config() - review = ReviewOptions(skip=True, roles=["bogus"], review_agent="ghost", two_pass=True, review_env={}) + settings = _make_settings(env={"X": "1"}, agent=None) - validate_review_config(config, review) + with pytest.raises(ValueError, match=r"set build\.review\.agent"): + validate_review_config(settings) - @pytest.mark.parametrize("roles", [None, []]) - def test_validate_review_config_none_and_empty_roles_no_iteration(self, roles: list[str] | None) -> None: - config = _make_build_config() - review = ReviewOptions(skip=False, review_agent=None, roles=roles, two_pass=False, review_env={}) + def test_validate_review_config_medium_never_checks_additional_wrapper( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Medium disables the external review — the additional wrapper is never resolved, + so a missing one passes clean (codex_enabled = false parity).""" + _patch_wrapper(tmp_path, monkeypatch, existing="claude") - validate_review_config(config, review) + settings = _make_settings(strategy="medium", additional_agent="ghost") - def test_validate_review_config_all_whitelist_roles_pass(self) -> None: - config = _make_build_config() - review = ReviewOptions( - skip=False, - review_agent=None, - roles=["quality", "implementation", "testing", "simplification", "documentation"], - two_pass=False, - review_env={}, - ) + assert validate_review_config(settings) is None - validate_review_config(config, review) + def test_validate_review_config_short_with_existing_additional_wrapper_passes( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Short always engages the external review — an existing additional wrapper passes.""" + _patch_wrapper(tmp_path, monkeypatch, existing="codex") - def test_validate_review_config_checks_roles_before_wrapper(self, tmp_path: Path) -> None: - """The role check runs first — an unknown role raises even when two_pass would also fail.""" - config = _make_build_config() - review = ReviewOptions(skip=False, review_agent="ghost", roles=["bogus"], two_pass=True, review_env={}) + settings = _make_settings(agent="codex", strategy="short", additional_agent="codex") - with pytest.raises(ValueError, match="bogus"): - validate_review_config(config, review) + assert validate_review_config(settings) is None - def test_validate_review_config_no_two_pass_skips_wrapper_check(self) -> None: - """two_pass False never resolves the wrapper — the task executor stays out of scope.""" - config = _make_build_config() - review = ReviewOptions(skip=False, review_agent="ghost", roles=None, two_pass=False, review_env={}) + @pytest.mark.parametrize("roles", [None, []]) + def test_validate_review_config_none_and_empty_roles_no_iteration( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, roles: list[str] | None + ) -> None: + """No declared roles means nothing to check against the whitelist.""" + _patch_wrapper(tmp_path, monkeypatch, existing="claude") - validate_review_config(config, review) + assert validate_review_config(_make_settings(roles=roles)) is None - def test_validate_review_config_env_with_agent_passes( + def test_validate_review_config_roles_before_env_gate( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A non-empty review env with a review agent reaches (and passes) the wrapper check.""" - wrapper = tmp_path / "codex-as-claude.sh" - wrapper.write_text("#!/bin/sh\n") - monkeypatch.setattr("goga.build.review_config.resolve_wrapper_path", lambda _agent: str(wrapper)) - - config = _make_build_config() - review = ReviewOptions(skip=False, review_agent="codex", roles=None, two_pass=True, review_env={"X": "y"}) + """The role check runs first — an unknown role raises even when the env gate would too.""" + _patch_wrapper(tmp_path, monkeypatch, existing="claude") - validate_review_config(config, review) + settings = _make_settings(roles=["auditor"], env={"X": "1"}, agent=None) - def test_validate_review_config_env_without_agent_raises(self) -> None: - """The gate names the offending value and the config key to set — both - halves of the error literal are pinned in one raise.""" - config = _make_build_config() - review = ReviewOptions(skip=False, review_agent=None, roles=None, two_pass=False, review_env={"X": "y"}) + with pytest.raises(ValueError, match="auditor"): + validate_review_config(settings) - with pytest.raises(ValueError, match=r"review env requires a review agent: set build\.review_executor\.agent"): - validate_review_config(config, review) + def test_validate_review_config_env_gate_before_wrapper_check( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The env gate sits between the roles and the wrapper check: env + no agent reports + the env gate, not the wrapper resolution.""" + _patch_wrapper(tmp_path, monkeypatch, existing="claude") - def test_validate_review_config_env_gate_skipped_run_silent(self) -> None: - """A skipped run never validates the review env — the layer is ignored entirely.""" - config = _make_build_config() - review = ReviewOptions(skip=True, review_agent=None, roles=None, two_pass=False, review_env={"X": "y"}) + settings = _make_settings(env={"X": "1"}, agent=None) - validate_review_config(config, review) + with pytest.raises(ValueError, match=r"env requires a review agent"): + validate_review_config(settings) - def test_validate_review_config_empty_env_no_agent_passes(self) -> None: - """An empty review env never triggers the gate — byte-compat with configs without env.""" - config = _make_build_config() - review = ReviewOptions(skip=False, review_agent=None, roles=None, two_pass=False, review_env={}) + def test_validate_review_config_review_wrapper_before_additional_wrapper( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Both wrappers missing under short — the review-agent wrapper is reported first.""" + _patch_wrapper(tmp_path, monkeypatch, existing="claude") - validate_review_config(config, review) + settings = _make_settings(agent="ghost", strategy="short", additional_agent="specter") - def test_validate_review_config_roles_before_env_gate(self) -> None: - """The role check runs first — an unknown role raises even when the env gate would also fail.""" - config = _make_build_config() - review = ReviewOptions(skip=False, review_agent=None, roles=["bogus"], two_pass=False, review_env={"X": "y"}) + with pytest.raises(ValueError, match=r"ghost-as-claude\.sh \(agent 'ghost'\)"): + validate_review_config(settings) - with pytest.raises(ValueError, match="bogus"): - validate_review_config(config, review) + def test_validate_review_config_strategy_check_last( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An unknown strategy never engages the additional-wrapper check (short always, + full only with an additional agent) — the strategy error is what surfaces.""" + _patch_wrapper(tmp_path, monkeypatch, existing="claude") - def test_validate_review_config_env_gate_before_wrapper_check(self) -> None: - """The env gate sits between the roles and the wrapper check: env + no agent + two_pass - configured by hand still reports the env gate, not the wrapper resolution.""" - config = _make_build_config() - review = ReviewOptions(skip=False, review_agent=None, roles=None, two_pass=True, review_env={"X": "y"}) + settings = _make_settings(strategy="fast", additional_agent="ghost") - with pytest.raises(ValueError, match=r"review env requires a review agent"): - validate_review_config(config, review) + with pytest.raises(ValueError, match="fast"): + validate_review_config(settings) From c55b7d9f64bbcc466be72c10f2e27e5d563ca2d3 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 21:01:00 +0000 Subject: [PATCH 094/205] feat: ralphex defaults sync with finalize materialization ralphex_runtime.py (Task 11) --- .goga/history/2026/add-hooks-to-build/plan.md | 18 +-- goga/build/ralphex_runtime.py | 46 ++++--- tests/build/test_ralphex_runtime.py | 115 +++++++++++------- 3 files changed, 108 insertions(+), 71 deletions(-) diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index d549062d..2d8f8892 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -1088,15 +1088,15 @@ Checkpoint: byte-identity of the default composition (full role set / no roles) — existing guard values kept; finalize materialization gated on the prompt being set. -- [ ] **Declaration**: Task 11 — ralphex defaults sync with finalize -- [ ] **Contract tests**: in `tests/build/test_ralphex_runtime.py` — `sync_ralphex_defaults(config, settings)` two-argument signature over `(BuildConfig, RunSettings)` (expected to fail at this stage) -- [ ] **Code**: re-signature `goga/build/ralphex_runtime.py`; roles now read from `settings.review.roles`; add the finalize materialization step -- [ ] **Interface verification**: `pytest tests/build/test_ralphex_runtime.py -x -q` — contract tests pass -- [ ] **Logic tests**: `test_sync_ralphex_defaults_materializes_finalize` (tmp cwd; vendored sources exist — use the real vendored dirs, custom `prompts_dir`/`agents_dir` pointing at tmp copies when isolation is needed; `RunSettings` with `finalize="Final pass: merge the review."` → `(tmp_path / ".ralphex/agents/finalize.txt").read_text() == "Final pass: merge the review."`; unset-finalize variant → file absent); keep the existing roles-filtering tests green on the new signature (full default set → byte-identical prompts) -- [ ] **Debugging**: `pytest tests/build/test_ralphex_runtime.py -x -q` — fix implementation code until all tests pass -- [ ] **Contract re-verification**: `.ralphex/config` untouched by this routine (owned by Task 12's routine) -- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary -- [ ] **Completion**: mark all checkboxes of this task complete +- [x] **Declaration**: Task 11 — ralphex defaults sync with finalize +- [x] **Contract tests**: in `tests/build/test_ralphex_runtime.py` — `sync_ralphex_defaults(config, settings)` two-argument signature over `(BuildConfig, RunSettings)` (expected to fail at this stage) +- [x] **Code**: re-signature `goga/build/ralphex_runtime.py`; roles now read from `settings.review.roles`; add the finalize materialization step +- [x] **Interface verification**: `pytest tests/build/test_ralphex_runtime.py -x -q` — contract tests pass +- [x] **Logic tests**: `test_sync_ralphex_defaults_materializes_finalize` (tmp cwd; vendored sources exist — use the real vendored dirs, custom `prompts_dir`/`agents_dir` pointing at tmp copies when isolation is needed; `RunSettings` with `finalize="Final pass: merge the review."` → `(tmp_path / ".ralphex/agents/finalize.txt").read_text() == "Final pass: merge the review."`; unset-finalize variant → file absent); keep the existing roles-filtering tests green on the new signature (full default set → byte-identical prompts) +- [x] **Debugging**: `pytest tests/build/test_ralphex_runtime.py -x -q` — fix implementation code until all tests pass +- [x] **Contract re-verification**: `.ralphex/config` untouched by this routine (owned by Task 12's routine) +- [x] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [x] **Completion**: mark all checkboxes of this task complete ### Task 12: Ralphex config generation with external surface — `ralphex_config.py` (TDD coding) diff --git a/goga/build/ralphex_runtime.py b/goga/build/ralphex_runtime.py index 83439171..06c9824b 100644 --- a/goga/build/ralphex_runtime.py +++ b/goga/build/ralphex_runtime.py @@ -5,7 +5,7 @@ from pathlib import Path from ..config import BuildConfig -from .review_options import ReviewOptions +from .run_settings import RunSettings logger = logging.getLogger(__name__) @@ -89,7 +89,7 @@ def _filter_review_prompt(text: str, selected: list[str]) -> str: return result -def sync_ralphex_defaults(config: BuildConfig, review: ReviewOptions) -> None: +def sync_ralphex_defaults(config: BuildConfig, settings: RunSettings) -> None: """Fully rewrite .ralphex/prompts/ and .ralphex/agents/ from their sources. Sources are the configured custom `prompts_dir`/`agents_dir` of `BuildConfig` @@ -98,16 +98,23 @@ def sync_ralphex_defaults(config: BuildConfig, review: ReviewOptions) -> None: previous run never survive. The agents directory is always copied whole (all review-agent definitions), regardless of the declared roles. - When `review.roles` is a non-empty list, both review prompts are filtered to - the selected roles: unselected `{{agent:X}}` lines are dropped and the - accompanying text (agent counters, launch wording) is adapted to the number - of remaining agent lines. With the full default set — or no roles at all — - the prompts land byte-identical to their sources; custom directories are - copied as-is, without filtering. + When the roles of the review part are a non-empty list, both review prompts + are filtered to the selected roles: unselected `{{agent:X}}` lines are + dropped and the accompanying text (agent counters, launch wording) is + adapted to the number of remaining agent lines. With the full default set — + or no roles at all — the prompts land byte-identical to their sources; + custom directories are copied as-is, without filtering. + + When the finalize prompt of the review part is set, its string is written + verbatim to `.ralphex/agents/finalize.txt` — goga's own step artifact, so + the materialization applies regardless of a custom agents_dir; when unset, + nothing is written and the step stays at the ralphex default (off). + `.ralphex/config` is never touched here — it belongs to the config routine. Args: config: Build configuration with the optional prompts_dir / agents_dir fields. - review: Resolved review options; only `roles` is read (duck-typed). + settings: Resolved run plan; the roles and the finalize prompt of its + review part drive the filtering and the materialization. """ prompts_src = Path(config.prompts_dir) if config.prompts_dir else _VENDORED_PROMPTS agents_src = Path(config.agents_dir) if config.agents_dir else _VENDORED_AGENTS @@ -124,19 +131,20 @@ def sync_ralphex_defaults(config: BuildConfig, review: ReviewOptions) -> None: _rewrite_dir(prompts_src, ralphex_dir / "prompts") _rewrite_dir(agents_src, ralphex_dir / "agents") - roles = review.roles - - if not roles: - logger.info("synced ralphex defaults", extra={"prompts": str(prompts_src), "agents": str(agents_src)}) - return + roles = settings.review.roles - if config.prompts_dir is None: + if roles and config.prompts_dir is None: for name in ("review_first.txt", "review_second.txt"): prompt_file = ralphex_dir / "prompts" / name filtered = _filter_review_prompt(prompt_file.read_text(), roles) prompt_file.write_text(filtered) - logger.info( - "synced ralphex defaults", - extra={"prompts": str(prompts_src), "agents": str(agents_src), "roles": list(roles)}, - ) + if settings.review.finalize is not None: + (ralphex_dir / "agents" / "finalize.txt").write_text(settings.review.finalize) + + extra: dict[str, object] = {"prompts": str(prompts_src), "agents": str(agents_src)} + + if roles: + extra["roles"] = list(roles) + + logger.info("synced ralphex defaults", extra=extra) diff --git a/tests/build/test_ralphex_runtime.py b/tests/build/test_ralphex_runtime.py index c85e1e3c..ad8ce57c 100644 --- a/tests/build/test_ralphex_runtime.py +++ b/tests/build/test_ralphex_runtime.py @@ -1,13 +1,13 @@ from __future__ import annotations import inspect -from dataclasses import dataclass +import typing from pathlib import Path import pytest from goga.build.ralphex_runtime import sync_ralphex_defaults -from goga.build.review_options import ReviewOptions -from goga.config import BuildConfig, TaskExecutorConfig +from goga.build.run_settings import PassSettings, ReviewPassSettings, RunSettings +from goga.config import AdditionalReviewConfig, BuildConfig _PROMPT_ROLES = ("quality", "implementation", "testing", "simplification", "documentation") _SECOND_ROLES = ("quality", "implementation") @@ -88,19 +88,24 @@ _CODEX_TEMPLATE = "# codex review prompt\n" -@dataclass(kw_only=True, frozen=True) -class _StubReview: - """Duck-typed stand-in for ReviewOptions until Task 7 lands.""" - - skip: bool = False - review_agent: str | None = None - roles: list[str] | None = None - two_pass: bool = False +def _make_build_config(**kwargs) -> BuildConfig: + return BuildConfig(agent=kwargs.pop("agent", "claude"), env={}, **kwargs) -def _make_build_config(**kwargs) -> BuildConfig: - task_executor = TaskExecutorConfig(agent=kwargs.pop("agent", "claude"), env={}) - return BuildConfig(task_executor=task_executor, **kwargs) +def _make_settings(roles: list[str] | None = None, finalize: str | None = None) -> RunSettings: + """Run plan carrying exactly the facts the sync reads: roles and finalize of the review part.""" + return RunSettings( + skip=False, + tasks=PassSettings(agent="claude", env={}), + review=ReviewPassSettings( + agent="claude", + env={}, + roles=roles, + strategy="medium", + finalize=finalize, + additional=AdditionalReviewConfig(agent="claude", patience=None, max_iterations=None), + ), + ) def _write_prompt_sources(prompts_dir: Path) -> None: @@ -138,28 +143,16 @@ def test_sync_ralphex_defaults_importable_from_module(self) -> None: def test_sync_ralphex_defaults_has_correct_signature(self) -> None: sig = inspect.signature(sync_ralphex_defaults) params = list(sig.parameters.keys()) - assert params == ["config", "review"] + assert params == ["config", "settings"] - def test_sync_ralphex_defaults_config_param_type(self) -> None: - # ReviewOptions (Task 7) stays an unresolvable string annotation by design, - # so raw signature annotations are asserted instead of get_type_hints. - sig = inspect.signature(sync_ralphex_defaults) - assert sig.parameters["config"].annotation == "BuildConfig" - - def test_sync_ralphex_defaults_review_param_is_string_annotation(self) -> None: - """`from __future__ import annotations` keeps every annotation a string, import or not.""" - sig = inspect.signature(sync_ralphex_defaults) - assert sig.parameters["review"].annotation == "ReviewOptions" + def test_sync_ralphex_defaults_param_types(self) -> None: + hints = typing.get_type_hints(sync_ralphex_defaults) + assert hints.get("config") is BuildConfig + assert hints.get("settings") is RunSettings def test_sync_ralphex_defaults_returns_none(self) -> None: - sig = inspect.signature(sync_ralphex_defaults) - assert sig.return_annotation == "None" - - def test_review_options_imported_from_sibling_module(self) -> None: - """Since Task 7 the parameter type is a real relative import, not a duck-typed placeholder.""" - import goga.build.ralphex_runtime as module - - assert module.ReviewOptions is ReviewOptions + hints = typing.get_type_hints(sync_ralphex_defaults) + assert hints.get("return") is type(None) def test_vendored_constants_point_into_assets(self) -> None: from goga.build.ralphex_runtime import _VENDORED_AGENTS, _VENDORED_PROMPTS @@ -178,7 +171,7 @@ def test_sync_ralphex_defaults_full_rewrite_byte_identical(self, tmp_path, monke stale.mkdir(parents=True) (stale / "obsolete.txt").write_text("stale content\n") - sync_ralphex_defaults(_make_build_config(), _StubReview(roles=None)) + sync_ralphex_defaults(_make_build_config(), _make_settings(roles=None)) dest_prompts = tmp_path / ".ralphex" / "prompts" assert not (dest_prompts / "obsolete.txt").exists() @@ -201,7 +194,7 @@ def test_sync_ralphex_defaults_full_roles_byte_identical(self, tmp_path, monkeyp prompts_src, _ = vendored_sources monkeypatch.chdir(tmp_path) - sync_ralphex_defaults(_make_build_config(), _StubReview(roles=list(_PROMPT_ROLES))) + sync_ralphex_defaults(_make_build_config(), _make_settings(roles=list(_PROMPT_ROLES))) dest_prompts = tmp_path / ".ralphex" / "prompts" assert (dest_prompts / "review_first.txt").read_bytes() == (prompts_src / "review_first.txt").read_bytes() @@ -211,7 +204,7 @@ def test_sync_filters_roles_and_adapts_counters(self, tmp_path, monkeypatch, ven prompts_src, _ = vendored_sources monkeypatch.chdir(tmp_path) - sync_ralphex_defaults(_make_build_config(), _StubReview(roles=["quality", "testing"])) + sync_ralphex_defaults(_make_build_config(), _make_settings(roles=["quality", "testing"])) first = (tmp_path / ".ralphex" / "prompts" / "review_first.txt").read_text() assert "{{agent:quality}}" in first @@ -233,7 +226,7 @@ def test_sync_ralphex_defaults_empty_roles_eq_absent(self, tmp_path, monkeypatch prompts_src, _ = vendored_sources monkeypatch.chdir(tmp_path) - sync_ralphex_defaults(_make_build_config(), _StubReview(roles=[])) + sync_ralphex_defaults(_make_build_config(), _make_settings(roles=[])) dest_prompts = tmp_path / ".ralphex" / "prompts" assert (dest_prompts / "review_first.txt").read_bytes() == (prompts_src / "review_first.txt").read_bytes() @@ -254,12 +247,12 @@ def test_sync_ralphex_defaults_missing_vendored_source_raises(self, tmp_path, mo ) with pytest.raises(ValueError, match="dump-defaults"): - sync_ralphex_defaults(_make_build_config(), _StubReview(roles=None)) + sync_ralphex_defaults(_make_build_config(), _make_settings(roles=None)) def test_sync_ralphex_defaults_empty_intersection(self, tmp_path, monkeypatch, vendored_sources) -> None: monkeypatch.chdir(tmp_path) - sync_ralphex_defaults(_make_build_config(), _StubReview(roles=["codex"])) + sync_ralphex_defaults(_make_build_config(), _make_settings(roles=["codex"])) first = (tmp_path / ".ralphex" / "prompts" / "review_first.txt").read_text() second = (tmp_path / ".ralphex" / "prompts" / "review_second.txt").read_text() @@ -282,7 +275,7 @@ def test_sync_ralphex_defaults_custom_dirs_copied_as_is(self, tmp_path, monkeypa (custom_agents / "quality.txt").write_text("custom quality agent\n") config = _make_build_config(prompts_dir=str(custom_prompts), agents_dir=str(custom_agents)) - sync_ralphex_defaults(config, _StubReview(roles=["quality"])) + sync_ralphex_defaults(config, _make_settings(roles=["quality"])) dest_prompts = tmp_path / ".ralphex" / "prompts" assert (dest_prompts / "review_first.txt").read_bytes() == (custom_prompts / "review_first.txt").read_bytes() @@ -306,7 +299,7 @@ def test_sync_ralphex_defaults_partial_custom_independent_sources(self, tmp_path config = _make_build_config(agents_dir=str(custom_agents)) with pytest.raises(ValueError, match="vendored ralphex defaults not found") as excinfo: - sync_ralphex_defaults(config, _StubReview(roles=None)) + sync_ralphex_defaults(config, _make_settings(roles=None)) assert "prompts" in str(excinfo.value) assert str(tmp_path / "does-not-exist" / "prompts") in str(excinfo.value) @@ -314,7 +307,7 @@ def test_sync_ralphex_defaults_partial_custom_independent_sources(self, tmp_path def test_filter_review_prompt_counts_by_remaining_lines(self, tmp_path, monkeypatch, vendored_sources) -> None: monkeypatch.chdir(tmp_path) - sync_ralphex_defaults(_make_build_config(), _StubReview(roles=["testing"])) + sync_ralphex_defaults(_make_build_config(), _make_settings(roles=["testing"])) first = (tmp_path / ".ralphex" / "prompts" / "review_first.txt").read_text() assert "{{agent:testing}}" in first @@ -328,7 +321,7 @@ def test_filter_review_prompt_zero_remaining_lines_leaves_counters_untouched( accompanying text keeps its source wording, with no counter rewrites.""" monkeypatch.chdir(tmp_path) - sync_ralphex_defaults(_make_build_config(), _StubReview(roles=["testing"])) + sync_ralphex_defaults(_make_build_config(), _make_settings(roles=["testing"])) second = (tmp_path / ".ralphex" / "prompts" / "review_second.txt").read_text() @@ -340,3 +333,39 @@ def test_filter_review_prompt_zero_remaining_lines_leaves_counters_untouched( assert "uses 0 agents" not in second assert "The agent invocation" not in second assert "until the agent" not in second + + def test_sync_ralphex_defaults_materializes_finalize(self, tmp_path, monkeypatch, vendored_sources) -> None: + monkeypatch.chdir(tmp_path) + + settings = _make_settings(finalize="Final pass: merge the review.") + sync_ralphex_defaults(_make_build_config(), settings) + + finalize_file = tmp_path / ".ralphex" / "agents" / "finalize.txt" + assert finalize_file.read_text() == "Final pass: merge the review." + + # The step artifact is goga's own file — it materializes even when the + # agents source is a custom directory that does not carry it. + custom_agents = tmp_path / "custom-agents" + custom_agents.mkdir() + (custom_agents / "quality.txt").write_text("custom quality agent\n") + + sync_ralphex_defaults(_make_build_config(agents_dir=str(custom_agents)), settings) + assert finalize_file.read_text() == "Final pass: merge the review." + + # Unset finalize writes nothing — and the full rewrite clears a stale + # finalize.txt left by a previous run, so the step falls back to the + # ralphex default (off). + sync_ralphex_defaults(_make_build_config(), _make_settings(finalize=None)) + assert not finalize_file.exists() + + def test_sync_ralphex_defaults_never_touches_ralphex_config(self, tmp_path, monkeypatch, vendored_sources) -> None: + """`.ralphex/config` belongs to the config routine; the sync leaves it byte-identical.""" + monkeypatch.chdir(tmp_path) + ralphex_dir = tmp_path / ".ralphex" + ralphex_dir.mkdir() + sentinel = ralphex_dir / "config" + sentinel.write_text("claude_command = /sentinel\n") + + sync_ralphex_defaults(_make_build_config(), _make_settings(roles=["quality"], finalize="done")) + + assert sentinel.read_text() == "claude_command = /sentinel\n" From 413dd1031040ca77838aedb06c4e8309167f37bc Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 21:04:30 +0000 Subject: [PATCH 095/205] feat: ralphex config generation ralphex_config.py with external review surface (Task 12) --- .goga/history/2026/add-hooks-to-build/plan.md | 18 +- goga/build/ralphex_config.py | 68 +++-- tests/build/test_ralphex_config.py | 239 ++++++++++++------ 3 files changed, 217 insertions(+), 108 deletions(-) diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index 2d8f8892..b2c1fe3e 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -1139,19 +1139,19 @@ INI lines joined with `\n` + trailing newline. Tasks-pass config carries the review keys too — harmless: `--tasks-only` ignores every review-phase key (practice note); keeps the routine a pure function of (settings, wrapper). -- [ ] **Declaration**: Task 12 — ralphex config generation with external surface -- [ ] **Contract tests**: in `tests/build/test_ralphex_config.py` — `write_ralphex_config(settings, wrapper_path)` signature (expected to fail at this stage) -- [ ] **Code**: rewrite `goga/build/ralphex_config.py` per the trace; add the `resolve_wrapper_path` import -- [ ] **Interface verification**: `pytest tests/build/test_ralphex_config.py -x -q` — contract tests pass -- [ ] **Logic tests**: `test_write_ralphex_config_strategies` (tmp cwd; three `RunSettings` variants — medium / full with additional agent "codex" / finalize set; wrapper path `"/home/goga/bin/claude-as-claude.sh"`; monkeypatch `goga.build.ralphex_config.resolve_wrapper_path` for the additional wrapper; assert +- [x] **Declaration**: Task 12 — ralphex config generation with external surface +- [x] **Contract tests**: in `tests/build/test_ralphex_config.py` — `write_ralphex_config(settings, wrapper_path)` signature (expected to fail at this stage) +- [x] **Code**: rewrite `goga/build/ralphex_config.py` per the trace; add the `resolve_wrapper_path` import +- [x] **Interface verification**: `pytest tests/build/test_ralphex_config.py -x -q` — contract tests pass +- [x] **Logic tests**: `test_write_ralphex_config_strategies` (tmp cwd; three `RunSettings` variants — medium / full with additional agent "codex" / finalize set; wrapper path `"/home/goga/bin/claude-as-claude.sh"`; monkeypatch `goga.build.ralphex_config.resolve_wrapper_path` for the additional wrapper; assert `medium: "codex_enabled = false" in text; "external_review_tool" not in text`; `full+additional: "external_review_tool = custom" in text and f"custom_review_script = {additional_wrapper}" in text and "codex_enabled" not in text`; `finalize set: "finalize_enabled = true" in text; unset variant: not in text`; `always: "move_plan_on_completion = false", "preserve_anthropic_api_key = true", f"claude_command = {wrapper}"`) -- [ ] **Debugging**: `pytest tests/build/test_ralphex_config.py -x -q` — fix implementation code until all tests pass -- [ ] **Contract re-verification**: key set matches the `ralphex` practice table exactly -- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary -- [ ] **Completion**: mark all checkboxes of this task complete +- [x] **Debugging**: `pytest tests/build/test_ralphex_config.py -x -q` — fix implementation code until all tests pass +- [x] **Contract re-verification**: key set matches the `ralphex` practice table exactly +- [x] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [x] **Completion**: mark all checkboxes of this task complete ### Task 13: Pass executor — `build_pass.py` re-signature (TDD coding) diff --git a/goga/build/ralphex_config.py b/goga/build/ralphex_config.py index 76df4c27..27573d2c 100644 --- a/goga/build/ralphex_config.py +++ b/goga/build/ralphex_config.py @@ -3,48 +3,70 @@ import logging from pathlib import Path -from ..config import BuildConfig +from ..agents import resolve_wrapper_path +from .run_settings import RunSettings logger = logging.getLogger(__name__) _DEFAULT_CLAUDE_ARGS = "--dangerously-skip-permissions --output-format stream-json --verbose" -def write_ralphex_config(config: BuildConfig, wrapper_path: str) -> None: +def write_ralphex_config(settings: RunSettings, wrapper_path: str) -> None: """Write the .ralphex/config INI for one ralphex pass. - Populates the ralphex config keys covered by the agent-wrappers contract: - `claude_command` set to the resolved absolute wrapper path of THE CURRENT - PASS, `claude_args` set to its fixed default (no config field overrides it - today), `codex_enabled` derived from `BuildConfig`, and - `preserve_anthropic_api_key` pinned to `true` so the ralphex runner does not - unset `ANTHROPIC_API_KEY` before invoking the agent wrapper. - `move_plan_on_completion` is pinned to `false` — goga relocates the plan - itself via `move_completed_plan`, so ralphex must never move it (its own - default is true, which would relocate the plan after pass 1 of a two-pass - run). No codex-specific ralphex keys are written. - - In a two-pass run this routine is called twice — each pass passes its own - executor wrapper (task wrapper for pass 1, review wrapper for pass 2), so - the `claude_command` rewrite between the passes is expressed by the two - calls themselves: the file is rewritten whole, never merged into. + Rewrites the file whole, never merged into a previous copy. The fixed + key block: `claude_command` set to the resolved absolute wrapper path of + THE CURRENT PASS, `claude_args` set to its fixed default (no settings + field overrides it today), `preserve_anthropic_api_key` pinned to `true` + so the ralphex runner hands `ANTHROPIC_API_KEY` to the agent wrapper, and + `move_plan_on_completion` pinned to `false` — goga relocates the plan + itself via `move_completed_plan`, so ralphex must never move it after + pass 1 of a two-pass run. + + The external-review surface derives only from the review part of the + settings: strategy `medium` writes `codex_enabled = false` (the external + review explicitly disabled) and no external keys; `full` and `short` + leave `codex_enabled` unwritten (the ralphex default, enabled) and, when + an additional agent is resolved, write `external_review_tool = custom` + with `custom_review_script` set to that agent's wrapper path — a None + additional agent leaves both unwritten (the ralphex default, codex). A + set finalize prompt writes `finalize_enabled = true`; when unset the key + stays unwritten and the step remains at the ralphex default (off). + + In a two-pass run this routine is called twice — the same settings, only + the executor wrapper differs (task wrapper for pass 1, review wrapper for + pass 2), so the `claude_command` rewrite between the passes is expressed + by the two calls themselves. The tasks-pass copy carries the review keys + too — harmless, `--tasks-only` ignores every review-phase key — which + keeps this routine a pure function of (settings, wrapper). Args: - config: Build configuration carrying the codex_review field. + settings: Resolved run plan; the external-review surface and the + finalize flag derive from its review part. wrapper_path: Resolved absolute in-container wrapper path of this pass. """ - ralphex_dir = Path(".ralphex") - ralphex_dir.mkdir(exist_ok=True) - - codex_enabled = str(config.codex_review or False).lower() + review = settings.review config_lines = [ f"claude_command = {wrapper_path}", f"claude_args = {_DEFAULT_CLAUDE_ARGS}", - f"codex_enabled = {codex_enabled}", "preserve_anthropic_api_key = true", "move_plan_on_completion = false", ] + additional_agent = review.additional.agent if review.additional is not None else None + + if review.strategy == "medium": + config_lines.append("codex_enabled = false") + elif additional_agent is not None: + config_lines.append("external_review_tool = custom") + config_lines.append(f"custom_review_script = {resolve_wrapper_path(additional_agent)}") + + if review.finalize is not None: + config_lines.append("finalize_enabled = true") + + ralphex_dir = Path(".ralphex") + ralphex_dir.mkdir(exist_ok=True) + (ralphex_dir / "config").write_text("\n".join(config_lines) + "\n") logger.info("wrote .ralphex/config", extra={"claude_command": wrapper_path}) diff --git a/tests/build/test_ralphex_config.py b/tests/build/test_ralphex_config.py index ba3d86fd..39f7464c 100644 --- a/tests/build/test_ralphex_config.py +++ b/tests/build/test_ralphex_config.py @@ -2,15 +2,44 @@ import inspect import typing +from pathlib import Path import pytest from goga.build.ralphex_config import write_ralphex_config -from goga.config import BuildConfig, TaskExecutorConfig +from goga.build.run_settings import PassSettings, ReviewPassSettings, RunSettings +from goga.config import AdditionalReviewConfig +WRAPPER_PATCH_TARGET = "goga.build.ralphex_config.resolve_wrapper_path" -def _make_build_config(**kwargs) -> BuildConfig: - task_executor = TaskExecutorConfig(agent=kwargs.pop("agent", "claude"), env={}) - return BuildConfig(task_executor=task_executor, **kwargs) +WRAPPER = "/home/goga/bin/claude-as-claude.sh" + + +def _make_settings( + strategy: str = "medium", + additional_agent: str | None = None, + finalize: str | None = None, +) -> RunSettings: + """Baseline run plan; each scenario changes exactly the keys derived from it.""" + return RunSettings( + skip=False, + tasks=PassSettings(agent="claude", env={}), + review=ReviewPassSettings( + agent="claude", + env={}, + strategy=strategy, + finalize=finalize, + additional=AdditionalReviewConfig(agent=additional_agent, patience=None, max_iterations=None), + ), + ) + + +def _patch_additional_wrapper(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str: + """Patch wrapper resolution at its import point; every agent maps to one fixed tmp file.""" + additional_wrapper = tmp_path / "codex-as-claude.sh" + additional_wrapper.write_text("#!/bin/sh\n") + + monkeypatch.setattr(WRAPPER_PATCH_TARGET, lambda _agent: str(additional_wrapper)) + return str(additional_wrapper) class TestWriteRalphexConfigContract: @@ -20,13 +49,11 @@ def test_write_ralphex_config_importable_from_module(self) -> None: def test_write_ralphex_config_has_correct_signature(self) -> None: sig = inspect.signature(write_ralphex_config) params = list(sig.parameters.keys()) - assert params == ["config", "wrapper_path"] - - def test_write_ralphex_config_config_param_type(self) -> None: - from goga.config import BuildConfig + assert params == ["settings", "wrapper_path"] + def test_write_ralphex_config_settings_param_type(self) -> None: hints = typing.get_type_hints(write_ralphex_config) - assert hints["config"] is BuildConfig + assert hints["settings"] is RunSettings def test_write_ralphex_config_wrapper_path_param_is_str(self) -> None: hints = typing.get_type_hints(write_ralphex_config) @@ -43,109 +70,169 @@ def test_default_claude_args_constant_moved_to_module(self) -> None: class TestWriteRalphexConfigLogic: - def test_write_ralphex_config_writes_all_five_keys(self, tmp_path, monkeypatch) -> None: + def test_write_ralphex_config_strategies( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The strategy table: medium disables the external review; full with an additional + agent routes it to the custom script; finalize gates the finalize flag.""" monkeypatch.chdir(tmp_path) - config = _make_build_config(codex_review=True) + additional_wrapper = _patch_additional_wrapper(tmp_path, monkeypatch) - write_ralphex_config(config, "/home/goga/bin/codex-as-claude.sh") + write_ralphex_config(_make_settings(strategy="medium"), WRAPPER) - config_text = (tmp_path / ".ralphex" / "config").read_text() - assert "claude_command = /home/goga/bin/codex-as-claude.sh" in config_text - assert "claude_args = --dangerously-skip-permissions --output-format stream-json --verbose" in config_text - assert "codex_enabled = true" in config_text - assert "preserve_anthropic_api_key = true" in config_text - assert "move_plan_on_completion = false" in config_text + text = (tmp_path / ".ralphex" / "config").read_text() + assert "codex_enabled = false" in text + assert "external_review_tool" not in text + assert "custom_review_script" not in text + assert "finalize_enabled" not in text + + write_ralphex_config(_make_settings(strategy="full", additional_agent="codex"), WRAPPER) + + text = (tmp_path / ".ralphex" / "config").read_text() + assert "external_review_tool = custom" in text + assert f"custom_review_script = {additional_wrapper}" in text + assert "codex_enabled" not in text + + write_ralphex_config(_make_settings(finalize="Final pass: merge the review."), WRAPPER) + + text = (tmp_path / ".ralphex" / "config").read_text() + assert "finalize_enabled = true" in text + + assert "move_plan_on_completion = false" in text + assert "preserve_anthropic_api_key = true" in text + assert f"claude_command = {WRAPPER}" in text - def test_write_ralphex_config_fixed_key_order(self, tmp_path, monkeypatch) -> None: + def test_write_ralphex_config_short_with_additional_agent( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Short engages the external review the same way as full.""" monkeypatch.chdir(tmp_path) - config = _make_build_config() + additional_wrapper = _patch_additional_wrapper(tmp_path, monkeypatch) - write_ralphex_config(config, "/home/goga/bin/claude-as-claude.sh") + write_ralphex_config(_make_settings(strategy="short", additional_agent="codex"), WRAPPER) + + text = (tmp_path / ".ralphex" / "config").read_text() + assert "external_review_tool = custom" in text + assert f"custom_review_script = {additional_wrapper}" in text + assert "codex_enabled" not in text + + def test_write_ralphex_config_degenerate_additional_agent_none( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A None additional agent leaves both external keys unwritten — the ralphex default + (codex) stays in force.""" + monkeypatch.chdir(tmp_path) + _patch_additional_wrapper(tmp_path, monkeypatch) + + write_ralphex_config(_make_settings(strategy="full", additional_agent=None), WRAPPER) + + text = (tmp_path / ".ralphex" / "config").read_text() + assert "external_review_tool" not in text + assert "custom_review_script" not in text + assert "codex_enabled" not in text + + def test_write_ralphex_config_medium_ignores_additional_agent( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Even a set additional agent writes no external key under medium — the surface is + explicitly disabled there.""" + monkeypatch.chdir(tmp_path) + _patch_additional_wrapper(tmp_path, monkeypatch) + + write_ralphex_config(_make_settings(strategy="medium", additional_agent="codex"), WRAPPER) + + text = (tmp_path / ".ralphex" / "config").read_text() + assert "codex_enabled = false" in text + assert "external_review_tool" not in text + assert "custom_review_script" not in text + + def test_write_ralphex_config_fixed_key_order( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The fixed block leads; the conditional keys follow in trace order.""" + monkeypatch.chdir(tmp_path) + _patch_additional_wrapper(tmp_path, monkeypatch) + + settings = _make_settings(strategy="full", additional_agent="codex", finalize="Done.") + write_ralphex_config(settings, WRAPPER) config_text = (tmp_path / ".ralphex" / "config").read_text() keys = [line.split(" = ", 1)[0] for line in config_text.strip().splitlines() if " = " in line] assert keys == [ "claude_command", "claude_args", - "codex_enabled", "preserve_anthropic_api_key", "move_plan_on_completion", + "external_review_tool", + "custom_review_script", + "finalize_enabled", ] - def test_write_ralphex_config_creates_ralphex_dir_when_missing(self, tmp_path, monkeypatch) -> None: + def test_write_ralphex_config_medium_key_order( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The medium shape: the fixed block plus the explicit codex_enabled = false.""" monkeypatch.chdir(tmp_path) - config = _make_build_config() - write_ralphex_config(config, "/home/goga/bin/claude-as-claude.sh") - - assert (tmp_path / ".ralphex" / "config").is_file() - - def test_write_ralphex_config_file_ends_with_newline(self, tmp_path, monkeypatch) -> None: - monkeypatch.chdir(tmp_path) - config = _make_build_config() - - write_ralphex_config(config, "/home/goga/bin/claude-as-claude.sh") + write_ralphex_config(_make_settings(strategy="medium"), WRAPPER) config_text = (tmp_path / ".ralphex" / "config").read_text() - assert config_text.endswith("\n") + keys = [line.split(" = ", 1)[0] for line in config_text.strip().splitlines() if " = " in line] + assert keys == [ + "claude_command", + "claude_args", + "preserve_anthropic_api_key", + "move_plan_on_completion", + "codex_enabled", + ] - def test_write_ralphex_config_codex_false_default(self, tmp_path, monkeypatch) -> None: + def test_write_ralphex_config_creates_ralphex_dir_when_missing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: monkeypatch.chdir(tmp_path) - config = _make_build_config(codex_review=None) - write_ralphex_config(config, "/home/goga/bin/claude-as-claude.sh") + write_ralphex_config(_make_settings(), WRAPPER) - config_text = (tmp_path / ".ralphex" / "config").read_text() - assert "codex_enabled = false" in config_text + assert (tmp_path / ".ralphex" / "config").is_file() - def test_write_ralphex_config_second_call_rewrites(self, tmp_path, monkeypatch) -> None: + def test_write_ralphex_config_file_ends_with_newline( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: monkeypatch.chdir(tmp_path) - config = _make_build_config() - write_ralphex_config(config, "/home/goga/bin/claude-as-claude.sh") - write_ralphex_config(config, "/home/goga/bin/codex-as-claude.sh") + write_ralphex_config(_make_settings(), WRAPPER) config_text = (tmp_path / ".ralphex" / "config").read_text() - assert "claude_command = /home/goga/bin/codex-as-claude.sh" in config_text - assert "claude_command = /home/goga/bin/claude-as-claude.sh" not in config_text + assert config_text.endswith("\n") - def test_write_ralphex_config_accepts_project_config_build(self, tmp_path, monkeypatch) -> None: - """The orchestrator passes `config.build` — a BuildConfig, not ProjectConfig.""" + def test_write_ralphex_config_second_call_rewrites_whole( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A two-pass run calls this twice with the same settings and a different wrapper — + the file is rewritten whole, never merged into: the old wrapper and the old + strategy-conditional keys are gone.""" monkeypatch.chdir(tmp_path) - from goga.config import PipelineConfig, ProjectConfig - - project = ProjectConfig( - lang="python", - image="goga:latest", - dockerfile=None, - build=_make_build_config(), - pipeline=PipelineConfig(agent="claude"), - ) + _patch_additional_wrapper(tmp_path, monkeypatch) - write_ralphex_config(project.build, "/home/goga/bin/claude-as-claude.sh") + write_ralphex_config(_make_settings(strategy="medium"), WRAPPER) + write_ralphex_config( + _make_settings(strategy="full", additional_agent="codex"), + "/home/goga/bin/codex-as-claude.sh", + ) config_text = (tmp_path / ".ralphex" / "config").read_text() - assert "claude_command = /home/goga/bin/claude-as-claude.sh" in config_text - - def test_write_ralphex_config_does_not_write_prompts_or_agents(self, tmp_path, monkeypatch) -> None: + assert "claude_command = /home/goga/bin/codex-as-claude.sh" in config_text + assert f"claude_command = {WRAPPER}" not in config_text + assert "codex_enabled" not in config_text + + def test_write_ralphex_config_does_not_write_prompts_or_agents( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Only the config file is this routine's artifact — prompts/agents belong to the + defaults sync.""" monkeypatch.chdir(tmp_path) - config = _make_build_config() - write_ralphex_config(config, "/home/goga/bin/claude-as-claude.sh") + write_ralphex_config(_make_settings(), WRAPPER) entries = {p.name for p in (tmp_path / ".ralphex").iterdir()} assert entries == {"config"} - - -@pytest.mark.parametrize( - ("codex_review", "expected"), - [(True, "true"), (False, "false"), (None, "false")], -) -def test_write_ralphex_config_codex_enabled_matrix(tmp_path, monkeypatch, codex_review, expected) -> None: - monkeypatch.chdir(tmp_path) - config = _make_build_config(codex_review=codex_review) - - write_ralphex_config(config, "/home/goga/bin/claude-as-claude.sh") - - config_text = (tmp_path / ".ralphex" / "config").read_text() - assert f"codex_enabled = {expected}" in config_text From fdcb2b0759fb091fcb2761822cb14231f8208efd Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 21:10:41 +0000 Subject: [PATCH 096/205] feat: pass executor re-signature build_pass.py carries RunSettings (Task 13) --- .goga/history/2026/add-hooks-to-build/plan.md | 18 +- goga/build/build_pass.py | 26 +-- tests/build/test_build_pass.py | 200 ++++++++++++------ 3 files changed, 160 insertions(+), 84 deletions(-) diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index b2c1fe3e..73e30c6c 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -1180,15 +1180,15 @@ Verified design trace: Constraint: do not assemble or invoke the ralphex command directly — only through `run_ralphex`. Secret safety: env values never in argv/logs/dry-run. -- [ ] **Declaration**: Task 13 — pass executor re-signature -- [ ] **Contract tests**: in `tests/build/test_build_pass.py` — `run_build_pass(plan, settings, options, wrapper_path, dry_run, env=None)` signature; delegates config write to `write_ralphex_config(settings, wrapper_path)` and launch to `run_ralphex(plan, options, dry_run, env=env)` (expected to fail at this stage) -- [ ] **Code**: rewrite `goga/build/build_pass.py` per the trace -- [ ] **Interface verification**: `pytest tests/build/test_build_pass.py -x -q` — contract tests pass -- [ ] **Logic tests**: positive — config file written before launch (order recorded via monkeypatched collaborators), exit code propagated unchanged (stub returns 7 → 7); negative — env layer forwarded verbatim to `run_ralphex` and never printed; edge — `env=None` passes pure inheritance -- [ ] **Debugging**: `pytest tests/build/test_build_pass.py -x -q` — fix implementation code until all tests pass -- [ ] **Contract re-verification**: no direct subprocess call to ralphex in the module -- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary -- [ ] **Completion**: mark all checkboxes of this task complete +- [x] **Declaration**: Task 13 — pass executor re-signature +- [x] **Contract tests**: in `tests/build/test_build_pass.py` — `run_build_pass(plan, settings, options, wrapper_path, dry_run, env=None)` signature; delegates config write to `write_ralphex_config(settings, wrapper_path)` and launch to `run_ralphex(plan, options, dry_run, env=env)` (expected to fail at this stage) +- [x] **Code**: rewrite `goga/build/build_pass.py` per the trace +- [x] **Interface verification**: `pytest tests/build/test_build_pass.py -x -q` — contract tests pass +- [x] **Logic tests**: positive — config file written before launch (order recorded via monkeypatched collaborators), exit code propagated unchanged (stub returns 7 → 7); negative — env layer forwarded verbatim to `run_ralphex` and never printed; edge — `env=None` passes pure inheritance +- [x] **Debugging**: `pytest tests/build/test_build_pass.py -x -q` — fix implementation code until all tests pass +- [x] **Contract re-verification**: no direct subprocess call to ralphex in the module +- [x] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [x] **Completion**: mark all checkboxes of this task complete ### Task 14: Plan relocation outcome — `plan_relocation.py` (TDD coding) diff --git a/goga/build/build_pass.py b/goga/build/build_pass.py index fb1245cf..fca3303c 100644 --- a/goga/build/build_pass.py +++ b/goga/build/build_pass.py @@ -1,13 +1,13 @@ from __future__ import annotations -from ..config import BuildConfig from ..ralphex import run_ralphex from .ralphex_config import write_ralphex_config +from .run_settings import RunSettings def run_build_pass( # noqa: PLR0913, PLR0917 — arity is CODEMANIFEST-mandated plan: str, - config: BuildConfig, + settings: RunSettings, options: dict[str, str | int | bool], wrapper_path: str, dry_run: bool, @@ -16,7 +16,7 @@ def run_build_pass( # noqa: PLR0913, PLR0917 — arity is CODEMANIFEST-mandated """Execute one ralphex pass: write the pass config, delegate the launch. The unit of multi-passness: each pass writes its own `.ralphex/config` - (so `claude_command` is the executor wrapper of THIS pass — the task + (so `claude_command` is the executor wrapper of THIS pass — the tasks wrapper for a tasks pass, the review wrapper for a review pass) and then delegates the launch to `run_ralphex`. The orchestrator composes passes on top of this routine; the ralphex command is never assembled or invoked @@ -24,23 +24,25 @@ def run_build_pass( # noqa: PLR0913, PLR0917 — arity is CODEMANIFEST-mandated Args: plan: Path to the plan file (markdown), passed verbatim to ralphex. - config: Build configuration (`BuildConfig`) of the run. - options: Resolved ralphex options of the pass; may carry the pass-mode - bare flags `tasks_only` or `review`, forwarded verbatim. - wrapper_path: Executor wrapper of the current pass (task wrapper or + settings: Resolved run plan (`RunSettings`) — carried to the config + routine; the pass interprets nothing on it. + options: Resolved ralphex options of the pass (composed by + `compose_pass_options`; carries exactly one pass-mode bare flag), + forwarded verbatim. + wrapper_path: Executor wrapper of the current pass (tasks wrapper or review wrapper), written into `.ralphex/config`. dry_run: When True, print instead of launching (the pass config is still written — a harmless dry-run side effect matching the established behavior). env: Optional environment layer ({str: str}) forwarded verbatim to - `run_ralphex` — the review pass receives the review env here; the - tasks pass runs without a layer. The pass adds no env logic of - its own: the overlay composition lives in the launcher, and the - layer never changes the ralphex config written for this pass. + `run_ralphex` — the tasks pass receives the root env here, the + review pass the review env. The pass adds no env logic of its + own: the overlay composition lives in the launcher, and the + layer never reaches the ralphex config written for this pass. Returns: The exit code returned by ralphex, propagated without transformation. """ - write_ralphex_config(config, wrapper_path) + write_ralphex_config(settings, wrapper_path) return run_ralphex(plan, options, dry_run, env=env) diff --git a/tests/build/test_build_pass.py b/tests/build/test_build_pass.py index 5a4765c7..55df138e 100644 --- a/tests/build/test_build_pass.py +++ b/tests/build/test_build_pass.py @@ -8,12 +8,24 @@ import goga.build.build_pass as build_pass_module import pytest from goga.build.build_pass import run_build_pass -from goga.config import BuildConfig, TaskExecutorConfig - - -def _make_build_config(**kwargs) -> BuildConfig: - task_executor = TaskExecutorConfig(agent=kwargs.pop("agent", "claude"), env={}) - return BuildConfig(task_executor=task_executor, **kwargs) +from goga.build.run_settings import PassSettings, ReviewPassSettings, RunSettings +from goga.config import AdditionalReviewConfig + +WRAPPER = "/home/goga/bin/claude-as-claude.sh" + + +def _make_settings() -> RunSettings: + """Baseline run plan; the pass is a pure conduit, so no scenario mutates it.""" + return RunSettings( + skip=False, + tasks=PassSettings(agent="claude", env={}), + review=ReviewPassSettings( + agent="claude", + env={}, + strategy="medium", + additional=AdditionalReviewConfig(agent="claude", patience=None, max_iterations=None), + ), + ) class TestRunBuildPassContract: @@ -23,13 +35,13 @@ def test_run_build_pass_importable_from_module(self) -> None: def test_run_build_pass_has_correct_signature(self) -> None: sig = inspect.signature(run_build_pass) params = list(sig.parameters.keys()) - assert params == ["plan", "config", "options", "wrapper_path", "dry_run", "env"] + assert params == ["plan", "settings", "options", "wrapper_path", "dry_run", "env"] assert sig.parameters["env"].default is None def test_run_build_pass_param_types(self) -> None: hints = typing.get_type_hints(run_build_pass) assert hints["plan"] is str - assert hints["config"] is BuildConfig + assert hints["settings"] is RunSettings assert hints["options"] == dict[str, str | int | bool] assert hints["wrapper_path"] is str assert hints["dry_run"] is bool @@ -39,64 +51,123 @@ def test_run_build_pass_returns_int(self) -> None: hints = typing.get_type_hints(run_build_pass) assert hints["return"] is int - def test_module_imports_run_ralphex(self) -> None: - """The ralphex launch is delegated via a module-level import — the - orchestrator and the tests patch goga.build.build_pass.run_ralphex.""" - from goga.ralphex import run_ralphex as origin + def test_module_imports_collaborators(self) -> None: + """Both collaborators are module-level imports — the tests (and the + orchestrator) patch goga.build.build_pass.write_ralphex_config / + run_ralphex.""" + from goga.build.ralphex_config import write_ralphex_config as config_origin + from goga.ralphex import run_ralphex as launch_origin - assert build_pass_module.run_ralphex is origin + assert build_pass_module.write_ralphex_config is config_origin + assert build_pass_module.run_ralphex is launch_origin def test_module_has_no_subprocess_call(self) -> None: source = inspect.getsource(build_pass_module) assert "subprocess" not in source + def test_run_build_pass_delegates_config_write(self, tmp_path, monkeypatch) -> None: + """The settings object is carried to the config routine verbatim, paired + with this pass's wrapper.""" + monkeypatch.chdir(tmp_path) + settings = _make_settings() + + with ( + mock.patch("goga.build.build_pass.write_ralphex_config") as mock_write, + mock.patch("goga.build.build_pass.run_ralphex", return_value=0), + ): + run_build_pass("plan.md", settings, {"tasks_only": True}, WRAPPER, False) + + mock_write.assert_called_once_with(settings, WRAPPER) + + def test_run_build_pass_delegates_launch(self, tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + options = {"review": True} + + with ( + mock.patch("goga.build.build_pass.write_ralphex_config"), + mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run, + ): + run_build_pass("plan.md", _make_settings(), options, WRAPPER, True, env={"A": "1"}) + + mock_run.assert_called_once_with("plan.md", options, True, env={"A": "1"}) + class TestRunBuildPassLogic: - def test_run_build_pass_writes_config_and_delegates(self, tmp_path, monkeypatch) -> None: + def test_run_build_pass_writes_config_before_launch(self, tmp_path, monkeypatch) -> None: + """The config routine runs to completion before the launch delegate is + invoked: the config is already on disk (with this pass's wrapper) at the + moment the launch fires.""" monkeypatch.chdir(tmp_path) - config = _make_build_config(codex_review=True) - options = {"worktree": True, "tasks_only": True} + seen: dict[str, str] = {} - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - exit_code = run_build_pass("plan.md", config, options, "/w/claude.sh", False) + def recording_launch(plan, options, dry_run, env=None): + seen["config_at_launch"] = Path(".ralphex/config").read_text() + return 0 + + monkeypatch.setattr(build_pass_module, "run_ralphex", recording_launch) + + exit_code = run_build_pass("plan.md", _make_settings(), {"tasks_only": True}, WRAPPER, False) assert exit_code == 0 - mock_run.assert_called_once_with("plan.md", {"worktree": True, "tasks_only": True}, False, env=None) - assert Path(".ralphex/config").exists() - assert "claude_command = /w/claude.sh" in Path(".ralphex/config").read_text() + assert f"claude_command = {WRAPPER}" in seen["config_at_launch"] - def test_run_build_pass_propagates_exit_code(self, tmp_path, monkeypatch) -> None: + def test_run_build_pass_propagates_exit_code_unchanged(self, tmp_path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) - config = _make_build_config() - with mock.patch("goga.build.build_pass.run_ralphex", return_value=42) as mock_run: - exit_code = run_build_pass("plan.md", config, {}, "/w/claude.sh", False) + with mock.patch("goga.build.build_pass.run_ralphex", return_value=7) as mock_run: + exit_code = run_build_pass("plan.md", _make_settings(), {"review": True}, WRAPPER, False) - assert exit_code == 42 + assert exit_code == 7 mock_run.assert_called_once() - def test_run_build_pass_dry_run_still_writes_config(self, tmp_path, monkeypatch) -> None: + def test_run_build_pass_forwards_env_verbatim_and_never_prints( + self, tmp_path, monkeypatch, capsys + ) -> None: + """The env layer reaches the launcher as the same object, never the + config file, the stdout, or the stderr — a secret boundary.""" monkeypatch.chdir(tmp_path) - config = _make_build_config() + env = {"ANTHROPIC_API_KEY": "sekret-token-value"} + recorded: dict[str, object] = {} - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - exit_code = run_build_pass("plan.md", config, {}, "/w/claude.sh", True) + def recording_launch(plan, options, dry_run, env=None): + recorded["env"] = env + return 0 - assert exit_code == 0 - mock_run.assert_called_once_with("plan.md", {}, True, env=None) - assert "claude_command = /w/claude.sh" in Path(".ralphex/config").read_text() + monkeypatch.setattr(build_pass_module, "run_ralphex", recording_launch) - def test_run_build_pass_no_direct_subprocess(self, tmp_path, monkeypatch) -> None: + run_build_pass("p.md", _make_settings(), {"review": True}, WRAPPER, False, env=env) + + assert recorded["env"] is env + config_text = Path(".ralphex/config").read_text() + assert "sekret-token-value" not in config_text + assert "ANTHROPIC_API_KEY" not in config_text + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + def test_run_build_pass_env_none_passes_pure_inheritance(self, tmp_path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) - config = _make_build_config() + recorded: dict[str, object] = {} - with ( - mock.patch("goga.build.build_pass.run_ralphex", return_value=0), - mock.patch("subprocess.call") as mock_call, - ): - run_build_pass("plan.md", config, {}, "/w/claude.sh", False) + def recording_launch(plan, options, dry_run, env=None): + recorded["env"] = env + return 0 - mock_call.assert_not_called() + monkeypatch.setattr(build_pass_module, "run_ralphex", recording_launch) + + run_build_pass("p.md", _make_settings(), {"tasks_only": True}, WRAPPER, False) + + assert recorded["env"] is None + + def test_run_build_pass_dry_run_still_writes_config(self, tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + + with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: + exit_code = run_build_pass("plan.md", _make_settings(), {}, WRAPPER, True) + + assert exit_code == 0 + mock_run.assert_called_once_with("plan.md", {}, True, env=None) + assert "claude_command = /home/goga/bin/claude-as-claude.sh" in Path(".ralphex/config").read_text() @pytest.mark.parametrize( ("options", "wrapper"), @@ -106,39 +177,42 @@ def test_run_build_pass_no_direct_subprocess(self, tmp_path, monkeypatch) -> Non ({}, "/home/goga/bin/claude-as-claude.sh"), ], ) - def test_run_build_pass_passes_options_verbatim(self, tmp_path, monkeypatch, options: dict, wrapper: str) -> None: + def test_run_build_pass_passes_options_verbatim( + self, tmp_path, monkeypatch, options: dict, wrapper: str + ) -> None: monkeypatch.chdir(tmp_path) - config = _make_build_config() with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - run_build_pass("plan.md", config, options, wrapper, False) + run_build_pass("plan.md", _make_settings(), options, wrapper, False) assert mock_run.call_args.args[1] is options - def test_run_build_pass_forwards_env_to_run_ralphex(self, tmp_path, monkeypatch) -> None: - """The env layer is forwarded verbatim as a kwarg; the pass adds no - env logic of its own and the config write is unaffected by it.""" - monkeypatch.chdir(tmp_path) - config = _make_build_config() - - with mock.patch("goga.build.build_pass.run_ralphex", return_value=7) as mock_run: - exit_code = run_build_pass("p.md", config, {"review": True}, "/w/codex.sh", False, env={"A": "1"}) - - assert exit_code == 7 - mock_run.assert_called_once_with("p.md", {"review": True}, False, env={"A": "1"}) - assert mock_run.call_args.args == ("p.md", {"review": True}, False) - assert mock_run.call_args.kwargs == {"env": {"A": "1"}} - assert "claude_command = /w/codex.sh" in Path(".ralphex/config").read_text() - def test_run_build_pass_config_reflects_pass_executor(self, tmp_path, monkeypatch) -> None: - """The second pass rewrites claude_command to the review wrapper.""" + """The second pass rewrites claude_command whole to the review wrapper — + the two calls with the same settings and different wrappers express the + two-pass config switch.""" monkeypatch.chdir(tmp_path) - config = _make_build_config() with mock.patch("goga.build.build_pass.run_ralphex", return_value=0): - run_build_pass("plan.md", config, {"tasks_only": True}, "/home/goga/bin/claude-as-claude.sh", False) - run_build_pass("plan.md", config, {"review": True}, "/home/goga/bin/codex-as-claude.sh", False) + run_build_pass("plan.md", _make_settings(), {"tasks_only": True}, WRAPPER, False) + run_build_pass( + "plan.md", + _make_settings(), + {"review": True}, + "/home/goga/bin/codex-as-claude.sh", + False, + ) config_text = Path(".ralphex/config").read_text() assert "claude_command = /home/goga/bin/codex-as-claude.sh" in config_text + assert f"claude_command = {WRAPPER}" not in config_text assert "move_plan_on_completion = false" in config_text + + def test_run_build_pass_no_direct_subprocess(self, tmp_path, monkeypatch) -> None: + with ( + mock.patch("goga.build.build_pass.run_ralphex", return_value=0), + mock.patch("subprocess.call") as mock_call, + ): + run_build_pass("plan.md", _make_settings(), {}, WRAPPER, False) + + mock_call.assert_not_called() From 2851f809097cefde4afb36973649b0e3a5bebf9d Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 21:13:54 +0000 Subject: [PATCH 097/205] feat: plan relocation returns RelocationOutcome plan_relocation.py (Task 14) --- .goga/history/2026/add-hooks-to-build/plan.md | 18 ++--- goga/build/plan_relocation.py | 19 ++++-- tests/build/test_plan_relocation.py | 67 ++++++++++++++----- 3 files changed, 73 insertions(+), 31 deletions(-) diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index 73e30c6c..72e2ef0b 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -1207,15 +1207,15 @@ Algorithm (manifest, verbatim): `not outcome or dry_run` → `RelocationOutcome(moved=True, destination=str(dest))`. Constraint: do not hard-code `docs/plans/` — the directory follows the plan file location. -- [ ] **Declaration**: Task 14 — plan relocation outcome -- [ ] **Contract tests**: in `tests/build/test_plan_relocation.py` — return type is `RelocationOutcome` (importable from `goga.build.hooks`) (expected to fail at this stage) -- [ ] **Code**: rewrite `goga/build/plan_relocation.py` per the algorithm -- [ ] **Interface verification**: `pytest tests/build/test_plan_relocation.py -x -q` — contract tests pass -- [ ] **Logic tests**: `test_move_completed_plan_returns_relocation_outcome` (setup `tmp_path/docs/plans/plan.md`; input `move_completed_plan(str(plan), outcome=True, dry_run=False)` → `relocation.moved is True`; `relocation.destination == str(tmp_path/"docs/plans/completed/plan.md")`; source gone; variants: `outcome=False` → `moved is False, destination is None`; `dry_run=True` → same not-moved outcome and file stays); `test_move_completed_plan_is_idempotent_by_name` (run the relocation twice on the same plan, recreating the source between calls → second call overwrites `completed/plan.md` without error) -- [ ] **Debugging**: `pytest tests/build/test_plan_relocation.py -x -q` — fix implementation code until all tests pass -- [ ] **Contract re-verification**: directory follows the plan file location (test with a non-default plan dir) -- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary -- [ ] **Completion**: mark all checkboxes of this task complete +- [x] **Declaration**: Task 14 — plan relocation outcome +- [x] **Contract tests**: in `tests/build/test_plan_relocation.py` — return type is `RelocationOutcome` (importable from `goga.build.hooks`) (expected to fail at this stage) +- [x] **Code**: rewrite `goga/build/plan_relocation.py` per the algorithm +- [x] **Interface verification**: `pytest tests/build/test_plan_relocation.py -x -q` — contract tests pass +- [x] **Logic tests**: `test_move_completed_plan_returns_relocation_outcome` (setup `tmp_path/docs/plans/plan.md`; input `move_completed_plan(str(plan), outcome=True, dry_run=False)` → `relocation.moved is True`; `relocation.destination == str(tmp_path/"docs/plans/completed/plan.md")`; source gone; variants: `outcome=False` → `moved is False, destination is None`; `dry_run=True` → same not-moved outcome and file stays); `test_move_completed_plan_is_idempotent_by_name` (run the relocation twice on the same plan, recreating the source between calls → second call overwrites `completed/plan.md` without error) +- [x] **Debugging**: `pytest tests/build/test_plan_relocation.py -x -q` — fix implementation code until all tests pass +- [x] **Contract re-verification**: directory follows the plan file location (test with a non-default plan dir) +- [x] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [x] **Completion**: mark all checkboxes of this task complete ### Task 15: The 12-step build cycle — `build.py` rewrite and retired-module deletion (TDD coding) diff --git a/goga/build/plan_relocation.py b/goga/build/plan_relocation.py index 75c49f64..6ccffd7c 100644 --- a/goga/build/plan_relocation.py +++ b/goga/build/plan_relocation.py @@ -3,16 +3,19 @@ import logging from pathlib import Path +from .hooks import RelocationOutcome + logger = logging.getLogger(__name__) -def move_completed_plan(plan: str, outcome: bool, dry_run: bool) -> None: +def move_completed_plan(plan: str, outcome: bool, dry_run: bool) -> RelocationOutcome: """Relocate a successfully completed plan to `/completed/`. - Called by the orchestrator after the final build pass: `outcome` is the - success of that pass, so a failed run keeps the plan in place for ralphex - to resume at its first unchecked checkbox, and a dry run — where nothing - executed — moves nothing either. + Called by the orchestrator after any started run: `outcome` is the + success of the final pass, so a failed run keeps the plan in place for + ralphex to resume at its first unchecked checkbox, and a dry run — where + nothing executed — moves nothing either. The returned outcome facts + (moved with the destination, or not moved) feed the completion event. The `completed/` directory is created next to the plan when missing and follows the plan's own location (`docs/plans/` is never hardcoded). The @@ -24,9 +27,12 @@ def move_completed_plan(plan: str, outcome: bool, dry_run: bool) -> None: plan: Path of the plan file, absolute or relative to the container cwd. outcome: Success of the final pass — only True relocates. dry_run: Dry-run flag of the run; a dry run never relocates. + + Returns: + The relocation outcome — moved with the destination, or not moved. """ if not outcome or dry_run: - return + return RelocationOutcome(moved=False, destination=None) src = Path(plan) dest_dir = src.parent / "completed" @@ -35,3 +41,4 @@ def move_completed_plan(plan: str, outcome: bool, dry_run: bool) -> None: src.replace(dest) logger.info("plan relocated", extra={"from": str(src), "to": str(dest)}) + return RelocationOutcome(moved=True, destination=str(dest)) diff --git a/tests/build/test_plan_relocation.py b/tests/build/test_plan_relocation.py index fb292342..3c9b20fe 100644 --- a/tests/build/test_plan_relocation.py +++ b/tests/build/test_plan_relocation.py @@ -4,6 +4,7 @@ import typing import pytest +from goga.build.hooks import RelocationOutcome from goga.build.plan_relocation import move_completed_plan @@ -28,9 +29,14 @@ def test_move_completed_plan_dry_run_param_is_bool(self) -> None: hints = typing.get_type_hints(move_completed_plan) assert hints["dry_run"] is bool - def test_move_completed_plan_returns_none(self) -> None: + def test_move_completed_plan_returns_relocation_outcome(self) -> None: hints = typing.get_type_hints(move_completed_plan) - assert hints["return"] is type(None) + assert hints["return"] is RelocationOutcome + + def test_relocation_outcome_importable_from_zone_facade(self) -> None: + import goga.build.hooks as zone + + assert zone.RelocationOutcome is RelocationOutcome class TestMoveCompletedPlanLogic: @@ -45,30 +51,58 @@ def test_move_completed_plan_moves_to_completed(self, tmp_path) -> None: assert not plan.exists() assert (plans_dir / "completed" / "x.md").read_text() == "P" - @pytest.mark.parametrize(("outcome", "dry_run"), [(False, False), (True, True)]) - def test_move_completed_plan_noop_on_failure_and_dry_run(self, tmp_path, outcome, dry_run) -> None: + def test_move_completed_plan_returns_relocation_outcome(self, tmp_path) -> None: plans_dir = tmp_path / "docs" / "plans" plans_dir.mkdir(parents=True) - plan = plans_dir / "x.md" + plan = plans_dir / "plan.md" plan.write_text("P") - move_completed_plan(str(plan), outcome, dry_run) + relocation = move_completed_plan(str(plan), outcome=True, dry_run=False) + + assert relocation.moved is True + assert relocation.destination == str(tmp_path / "docs" / "plans" / "completed" / "plan.md") + assert not plan.exists() + failed = move_completed_plan(str(plan), outcome=False, dry_run=False) + assert failed.moved is False + assert failed.destination is None + + plan.write_text("P") + rehearsal = move_completed_plan(str(plan), outcome=True, dry_run=True) + assert rehearsal.moved is False + assert rehearsal.destination is None assert plan.read_text() == "P" - assert not (plans_dir / "completed").exists() - def test_move_completed_plan_same_name_overwrites(self, tmp_path) -> None: + def test_move_completed_plan_is_idempotent_by_name(self, tmp_path) -> None: plans_dir = tmp_path / "docs" / "plans" - completed_dir = plans_dir / "completed" - completed_dir.mkdir(parents=True) - (completed_dir / "x.md").write_text("OLD") - plan = plans_dir / "x.md" - plan.write_text("NEW") + plans_dir.mkdir(parents=True) + plan = plans_dir / "plan.md" - move_completed_plan(str(plan), True, False) + plan.write_text("first") + first = move_completed_plan(str(plan), outcome=True, dry_run=False) + assert first.moved is True + plan.write_text("second") + second = move_completed_plan(str(plan), outcome=True, dry_run=False) + + assert second.moved is True + assert second.destination == first.destination + assert (plans_dir / "completed" / "plan.md").read_text() == "second" assert not plan.exists() - assert (completed_dir / "x.md").read_text() == "NEW" + + @pytest.mark.parametrize(("outcome", "dry_run"), [(False, False), (True, True)]) + def test_move_completed_plan_noop_on_failure_and_dry_run(self, tmp_path, outcome, dry_run) -> None: + plans_dir = tmp_path / "docs" / "plans" + plans_dir.mkdir(parents=True) + plan = plans_dir / "x.md" + plan.write_text("P") + + relocation = move_completed_plan(str(plan), outcome, dry_run) + + assert relocation.moved is False + assert relocation.destination is None + assert plan.read_text() == "P" + assert not (plans_dir / "completed").exists() def test_move_completed_plan_relative_path(self, tmp_path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) @@ -87,9 +121,10 @@ def test_move_completed_plan_dir_follows_plan_location(self, tmp_path) -> None: plan = other_dir / "feature.md" plan.write_text("P") - move_completed_plan(str(plan), True, False) + relocation = move_completed_plan(str(plan), True, False) assert (other_dir / "completed" / "feature.md").read_text() == "P" + assert relocation.destination == str(other_dir / "completed" / "feature.md") assert not (tmp_path / "docs").exists() def test_move_completed_plan_creates_nested_completed_dir(self, tmp_path) -> None: From d4655948c5fc820250578bd0f52f94788b518470 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 21:28:27 +0000 Subject: [PATCH 098/205] feat: 12-step two-pass build cycle build.py rewrite with checkpoints (Task 15) --- .goga/history/2026/add-hooks-to-build/plan.md | 24 +- goga/build/build.py | 344 ++- goga/build/review_options.py | 107 - tests/build/conftest.py | 12 + tests/build/test_build.py | 1836 ++++++++--------- tests/build/test_build_resolved_wrapper.py | 74 +- tests/build/test_review_options.py | 275 --- tests/build/test_shipped_ralphex_assets.py | 20 +- 8 files changed, 1168 insertions(+), 1524 deletions(-) delete mode 100644 goga/build/review_options.py create mode 100644 tests/build/conftest.py delete mode 100644 tests/build/test_review_options.py diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index 72e2ef0b..88a925b8 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -1317,18 +1317,18 @@ orchestration tests monkeypatch `goga.build.build.run_build_pass` (or `goga.build.build`'s import point. Wrapper existence tests monkeypatch `resolve_wrapper_path` at its import point. -- [ ] **Declaration**: Task 15 — the 12-step build cycle -- [ ] **Contract tests**: in `tests/build/test_build.py` — `build(plan, config, cli_options)` importable from `goga.build`; the cycle calls `resolve_run_settings`, `validate_review_config`, `sync_ralphex_defaults`, `compose_pass_options`, `run_build_pass`, `move_completed_plan`, and the five `BuildHooks` checkpoints in the traced order (expected to fail at this stage) -- [ ] **Code**: rewrite `goga/build/build.py` per the 12-step trace (steps 0–3.5 guard, 4–11, return); new `_completion_statuses(work)` helper; imports from `.run_settings`, `.pass_options`, `.review_config`, `.ralphex_runtime`, `.build_pass`, `.plan_relocation`, and the zone facade via `from .hooks import …` (relative intra-package, mirroring `goga/pipeline/run_pipeline.py`); `from ..history import resolve_current_branch_name, resolve_topic_dir, collect_topic_statuses` -- [ ] **Code**: delete `goga/build/review_options.py` and `tests/build/test_review_options.py`; remove `_resolve_options` and `_review_scoped_options` from `build.py`; no compatibility shims -- [ ] **Interface verification**: `pytest tests/build/test_build.py -x -q` — contract tests pass -- [ ] **Logic tests** (in `tests/build/test_build.py`): `test_build_runs_two_passes_with_bound_settings` (setup: tmp cwd with config; monkeypatch `goga.build.build.run_build_pass` recording `(options, wrapper, env)` returning 0; `resolve_current_branch_name` → `"add-hooks-to-build"`, `resolve_topic_dir` → raises ValueError (branch-only), `collect_topic_statuses` → `[]`; `cli_options` all None, `dry_run` False; no tool packages pinned → assert `run_build_pass` called exactly twice; first call `options["tasks_only"] is True` and `env == {"A":"1"}`; second call `options["review"] is True` and env is the review layer — never the root env; return 0; plan relocated); `test_build_skipped_review_single_tasks_pass` (same + `cli_options={"skip_review": True}` → exactly one `run_build_pass` call (`tasks_only`); return value = that pass's code); `test_build_failed_tasks_pass_skips_review` (`run_build_pass` first call returns 1 → one pass call only; `pass_completed` for tasks carries `exit_code == 1`; `BuildCompleted.stages == ["tasks"]`; relocation not moved; return 1); `test_build_vetoed_run_blocks_before_any_pass` (two-pass setup + one tool whose `validate_build` hook vetoes `"policy"`; `run_build_pass` recorder → return 1; `run_build_pass` never called; plan file in place; NO notification hook of the tool ran; exactly one `logger.error` record carrying the violation triple — caplog); `test_build_pre_launch_failures_fire_no_events` (tool subscribed to all five actions — recorder; parametrize: uncommitted CODEMANIFEST (patch `_find_uncommitted_manifests` → `["x/CODEMANIFEST"]`), invalid review config (patch `validate_review_config` → raise), unavailable defaults (patch `sync_ralphex_defaults` → raise), no build agent on a skip run (config with root agent None + `cli_options={"skip_review": True}` — the step-3.5 guard path) → return 1; zero hook invocations across all five actions in every variant); `test_unsluggable_branch_falls_back_to_branch_only` (`resolve_topic_dir` raises ValueError; `resolve_current_branch_name` → None → `WorkIdentity.branch == "unknown"`, `slug is None`; statuses `[]`; run proceeds normally); `test_build_statuses_recomputed_after_relocation` (topic-hosting branch: `resolve_current_branch_name → "add-hooks-to-build"`, `resolve_topic_dir → Path(".goga/history/2026/add-hooks-to-build")` (is_dir True); `collect_topic_statuses` stub returning `[TopicRecord("add-hooks-to-build", ["backlog", "designed"])]`; successful run → recorded `BuildCompleted.statuses == ["backlog", "designed"]`; `collect_topic_statuses` called with `year="2026"` AFTER `move_completed_plan` (order recorded); branch-only variant delivers `[]`); `test_stage_facts_carry_env_names_only` (settings with `tasks.env={"A":"1","B":"2"}` → orchestration facts: `StageFacts.env == ["A", "B"]` sorted names; walk `dataclasses.fields` of every context and assert no string member equals `"1"`/`"2"`) -- [ ] **Code**: rewrite `tests/build/test_build_resolved_wrapper.py` onto the two-part schema (`build.agent` at the root); delete the `codex_review → codex_enabled` case (the key is retired; the strategy-table test of Task 12 covers the new derivation); keep the uncommitted-manifests / ralphex-missing / custom-prompts-dir cases on the new fixtures -- [ ] **Code**: update `tests/build/test_shipped_ralphex_assets.py` — replace the `BuildConfig(task_executor=TaskExecutorConfig(...))` construction with the two-part `BuildConfig(agent=..., env={})`; the vendored asset assertions are unchanged -- [ ] **Debugging**: `pytest tests/build/ -x -q` — fix implementation code until all tests pass -- [ ] **Contract re-verification**: facade `from goga.build import build` resolves; no compatibility shims (`grep -rn "review_options" goga/ tests/` empty); `--worktree`/`--skip-finalize`/`worktree`/`skip_finalize` absent from `goga/build/` -- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting, apply decomposition if necessary -- [ ] **Completion**: mark all checkboxes of this task complete +- [x] **Declaration**: Task 15 — the 12-step build cycle +- [x] **Contract tests**: in `tests/build/test_build.py` — `build(plan, config, cli_options)` importable from `goga.build`; the cycle calls `resolve_run_settings`, `validate_review_config`, `sync_ralphex_defaults`, `compose_pass_options`, `run_build_pass`, `move_completed_plan`, and the five `BuildHooks` checkpoints in the traced order (expected to fail at this stage) +- [x] **Code**: rewrite `goga/build/build.py` per the 12-step trace (steps 0–3.5 guard, 4–11, return); new `_completion_statuses(work)` helper; imports from `.run_settings`, `.pass_options`, `.review_config`, `.ralphex_runtime`, `.build_pass`, `.plan_relocation`, and the zone facade via `from .hooks import …` (relative intra-package, mirroring `goga/pipeline/run_pipeline.py`); `from ..history import resolve_current_branch_name, resolve_topic_dir, collect_topic_statuses` +- [x] **Code**: delete `goga/build/review_options.py` and `tests/build/test_review_options.py`; remove `_resolve_options` and `_review_scoped_options` from `build.py`; no compatibility shims +- [x] **Interface verification**: `pytest tests/build/test_build.py -x -q` — contract tests pass +- [x] **Logic tests** (in `tests/build/test_build.py`): `test_build_runs_two_passes_with_bound_settings` (setup: tmp cwd with config; monkeypatch `goga.build.build.run_build_pass` recording `(options, wrapper, env)` returning 0; `resolve_current_branch_name` → `"add-hooks-to-build"`, `resolve_topic_dir` → raises ValueError (branch-only), `collect_topic_statuses` → `[]`; `cli_options` all None, `dry_run` False; no tool packages pinned → assert `run_build_pass` called exactly twice; first call `options["tasks_only"] is True` and `env == {"A":"1"}`; second call `options["review"] is True` and env is the review layer — never the root env; return 0; plan relocated); `test_build_skipped_review_single_tasks_pass` (same + `cli_options={"skip_review": True}` → exactly one `run_build_pass` call (`tasks_only`); return value = that pass's code); `test_build_failed_tasks_pass_skips_review` (`run_build_pass` first call returns 1 → one pass call only; `pass_completed` for tasks carries `exit_code == 1`; `BuildCompleted.stages == ["tasks"]`; relocation not moved; return 1); `test_build_vetoed_run_blocks_before_any_pass` (two-pass setup + one tool whose `validate_build` hook vetoes `"policy"`; `run_build_pass` recorder → return 1; `run_build_pass` never called; plan file in place; NO notification hook of the tool ran; exactly one `logger.error` record carrying the violation triple — caplog); `test_build_pre_launch_failures_fire_no_events` (tool subscribed to all five actions — recorder; parametrize: uncommitted CODEMANIFEST (patch `_find_uncommitted_manifests` → `["x/CODEMANIFEST"]`), invalid review config (patch `validate_review_config` → raise), unavailable defaults (patch `sync_ralphex_defaults` → raise), no build agent on a skip run (config with root agent None + `cli_options={"skip_review": True}` — the step-3.5 guard path) → return 1; zero hook invocations across all five actions in every variant); `test_unsluggable_branch_falls_back_to_branch_only` (`resolve_topic_dir` raises ValueError; `resolve_current_branch_name` → None → `WorkIdentity.branch == "unknown"`, `slug is None`; statuses `[]`; run proceeds normally); `test_build_statuses_recomputed_after_relocation` (topic-hosting branch: `resolve_current_branch_name → "add-hooks-to-build"`, `resolve_topic_dir → Path(".goga/history/2026/add-hooks-to-build")` (is_dir True); `collect_topic_statuses` stub returning `[TopicRecord("add-hooks-to-build", ["backlog", "designed"])]`; successful run → recorded `BuildCompleted.statuses == ["backlog", "designed"]`; `collect_topic_statuses` called with `year="2026"` AFTER `move_completed_plan` (order recorded); branch-only variant delivers `[]`); `test_stage_facts_carry_env_names_only` (settings with `tasks.env={"A":"1","B":"2"}` → orchestration facts: `StageFacts.env == ["A", "B"]` sorted names; walk `dataclasses.fields` of every context and assert no string member equals `"1"`/`"2"`) +- [x] **Code**: rewrite `tests/build/test_build_resolved_wrapper.py` onto the two-part schema (`build.agent` at the root); delete the `codex_review → codex_enabled` case (the key is retired; the strategy-table test of Task 12 covers the new derivation); keep the uncommitted-manifests / ralphex-missing / custom-prompts-dir cases on the new fixtures +- [x] **Code**: update `tests/build/test_shipped_ralphex_assets.py` — replace the `BuildConfig(task_executor=TaskExecutorConfig(...))` construction with the two-part `BuildConfig(agent=..., env={})`; the vendored asset assertions are unchanged +- [x] **Debugging**: `pytest tests/build/ -x -q` — fix implementation code until all tests pass +- [x] **Contract re-verification**: facade `from goga.build import build` resolves; no compatibility shims (`grep -rn "review_options" goga/ tests/` empty); `--worktree`/`--skip-finalize`/`worktree`/`skip_finalize` absent from `goga/build/` +- [x] **Lint**: `ruff check goga/build tests/build` — fix formatting, apply decomposition if necessary +- [x] **Completion**: mark all checkboxes of this task complete ### Task 16: In-container CLI surface — `__main__.py` (TDD coding) diff --git a/goga/build/build.py b/goga/build/build.py index e588880a..6ae8b9d6 100644 --- a/goga/build/build.py +++ b/goga/build/build.py @@ -6,11 +6,14 @@ from ..agents import resolve_wrapper_path from ..config import ProjectConfig +from ..history import collect_topic_statuses, resolve_current_branch_name, resolve_topic_dir from .build_pass import run_build_pass +from .hooks import AdditionalFacts, BuildHooks, BuildMoment, StageFacts, WorkIdentity +from .pass_options import compose_pass_options from .plan_relocation import move_completed_plan from .ralphex_runtime import sync_ralphex_defaults from .review_config import validate_review_config -from .review_options import ReviewOptions, resolve_review_options +from .run_settings import RunSettings, resolve_run_settings logger = logging.getLogger(__name__) @@ -58,103 +61,248 @@ def _find_uncommitted_manifests() -> list[str]: return uncommitted -def _resolve_options(config: ProjectConfig, cli_options: dict) -> dict[str, str | int | bool]: - """Resolve the universal ralphex options with precedence CLI > BuildConfig > omit. +def _prepare_run_settings(config: ProjectConfig, cli_options: dict) -> RunSettings | None: + """Steps 1-3.5: resolve the run settings and run every pre-side-effect check. - Applies the precedence HERE in the build domain so `run_ralphex` performs no - resolution. For store_true bool keys, a CLI value of False is treated as - "not set -> defer to config" — bit-identical to the original - ``if cli_value or getattr(config.build, ...)`` semantics. For scalar keys the - CLI value wins when present (not None) and otherwise falls back to BuildConfig. - This helper knows no ralphex flag names; `run_ralphex` maps the resolved keys. - - Only the universal options (worktree, skip_finalize, session_timeout, - idle_timeout, wait, max_iterations) are resolved here — they apply to every - ralphex pass. The review-scoped keys (`review_patience`, `base_ref`) are NOT - resolved here: their owner is `resolve_review_options`, which applies the - precedence CLI > `ReviewExecutorConfig` > omit, and the orchestrator joins - them onto review-carrying passes only. - - The pass-mode keys `tasks_only`/`review` are deliberately NOT resolved here — - they are mode flags of a single pass, laid on top of the base options by the - orchestrator with a dict copy, never read from config or CLI passthrough. + Resolution first (``resolve_run_settings`` — pure), then the review-config + semantic validation, then the ralphex defaults sync — both before any + launch side effect and before the first checkpoint. The step-3.5 guard + rejects a run with no resolved tasks agent: ``validate_review_config`` + returns early on a skipped review, so without the guard a degenerate + skip-run would crash at the tasks wrapper resolution after the start + notification had already fired. Args: - config: Project configuration carrying BuildConfig option defaults. + config: Project configuration; ``config.build`` is the two-part build + configuration (guaranteed non-None by the host launcher guard). cli_options: CLI flags from the build invocation. Returns: - Resolved option dict keyed by ralphex option name. + The resolved run settings, or None when a check failed — the failure + is already logged; the caller returns 1 without firing any event. + """ + settings = resolve_run_settings(config.build, cli_options) + + try: + validate_review_config(settings) + except ValueError as error: + logger.error("invalid review configuration", extra={"detail": str(error)}) + return None + + try: + sync_ralphex_defaults(config.build, settings) + except ValueError as error: + logger.error("ralphex defaults unavailable", extra={"detail": str(error)}) + return None + + if settings.tasks.agent is None: + logger.error("no build agent resolved: set build.agent in .goga/config.yml") + return None + + return settings + + +def _resolve_work_identity() -> WorkIdentity: + """Resolve the work identity of the run — the branch and its hosted topic. + + The single git read of the cycle: the branch name with the ``"unknown"`` + fallback, then the pure topic-directory composition guarded against an + unsluggable branch (such a branch hosts no topic — the branch-only form). + A composed topic directory that exists as a directory hosts the topic; + its name is the slug and its parent's name the year. + + Returns: + The work identity — topic-hosting (branch, slug, year) or branch-only. """ - resolved: dict[str, str | int | bool] = {} + branch = resolve_current_branch_name() or "unknown" + + try: + topic_dir = resolve_topic_dir(branch) + except ValueError: + topic_dir = None + + if topic_dir is not None and topic_dir.is_dir(): + return WorkIdentity(branch=branch, slug=topic_dir.name, year=topic_dir.parent.name) - for key in ("worktree", "skip_finalize"): - resolved[key] = bool(cli_options.get(key) or getattr(config.build, key)) + return WorkIdentity(branch=branch) - for key in ("session_timeout", "idle_timeout", "wait", "max_iterations"): - cli_value = cli_options.get(key) - resolved[key] = cli_value if cli_value is not None else getattr(config.build, key) - return resolved +def _tasks_stage_facts(settings: RunSettings) -> StageFacts: + """Project the resolved tasks part onto the delivered tasks facts. + The review-only members are None on the tasks part and the env carries + names only — ``sorted(env)`` — so the delivered facts stay deterministic + and secret-free. + """ + tasks = settings.tasks + + return StageFacts( + stage="tasks", + agent=tasks.agent, + env=sorted(tasks.env), + max_iterations=tasks.max_iterations, + session_timeout=tasks.session_timeout, + idle_timeout=tasks.idle_timeout, + wait=tasks.wait, + roles=None, + base_ref=None, + strategy=None, + finalize=None, + additional=None, + ) + + +def _review_stage_facts(settings: RunSettings) -> StageFacts: + """Project the resolved review part onto the delivered review facts. + + Always constructed — a skipped review still delivers its resolved facts + (the facts describe the resolved settings, not the execution); the + additional block mirrors into ``AdditionalFacts``. + """ + review = settings.review + + return StageFacts( + stage="review", + agent=review.agent, + env=sorted(review.env), + max_iterations=review.max_iterations, + session_timeout=review.session_timeout, + idle_timeout=review.idle_timeout, + wait=review.wait, + roles=review.roles, + base_ref=review.base_ref, + strategy=review.strategy, + finalize=review.finalize, + additional=AdditionalFacts( + agent=review.additional.agent, + patience=review.additional.patience, + max_iterations=review.additional.max_iterations, + ), + ) -def _review_scoped_options(review: ReviewOptions) -> dict[str, str | int]: - """Project the review-scoped fields of a resolved ReviewOptions into an options fragment. - The inverse end of the naming split fixed by the contract: the options key - for the diff base is `base_ref` (the ralphex option name), the key for the - stop threshold re-expands to `review_patience`. Both keys are ABSENT from - the fragment when unset — never present-with-None — so an unset source - yields an empty dict and the composed pass options stay byte-identical to - a run that never declared review bounds. +def _stage_agent(settings: RunSettings, stage: str) -> str: + """The executor agent of one stage: the tasks agent, or the review-stage agent. + + The review stage runs under the additional agent when the strategy is + short (the external-only pass), otherwise under the review agent — both + already resolved with inheritance applied. + """ + if stage == "tasks": + return settings.tasks.agent + + if settings.review.strategy == "short": + return settings.review.additional.agent + + return settings.review.agent + + +def _stage_env_layer(settings: RunSettings, stage: str) -> dict[str, str] | None: + """The env layer of one stage; an empty dict means pure inheritance (None).""" + env = settings.tasks.env if stage == "tasks" else settings.review.env + + return env or None + + +def _launch_pass( + hooks: BuildHooks, + moment: BuildMoment, + settings: RunSettings, + facts: StageFacts, + stage: str, +) -> int: + """Run one pass: emit its start, launch it, emit its completion. + + The emit/launch/emit triple of a stage — the completion emission fires on + every return path with the actual exit code; completion is a fact, not a + success claim. The launch itself is delegated to ``run_build_pass`` + (config write + ``run_ralphex``); the ralphex command is never assembled + here. The plan path and the dry-run flag travel on the moment. Args: - review: Resolved review options of the run (already precedence-reduced - and whitespace-normalized by `resolve_review_options`). + hooks: The checkpoint surface of the run. + moment: The uniform envelope of the run (plan path and dry-run flag). + settings: The resolved run plan of the run. + facts: The stage facts of the pass being launched. + stage: The stage identity — exactly ``tasks`` or ``review``. Returns: - The review-scoped options fragment joined onto review-carrying passes. + The exit code of the pass, propagated unchanged. """ - options: dict[str, str | int] = {} + options = compose_pass_options(settings, stage) + wrapper = resolve_wrapper_path(_stage_agent(settings, stage)) + + hooks.emit_pass_started(moment, facts) + exit_code = run_build_pass( + moment.plan, + settings, + options, + wrapper, + moment.dry_run, + env=_stage_env_layer(settings, stage), + ) + hooks.emit_pass_completed(moment, facts, exit_code) - if review.base_ref is not None: - options["base_ref"] = review.base_ref - if review.patience is not None: - options["review_patience"] = review.patience + return exit_code - return options + +def _completion_statuses(work: WorkIdentity) -> list[str]: + """Re-read the history statuses of the work at the completion moment. + + Called after the relocation attempt, so the listing reflects the tree as + the completion event finds it. The branch-only form (no hosted topic) + yields an empty list without reading the tree; a hosted topic absent + from the listing yields an empty list too. + + Args: + work: The work identity of the run. + + Returns: + The maximal present statuses of the work's topic, in scale order. + """ + if work.slug is None: + return [] + + for record in collect_topic_statuses(year=work.year): + if record.topic == work.slug: + return record.statuses + + return [] def build(plan: str, config: ProjectConfig, cli_options: dict) -> int: - """Execute the build pipeline for a plan, orchestrating its review phase. - - Algorithm 0-9: git pre-check on uncommitted CODEMANIFEST files; task wrapper - resolution; review-option reduction (`resolve_review_options`); semantic - validation of the review configuration and the defaults sync — both before - any launch side effect; base option resolution (CLI > BuildConfig > omit); - the pass loop, where each pass writes its own `.ralphex/config` and delegates - the launch to `run_ralphex` via `run_build_pass`; plan relocation on success - of the final pass; the exit code of the last pass is returned. - - Pass modes: a skipped run makes exactly one tasks-only pass (no review phase - of any kind, the review env ignored entirely); a two-pass run (review - executor with a differing agent OR a non-empty review env) runs tasks-only - first — without an env layer — and, when it succeeds, a review-only pass - with the review wrapper and the review env as its environment layer; a - pass-1 failure exits with its code and skips pass 2 (and its env layer); - anything else is one full pass, without a layer. Review-scoped options - (base_ref, review_patience) join the options of review-carrying passes - only — the full-mode single pass and the two-pass review pass; a skip run - and the tasks-only pass carry universal options only. + """Execute the stable two-pass build cycle for a plan through ralphex. + + Algorithm 0-11: git pre-check on uncommitted CODEMANIFEST files (a + failure returns 1 before any event fires — the moment never happened); + settings resolution with the pre-side-effect validations (review-config + semantics, ralphex defaults sync, the no-build-agent guard); fact + resolution — the work identity (one git read) and both stage facts with + inheritance already applied; the validation gate before the first pass + (not approved → one merged error, exit 1, no pass, no relocation, no + further events); the start notification; the tasks pass; the review + pass when the tasks pass succeeded and the review is not skipped; plan + relocation on final-pass success; the history-status re-read; the + completion notification; the exit code of the last executed pass. + + Pass modes: a non-skipped run is exactly two passes — tasks-only first, + review second (the external-only pass under the short strategy, carried + by the additional agent's wrapper); a skipped review yields exactly one + tasks pass. The tasks pass runs under the root env layer + (``settings.tasks.env or None`` — an empty dict is pure inheritance), + the review pass under the review env layer the same way; neither layer + ever appears in options, argv, or logs. Args: plan: Path to the build plan file. - config: Project configuration with build settings and task executor. + config: Project configuration; its two-part ``build`` section is the + settings source of the cycle. cli_options: CLI flags such as dry_run, skip_manifest_check, - skip_review, worktree, etc. + skip_review, base_ref, and the session knobs. Returns: - The exit code of the last executed pass; 1 on a pre-launch failure. + The exit code of the last executed pass; 1 on a pre-launch failure + or a vetoed gate. """ if not cli_options.get("skip_manifest_check"): try: @@ -167,47 +315,39 @@ def build(plan: str, config: ProjectConfig, cli_options: dict) -> int: dry_run = cli_options.get("dry_run", False) - task_wrapper = resolve_wrapper_path(config.build.task_executor.agent) + settings = _prepare_run_settings(config, cli_options) - review = resolve_review_options(config.build, cli_options) + if settings is None: + return 1 - review_scoped = _review_scoped_options(review) + work = _resolve_work_identity() + moment = BuildMoment(plan=plan, work=work, dry_run=dry_run) + tasks_facts = _tasks_stage_facts(settings) + review_facts = _review_stage_facts(settings) - if not review.skip: - try: - validate_review_config(config.build, review) - except ValueError as error: - logger.error("invalid review configuration", extra={"detail": str(error)}) - return 1 + hooks = BuildHooks() + verdict = hooks.validate_build(moment, tasks_facts, review_facts, settings.skip) - try: - sync_ralphex_defaults(config.build, review) - except ValueError as error: - logger.error("ralphex defaults unavailable", extra={"detail": str(error)}) + if not verdict.approved: + logger.error( + "build blocked by hook vetoes", + extra={"violations": [f"{v.tool}/{v.hook}: {v.reason}" for v in verdict.violations]}, + ) return 1 - base = _resolve_options(config, cli_options) + hooks.emit_build_started(moment, tasks_facts, review_facts, settings.skip) logger.info("launching build passes", extra={"plan": plan, "dry_run": dry_run}) - if review.skip: - exit_code = run_build_pass(plan, config.build, {**base, "tasks_only": True}, task_wrapper, dry_run) - elif review.two_pass: - review_wrapper = resolve_wrapper_path(review.review_agent) - exit_code = run_build_pass(plan, config.build, {**base, "tasks_only": True}, task_wrapper, dry_run) - - if exit_code == 0: - exit_code = run_build_pass( - plan, - config.build, - {**base, "review": True, **review_scoped}, - review_wrapper, - dry_run, - env=review.review_env, - ) - else: - exit_code = run_build_pass(plan, config.build, {**base, **review_scoped}, task_wrapper, dry_run) - - move_completed_plan(plan, outcome=(exit_code == 0), dry_run=dry_run) + stages = ["tasks"] + exit_code = _launch_pass(hooks, moment, settings, tasks_facts, "tasks") + + if exit_code == 0 and not settings.skip: + exit_code = _launch_pass(hooks, moment, settings, review_facts, "review") + stages.append("review") + + relocation = move_completed_plan(plan, outcome=(exit_code == 0), dry_run=dry_run) + statuses = _completion_statuses(work) + hooks.emit_build_completed(moment, exit_code, stages, relocation, statuses) return exit_code diff --git a/goga/build/review_options.py b/goga/build/review_options.py deleted file mode 100644 index 940ceb17..00000000 --- a/goga/build/review_options.py +++ /dev/null @@ -1,107 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from ..config import BuildConfig - - -@dataclass(kw_only=True, frozen=True) -class ReviewOptions: - """Fully reduced review-phase decision for a single `goga build` run. - - Immutable value-object produced by `resolve_review_options` — never loaded - from YAML directly. `skip` is the final skip decision (False when no source - set it); `review_agent` and `roles` are verbatim from `build.review_executor`; - `two_pass` is True when a review executor agent is set and either differs - from the task executor agent or carries a non-empty review env; - `review_env` is the review-pass environment layer, verbatim (an empty dict - when the section declares no env); `base_ref` is the resolved review diff - base (None when unset, whitespace-normalized by the resolver); `patience` - is the resolved external-review stop threshold (None when unset). The two - review-scoped fields are forwarded to review-carrying passes only. Branch - priority between `skip` and `two_pass` belongs to the orchestrator, not to - this value-object. - """ - - skip: bool - review_agent: str | None - roles: list[str] | None - two_pass: bool - review_env: dict[str, str] - base_ref: str | None = None - patience: int | None = None - - -def resolve_review_options(config: BuildConfig, cli_options: dict) -> ReviewOptions: - """Reduce the tri-state skip flag and the review executor section to a decision. - - Pure function — no I/O, no validation of values. Precedence for `skip` is - CLI > ProjectConfig > omit: a non-None `cli_options["skip_review"]` wins, - otherwise `build.review_executor.skip` (when the section exists), otherwise - False. An empty roles list travels to the consumer as an empty list (the - "full default set" reading belongs to the consumer). - - `two_pass` is True when a review agent is set and it differs from the task - executor agent OR the review env is non-empty (dictionary equality with - `task_executor.env` is irrelevant — only non-emptiness is checked). - `review_env` is `build.review_executor.env` verbatim (shared by reference, - like `TaskExecutorConfig.env`) — an empty dict when there is no section. - - The review-scoped options resolve with the precedence CLI > - `build.review_executor.*` > omit: `base_ref` takes - `cli_options["base_ref"]` when set, otherwise - `build.review_executor.base_ref`; an empty or whitespace-only value from - either source counts as unset (resolved to None; a padded value resolves - to its stripped form — the CLI path and directly-constructed configs are - not loader-normalized). `patience` takes `cli_options["review_patience"]` - when set, otherwise `build.review_executor.patience`. An absent - review_executor section leaves both None. The resolved `base_ref` is never - checked for resolvability or format — diagnostics of the review diff base - belong to ralphex. - - Args: - config: Build configuration with the optional review_executor section. - cli_options: In-container CLI options; the keys read here are - `skip_review` (bool | None — None = flag not given), `base_ref` - (str | None — an empty or whitespace-only value counts as unset), - and `review_patience` (int | None). - - Returns: - The resolved ReviewOptions: the skip decision, review agent, roles, - env, and two_pass flag, plus the review-scoped base_ref and patience. - """ - review_executor = config.review_executor - - cli_skip = cli_options.get("skip_review") - - if cli_skip is not None: - skip = cli_skip - elif review_executor is not None and review_executor.skip is not None: - skip = review_executor.skip - else: - skip = False - - review_agent = review_executor.agent if review_executor is not None else None - roles = review_executor.roles if review_executor is not None else None - review_env = review_executor.env if review_executor is not None else {} - two_pass = review_agent is not None and (review_agent != config.task_executor.agent or bool(review_env)) - - base_ref = cli_options.get("base_ref") - if base_ref is None and review_executor is not None: - base_ref = review_executor.base_ref - if base_ref is not None: - base_ref = base_ref.strip() or None - - patience = cli_options.get("review_patience") - if patience is None and review_executor is not None: - patience = review_executor.patience - - return ReviewOptions( - skip=skip, - review_agent=review_agent, - roles=roles, - two_pass=two_pass, - review_env=review_env, - base_ref=base_ref, - patience=patience, - ) diff --git a/tests/build/conftest.py b/tests/build/conftest.py new file mode 100644 index 00000000..c09a4345 --- /dev/null +++ b/tests/build/conftest.py @@ -0,0 +1,12 @@ +"""Shared fixtures of the build cell tests — the platform boundary. + +Re-exports the two boundary fixtures of the hooks platform tests +(``pin_package_environment`` / ``install_tool_package``) so the orchestration +suites of ``tests/build`` pin the same two outside-world points — the +``packages_distributions`` read and the ``sys.modules`` entry of a +``goga_tool_*`` package — with the platform code under test running for real. +""" + +from __future__ import annotations + +from tests.hooks.conftest import install_tool_package, pin_package_environment # noqa: F401 diff --git a/tests/build/test_build.py b/tests/build/test_build.py index 8cd769e6..54614b35 100644 --- a/tests/build/test_build.py +++ b/tests/build/test_build.py @@ -1,29 +1,48 @@ +"""Contract and logic tests for the entity declared in ``goga/build/CODEMANIFEST`` +with ``location: build.py``: + +- ``build(plan, config, cli_options)`` — the stable two-pass build cycle with + its five hooks checkpoints + +Orchestration tests follow the design's General Setup: ``run_build_pass`` is +monkeypatched at ``goga.build.build``'s import point with a recording stub, +runs happen inside ``tmp_path`` (the ``.ralphex/`` writes land there), and +``resolve_current_branch_name``/``resolve_topic_dir``/``collect_topic_statuses`` +are pinned at the same import point. Wrapper-existence checks are pinned at +``goga.build.review_config``'s import point. The gate and the notifications run +the real platform over the boundary fixtures of ``tests/hooks/conftest.py``. +""" + from __future__ import annotations +import dataclasses import inspect +import logging import subprocess import sys from contextlib import contextmanager +from dataclasses import is_dataclass from pathlib import Path from unittest import mock import pytest from goga.build.build import ( _parse_porcelain_path, - _resolve_options, _unquote_git_path, build, ) -from goga.build.ralphex_config import write_ralphex_config -from goga.build.ralphex_runtime import sync_ralphex_defaults -from goga.build.review_options import ReviewOptions +from goga.build.hooks import BuildHooks +from goga.build.plan_relocation import move_completed_plan as _real_move_completed_plan from goga.config import ( + AdditionalReviewConfig, BuildConfig, PipelineConfig, ProjectConfig, - ReviewExecutorConfig, - TaskExecutorConfig, + ReviewConfig, ) +from goga.history import TopicRecord + +build_module = sys.modules["goga.build.build"] TEST_ENV_VARS = { "ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-4.7", @@ -58,21 +77,37 @@ "emit them both in one response\n" ) +_SOFT_ACTIONS = ("build_started", "pass_started", "pass_completed", "build_completed") +_ALL_ACTIONS = ("validate_build", *_SOFT_ACTIONS) + +# The full cli_options surface the in-container entrypoint forwards; every +# knob key present with None — the "cli_options all None" form. +_FULL_CLI_OPTIONS = { + "dry_run": False, + "skip_manifest_check": True, + "skip_review": None, + "base_ref": None, + "review_patience": None, + "session_timeout": None, + "idle_timeout": None, + "wait": None, + "max_iterations": None, +} + def _make_config( agent: str = "claude", env: dict | None = None, - review_executor: ReviewExecutorConfig | None = None, + review: ReviewConfig | None = None, **build_kwargs: object, ) -> ProjectConfig: - """Build a ProjectConfig; review_executor defaults to None (no review section).""" - task_executor = TaskExecutorConfig(agent=agent, env=env or {}) - build = BuildConfig(task_executor=task_executor, review_executor=review_executor, **build_kwargs) # type: ignore[arg-type] + """Build a ProjectConfig on the two-part build model; review defaults to None.""" + build_section = BuildConfig(agent=agent, env=env or {}, review=review, **build_kwargs) # type: ignore[arg-type] return ProjectConfig( lang="python", image="goga:latest", dockerfile=None, - build=build, + build=build_section, pipeline=PipelineConfig(agent="claude"), ) @@ -100,125 +135,218 @@ def _mock_vendored_sources(tmp_path: Path): yield prompts_dir, agents_dir +def _pin_orchestration_boundary( + monkeypatch, + tmp_path: Path, + *, + branch: str | None = "add-hooks-to-build", +) -> None: + """Pin the outside-world reads of the cycle: branch, topic dir, statuses, wrappers. + + The default pins the branch-only form (``resolve_topic_dir`` raises the + unsluggable-branch ValueError, no topic statuses); the wrapper-existence + check of the review-config validation resolves every agent to one existing + tmp file. + """ + wrapper = tmp_path / "claude-as-claude.sh" + wrapper.write_text("#!/bin/sh\n") + + def _unsluggable(_topic: str, _year: str | None = None) -> Path: + raise ValueError(f"topic input {_topic!r} normalizes to an empty topic slug") + + monkeypatch.setattr(build_module, "resolve_current_branch_name", lambda: branch) + monkeypatch.setattr(build_module, "resolve_topic_dir", _unsluggable) + monkeypatch.setattr(build_module, "collect_topic_statuses", lambda _year=None: []) + monkeypatch.setattr("goga.build.review_config.resolve_wrapper_path", lambda _agent: str(wrapper)) + + def _run_build_in_tmp( tmp_path: Path, monkeypatch, - plan: str = "plan.md", cli_options: dict | None = None, config: ProjectConfig | None = None, + *, + branch: str | None = "add-hooks-to-build", ) -> int: - """chdir into tmp_path, write a plan, and run build() with mocked defaults sources.""" + """chdir into tmp_path, write a plan, pin the boundary, and run build().""" monkeypatch.chdir(tmp_path) - Path(plan).write_text("# plan\n") + Path("plan.md").write_text("# plan\n") + _pin_orchestration_boundary(monkeypatch, tmp_path, branch=branch) if config is None: config = _make_config() with _mock_vendored_sources(tmp_path): - return build(plan, config, cli_options or {}) # type: ignore[arg-type] + return build("plan.md", config, cli_options or {}) + + +def _recording_call(name: str, real, order: list[str]): + """A delegating wrapper recording ``name`` before every call of ``real``.""" + + def _call(*args, **kwargs): + order.append(name) + return real(*args, **kwargs) + + return _call + + +def _install_recording_tool(install_tool_package, recorded: list[tuple[str, object]]): + """Install one fake tool subscribing to all five build actions, recording contexts.""" + + def register(hooks: object) -> None: + def make(action: str): + def hook(self: object, context: object) -> None: + recorded.append((action, context)) + + return hook + + for action in _ALL_ACTIONS: + hooks.subscribe("build", action, action, make(action)) # type: ignore[attr-defined] + + return install_tool_package("goga_tool_demo", register_hooks=register) + + +def _install_vetoing_tool(install_tool_package, recorded: list[tuple[str, object]]): + """Install one fake tool whose ``guard`` validation hook vetoes with 'policy'.""" + + def register(hooks: object) -> None: + def guard(self: object, context: object) -> None: + recorded.append(("validate_build", context)) + context.veto("policy") + + def make(action: str): + def hook(self: object, context: object) -> None: + recorded.append((action, context)) + + return hook + + hooks.subscribe("build", "validate_build", "guard", guard) # type: ignore[attr-defined] + for action in _SOFT_ACTIONS: + hooks.subscribe("build", action, action, make(action)) # type: ignore[attr-defined] + + return install_tool_package("goga_tool_a", register_hooks=register) + + +def _assert_no_secret_strings(value: object, secrets: frozenset[str]) -> None: + """Walk a delivered context recursively; no string member equals a secret value.""" + if is_dataclass(value) and not isinstance(value, type): + for field in dataclasses.fields(value): + _assert_no_secret_strings(getattr(value, field.name), secrets) + elif isinstance(value, str): + assert value not in secrets + elif isinstance(value, (list, tuple, set, frozenset)): + for item in value: + _assert_no_secret_strings(item, secrets) -# --- Helper tests --- +def _init_git_repo(path: Path) -> None: + subprocess.run(["git", "init"], cwd=path, capture_output=True, check=True) + subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=path, capture_output=True, check=True) + subprocess.run(["git", "config", "user.name", "Test"], cwd=path, capture_output=True, check=True) + + +# --- Contract tests --- + + +class TestBuildCycleContract: + def test_build_importable_from_facade(self) -> None: + """build() is accessible from the goga.build facade.""" + from goga.build import build as facade_build + assert facade_build is build -class TestBuildContract: def test_build_signature_is_plan_config_cli_options(self) -> None: sig = inspect.signature(build) assert list(sig.parameters) == ["plan", "config", "cli_options"] - - def test_build_returns_int_annotation(self) -> None: - sig = inspect.signature(build) - # `from __future__ import annotations` defers annotations to strings, - # so the return annotation is the string "int". assert sig.return_annotation in ("int", int) - def test_make_config_uses_task_executor_config_not_task_executor(self) -> None: - config = _make_config() - # TaskExecutorConfig is the renamed class; the old TaskExecutor must - # no longer be the type carried on BuildConfig.task_executor. - assert isinstance(config.build.task_executor, TaskExecutorConfig) - assert not hasattr(config.build, "image") - assert config.image == "goga:latest" - - # The absorbed-private-helpers assertion lives in test_contract.py - # (test_absorbed_private_helpers_removed_from_module) — the plan assigns - # that contract check to the contract file. - - -class TestResolveOptions: - def test_resolve_options_cli_overrides_config_scalar(self) -> None: - # CLI present wins over BuildConfig for scalar keys. - resolved = _resolve_options(_make_config(max_iterations=5), {"max_iterations": 10}) - assert resolved["max_iterations"] == 10 - - def test_resolve_options_bool_false_defers_to_config(self) -> None: - # store_true nuance: a CLI False is "not set" -> defer to config. - resolved = _resolve_options(_make_config(worktree=True), {"worktree": False}) - assert resolved["worktree"] is True - - def test_resolve_options_config_value_when_cli_absent(self) -> None: - # No CLI value -> fall back to BuildConfig. - resolved = _resolve_options(_make_config(worktree=True), {}) - assert resolved["worktree"] is True - - def test_resolve_options_omits_when_config_none(self) -> None: - # With neither CLI nor config set, the resolved values carry the omit - # semantics through to run_ralphex (bool False, scalar None). - resolved = _resolve_options(_make_config(), {}) - assert resolved["worktree"] is False - assert resolved["skip_finalize"] is False - assert resolved["session_timeout"] is None - assert resolved["idle_timeout"] is None - assert resolved["wait"] is None - assert resolved["max_iterations"] is None - assert "review_patience" not in resolved - - def test_resolve_options_universal_zone_drops_review_patience(self) -> None: - # Two-zone contract: the universal resolver owns worktree/skip_finalize/ - # session_timeout/idle_timeout/wait/max_iterations only — the - # review-scoped keys (review_patience, base_ref) are resolved by - # resolve_review_options, never here, even when the CLI carries them. - resolved = _resolve_options(_make_config(), {"review_patience": 5, "base_ref": "x"}) - assert "review_patience" not in resolved - assert "base_ref" not in resolved - - def test_resolve_options_skip_finalize_config_value_when_cli_absent(self) -> None: - # Mirror of the worktree case for skip_finalize (the second bool key): - # no CLI value -> fall back to BuildConfig. - resolved = _resolve_options(_make_config(skip_finalize=True), {}) - - assert resolved["skip_finalize"] is True - - def test_resolve_options_round_trips_into_build_command(self) -> None: - # End-to-end pin: resolved options flow bit-identically through - # _build_command. Covers the split contract _resolve_options (build) -> - # _build_command (ralphex), including the ""/None scalar filter so the - # two halves cannot drift on what "omitted" means. - from goga.ralphex.run_ralphex import _build_command + def test_cycle_calls_collaborators_in_traced_order( + self, + tmp_path: Path, + monkeypatch, + pin_package_environment, + ) -> None: + """The 12-step cycle: resolution → validation → sync → gate → start → + passes (compose/emit/launch/emit each) → relocation → statuses → completion.""" + pin_package_environment({}) + order: list[str] = [] + + real_compose = build_module.compose_pass_options + + def _compose(settings, stage): + order.append(f"compose_pass_options:{stage}") + return real_compose(settings, stage) + + def _move(plan, outcome, dry_run): + order.append("move_completed_plan") + return _real_move_completed_plan(plan, outcome, dry_run) + + def _pass(*args, **kwargs): + order.append("run_build_pass") + return 0 + + class _RecordingHooks(BuildHooks): + def validate_build(self, moment, tasks, review, skip): + order.append("validate_build") + return super().validate_build(moment, tasks, review, skip) + + def emit_build_started(self, moment, tasks, review, skip): + order.append("emit_build_started") + super().emit_build_started(moment, tasks, review, skip) + + def emit_pass_started(self, moment, facts): + order.append("emit_pass_started") + super().emit_pass_started(moment, facts) + + def emit_pass_completed(self, moment, facts, exit_code): + order.append("emit_pass_completed") + super().emit_pass_completed(moment, facts, exit_code) + + def emit_build_completed(self, moment, exit_code, stages, relocation, statuses): + order.append("emit_build_completed") + super().emit_build_completed(moment, exit_code, stages, relocation, statuses) + + monkeypatch.setattr( + build_module, + "resolve_run_settings", + _recording_call("resolve_run_settings", build_module.resolve_run_settings, order), + ) + monkeypatch.setattr( + build_module, + "validate_review_config", + _recording_call("validate_review_config", build_module.validate_review_config, order), + ) + monkeypatch.setattr( + build_module, + "sync_ralphex_defaults", + _recording_call("sync_ralphex_defaults", build_module.sync_ralphex_defaults, order), + ) + monkeypatch.setattr(build_module, "compose_pass_options", _compose) + monkeypatch.setattr(build_module, "move_completed_plan", _move) + monkeypatch.setattr(build_module, "BuildHooks", _RecordingHooks) - config = _make_config(worktree=True, skip_finalize=True) - cli = {"session_timeout": "30m", "max_iterations": 10, "idle_timeout": "", "wait": None} - resolved = _resolve_options(config, cli) - cmd = _build_command("plan.md", resolved) + with mock.patch("goga.build.build.run_build_pass", side_effect=_pass): + result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options=dict(_FULL_CLI_OPTIONS)) - assert cmd == [ - "ralphex", - "plan.md", - "--config-dir", - ".ralphex/", - "--worktree", - "--skip-finalize", - "--session-timeout", - "30m", - "--max-iterations", - "10", + assert result == 0 + assert order == [ + "resolve_run_settings", + "validate_review_config", + "sync_ralphex_defaults", + "validate_build", + "emit_build_started", + "compose_pass_options:tasks", + "emit_pass_started", + "run_build_pass", + "emit_pass_completed", + "compose_pass_options:review", + "emit_pass_started", + "run_build_pass", + "emit_pass_completed", + "move_completed_plan", + "emit_build_completed", ] - def test_resolve_options_has_no_pass_mode_keys(self) -> None: - # tasks_only/review are pass-mode flags laid on by the orchestrator's - # dict copy, never resolved from CLI or config. - cli = {"tasks_only": True, "review": True} - resolved = _resolve_options(_make_config(), cli) - assert "tasks_only" not in resolved - assert "review" not in resolved + +# --- Git pre-check helper tests --- class TestUnquoteGitPath: @@ -252,343 +380,560 @@ def test_empty_after_prefix(self) -> None: assert _parse_porcelain_path("M ") is None -# --- Ralphex config writer tests (migrated to the public write_ralphex_config) --- - +# --- Manifest pre-check (step 0) --- -class TestWriteRalphexConfig: - def test_writes_resolved_wrapper_to_claude_command(self, tmp_path, monkeypatch) -> None: - monkeypatch.chdir(tmp_path) - config = _make_config(agent="codex") - write_ralphex_config(config.build, "/home/goga/bin/codex-as-claude.sh") +class TestManifestCheck: + @mock.patch("goga.build.build.run_build_pass", return_value=0) + def test_all_committed_proceeds(self, mock_pass, tmp_path, monkeypatch) -> None: + _init_git_repo(tmp_path) + manifest = tmp_path / "CODEMANIFEST" + manifest.write_text("content") + subprocess.run(["git", "add", "CODEMANIFEST"], cwd=tmp_path, capture_output=True, check=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=tmp_path, capture_output=True, check=True) - config_text = (tmp_path / ".ralphex" / "config").read_text() - assert "claude_command = /home/goga/bin/codex-as-claude.sh" in config_text + result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={"skip_manifest_check": False}) + assert result == 0 - def test_writes_claude_args_default(self, tmp_path, monkeypatch) -> None: - monkeypatch.chdir(tmp_path) - config = _make_config() + def test_uncommitted_manifest_returns_1(self, tmp_path, monkeypatch) -> None: + _init_git_repo(tmp_path) + manifest = tmp_path / "CODEMANIFEST" + manifest.write_text("content") - write_ralphex_config(config.build, "/home/goga/bin/claude-as-claude.sh") + result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={"skip_manifest_check": False}) + assert result == 1 - config_text = (tmp_path / ".ralphex" / "config").read_text() - assert "claude_args = --dangerously-skip-permissions --output-format stream-json --verbose" in config_text + @mock.patch("goga.build.build.run_build_pass", return_value=0) + def test_skip_manifest_check(self, mock_pass, tmp_path, monkeypatch) -> None: + _init_git_repo(tmp_path) + manifest = tmp_path / "CODEMANIFEST" + manifest.write_text("content") - def test_codex_enabled_false_by_default(self, tmp_path, monkeypatch) -> None: - monkeypatch.chdir(tmp_path) - config = _make_config() + result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={"skip_manifest_check": True}) + assert result == 0 - write_ralphex_config(config.build, "/home/goga/bin/claude-as-claude.sh") + def test_not_git_repo_returns_1(self, tmp_path, monkeypatch) -> None: + result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={"skip_manifest_check": False}) + assert result == 1 - config_text = (tmp_path / ".ralphex" / "config").read_text() - assert "codex_enabled = false" in config_text + @mock.patch("goga.build.build.run_build_pass", return_value=0) + def test_no_codemanifest_files_proceeds(self, mock_pass, tmp_path, monkeypatch) -> None: + _init_git_repo(tmp_path) + (tmp_path / ".gitkeep").write_text("") + subprocess.run(["git", "add", ".gitkeep"], cwd=tmp_path, capture_output=True, check=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=tmp_path, capture_output=True, check=True) - def test_codex_review_true_maps_to_codex_enabled_true(self, tmp_path, monkeypatch) -> None: - monkeypatch.chdir(tmp_path) - config = _make_config(codex_review=True) + result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={"skip_manifest_check": False}) + assert result == 0 - write_ralphex_config(config.build, "/home/goga/bin/claude-as-claude.sh") + def test_multiple_uncommitted_lists_all(self, tmp_path, monkeypatch) -> None: + _init_git_repo(tmp_path) + (tmp_path / ".gitkeep").write_text("") + subprocess.run(["git", "add", ".gitkeep"], cwd=tmp_path, capture_output=True, check=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=tmp_path, capture_output=True, check=True) + for d in ("a", "b", "c"): + subdir = tmp_path / d + subdir.mkdir() + (subdir / "CODEMANIFEST").write_text(f"content {d}") - config_text = (tmp_path / ".ralphex" / "config").read_text() - assert "codex_enabled = true" in config_text + result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={"skip_manifest_check": False}) + assert result == 1 - def test_does_not_write_codex_specific_keys(self, tmp_path, monkeypatch) -> None: - monkeypatch.chdir(tmp_path) - config = _make_config(agent="codex") - write_ralphex_config(config.build, "/home/goga/bin/codex-as-claude.sh") +# --- The two-pass cycle (steps 4-12) --- - config_text = (tmp_path / ".ralphex" / "config").read_text() - assert "executor" not in config_text - assert "codex_command" not in config_text - assert "codex_sandbox" not in config_text - assert "codex_reasoning_effort" not in config_text - def test_does_not_generate_wrapper_scripts(self, tmp_path, monkeypatch) -> None: - monkeypatch.chdir(tmp_path) - config = _make_config(agent="claude") +class TestTwoPassCycle: + def test_build_runs_two_passes_with_bound_settings( + self, + tmp_path: Path, + monkeypatch, + pin_package_environment, + ) -> None: + """Two passes with the resolved settings bound to each: tasks-only with the + root env layer, review with the review env layer — never the other way.""" + pin_package_environment({}) + config = _make_config(env={"A": "1"}, review=ReviewConfig(agent="codex", env={"R": "2"})) - write_ralphex_config(config.build, "/home/goga/bin/claude-as-claude.sh") + with mock.patch("goga.build.build.run_build_pass", return_value=0) as mock_pass: + result = _run_build_in_tmp(tmp_path, monkeypatch, config=config, cli_options=dict(_FULL_CLI_OPTIONS)) - ralphex_dir = tmp_path / ".ralphex" - entries = {p.name for p in ralphex_dir.iterdir()} - assert entries == {"config"} + assert result == 0 + assert mock_pass.call_count == 2 - def test_overwrites_stale_config_without_merging(self, tmp_path, monkeypatch) -> None: - """A pre-existing .ralphex/config is overwritten, not merged into.""" - monkeypatch.chdir(tmp_path) - ralphex_dir = tmp_path / ".ralphex" - ralphex_dir.mkdir() - (ralphex_dir / "config").write_text("stale_key = stale_value\nclaude_command = OLD_PATH\n") + first, second = mock_pass.call_args_list + assert first.args[0] == "plan.md" + assert first.args[2]["tasks_only"] is True + assert "review" not in first.args[2] + assert first.kwargs["env"] == {"A": "1"} + assert first.args[3] == "/home/goga/bin/claude-as-claude.sh" - config = _make_config(agent="codex") - write_ralphex_config(config.build, "/home/goga/bin/codex-as-claude.sh") + assert second.args[2]["review"] is True + assert "tasks_only" not in second.args[2] + assert second.kwargs["env"] == {"R": "2"} + assert second.args[3] == "/home/goga/bin/codex-as-claude.sh" - config_text = (ralphex_dir / "config").read_text() - assert "stale_key" not in config_text - assert "OLD_PATH" not in config_text - keys = {line.split(" = ", 1)[0] for line in config_text.strip().splitlines() if " = " in line} - assert keys == { - "claude_command", - "claude_args", - "codex_enabled", - "preserve_anthropic_api_key", - "move_plan_on_completion", - } - - def test_move_plan_on_completion_always_false(self, tmp_path, monkeypatch) -> None: - """goga relocates the plan itself, so ralphex never must.""" - monkeypatch.chdir(tmp_path) - config = _make_config() + # A successful final pass relocates the plan. + assert not (tmp_path / "plan.md").exists() + assert (tmp_path / "completed" / "plan.md").read_text() == "# plan\n" - write_ralphex_config(config.build, "/home/goga/bin/claude-as-claude.sh") + def test_build_skipped_review_single_tasks_pass( + self, + tmp_path: Path, + monkeypatch, + pin_package_environment, + ) -> None: + """A skipped review yields exactly one tasks pass; the return value is + that pass's code.""" + pin_package_environment({}) + config = _make_config(review=ReviewConfig(skip=True)) + + with mock.patch("goga.build.build.run_build_pass", side_effect=[7]) as mock_pass: + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + config=config, + cli_options={**_FULL_CLI_OPTIONS, "skip_review": True}, + ) - config_text = (tmp_path / ".ralphex" / "config").read_text() - assert "move_plan_on_completion = false" in config_text + assert result == 7 + assert mock_pass.call_count == 1 + assert mock_pass.call_args.args[2]["tasks_only"] is True + + def test_build_failed_tasks_pass_skips_review( + self, + tmp_path: Path, + monkeypatch, + pin_package_environment, + install_tool_package, + ) -> None: + """A failed tasks pass never reaches the review pass; the completion + facts carry the failure.""" + recorded: list[tuple[str, object]] = [] + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + _install_recording_tool(install_tool_package, recorded) + + with mock.patch("goga.build.build.run_build_pass", return_value=1) as mock_pass: + result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options=dict(_FULL_CLI_OPTIONS)) - def test_codex_review_none_maps_to_codex_enabled_false(self, tmp_path, monkeypatch) -> None: - """An explicit codex_review=None still renders codex_enabled = false.""" - monkeypatch.chdir(tmp_path) - config = _make_config(codex_review=None) + assert result == 1 + assert mock_pass.call_count == 1 - write_ralphex_config(config.build, "/home/goga/bin/claude-as-claude.sh") + pass_completed = next(context for action, context in recorded if action == "pass_completed") + assert pass_completed.exit_code == 1 + assert pass_completed.facts.stage == "tasks" - config_text = (tmp_path / ".ralphex" / "config").read_text() - assert "codex_enabled = false" in config_text + completed = next(context for action, context in recorded if action == "build_completed") + assert completed.exit_code == 1 + assert completed.stages == ["tasks"] + assert completed.relocation.moved is False - def test_writes_preserve_anthropic_api_key_true(self, tmp_path, monkeypatch) -> None: - """preserve_anthropic_api_key is pinned to true so ralphex keeps ANTHROPIC_API_KEY.""" - monkeypatch.chdir(tmp_path) - config = _make_config() + # A failed run keeps the plan in place for ralphex to resume. + assert (tmp_path / "plan.md").is_file() + assert not (tmp_path / "completed").exists() - write_ralphex_config(config.build, "/home/goga/bin/claude-as-claude.sh") + def test_build_vetoed_run_blocks_before_any_pass( + self, + tmp_path: Path, + monkeypatch, + caplog, + pin_package_environment, + install_tool_package, + ) -> None: + """A vetoed gate: one merged error, exit 1, no pass, no relocation, and + no notification of the tool ever runs.""" + recorded: list[tuple[str, object]] = [] + pin_package_environment({"goga_tool_a": ["a-dist"]}) + _install_vetoing_tool(install_tool_package, recorded) + + with mock.patch("goga.build.build.run_build_pass", return_value=0) as mock_pass: + result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options=dict(_FULL_CLI_OPTIONS)) - config_text = (tmp_path / ".ralphex" / "config").read_text() - assert "preserve_anthropic_api_key = true" in config_text + assert result == 1 + mock_pass.assert_not_called() + assert (tmp_path / "plan.md").is_file() + assert not (tmp_path / "completed").exists() + # Only the validation hook of the tool ran — no notification fired. + assert [action for action, _context in recorded] == ["validate_build"] -class TestSyncDefaults: - """Migrated from TestCopyDefaults onto the public sync_ralphex_defaults.""" + errors = [ + record + for record in caplog.records + if record.name == "goga.build.build" and record.levelno == logging.ERROR + ] + assert len(errors) == 1 + assert errors[0].getMessage() == "build blocked by hook vetoes" + assert errors[0].violations == ["a/guard: policy"] - def _review(self, roles: list[str] | None = None) -> ReviewOptions: - return ReviewOptions(skip=False, review_agent=None, roles=roles, two_pass=False, review_env={}) + @pytest.mark.parametrize( + "variant", + ["uncommitted-manifests", "invalid-review-config", "defaults-unavailable", "no-build-agent-skip-run"], + ) + def test_build_pre_launch_failures_fire_no_events( + self, + tmp_path: Path, + monkeypatch, + pin_package_environment, + install_tool_package, + variant: str, + ) -> None: + """Steps 0-3.5 failures return 1 before the moment exists — zero hook + invocations across all five actions in every variant.""" + recorded: list[tuple[str, object]] = [] + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + _install_recording_tool(install_tool_package, recorded) - def test_prompts_copied(self, tmp_path, monkeypatch) -> None: - monkeypatch.chdir(tmp_path) - with _mock_vendored_sources(tmp_path): - sync_ralphex_defaults(_make_config().build, self._review()) + config = _make_config() + cli_options = {**_FULL_CLI_OPTIONS, "skip_manifest_check": True} + + if variant == "uncommitted-manifests": + cli_options["skip_manifest_check"] = False + monkeypatch.setattr(build_module, "_find_uncommitted_manifests", lambda: ["x/CODEMANIFEST"]) + elif variant == "invalid-review-config": + monkeypatch.setattr( + build_module, + "validate_review_config", + mock.Mock(side_effect=ValueError("bad review config")), + ) + elif variant == "defaults-unavailable": + monkeypatch.setattr( + build_module, + "sync_ralphex_defaults", + mock.Mock(side_effect=ValueError("no defaults")), + ) + else: + # The degenerate skip-run: no root agent and the review skipped — + # the step-3.5 guard path (validation returns early on skip). + config = _make_config(agent=None, review=ReviewConfig(skip=True)) + cli_options["skip_review"] = True - prompts_dir = tmp_path / ".ralphex" / "prompts" - assert prompts_dir.is_dir() - expected = {"task.txt", "codex.txt", "review_first.txt", "review_second.txt"} - actual = {f.name for f in prompts_dir.iterdir() if f.is_file()} - assert expected.issubset(actual) + with mock.patch("goga.build.build.run_build_pass", return_value=0) as mock_pass: + result = _run_build_in_tmp(tmp_path, monkeypatch, config=config, cli_options=cli_options) - def test_agents_copied(self, tmp_path, monkeypatch) -> None: - monkeypatch.chdir(tmp_path) - with _mock_vendored_sources(tmp_path): - sync_ralphex_defaults(_make_config().build, self._review()) + assert result == 1 + mock_pass.assert_not_called() + assert recorded == [] + + def test_unsluggable_branch_falls_back_to_branch_only( + self, + tmp_path: Path, + monkeypatch, + pin_package_environment, + install_tool_package, + ) -> None: + """An unresolvable branch yields the 'unknown' branch-only work identity; + the run proceeds normally with empty statuses.""" + recorded: list[tuple[str, object]] = [] + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + _install_recording_tool(install_tool_package, recorded) + + with mock.patch("goga.build.build.run_build_pass", return_value=0) as mock_pass: + result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options=dict(_FULL_CLI_OPTIONS), branch=None) - agents_dir = tmp_path / ".ralphex" / "agents" - assert agents_dir.is_dir() - expected = {f"{role}.txt" for role in _VENDORED_ROLES} - actual = {f.name for f in agents_dir.iterdir() if f.is_file()} - assert expected.issubset(actual) + assert result == 0 + assert mock_pass.call_count == 2 + + completed = next(context for action, context in recorded if action == "build_completed") + assert completed.moment.work.branch == "unknown" + assert completed.moment.work.slug is None + assert completed.moment.work.year is None + assert completed.statuses == [] + + @pytest.mark.parametrize("topic_hosted", [True, False]) + def test_build_statuses_recomputed_after_relocation( + self, + tmp_path: Path, + monkeypatch, + pin_package_environment, + install_tool_package, + topic_hosted: bool, + ) -> None: + """The statuses re-read happens AFTER the relocation attempt and carries + the hosted topic's record; the branch-only form delivers [].""" + recorded: list[tuple[str, object]] = [] + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + _install_recording_tool(install_tool_package, recorded) + + topic_dir = tmp_path / ".goga" / "history" / "2026" / "add-hooks-to-build" + if topic_hosted: + topic_dir.mkdir(parents=True) + + order: list[str] = [] + seen_years: list[str | None] = [] + + def _collect(year=None): + order.append("statuses") + seen_years.append(year) + return [TopicRecord(topic="add-hooks-to-build", statuses=["backlog", "designed"])] + + def _move(plan, outcome, dry_run): + order.append("move") + return _real_move_completed_plan(plan, outcome, dry_run) - def test_overwrites_existing(self, tmp_path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) - prompts_dir = tmp_path / ".ralphex" / "prompts" - prompts_dir.mkdir(parents=True) - (prompts_dir / "task.txt").write_text("ORIGINAL") - - with _mock_vendored_sources(tmp_path): - sync_ralphex_defaults(_make_config().build, self._review()) - - assert (prompts_dir / "task.txt").read_text() != "ORIGINAL" + Path("plan.md").write_text("# plan\n") - def test_missing_vendored_source_raises_value_error(self, tmp_path, monkeypatch) -> None: - monkeypatch.chdir(tmp_path) - from goga.build import ralphex_runtime + wrapper = tmp_path / "claude-as-claude.sh" + wrapper.write_text("#!/bin/sh\n") + monkeypatch.setattr(build_module, "resolve_current_branch_name", lambda: "add-hooks-to-build") + monkeypatch.setattr( + build_module, + "resolve_topic_dir", + lambda _topic, _year=None: Path(".goga/history/2026/add-hooks-to-build"), + ) + monkeypatch.setattr(build_module, "collect_topic_statuses", _collect) + monkeypatch.setattr(build_module, "move_completed_plan", _move) + monkeypatch.setattr("goga.build.review_config.resolve_wrapper_path", lambda _agent: str(wrapper)) with ( - mock.patch.object(ralphex_runtime, "_VENDORED_PROMPTS", Path("/nonexistent")), - mock.patch.object(ralphex_runtime, "_VENDORED_AGENTS", Path("/nonexistent")), - pytest.raises(ValueError, match="dump-defaults"), + _mock_vendored_sources(tmp_path), + mock.patch("goga.build.build.run_build_pass", return_value=0), ): - sync_ralphex_defaults(_make_config().build, self._review()) - - def test_empty_defaults_subdirs_no_error(self, tmp_path, monkeypatch) -> None: - monkeypatch.chdir(tmp_path) - empty_prompts = tmp_path / "fake" / "prompts" - empty_agents = tmp_path / "fake" / "agents" - empty_prompts.mkdir(parents=True) - empty_agents.mkdir(parents=True) - config = _make_config(prompts_dir=str(empty_prompts), agents_dir=str(empty_agents)) - - sync_ralphex_defaults(config.build, self._review()) - - assert (tmp_path / ".ralphex" / "prompts").is_dir() - assert (tmp_path / ".ralphex" / "agents").is_dir() - - def test_custom_prompts_dir(self, tmp_path, monkeypatch) -> None: - monkeypatch.chdir(tmp_path) - custom_prompts = tmp_path / "custom" / "prompts" - custom_prompts.mkdir(parents=True) - (custom_prompts / "custom_task.txt").write_text("custom content") - - config = _make_config(prompts_dir=str(custom_prompts)) - with _mock_vendored_sources(tmp_path): - sync_ralphex_defaults(config.build, self._review()) + result = build("plan.md", _make_config(), dict(_FULL_CLI_OPTIONS)) - copied = tmp_path / ".ralphex" / "prompts" / "custom_task.txt" - assert copied.is_file() - assert copied.read_text() == "custom content" + assert result == 0 + completed = next(context for action, context in recorded if action == "build_completed") + + if topic_hosted: + assert order == ["move", "statuses"] + assert seen_years == ["2026"] + assert completed.statuses == ["backlog", "designed"] + else: + # The branch hosts no topic: no statuses read happens at all — the + # branch-only form delivers [] without touching the tree. + assert order == ["move"] + assert seen_years == [] + assert completed.statuses == [] + + def test_stage_facts_carry_env_names_only( + self, + tmp_path: Path, + monkeypatch, + pin_package_environment, + install_tool_package, + ) -> None: + """The delivered facts carry env NAMES (sorted), never values — walking + every delivered context finds no tasks-env value string.""" + recorded: list[tuple[str, object]] = [] + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + _install_recording_tool(install_tool_package, recorded) + + config = _make_config(env={"B": "2", "A": "1"}, review=ReviewConfig(agent="codex", env={"C": "3"})) + + with mock.patch("goga.build.build.run_build_pass", return_value=0): + result = _run_build_in_tmp(tmp_path, monkeypatch, config=config, cli_options=dict(_FULL_CLI_OPTIONS)) -# --- Full build function tests --- + assert result == 0 + tasks_started = next( + context for action, context in recorded if action == "pass_started" and context.facts.stage == "tasks" + ) + review_started = next( + context for action, context in recorded if action == "pass_started" and context.facts.stage == "review" + ) + assert tasks_started.facts.env == ["A", "B"] + assert review_started.facts.env == ["C"] + + for _action, context in recorded: + _assert_no_secret_strings(context, frozenset({"1", "2"})) + + def test_build_short_strategy_review_pass_under_additional_wrapper( + self, + tmp_path: Path, + monkeypatch, + pin_package_environment, + ) -> None: + """Under short, the review pass is the external-only pass under the + additional agent's wrapper.""" + pin_package_environment({}) + config = _make_config( + review=ReviewConfig( + agent="codex", + strategy="short", + additional=AdditionalReviewConfig(agent="cursor", patience=2, max_iterations=None), + ), + ) -class TestBuildDryRun: - """build() delegates dry_run to run_ralphex through run_build_pass (the - dry-run short-circuit lives in run_ralphex). Verified at the delegation - seam of the pass unit.""" + with mock.patch("goga.build.build.run_build_pass", return_value=0) as mock_pass: + result = _run_build_in_tmp(tmp_path, monkeypatch, config=config, cli_options=dict(_FULL_CLI_OPTIONS)) - def test_dry_run_returns_0(self, tmp_path, monkeypatch) -> None: - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0): + assert result == 0 + assert mock_pass.call_count == 2 + + second = mock_pass.call_args_list[1] + assert second.args[2]["external_only"] is True + assert "review" not in second.args[2] + assert second.args[2]["review_patience"] == 2 + assert second.args[3] == "/home/goga/bin/cursor-as-claude.sh" + + def test_build_cli_no_skip_review_overrides_config_skip( + self, + tmp_path: Path, + monkeypatch, + pin_package_environment, + ) -> None: + """CLI False beats config skip: true — the full two-pass cycle runs + with validation active.""" + pin_package_environment({}) + config = _make_config(review=ReviewConfig(skip=True)) + + with mock.patch("goga.build.build.run_build_pass", return_value=0) as mock_pass: result = _run_build_in_tmp( tmp_path, monkeypatch, - cli_options={"dry_run": True, "skip_manifest_check": True}, + config=config, + cli_options={**_FULL_CLI_OPTIONS, "skip_review": False}, ) - assert result == 0 - def test_dry_run_passes_dry_run_to_run_ralphex(self, tmp_path, monkeypatch) -> None: - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - _run_build_in_tmp( - tmp_path, - monkeypatch, - cli_options={"dry_run": True, "skip_manifest_check": True}, - ) - # dry_run reaches run_ralphex as the positional 3rd arg. - assert mock_run.call_args.args[2] is True + assert result == 0 + assert mock_pass.call_count == 2 + + def test_build_review_pass_failure_propagates_and_keeps_plan( + self, + tmp_path: Path, + monkeypatch, + pin_package_environment, + ) -> None: + """A failed review pass after a successful tasks pass: the LAST pass's + code returns and the relocation outcome follows it.""" + pin_package_environment({}) + config = _make_config(review=ReviewConfig(agent="codex")) + + with mock.patch("goga.build.build.run_build_pass", side_effect=[0, 1]) as mock_pass: + result = _run_build_in_tmp(tmp_path, monkeypatch, config=config, cli_options=dict(_FULL_CLI_OPTIONS)) + assert result == 1 + assert mock_pass.call_count == 2 + assert (tmp_path / "plan.md").is_file() + assert not (tmp_path / "completed").exists() -class TestBuildDelegation: - """build() delegates each pass to run_ralphex (via run_build_pass) with resolved options.""" + def test_build_empty_review_env_means_pure_inheritance( + self, + tmp_path: Path, + monkeypatch, + pin_package_environment, + ) -> None: + """An empty review env is no layer at all — env None, never {}.""" + pin_package_environment({}) + config = _make_config(review=ReviewConfig(agent="codex", env={})) - def test_build_delegates_to_run_ralphex_with_resolved_options(self, tmp_path, monkeypatch) -> None: - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=_make_config(worktree=True), - cli_options={"skip_manifest_check": True}, - ) + with mock.patch("goga.build.build.run_build_pass", return_value=0) as mock_pass: + result = _run_build_in_tmp(tmp_path, monkeypatch, config=config, cli_options=dict(_FULL_CLI_OPTIONS)) assert result == 0 - mock_run.assert_called_once() - args = mock_run.call_args.args - assert args[0] == "plan.md" - assert args[1]["worktree"] is True - assert args[2] is False # dry_run positional - assert "dry_run" not in args[1] + second = mock_pass.call_args_list[1] + assert second.kwargs["env"] is None - def test_build_returns_run_ralphex_exit_code(self, tmp_path, monkeypatch) -> None: - with mock.patch("goga.build.build_pass.run_ralphex", return_value=42): - result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={"skip_manifest_check": True}) - assert result == 42 +# --- Pre-launch failure paths through the real collaborators --- - def test_build_dry_run_delegates_with_dry_run_true(self, tmp_path, monkeypatch) -> None: - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - _run_build_in_tmp(tmp_path, monkeypatch, cli_options={"dry_run": True, "skip_manifest_check": True}) - assert mock_run.call_args.args[2] is True +class TestPreLaunchFailures: + def test_build_invalid_review_config_returns_1_before_side_effects(self, tmp_path, monkeypatch) -> None: + """A bogus role is rejected by the real validation before any side effect.""" + config = _make_config(review=ReviewConfig(roles=["bogus"])) + with mock.patch("goga.build.build.run_build_pass", return_value=0) as mock_pass: + result = _run_build_in_tmp(tmp_path, monkeypatch, config=config, cli_options=dict(_FULL_CLI_OPTIONS)) -class TestBuildFullExecution: - """build() returns whatever the last pass returns — mocked at the delegation - seam of the pass unit, decoupling the build tests from ralphex internals.""" + assert result == 1 + mock_pass.assert_not_called() + assert not (tmp_path / ".ralphex").exists() - @mock.patch("goga.build.build_pass.run_ralphex", return_value=0) - def test_full_execution_returns_0(self, mock_run, tmp_path, monkeypatch) -> None: - result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={"skip_manifest_check": True}) - assert result == 0 + def test_defaults_missing_returns_1(self, tmp_path, monkeypatch) -> None: + """Missing vendored defaults abort the run before any pass.""" + from goga.build import ralphex_runtime - @mock.patch("goga.build.build_pass.run_ralphex", return_value=42) - def test_propagates_exit_code(self, mock_run, tmp_path, monkeypatch) -> None: - result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={"skip_manifest_check": True}) - assert result == 42 + monkeypatch.chdir(tmp_path) + Path("plan.md").write_text("# plan\n") + _pin_orchestration_boundary(monkeypatch, tmp_path) + with ( + mock.patch.object(ralphex_runtime, "_VENDORED_PROMPTS", Path("/nonexistent")), + mock.patch.object(ralphex_runtime, "_VENDORED_AGENTS", Path("/nonexistent")), + mock.patch("goga.build.build.run_build_pass", return_value=0) as mock_pass, + ): + result = build("plan.md", _make_config(), {"skip_manifest_check": True}) -class TestBuildDoesNotWriteClaudeSettings: - """build()/run_ralphex never writes a .claude/settings.json — env delivery is - handled by the host launcher's docker env-file, not by this code path.""" + assert result == 1 + mock_pass.assert_not_called() - @mock.patch("goga.build.build_pass.run_ralphex", return_value=0) - def test_does_not_write_claude_settings(self, mock_run, tmp_path, monkeypatch) -> None: - config = _make_config(env=TEST_ENV_VARS) - _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True}, - ) + def test_missing_custom_prompts_dir_returns_1(self, tmp_path, monkeypatch) -> None: + """A non-existent custom prompts_dir aborts at the sync, before any pass.""" + config = _make_config(prompts_dir="/nonexistent/prompts-path") - assert not (tmp_path / ".claude" / "settings.json").exists() + with mock.patch("goga.build.build.run_build_pass", return_value=0) as mock_pass: + result = _run_build_in_tmp(tmp_path, monkeypatch, config=config, cli_options=dict(_FULL_CLI_OPTIONS)) + assert result == 1 + mock_pass.assert_not_called() + assert not (tmp_path / ".ralphex" / "prompts").exists() -class TestBuildArbitraryAgent: - def test_arbitrary_agent_resolves_and_proceeds(self, tmp_path, monkeypatch) -> None: - config = _make_config(agent="gemini") - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"dry_run": True, "skip_manifest_check": True}, - ) - assert result == 0 - config_text = (tmp_path / ".ralphex" / "config").read_text() - assert "claude_command = /home/goga/bin/gemini-as-claude.sh" in config_text + def test_no_build_agent_on_non_skipped_run_returns_1(self, tmp_path, monkeypatch) -> None: + """Without an agent, the review-config validation rejects the run first + (env-requires-agent aside, the None resolved agent is a clean error).""" + config = _make_config(agent=None) + with mock.patch("goga.build.build.run_build_pass", return_value=0) as mock_pass: + result = _run_build_in_tmp(tmp_path, monkeypatch, config=config, cli_options=dict(_FULL_CLI_OPTIONS)) -class TestBuildCodexAgent: - def test_codex_dry_run_returns_0(self, tmp_path, monkeypatch) -> None: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=_make_config(agent="codex"), - cli_options={"dry_run": True, "skip_manifest_check": True}, - ) - assert result == 0 + assert result == 1 + mock_pass.assert_not_called() - @mock.patch("goga.build.build_pass.run_ralphex", return_value=0) - def test_codex_no_claude_settings(self, mock_run, tmp_path, monkeypatch) -> None: - config = _make_config(agent="codex") - _run_build_in_tmp(tmp_path, monkeypatch, config=config, cli_options={"skip_manifest_check": True}) + def test_returns_1_when_ralphex_missing(self, tmp_path, monkeypatch) -> None: + """A PATH-missing ralphex (launcher exit 1) fails the tasks pass; the + review pass never launches and subprocess is never invoked directly.""" - assert not (tmp_path / ".claude").exists() + def _fail(*args, **kwargs): + raise AssertionError("must not invoke subprocess.call") + monkeypatch.setattr(subprocess, "call", _fail) -class TestBuildDefaultsDirNotFound: - def test_defaults_missing_returns_1(self, tmp_path, monkeypatch) -> None: - """Missing vendored defaults abort the run: the ValueError of the sync is - caught by the orchestrator, which logs and returns 1 before any pass.""" - from goga.build import ralphex_runtime + with mock.patch("goga.build.build_pass.run_ralphex", return_value=1) as mock_run: + result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={**_FULL_CLI_OPTIONS, "dry_run": False}) - monkeypatch.chdir(tmp_path) - Path("plan.md").write_text("# plan\n") - with ( - mock.patch.object(ralphex_runtime, "_VENDORED_PROMPTS", Path("/nonexistent")), - mock.patch.object(ralphex_runtime, "_VENDORED_AGENTS", Path("/nonexistent")), - mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run, - ): - result = build("plan.md", _make_config(), {"skip_manifest_check": True}) assert result == 1 - mock_run.assert_not_called() + mock_run.assert_called_once() + +# --- Pass delegation and env boundaries --- + + +class TestPassDelegation: + def test_build_returns_last_pass_exit_code(self, tmp_path, monkeypatch) -> None: + """The returned code is the LAST executed pass's code, not an aggregate.""" + with mock.patch("goga.build.build.run_build_pass", side_effect=[0, 42]) as mock_pass: + result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options=dict(_FULL_CLI_OPTIONS)) + + assert result == 42 + assert mock_pass.call_count == 2 + assert (tmp_path / "plan.md").is_file() + + def test_dry_run_reaches_every_pass(self, tmp_path, monkeypatch) -> None: + """Both passes of a dry run rehearse with dry_run=True and the plan + stays in place.""" + with mock.patch("goga.build.build.run_build_pass", return_value=0) as mock_pass: + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + cli_options={**_FULL_CLI_OPTIONS, "dry_run": True}, + ) + + assert result == 0 + assert mock_pass.call_count == 2 + assert all(call.args[4] is True for call in mock_pass.call_args_list) + assert (tmp_path / "plan.md").is_file() + assert not (tmp_path / "completed").exists() + + @mock.patch("goga.build.build_pass.run_ralphex", return_value=0) + def test_does_not_write_claude_settings(self, mock_run, tmp_path, monkeypatch) -> None: + config = _make_config(env=TEST_ENV_VARS) + _run_build_in_tmp(tmp_path, monkeypatch, config=config, cli_options=dict(_FULL_CLI_OPTIONS)) + + assert not (tmp_path / ".claude" / "settings.json").exists() -class TestBuildRepeatedBuild: @mock.patch("goga.build.build_pass.run_ralphex", return_value=0) def test_repeated_build_overwrites(self, mock_run, tmp_path, monkeypatch) -> None: cli_options = {"skip_manifest_check": True} @@ -601,19 +946,124 @@ def test_repeated_build_overwrites(self, mock_run, tmp_path, monkeypatch) -> Non _run_build_in_tmp(tmp_path, monkeypatch, cli_options=cli_options) assert modified_file.read_text() != "USER MODIFICATION" + @mock.patch("goga.build.build_pass.run_ralphex", return_value=0) + def test_custom_prompts_dir(self, mock_run, tmp_path, monkeypatch) -> None: + custom_prompts = tmp_path / "custom" / "prompts" + custom_prompts.mkdir(parents=True) + (custom_prompts / "custom_task.txt").write_text("custom content") -# --- Ralphex lifecycle reuse tests --- -# -# The in-container build() must NOT wipe .ralphex/ itself. The directory -# arrives as a prepared bind-mount owned by the host launcher -# (goga/commands/build); build() only rewrites the prompts/agents subdirectories -# (the sync contract) and the pass config. The host wipes .ralphex/ only when -# `goga build --clean` is passed before launch. + config = _make_config(prompts_dir=str(custom_prompts)) + _run_build_in_tmp(tmp_path, monkeypatch, config=config, cli_options={"skip_manifest_check": True}) + copied = tmp_path / ".ralphex" / "prompts" / "custom_task.txt" + assert copied.read_text() == "custom content" -class TestRalphexLifecycleReuse: - def test_build_reuses_existing_ralphex_dir(self, tmp_path, monkeypatch) -> None: - """A pre-existing .ralphex/config is overwritten with the new claude_command; + +# --- Review-scoped pass composition --- + + +class TestReviewScopedPassComposition: + """Review-scoped options (base_ref, review_patience) join the review pass + only; the tasks pass carries the tasks knobs only.""" + + def test_scoped_options_only_on_review_pass(self, tmp_path, monkeypatch) -> None: + config = _make_config(review=ReviewConfig(agent="codex", base_ref="origin/1.2.x")) + + with mock.patch("goga.build.build.run_build_pass", return_value=0) as mock_pass: + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + config=config, + cli_options={**_FULL_CLI_OPTIONS, "review_patience": 7}, + ) + + assert result == 0 + first, second = mock_pass.call_args_list + assert "base_ref" not in first.args[2] + assert "review_patience" not in first.args[2] + assert second.args[2]["base_ref"] == "origin/1.2.x" + assert second.args[2]["review_patience"] == 7 + assert second.args[2]["review"] is True + + def test_tasks_knobs_bound_to_tasks_pass(self, tmp_path, monkeypatch) -> None: + """The root knobs reach the tasks pass; the review pass carries the + review session knobs with inheritance applied.""" + config = _make_config( + session_timeout="30m", + max_iterations=9, + review=ReviewConfig(agent="codex", session_timeout="10m"), + ) + + with mock.patch("goga.build.build.run_build_pass", return_value=0) as mock_pass: + result = _run_build_in_tmp(tmp_path, monkeypatch, config=config, cli_options=dict(_FULL_CLI_OPTIONS)) + + assert result == 0 + first, second = mock_pass.call_args_list + assert first.args[2]["session_timeout"] == "30m" + assert first.args[2]["max_iterations"] == 9 + assert second.args[2]["session_timeout"] == "10m" + assert "max_iterations" not in second.args[2] + + def test_cli_knobs_override_config(self, tmp_path, monkeypatch) -> None: + config = _make_config(max_iterations=5, session_timeout="30m") + + with mock.patch("goga.build.build.run_build_pass", return_value=0) as mock_pass: + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + config=config, + cli_options={**_FULL_CLI_OPTIONS, "max_iterations": 10, "session_timeout": "99m"}, + ) + + assert result == 0 + first = mock_pass.call_args_list[0] + assert first.args[2]["max_iterations"] == 10 + assert first.args[2]["session_timeout"] == "99m" + + def test_skip_run_omits_scoped_options(self, tmp_path, monkeypatch) -> None: + config = _make_config(review=ReviewConfig(skip=True, base_ref="origin/1.2.x")) + + with mock.patch("goga.build.build.run_build_pass", return_value=0) as mock_pass: + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + config=config, + cli_options={**_FULL_CLI_OPTIONS, "skip_review": True}, + ) + + assert result == 0 + assert mock_pass.call_count == 1 + options = mock_pass.call_args.args[2] + assert options["tasks_only"] is True + assert "base_ref" not in options + assert "review_patience" not in options + + def test_no_source_scoped_keys_absent(self, tmp_path, monkeypatch) -> None: + """With neither a config source nor CLI values, the review pass options + carry exactly the mode flag.""" + from goga.ralphex.run_ralphex import _build_command + + with mock.patch("goga.build.build.run_build_pass", return_value=0) as mock_pass: + result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options=dict(_FULL_CLI_OPTIONS)) + + assert result == 0 + second_options = mock_pass.call_args_list[1].args[2] + assert second_options == {"review": True} + assert _build_command("plan.md", second_options) == [ + "ralphex", + "plan.md", + "--config-dir", + ".ralphex/", + "--review", + ] + + +# --- .ralphex/ lifecycle reuse --- + + +class TestRalphexLifecycleReuse: + def test_build_reuses_existing_ralphex_dir(self, tmp_path, monkeypatch) -> None: + """A pre-existing .ralphex/config is overwritten with the new claude_command; prompts/agents are brought to the source state (full rewrite), while unrelated state directly under .ralphex/ survives — build() never wipes the mounted directory itself.""" @@ -653,7 +1103,7 @@ def test_build_does_not_wipe_ralphex_on_manifest_check_failure(self, tmp_path, m # tmp_path is not a git repo, so the manifest check fails before any # .ralphex/ interaction occurs. - result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={}) + result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={"skip_manifest_check": False}) assert result == 1 assert (ralphex_dir / "keep.txt").read_text() == "survivor" @@ -682,11 +1132,14 @@ class TestRalphexCleanupRemovedContract: only its prompts/ and agents/ subdirectories are rewritten by the sync.""" def test_cleanup_ralphex_dir_not_defined_in_module(self) -> None: - # Use sys.modules because goga/build/__init__.py shadows the `build` - # attribute with the function of the same name. - build_module = sys.modules["goga.build.build"] assert not hasattr(build_module, "_cleanup_ralphex_dir") + def test_retired_helpers_removed_from_module(self) -> None: + """The superseded private option helpers are gone — their contracts + were absorbed by resolve_run_settings / compose_pass_options.""" + assert not hasattr(build_module, "_resolve_options") + assert not hasattr(build_module, "_review_scoped_options") + @mock.patch("goga.build.build_pass.run_ralphex", return_value=0) def test_build_never_calls_rmtree_on_ralphex_path(self, mock_run, tmp_path, monkeypatch) -> None: """During a full build execution, shutil.rmtree is called only on @@ -708,635 +1161,32 @@ def test_build_never_calls_rmtree_on_ralphex_path(self, mock_run, tmp_path, monk assert (tmp_path / ".ralphex" / "keep.txt").read_text() == "survivor" -def _init_git_repo(path: Path) -> None: - subprocess.run(["git", "init"], cwd=path, capture_output=True, check=True) - subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=path, capture_output=True, check=True) - subprocess.run(["git", "config", "user.name", "Test"], cwd=path, capture_output=True, check=True) - - -class TestManifestCheck: - @mock.patch("goga.build.build_pass.run_ralphex", return_value=0) - def test_all_committed_proceeds(self, mock_run, tmp_path, monkeypatch) -> None: - _init_git_repo(tmp_path) - manifest = tmp_path / "CODEMANIFEST" - manifest.write_text("content") - subprocess.run(["git", "add", "CODEMANIFEST"], cwd=tmp_path, capture_output=True, check=True) - subprocess.run(["git", "commit", "-m", "init"], cwd=tmp_path, capture_output=True, check=True) - - result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={}) - assert result == 0 - - def test_uncommitted_manifest_returns_1(self, tmp_path, monkeypatch) -> None: - _init_git_repo(tmp_path) - manifest = tmp_path / "CODEMANIFEST" - manifest.write_text("content") - - result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={}) - assert result == 1 - - @mock.patch("goga.build.build_pass.run_ralphex", return_value=0) - def test_skip_manifest_check(self, mock_run, tmp_path, monkeypatch) -> None: - _init_git_repo(tmp_path) - manifest = tmp_path / "CODEMANIFEST" - manifest.write_text("content") - - result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={"skip_manifest_check": True}) - assert result == 0 - - def test_not_git_repo_returns_1(self, tmp_path, monkeypatch) -> None: - result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={}) - assert result == 1 - - @mock.patch("goga.build.build_pass.run_ralphex", return_value=0) - def test_no_codemanifest_files_proceeds(self, mock_run, tmp_path, monkeypatch) -> None: - _init_git_repo(tmp_path) - (tmp_path / ".gitkeep").write_text("") - subprocess.run(["git", "add", ".gitkeep"], cwd=tmp_path, capture_output=True, check=True) - subprocess.run(["git", "commit", "-m", "init"], cwd=tmp_path, capture_output=True, check=True) - - result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={}) - assert result == 0 - - def test_multiple_uncommitted_lists_all(self, tmp_path, monkeypatch) -> None: - _init_git_repo(tmp_path) - (tmp_path / ".gitkeep").write_text("") - subprocess.run(["git", "add", ".gitkeep"], cwd=tmp_path, capture_output=True, check=True) - subprocess.run(["git", "commit", "-m", "init"], cwd=tmp_path, capture_output=True, check=True) - for d in ("a", "b", "c"): - subdir = tmp_path / d - subdir.mkdir() - (subdir / "CODEMANIFEST").write_text(f"content {d}") - - result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options={}) - assert result == 1 - - -class TestBuildConfigFlags: - """Option precedence (CLI > BuildConfig) flows through to run_ralphex as the - resolved `options` dict. Verified at the delegation seam; the bool/scalar flag - assembly itself is covered in tests/ralphex/test_run_ralphex.py.""" - - @mock.patch("goga.build.build_pass.run_ralphex", return_value=0) - def test_worktree_from_config(self, mock_run, tmp_path, monkeypatch) -> None: - config = _make_config(worktree=True) - _run_build_in_tmp(tmp_path, monkeypatch, config=config, cli_options={"skip_manifest_check": True}) - - assert mock_run.call_args.args[1]["worktree"] is True - - @mock.patch("goga.build.build_pass.run_ralphex", return_value=0) - def test_cli_worktree_overrides_config(self, mock_run, tmp_path, monkeypatch) -> None: - config = _make_config(worktree=False) - _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"worktree": True, "skip_manifest_check": True}, - ) - - # CLI worktree=True overrides config=False via _resolve_options. - assert mock_run.call_args.args[1]["worktree"] is True - - @mock.patch("goga.build.build_pass.run_ralphex", return_value=0) - def test_custom_prompts_dir(self, mock_run, tmp_path, monkeypatch) -> None: - custom_prompts = tmp_path / "custom" / "prompts" - custom_prompts.mkdir(parents=True) - (custom_prompts / "custom_task.txt").write_text("custom content") - - config = _make_config(prompts_dir=str(custom_prompts)) - _run_build_in_tmp(tmp_path, monkeypatch, config=config, cli_options={"skip_manifest_check": True}) - - assert (tmp_path / ".ralphex" / "prompts" / "custom_task.txt").is_file() - assert (tmp_path / ".ralphex" / "prompts" / "custom_task.txt").read_text() == "custom content" - - -# --- Review-phase orchestration (skip / two-pass / relocation) --- - - -class TestBuildReviewPhaseOrchestration: - """The orchestrator's pass modes on top of run_build_pass.""" - - def _passes(self, mock_run) -> list[dict]: - return [call.args[1] for call in mock_run.call_args_list] - - def test_build_skip_run_single_tasks_only_pass(self, tmp_path, monkeypatch) -> None: - config = _make_config(review_executor=ReviewExecutorConfig(skip=True)) - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True, "skip_review": None}, - ) - - assert result == 0 - assert mock_run.call_count == 1 - options = mock_run.call_args.args[1] - assert options["tasks_only"] is True - assert "review" not in options - # Success relocates the plan. - assert not (tmp_path / "plan.md").exists() - assert (tmp_path / "completed" / "plan.md").read_text() == "# plan\n" - - def test_build_skip_run_still_syncs_and_filters_roles(self, tmp_path, monkeypatch) -> None: - config = _make_config(review_executor=ReviewExecutorConfig(skip=True, roles=["quality"])) - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True}, - ) - - assert result == 0 - assert mock_run.call_count == 1 - assert mock_run.call_args.args[1]["tasks_only"] is True - # The skip decision never suppresses the defaults sync: roles filter - # the review prompts even though no review pass will run. - review_first = (tmp_path / ".ralphex" / "prompts" / "review_first.txt").read_text() - assert "{{agent:quality}}" in review_first - assert "{{agent:implementation}}" not in review_first - assert not (tmp_path / "plan.md").exists() - assert (tmp_path / "completed" / "plan.md").is_file() - - def test_build_two_pass_second_pass_review_mode(self, tmp_path, monkeypatch) -> None: - config = _make_config(review_executor=ReviewExecutorConfig(agent="codex")) - review_wrapper = tmp_path / "codex-as-claude.sh" - review_wrapper.write_text("#!/bin/sh\n") - - with ( - mock.patch("goga.build.review_config.resolve_wrapper_path", return_value=str(review_wrapper)), - mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run, - ): - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True}, - ) - - assert result == 0 - assert mock_run.call_count == 2 - first, second = self._passes(mock_run) - assert first["tasks_only"] is True - assert "review" not in first - assert second["review"] is True - assert "tasks_only" not in second - # The final pass config carries the review executor wrapper; the real - # resolve_wrapper_path of the orchestrator built it (string resolve). - config_text = (tmp_path / ".ralphex" / "config").read_text() - assert "claude_command = /home/goga/bin/codex-as-claude.sh" in config_text - assert "move_plan_on_completion = false" in config_text - - def test_build_two_pass_pass2_carries_review_env_pass1_without(self, tmp_path, monkeypatch) -> None: - """Pass 1 runs without the env layer, pass 2 carries it — the asymmetry - keeps review-only variables out of the tasks pass. Contract: the second - call to run_ralphex of a two-pass run carries the review env layer; the - build() signature itself stays (plan, config, cli_options).""" - config = _make_config(review_executor=ReviewExecutorConfig(agent="codex", env={"ANTHROPIC_MODEL": "reviewer"})) - review_wrapper = tmp_path / "codex-as-claude.sh" - review_wrapper.write_text("#!/bin/sh\n") - - with ( - mock.patch("goga.build.review_config.resolve_wrapper_path", return_value=str(review_wrapper)), - mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run, - ): - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True}, - ) - - assert result == 0 - assert mock_run.call_count == 2 - first, second = mock_run.call_args_list - assert "env" not in first.kwargs or first.kwargs["env"] is None - assert second.kwargs["env"] == {"ANTHROPIC_MODEL": "reviewer"} - - def test_build_env_only_induction_two_pass(self, tmp_path, monkeypatch) -> None: - """Same agent on both executors: the two-pass mode is induced by the env - alone, and the pass-2 wrapper resolves via that same (matching) agent.""" - config = _make_config( - review_executor=ReviewExecutorConfig(agent="claude", env={"M": "r"}), - ) - review_wrapper = tmp_path / "claude-as-claude.sh" - review_wrapper.write_text("#!/bin/sh\n") - - with ( - mock.patch("goga.build.review_config.resolve_wrapper_path", return_value=str(review_wrapper)), - mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run, - ): - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True}, - ) - - assert result == 0 - assert mock_run.call_count == 2 - second = mock_run.call_args_list[1] - assert second.args[1]["review"] is True - assert second.kwargs["env"] == {"M": "r"} - - def test_build_env_requires_agent_returns_1_without_launch(self, tmp_path, monkeypatch) -> None: - """A review env without a review agent is rejected by the validation gate - before any side effect: no launch, no .ralphex/ sync.""" - config = _make_config(review_executor=ReviewExecutorConfig(env={"X": "y"})) - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True}, - ) - - assert result == 1 - assert mock_run.call_count == 0 - assert not (tmp_path / ".ralphex").exists() - - def test_build_skip_run_ignores_review_env(self, tmp_path, monkeypatch) -> None: - """A skipped run ignores the review env entirely: one tasks-only pass, - no env layer, no validation of it.""" - config = _make_config(review_executor=ReviewExecutorConfig(skip=True, agent="codex", env={"X": "y"})) - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True}, - ) - - assert result == 0 - assert mock_run.call_count == 1 - first = mock_run.call_args_list[0] - assert "env" not in first.kwargs or first.kwargs["env"] is None - assert first.args[1]["tasks_only"] is True - - def test_build_full_pass_no_env_layer(self, tmp_path, monkeypatch) -> None: - """No review executor at all: a single full pass, never an env layer.""" - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=_make_config(review_executor=None), - cli_options={"skip_manifest_check": True}, - ) - - assert result == 0 - assert mock_run.call_count == 1 - # No layer is delivered: env arrives as the default None, never a dict. - assert mock_run.call_args.kwargs["env"] is None - - def test_build_two_pass_pass1_failure_skips_pass2(self, tmp_path, monkeypatch) -> None: - """A failed pass 1 exits with its code — pass 2 (and its env layer) - never launches, even with a declared review env.""" - config = _make_config(review_executor=ReviewExecutorConfig(agent="codex", env={"X": "y"})) - review_wrapper = tmp_path / "codex-as-claude.sh" - review_wrapper.write_text("#!/bin/sh\n") - - with ( - mock.patch("goga.build.review_config.resolve_wrapper_path", return_value=str(review_wrapper)), - mock.patch("goga.build.build_pass.run_ralphex", side_effect=[1]) as mock_run, - ): - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True}, - ) - - assert result == 1 - assert mock_run.call_count == 1 - # The single call is the tasks pass — no env layer anywhere. - assert "env" not in mock_run.call_args.kwargs or mock_run.call_args.kwargs["env"] is None - # A failed run keeps the plan in place for ralphex to resume. - assert (tmp_path / "plan.md").is_file() - assert not (tmp_path / "completed").exists() - - def test_build_two_pass_pass2_failure_propagates_and_keeps_plan(self, tmp_path, monkeypatch) -> None: - """Pass 1 succeeded but pass 2 failed — the run is a failure. - - The returned code is the LAST pass's code and the relocation outcome is - computed from it, so a failed review pass must keep the plan in place - exactly like a failed task pass. - """ - config = _make_config(review_executor=ReviewExecutorConfig(agent="codex")) - review_wrapper = tmp_path / "codex-as-claude.sh" - review_wrapper.write_text("#!/bin/sh\n") - - with ( - mock.patch("goga.build.review_config.resolve_wrapper_path", return_value=str(review_wrapper)), - mock.patch("goga.build.build_pass.run_ralphex", side_effect=[0, 1]) as mock_run, - ): - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True}, - ) - - assert result == 1 - assert mock_run.call_count == 2 - second = self._passes(mock_run)[1] - assert second["review"] is True - assert "tasks_only" not in second - assert (tmp_path / "plan.md").is_file() - assert not (tmp_path / "completed").exists() - - def test_build_invalid_review_config_returns_1_before_side_effects(self, tmp_path, monkeypatch) -> None: - config = _make_config(review_executor=ReviewExecutorConfig(roles=["bogus"])) - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True}, - ) - - assert result == 1 - mock_run.assert_not_called() - assert not (tmp_path / ".ralphex").exists() - - def test_build_resolves_skip_from_config_when_cli_none(self, tmp_path, monkeypatch) -> None: - config = _make_config(review_executor=ReviewExecutorConfig(skip=True)) - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True}, - ) - - assert result == 0 - assert mock_run.call_count == 1 - assert mock_run.call_args.args[1]["tasks_only"] is True - - def test_build_skip_wins_over_two_pass(self, tmp_path, monkeypatch) -> None: - # agent differs from the task agent, so two_pass resolves True — but the - # skip branch takes priority: no review phase of any kind. - config = _make_config(review_executor=ReviewExecutorConfig(skip=True, agent="codex")) - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True}, - ) - - assert result == 0 - assert mock_run.call_count == 1 - assert mock_run.call_args.args[1]["tasks_only"] is True - - def test_build_dry_run_two_pass_prints_both_and_keeps_plan(self, tmp_path, monkeypatch) -> None: - config = _make_config(review_executor=ReviewExecutorConfig(agent="codex")) - review_wrapper = tmp_path / "codex-as-claude.sh" - review_wrapper.write_text("#!/bin/sh\n") - - with ( - mock.patch("goga.build.review_config.resolve_wrapper_path", return_value=str(review_wrapper)), - mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run, - ): - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True, "dry_run": True}, - ) - - assert result == 0 - # A dry run prints the commands of EVERY planned pass. - assert mock_run.call_count == 2 - assert all(call.args[2] is True for call in mock_run.call_args_list) - # ... and moves nothing. - assert (tmp_path / "plan.md").is_file() - assert not (tmp_path / "completed").exists() - - def test_build_no_review_config_single_full_pass(self, tmp_path, monkeypatch) -> None: - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=_make_config(review_executor=None), - cli_options={"skip_manifest_check": True}, - ) - - assert result == 0 - assert mock_run.call_count == 1 - options = mock_run.call_args.args[1] - assert "tasks_only" not in options - assert "review" not in options - assert not (tmp_path / "plan.md").exists() - assert (tmp_path / "completed" / "plan.md").is_file() - - def test_build_cli_no_skip_review_overrides_config_skip(self, tmp_path, monkeypatch) -> None: - """CLI False beats config `skip: true` — the full cycle runs with validation.""" - config = _make_config(review_executor=ReviewExecutorConfig(skip=True, roles=["bogus"])) - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True, "skip_review": False}, - ) - - # The forced full pass activates validation, which rejects the bogus role. - assert result == 1 - mock_run.assert_not_called() - - def test_build_cli_no_skip_review_forces_full_pass(self, tmp_path, monkeypatch) -> None: - """CLI False against a valid config-skip runs the full single pass.""" - config = _make_config(review_executor=ReviewExecutorConfig(skip=True)) - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True, "skip_review": False}, - ) - - assert result == 0 - assert mock_run.call_count == 1 - options = mock_run.call_args.args[1] - assert "tasks_only" not in options - assert "review" not in options - - def test_build_skip_run_skips_validation(self, tmp_path, monkeypatch) -> None: - """A skipped run never validates roles — a bogus role is never read.""" - config = _make_config(review_executor=ReviewExecutorConfig(skip=True, roles=["bogus"])) - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True}, - ) - - assert result == 0 - assert mock_run.call_count == 1 - assert mock_run.call_args.args[1]["tasks_only"] is True - - -class TestReviewScopedPassComposition: - """Contract: review-scoped options (base_ref, review_patience) join the - options of review-carrying passes ONLY — the full-mode single pass and the - two-pass review pass. A skip run and the tasks-only pass carry universal - options only. Key-presence per pass is the API surface under contract.""" - - def test_full_pass_carries_review_scoped_options(self, tmp_path, monkeypatch) -> None: - # Same agent as the task executor and an empty review env -> a single - # full pass, which IS review-carrying: the scoped options ride along. - config = _make_config(review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/1.2.x", patience=3)) - - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True}, - ) - - assert result == 0 - assert mock_run.call_count == 1 - assert mock_run.call_args.args[1]["base_ref"] == "origin/1.2.x" - assert mock_run.call_args.args[1]["review_patience"] == 3 - - def test_two_pass_review_scoped_options_only_on_review_pass(self, tmp_path, monkeypatch) -> None: - # A differing review agent induces the two-pass mode: pass 1 is - # tasks-only (universal options only), pass 2 is the review pass and - # carries the scoped options. - config = _make_config(review_executor=ReviewExecutorConfig(agent="codex", base_ref="origin/1.2.x", patience=3)) - review_wrapper = tmp_path / "codex-as-claude.sh" - review_wrapper.write_text("#!/bin/sh\n") - - with ( - mock.patch("goga.build.review_config.resolve_wrapper_path", return_value=str(review_wrapper)), - mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run, - ): - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True}, - ) - - assert result == 0 - assert mock_run.call_count == 2 - first = mock_run.call_args_list[0].args[1] - assert "base_ref" not in first - assert "review_patience" not in first - second = mock_run.call_args_list[1].args[1] - assert second["base_ref"] == "origin/1.2.x" - assert second["review_patience"] == 3 - assert second["review"] is True - - def test_cli_scoped_options_override_config_on_review_pass(self, tmp_path, monkeypatch) -> None: - # The CLI source flows through the same composition: cli_options carry - # base_ref/review_patience, the config declares different values, and - # the CLI wins on the review-carrying (here: single full) pass. - config = _make_config(review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/main", patience=3)) - - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True, "base_ref": "origin/1.2.x", "review_patience": 7}, - ) - - assert result == 0 - assert mock_run.call_count == 1 - assert mock_run.call_args.args[1]["base_ref"] == "origin/1.2.x" - assert mock_run.call_args.args[1]["review_patience"] == 7 - - def test_cli_scoped_options_without_review_executor_section(self, tmp_path, monkeypatch) -> None: - # A minimal config with no review_executor section still honors - # CLI-sourced review bounds on the single full pass — the resolver - # must read the CLI source without gating it on the section. - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=_make_config(), - cli_options={"skip_manifest_check": True, "base_ref": "origin/1.2.x", "review_patience": 4}, - ) - - assert result == 0 - assert mock_run.call_count == 1 - assert mock_run.call_args.args[1]["base_ref"] == "origin/1.2.x" - assert mock_run.call_args.args[1]["review_patience"] == 4 - - def test_skip_run_omits_review_scoped_options(self, tmp_path, monkeypatch) -> None: - # A skip run has no review phase of any kind: even with review bounds - # declared, the single tasks-only pass carries universal options only. - config = _make_config(review_executor=ReviewExecutorConfig(skip=True, base_ref="origin/1.2.x", patience=3)) - - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=config, - cli_options={"skip_manifest_check": True}, - ) - - assert result == 0 - assert mock_run.call_count == 1 - assert "base_ref" not in mock_run.call_args.args[1] - assert "review_patience" not in mock_run.call_args.args[1] - assert mock_run.call_args.args[1]["tasks_only"] is True - - def test_no_source_review_scoped_keys_absent_command_byte_identical(self, tmp_path, monkeypatch) -> None: - # Backward-compat criterion: with neither a review_executor section nor - # scoped CLI options, the keys stay absent from the captured options and - # the assembled ralphex command is byte-identical to the pre-change - # behavior — the bare prefix plus only the universal flags the fixture - # actually sets (here: none). - from goga.ralphex.run_ralphex import _build_command - - with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: - result = _run_build_in_tmp( - tmp_path, - monkeypatch, - config=_make_config(), - cli_options={"skip_manifest_check": True}, - ) - - assert result == 0 - captured_options = mock_run.call_args.args[1] - assert "base_ref" not in captured_options - assert "review_patience" not in captured_options - assert _build_command("plan.md", captured_options) == [ - "ralphex", - "plan.md", - "--config-dir", - ".ralphex/", - ] - - # --- Integration: secret-safe dry-run across the orchestration/launcher seam --- class TestBuildDryRunSecretSafeIntegration: """Cross-entity scenario joining the orchestrator (goga/build) with the launcher's print (goga/ralphex): a two-pass dry run prints the argv of both - passes and never the contents of the review env layer. + passes and never the contents of any env layer. - The real launcher runs here: its dry-run branch performs no PATH check and - no subprocess, so the seam under test is the actual print the container - would emit — a regression in either the orchestration (folding the env into - the options) or the launcher's print fails this test.""" + The real launcher and the real pass executor run here: the dry-run branch + performs no PATH check and no subprocess, so the seam under test is the + actual print the container would emit — a regression in either the + orchestration (folding an env into the options) or the launcher's print + fails this test.""" def test_build_dry_run_two_pass_no_env_in_output(self, tmp_path, monkeypatch, capsys) -> None: - config = _make_config(review_executor=ReviewExecutorConfig(agent="codex", env={"ANTHROPIC_MODEL": "reviewer"})) - review_wrapper = tmp_path / "codex-as-claude.sh" - review_wrapper.write_text("#!/bin/sh\n") + config = _make_config( + env={"TASKS_SECRET": "tasks-value"}, + review=ReviewConfig(agent="codex", env={"ANTHROPIC_MODEL": "reviewer"}), + ) - with mock.patch("goga.build.review_config.resolve_wrapper_path", return_value=str(review_wrapper)): + with _mock_vendored_sources(tmp_path): result = _run_build_in_tmp( tmp_path, monkeypatch, config=config, - cli_options={"skip_manifest_check": True, "dry_run": True}, + cli_options={**_FULL_CLI_OPTIONS, "dry_run": True}, ) assert result == 0 @@ -1345,9 +1195,11 @@ def test_build_dry_run_two_pass_no_env_in_output(self, tmp_path, monkeypatch, ca assert captured.err.count("ralphex") >= 2 assert "--tasks-only" in captured.err assert "--review" in captured.err - # The env layer value never reaches the dry-run output. + # No env layer value or name reaches the dry-run output. assert "reviewer" not in captured.err assert "ANTHROPIC_MODEL" not in captured.err + assert "tasks-value" not in captured.err + assert "TASKS_SECRET" not in captured.err # A dry run relocates nothing. assert (tmp_path / "plan.md").is_file() assert not (tmp_path / "completed").exists() diff --git a/tests/build/test_build_resolved_wrapper.py b/tests/build/test_build_resolved_wrapper.py index 547e98e5..811ac4ec 100644 --- a/tests/build/test_build_resolved_wrapper.py +++ b/tests/build/test_build_resolved_wrapper.py @@ -1,6 +1,18 @@ +"""End-to-end wrapper-resolution flow of the build cycle over a loaded config. + +The orchestrator resolves each pass's executor agent through +``resolve_wrapper_path`` (goga/agents) and writes the resolved absolute path +into the ``.ralphex/config`` ``claude_command`` of that pass. These scenarios +load a real ``.goga/config.yml`` through ``load_project_config`` (the two-part +schema — ``build.agent`` at the root), run the cycle in dry-run mode with the +ralphex launch mocked at the launcher seam, and pin the orchestration boundary +per the design's General Setup. +""" + from __future__ import annotations import subprocess +import sys from contextlib import contextmanager from pathlib import Path from unittest import mock @@ -9,15 +21,16 @@ from goga.build import build from goga.config import load_project_config +build_module = sys.modules["goga.build.build"] + def _write_config( tmp_path: Path, *, agent: str = "claude", - codex_review: bool | None = None, prompts_dir: str | None = None, ) -> None: - """Materialize a .goga/config.yml under tmp_path with the requested schema.""" + """Materialize a .goga/config.yml under tmp_path in the two-part schema.""" goga_dir = tmp_path / ".goga" goga_dir.mkdir(parents=True, exist_ok=True) @@ -27,11 +40,8 @@ def _write_config( "pipeline:", " agent: claude", "build:", - " task_executor:", - f" agent: {agent}", + f" agent: {agent}", ] - if codex_review is not None: - lines.append(f" codex_review: {str(codex_review).lower()}") if prompts_dir is not None: lines.append(f" prompts_dir: {prompts_dir}") @@ -61,6 +71,21 @@ def _mock_vendored_sources(tmp_path: Path): yield +def _pin_boundary(monkeypatch, tmp_path: Path) -> None: + """Pin the branch/topic/statuses reads and the wrapper-existence check.""" + wrapper = tmp_path / "claude-as-claude.sh" + wrapper.write_text("#!/bin/sh\n") + + monkeypatch.setattr(build_module, "resolve_current_branch_name", lambda: "add-hooks-to-build") + + def _unsluggable(_topic: str, _year: str | None = None) -> Path: + raise ValueError("unsluggable branch") + + monkeypatch.setattr(build_module, "resolve_topic_dir", _unsluggable) + monkeypatch.setattr(build_module, "collect_topic_statuses", lambda _year=None: []) + monkeypatch.setattr("goga.build.review_config.resolve_wrapper_path", lambda _agent: str(wrapper)) + + def _load_config(tmp_path: Path, monkeypatch): """Chdir into tmp_path and load the .goga/config.yml written there.""" monkeypatch.chdir(tmp_path) @@ -98,13 +123,16 @@ def test_build_writes_resolved_wrapper_to_ralphex_config( monkeypatch, agent: str, ) -> None: - """build() writes the resolved wrapper path into .ralphex/config claude_command. + """build() writes the resolved wrapper path of each pass into .ralphex/config + claude_command. Parameterization over arbitrary agent names pins both the absence of a whitelist and the absence of branching by agent name. """ _write_config(tmp_path, agent=agent) config = _load_config(tmp_path, monkeypatch) + Path("plan.md").write_text("# plan\n") + _pin_boundary(monkeypatch, tmp_path) cli_options = {"dry_run": True, "skip_manifest_check": True} with ( @@ -118,6 +146,7 @@ def test_build_writes_resolved_wrapper_to_ralphex_config( assert f"claude_command = /home/goga/bin/{agent}-as-claude.sh" in config_text assert "claude-wrapper.sh" not in config_text assert "codex-wrapper.sh" not in config_text + # The default (medium) strategy explicitly disables the external review. assert "codex_enabled = false" in config_text @@ -144,6 +173,8 @@ def test_build_rejects_uncommitted_manifests( _write_config(tmp_path, agent="claude") config = _load_config(tmp_path, monkeypatch) + Path("plan.md").write_text("# plan\n") + _pin_boundary(monkeypatch, tmp_path) cli_options = {"skip_manifest_check": False, "dry_run": True} result = build("plan.md", config, cli_options) @@ -158,9 +189,12 @@ def test_build_returns_1_when_ralphex_missing( tmp_path: Path, monkeypatch, ) -> None: - """A missing ralphex binary aborts the pass without invoking subprocess.call.""" + """A missing ralphex binary fails the tasks pass (exit 1) without + invoking subprocess.call; the review pass never launches.""" _write_config(tmp_path, agent="claude") config = _load_config(tmp_path, monkeypatch) + Path("plan.md").write_text("# plan\n") + _pin_boundary(monkeypatch, tmp_path) def _fail(*args, **kwargs): pytest.fail("must not invoke subprocess.call") @@ -188,6 +222,8 @@ def test_build_missing_custom_prompts_dir_returns_1( requires the source to exist (the old silent skip is superseded).""" _write_config(tmp_path, agent="claude", prompts_dir="/nonexistent/prompts-path") config = _load_config(tmp_path, monkeypatch) + Path("plan.md").write_text("# plan\n") + _pin_boundary(monkeypatch, tmp_path) cli_options = {"dry_run": True, "skip_manifest_check": True} with ( @@ -200,25 +236,3 @@ def test_build_missing_custom_prompts_dir_returns_1( mock_run.assert_not_called() # The failure happens before any .ralphex side effect. assert not (tmp_path / ".ralphex" / "prompts").exists() - - -class TestBuildCodexReviewMapping: - def test_build_codex_review_maps_to_codex_enabled_true( - self, - tmp_path: Path, - monkeypatch, - ) -> None: - """BuildConfig.codex_review=True maps to codex_enabled = true in ralphex config.""" - _write_config(tmp_path, agent="claude", codex_review=True) - config = _load_config(tmp_path, monkeypatch) - cli_options = {"dry_run": True, "skip_manifest_check": True} - - with ( - _mock_vendored_sources(tmp_path), - mock.patch("goga.build.build_pass.run_ralphex", return_value=0), - ): - result = build("plan.md", config, cli_options) - - assert result == 0 - config_text = (tmp_path / ".ralphex" / "config").read_text() - assert "codex_enabled = true" in config_text diff --git a/tests/build/test_review_options.py b/tests/build/test_review_options.py deleted file mode 100644 index ea2d1629..00000000 --- a/tests/build/test_review_options.py +++ /dev/null @@ -1,275 +0,0 @@ -from __future__ import annotations - -import dataclasses -import inspect -import typing - -import pytest -from goga.build.review_options import ReviewOptions, resolve_review_options -from goga.config import BuildConfig, ReviewExecutorConfig, TaskExecutorConfig - - -def _make_build_config(**kwargs) -> BuildConfig: - task_executor = TaskExecutorConfig(agent=kwargs.pop("task_agent", "claude"), env={}) - return BuildConfig(task_executor=task_executor, **kwargs) - - -class TestReviewOptionsContract: - def test_both_names_importable_from_module(self) -> None: - assert callable(resolve_review_options) - assert inspect.isclass(ReviewOptions) - - def test_resolve_review_options_has_correct_signature(self) -> None: - sig = inspect.signature(resolve_review_options) - params = list(sig.parameters.keys()) - assert params == ["config", "cli_options"] - - def test_resolve_review_options_config_param_type(self) -> None: - hints = typing.get_type_hints(resolve_review_options) - assert hints["config"] is BuildConfig - - def test_resolve_review_options_cli_options_param_is_dict(self) -> None: - hints = typing.get_type_hints(resolve_review_options) - assert hints["cli_options"] is dict - - def test_resolve_review_options_returns_review_options(self) -> None: - hints = typing.get_type_hints(resolve_review_options) - assert hints["return"] is ReviewOptions - - def test_review_options_declared_fields(self) -> None: - fields = {f.name for f in dataclasses.fields(ReviewOptions)} - assert fields == {"skip", "review_agent", "roles", "two_pass", "review_env", "base_ref", "patience"} - - def test_review_options_field_types(self) -> None: - hints = typing.get_type_hints(ReviewOptions) - assert hints["skip"] is bool - assert hints["review_agent"] == str | None - assert hints["roles"] == list[str] | None - assert hints["two_pass"] is bool - assert hints["review_env"] == dict[str, str] - assert hints["base_ref"] == str | None - assert hints["patience"] == int | None - - def test_review_options_declared_fields_include_review_env(self) -> None: - """`review_env` is the fifth field, required — no default factory.""" - names = [f.name for f in dataclasses.fields(ReviewOptions)] - assert names == ["skip", "review_agent", "roles", "two_pass", "review_env", "base_ref", "patience"] - env_field = next(f for f in dataclasses.fields(ReviewOptions) if f.name == "review_env") - assert env_field.default is dataclasses.MISSING - assert env_field.default_factory is dataclasses.MISSING - - def test_review_options_is_kw_only_and_frozen(self) -> None: - """kw_only is enforced: positional construction is rejected.""" - assert ReviewOptions.__dataclass_params__.frozen is True - with pytest.raises(TypeError): - ReviewOptions(False, None, None, False, {}) # type: ignore[misc] - - def test_review_options_declares_base_ref_and_patience_fields(self) -> None: - assert {"base_ref", "patience"} <= set(ReviewOptions.__dataclass_fields__) - - def test_resolve_review_options_docstring_lists_three_keys(self) -> None: - """The docstring names the three cli_options keys; the old one-key wording is gone.""" - doc = resolve_review_options.__doc__ or "" - - for key in ("skip_review", "base_ref", "review_patience"): - assert key in doc - assert "only `skip_review` is read" not in doc - - -class TestResolveReviewOptionsLogic: - @pytest.mark.parametrize("cli", [None, True, False]) - @pytest.mark.parametrize("config_skip", [None, True, False]) - def test_resolve_review_options_full_tri_state_matrix(self, cli, config_skip) -> None: - review_executor = None if config_skip is None else ReviewExecutorConfig(skip=config_skip) - config = _make_build_config(review_executor=review_executor) - - result = resolve_review_options(config, {"skip_review": cli}) - - expected = cli if cli is not None else (config_skip if config_skip is not None else False) - assert result.skip is expected - - def test_resolve_review_options_cli_overrides_config(self) -> None: - config = _make_build_config(review_executor=ReviewExecutorConfig(skip=True)) - - result = resolve_review_options(config, {"skip_review": False}) - - assert result.skip is False - - def test_resolve_review_options_two_pass_when_agents_differ(self) -> None: - config = _make_build_config( - review_executor=ReviewExecutorConfig(agent="codex", roles=["quality"]), - ) - - result = resolve_review_options(config, {}) - - assert result.two_pass is True - assert result.review_agent == "codex" - assert result.roles == ["quality"] - assert result.skip is False - - def test_resolve_review_options_same_agents_single_pass(self) -> None: - config = _make_build_config( - task_agent="claude", - review_executor=ReviewExecutorConfig(agent="claude"), - ) - - result = resolve_review_options(config, {}) - - assert result.two_pass is False - - def test_resolve_review_options_no_review_executor_defaults(self) -> None: - config = _make_build_config(review_executor=None) - - result = resolve_review_options(config, {}) - - assert result == ReviewOptions(skip=False, review_agent=None, roles=None, two_pass=False, review_env={}) - - def test_resolve_review_options_empty_roles_verbatim(self) -> None: - config = _make_build_config(review_executor=ReviewExecutorConfig(roles=[])) - - result = resolve_review_options(config, {}) - - assert result.roles == [] - - def test_resolve_review_options_two_pass_independent_of_skip(self) -> None: - config = _make_build_config( - task_agent="claude", - review_executor=ReviewExecutorConfig(skip=True, agent="codex"), - ) - - result = resolve_review_options(config, {}) - - assert result.two_pass is True - assert result.skip is True - - def test_review_options_is_frozen(self) -> None: - options = ReviewOptions(skip=False, review_agent=None, roles=None, two_pass=False, review_env={}) - - with pytest.raises(dataclasses.FrozenInstanceError): - options.skip = True - - def test_resolve_review_options_env_nonempty_same_agent_two_pass(self) -> None: - """A non-empty review env induces two_pass even when both agents match.""" - config = _make_build_config( - review_executor=ReviewExecutorConfig(agent="claude", env={"M": "r"}), - ) - - result = resolve_review_options(config, {"skip_review": None}) - - assert result.two_pass is True - assert result.review_agent == "claude" - assert result.review_env == {"M": "r"} - - def test_resolve_review_options_env_equal_to_task_env_still_two_pass(self) -> None: - """Induction checks non-emptiness, not dictionary equality with task env.""" - config = BuildConfig( - task_executor=TaskExecutorConfig(agent="claude", env={"M": "r"}), - review_executor=ReviewExecutorConfig(agent="claude", env={"M": "r"}), - ) - - result = resolve_review_options(config, {}) - - assert result.two_pass is True - assert result.review_env == {"M": "r"} - - def test_resolve_review_options_env_empty_same_agent_single_pass(self) -> None: - """An empty review env keeps the single-pass path for matching agents.""" - config = _make_build_config( - task_agent="claude", - review_executor=ReviewExecutorConfig(agent="claude", env={}), - ) - - result = resolve_review_options(config, {}) - - assert result.two_pass is False - assert result.review_env == {} - - def test_resolve_review_options_env_without_agent_single_pass(self) -> None: - """A non-empty env without an agent does NOT induce two_pass — the - formula requires an agent; the env-without-agent misconfiguration is - the consumer's gate (validate_review_config), not this reduction.""" - config = _make_build_config( - task_agent="claude", - review_executor=ReviewExecutorConfig(env={"X": "y"}), - ) - - result = resolve_review_options(config, {}) - - assert result.two_pass is False - assert result.review_agent is None - assert result.review_env == {"X": "y"} - - def test_resolve_review_options_base_ref_cli_overrides_config(self) -> None: - config = _make_build_config( - review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/main"), - ) - - result = resolve_review_options(config, {"base_ref": "origin/1.2.x"}) - - assert result.base_ref == "origin/1.2.x" - - def test_resolve_review_options_base_ref_from_config_when_cli_absent(self) -> None: - config = _make_build_config( - review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/1.2.x"), - ) - - result = resolve_review_options(config, {}) - - assert result.base_ref == "origin/1.2.x" - - def test_resolve_review_options_patience_cli_overrides_config(self) -> None: - """Pins the naming split: the cli_options KEY is review_patience, - the ReviewOptions FIELD is patience.""" - config = _make_build_config( - review_executor=ReviewExecutorConfig(agent="claude", patience=3), - ) - - result = resolve_review_options(config, {"review_patience": 7}) - - assert result.patience == 7 - - def test_resolve_review_options_base_ref_empty_whitespace_resolves_unset(self) -> None: - config = _make_build_config( - review_executor=ReviewExecutorConfig(agent="claude", base_ref=" "), - ) - - result = resolve_review_options(config, {}) - - assert result.base_ref is None - - @pytest.mark.parametrize( - ("config_base_ref", "cli_options"), - [ - (" origin/1.2.x ", {}), - (None, {"base_ref": " origin/1.2.x "}), - ], - ids=["config-source", "cli-source"], - ) - def test_resolve_review_options_base_ref_padded_value_stripped(self, config_base_ref, cli_options) -> None: - """Exact equality — an implementation that only checks emptiness - without assigning the stripped value fails.""" - config = _make_build_config( - review_executor=ReviewExecutorConfig(agent="claude", base_ref=config_base_ref), - ) - - result = resolve_review_options(config, cli_options) - - assert result.base_ref == "origin/1.2.x" - - def test_resolve_review_options_base_ref_empty_cli_means_unset_not_fallback(self) -> None: - """An explicitly empty CLI value does NOT fall through to the config.""" - config = _make_build_config( - review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/main"), - ) - - result = resolve_review_options(config, {"base_ref": ""}) - - assert result.base_ref is None - - def test_resolve_review_options_no_review_executor_leaves_scoped_none(self) -> None: - config = _make_build_config(review_executor=None) - - result = resolve_review_options(config, {}) - - assert result.base_ref is None - assert result.patience is None diff --git a/tests/build/test_shipped_ralphex_assets.py b/tests/build/test_shipped_ralphex_assets.py index 6b8b6865..a88d0b66 100644 --- a/tests/build/test_shipped_ralphex_assets.py +++ b/tests/build/test_shipped_ralphex_assets.py @@ -91,16 +91,24 @@ def test_shipped_review_prompts_carry_counter_fragments() -> None: def test_shipped_assets_sync_byte_identical_without_roles(tmp_path, monkeypatch) -> None: """A no-roles sync copies the real vendored assets byte-identically end to end.""" from goga.build.ralphex_runtime import sync_ralphex_defaults - from goga.build.review_options import ReviewOptions - from goga.config import BuildConfig, TaskExecutorConfig + from goga.build.run_settings import PassSettings, ReviewPassSettings, RunSettings + from goga.config import AdditionalReviewConfig, BuildConfig monkeypatch.chdir(tmp_path) - config = BuildConfig(task_executor=TaskExecutorConfig(agent="claude", env={})) - - sync_ralphex_defaults( - config, ReviewOptions(skip=False, review_agent=None, roles=None, two_pass=False, review_env={}) + config = BuildConfig(agent="claude", env={}) + settings = RunSettings( + skip=False, + tasks=PassSettings(agent="claude", env={}), + review=ReviewPassSettings( + agent="claude", + env={}, + strategy="medium", + additional=AdditionalReviewConfig(agent="claude", patience=None, max_iterations=None), + ), ) + sync_ralphex_defaults(config, settings) + assert (_PROMPTS_DIR / "task.txt").read_bytes() == (tmp_path / ".ralphex" / "prompts" / "task.txt").read_bytes() assert (_PROMPTS_DIR / "review_first.txt").read_bytes() == ( tmp_path / ".ralphex" / "prompts" / "review_first.txt" From 8ecb61691159283631dacff80dd2e6def5348329 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 21:31:40 +0000 Subject: [PATCH 099/205] feat: in-container CLI surface without retired flags __main__.py (Task 16) --- .goga/history/2026/add-hooks-to-build/plan.md | 20 ++-- goga/build/__main__.py | 10 +- tests/build/test_contract.py | 29 ++++++ tests/build/test_main.py | 93 +++++++++++++++++-- 4 files changed, 129 insertions(+), 23 deletions(-) diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index 88a925b8..b845e832 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -1361,16 +1361,16 @@ keys removed from the dict) → load_project_config() → build(...) → exit co Current stale lines: `goga/build/__main__.py:22–23` (`--worktree`, `--skip-finalize` add_argument) and `:38–39` (their cli_options keys). -- [ ] **Declaration**: Task 16 — in-container CLI surface -- [ ] **Contract tests**: in `tests/build/test_main.py` — `main()` forwards exactly the nine cli_options keys; `--worktree`/`--skip-finalize` exit with argparse error (expected to fail at this stage) -- [ ] **Code**: update `goga/build/__main__.py` per the trace (remove the two flags and their dict keys) -- [ ] **Interface verification**: `pytest tests/build/test_main.py -x -q` — contract tests pass -- [ ] **Logic tests**: `test_main_argparse_surface_matches_contract` (monkeypatch `sys.argv` / `ensure_in_docker`; patch `goga.build.__main__.build`; input `["goga.build", "plan.md", "--skip-review", "--review-patience", "3"]`; repeat with `["goga.build", "plan.md", "--no-skip-review"]` → forwarded `cli_options["skip_review"] is True` / `cli_options["review_patience"] == 3`; the `--no-skip-review` variant forwards `cli_options["skip_review"] is False` (the tri-state False arm); parsing `--worktree` or `--skip-finalize` exits with SystemExit 2; guard `ensure_in_docker` called first — both branches covered per the manifest requirement) -- [ ] **Code**: update `tests/build/test_contract.py` to the new cell surface (facade `build`; module imports `goga.build.run_settings` / `goga.build.pass_options`; no retired names) -- [ ] **Debugging**: `pytest tests/build/ -x -q` — fix implementation code until all tests pass -- [ ] **Contract re-verification**: cli_options keys match `resolve_run_settings`'s read set exactly -- [ ] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary -- [ ] **Completion**: mark all checkboxes of this task complete +- [x] **Declaration**: Task 16 — in-container CLI surface +- [x] **Contract tests**: in `tests/build/test_main.py` — `main()` forwards exactly the nine cli_options keys; `--worktree`/`--skip-finalize` exit with argparse error (expected to fail at this stage) +- [x] **Code**: update `goga/build/__main__.py` per the trace (remove the two flags and their dict keys) +- [x] **Interface verification**: `pytest tests/build/test_main.py -x -q` — contract tests pass +- [x] **Logic tests**: `test_main_argparse_surface_matches_contract` (monkeypatch `sys.argv` / `ensure_in_docker`; patch `goga.build.__main__.build`; input `["goga.build", "plan.md", "--skip-review", "--review-patience", "3"]`; repeat with `["goga.build", "plan.md", "--no-skip-review"]` → forwarded `cli_options["skip_review"] is True` / `cli_options["review_patience"] == 3`; the `--no-skip-review` variant forwards `cli_options["skip_review"] is False` (the tri-state False arm); parsing `--worktree` or `--skip-finalize` exits with SystemExit 2; guard `ensure_in_docker` called first — both branches covered per the manifest requirement) +- [x] **Code**: update `tests/build/test_contract.py` to the new cell surface (facade `build`; module imports `goga.build.run_settings` / `goga.build.pass_options`; no retired names) +- [x] **Debugging**: `pytest tests/build/ -x -q` — fix implementation code until all tests pass +- [x] **Contract re-verification**: cli_options keys match `resolve_run_settings`'s read set exactly +- [x] **Lint**: `ruff check goga/build tests/build` — fix formatting if necessary +- [x] **Completion**: mark all checkboxes of this task complete ### Task 17: Host launcher surface — `goga/commands/build` (TDD coding) diff --git a/goga/build/__main__.py b/goga/build/__main__.py index a7a61dcc..144c5ce6 100644 --- a/goga/build/__main__.py +++ b/goga/build/__main__.py @@ -19,8 +19,6 @@ def main() -> int: parser = argparse.ArgumentParser(prog="goga.build", description="Run goga build inside Docker") parser.add_argument("plan", help="Path to the build plan file") parser.add_argument("--dry-run", action="store_true") - parser.add_argument("--worktree", action="store_true") - parser.add_argument("--skip-finalize", action="store_true") parser.add_argument("--skip-manifest-check", action="store_true") parser.add_argument("--skip-review", dest="skip_review", action="store_true", default=None) parser.add_argument("--no-skip-review", dest="skip_review", action="store_false") @@ -35,17 +33,15 @@ def main() -> int: config = load_project_config() cli_options = { - "worktree": args.worktree, - "skip_finalize": args.skip_finalize, + "dry_run": args.dry_run, "skip_manifest_check": args.skip_manifest_check, "skip_review": args.skip_review, + "base_ref": args.base_ref, + "review_patience": args.review_patience, "session_timeout": args.session_timeout, "idle_timeout": args.idle_timeout, "wait": args.wait, "max_iterations": args.max_iterations, - "review_patience": args.review_patience, - "base_ref": args.base_ref, - "dry_run": args.dry_run, } return build(args.plan, config, cli_options) diff --git a/tests/build/test_contract.py b/tests/build/test_contract.py index 039755d9..9a5aa112 100644 --- a/tests/build/test_contract.py +++ b/tests/build/test_contract.py @@ -46,3 +46,32 @@ def test_build_cli_options_param_is_dict(self) -> None: def test_build_returns_int(self) -> None: hints = typing.get_type_hints(build) assert hints["return"] is int + + def test_run_settings_module_surface(self) -> None: + """Contract: the settings resolver lives at goga.build.run_settings.""" + from goga.build import run_settings + + assert callable(run_settings.resolve_run_settings) + assert hasattr(run_settings, "RunSettings") + assert hasattr(run_settings, "PassSettings") + assert hasattr(run_settings, "ReviewPassSettings") + + def test_pass_options_module_surface(self) -> None: + """Contract: the options composer lives at goga.build.pass_options.""" + from goga.build import pass_options + + assert callable(pass_options.compose_pass_options) + + def test_retired_review_options_module_deleted(self) -> None: + """Contract: goga.build.review_options is gone — no compatibility shims.""" + import importlib.util + + assert importlib.util.find_spec("goga.build.review_options") is None + + def test_retired_cli_keys_absent_from_main(self) -> None: + """Contract: the in-container surface carries no worktree/skip_finalize keys.""" + import goga.build.__main__ as build_main + + source = inspect.getsource(build_main) + assert "worktree" not in source + assert "skip_finalize" not in source diff --git a/tests/build/test_main.py b/tests/build/test_main.py index f16c8859..025989c6 100644 --- a/tests/build/test_main.py +++ b/tests/build/test_main.py @@ -12,7 +12,7 @@ def _write_goga_yml(tmp_path: Path) -> None: data = { "language": "python", - "build": {"task_executor": {"agent": "claude"}}, + "build": {"agent": "claude"}, } (tmp_path / ".goga").mkdir(exist_ok=True) (tmp_path / ".goga" / "config.yml").write_text(yaml.dump(data)) @@ -51,14 +51,14 @@ def test_main_calls_build_with_parsed_args(self, mock_build, mock_config, tmp_pa with ( mock.patch.dict(os.environ, {"GOGA_DOCKER": "1"}), - mock.patch("sys.argv", ["goga.build", "plan.md", "--worktree", "--skip-manifest-check"]), + mock.patch("sys.argv", ["goga.build", "plan.md", "--skip-review", "--skip-manifest-check"]), ): main() call_args = mock_build.call_args assert call_args[0][0] == "plan.md" cli_options = call_args[0][2] - assert cli_options["worktree"] is True + assert cli_options["skip_review"] is True assert cli_options["skip_manifest_check"] is True @mock.patch("goga.build.__main__.load_project_config") @@ -140,7 +140,7 @@ def test_main_base_ref_flag(self, mock_build, mock_config, tmp_path, monkeypatch @mock.patch("goga.build.__main__.build", return_value=0) def test_main_base_ref_absent_defaults_none(self, mock_build, mock_config, tmp_path, monkeypatch) -> None: # Key present, value None — the tri-state survives to the resolver, - # which then falls through to build.review_executor.base_ref. + # which then falls through to build.review.base_ref. monkeypatch.chdir(tmp_path) _write_goga_yml(tmp_path) @@ -175,14 +175,14 @@ def test_build_main_proceeds_after_guard_in_container(self, monkeypatch) -> None with ( mock.patch("goga.build.__main__.build", return_value=42) as mock_build, mock.patch("goga.build.__main__.load_project_config"), - mock.patch("sys.argv", ["goga.build", "plan.md", "--worktree"]), + mock.patch("sys.argv", ["goga.build", "plan.md", "--skip-manifest-check"]), ): assert main() == 42 call_args = mock_build.call_args assert call_args[0][0] == "plan.md" cli_options = call_args[0][2] - assert cli_options["worktree"] is True + assert cli_options["skip_manifest_check"] is True def test_build_main_refuses_on_host(self, monkeypatch, capsys) -> None: monkeypatch.delenv("GOGA_DOCKER", raising=False) @@ -304,3 +304,84 @@ def test_main_help_lists_both_flags(self, mock_build, mock_config, monkeypatch, help_text = capsys.readouterr().out assert "--skip-review" in help_text assert "--no-skip-review" in help_text + + +class TestCliOptionsSurface: + """Contract: main() forwards exactly the nine live cli_options keys; the retired flags are parse errors.""" + + def test_main_forwards_exactly_nine_cli_option_keys(self, monkeypatch) -> None: + """Contract: cli_options carries the nine live keys and nothing else.""" + + monkeypatch.setenv("GOGA_DOCKER", "1") + + with ( + mock.patch("goga.build.__main__.build", return_value=0) as mock_build, + mock.patch("goga.build.__main__.load_project_config"), + mock.patch("sys.argv", ["goga.build", "plan.md", "--skip-manifest-check"]), + ): + main() + + cli_options = mock_build.call_args[0][2] + assert set(cli_options) == { + "dry_run", + "skip_manifest_check", + "skip_review", + "base_ref", + "review_patience", + "session_timeout", + "idle_timeout", + "wait", + "max_iterations", + } + + @pytest.mark.parametrize("flag", ["--worktree", "--skip-finalize"]) + def test_main_rejects_retired_flags(self, monkeypatch, flag) -> None: + """Contract: --worktree/--skip-finalize exit with an argparse error and never reach build.""" + + monkeypatch.setenv("GOGA_DOCKER", "1") + + with ( + mock.patch("goga.build.__main__.build", return_value=0) as mock_build, + mock.patch("goga.build.__main__.load_project_config"), + mock.patch("sys.argv", ["goga.build", "plan.md", flag]), + pytest.raises(SystemExit) as exc_info, + ): + main() + + assert exc_info.value.code == 2 + assert mock_build.call_count == 0 + + def test_main_argparse_surface_matches_contract(self, monkeypatch) -> None: + """The surface forwards the tri-state pair and the patience knob; the guard runs first.""" + + call_order: list[str] = [] + forwarded: list[dict] = [] + + def _record_ensure(*_args: object, **_kwargs: object) -> None: + call_order.append("ensure_in_docker") + + def _capture_build(plan: str, config: object, cli_options: dict) -> int: + call_order.append("build") + forwarded.append(cli_options) + return 0 + + with ( + mock.patch("goga.build.__main__.ensure_in_docker", side_effect=_record_ensure), + mock.patch("goga.build.__main__.build", side_effect=_capture_build), + mock.patch("goga.build.__main__.load_project_config"), + mock.patch("sys.argv", ["goga.build", "plan.md", "--skip-review", "--review-patience", "3"]), + ): + main() + + assert forwarded[0]["skip_review"] is True + assert forwarded[0]["review_patience"] == 3 + + with ( + mock.patch("goga.build.__main__.build", side_effect=_capture_build), + mock.patch("goga.build.__main__.load_project_config"), + mock.patch("sys.argv", ["goga.build", "plan.md", "--no-skip-review"]), + ): + main() + + assert forwarded[1]["skip_review"] is False + assert call_order == ["ensure_in_docker", "build", "build"] From ba899f055c110ff7716bfd9b46992fe2562345f9 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 21:40:15 +0000 Subject: [PATCH 100/205] feat: host launcher two-part surface without retired flags goga/commands/build (Task 17) --- .goga/history/2026/add-hooks-to-build/plan.md | 24 +- goga/commands/build/build.py | 81 ++--- tests/commands/build/test_build.py | 173 ++++++++- ...test_build_credential_mount_integration.py | 4 +- .../build/test_build_home_integration.py | 24 +- .../build/test_build_proxy_hosts_update.py | 6 +- ...est_build_runtime_isolation_integration.py | 8 +- tests/commands/conftest.py | 27 +- .../test_integration_launcher_tmpfile.py | 4 +- tests/commands/pipeline/test_pipeline.py | 4 +- .../pipeline/test_pipeline_command.py | 9 +- .../pipeline/test_pipeline_contract.py | 3 +- ...t_pipeline_credential_mount_integration.py | 4 +- .../pipeline/test_pipeline_dispatch.py | 4 +- .../test_pipeline_home_integration.py | 4 +- .../pipeline/test_pipeline_workflow_flags.py | 3 +- .../pipeline/test_run_pipeline_container.py | 8 +- .../test_run_pipeline_container_afm_config.py | 4 +- .../test_run_pipeline_container_contract.py | 4 +- .../test_run_pipeline_container_persistent.py | 4 +- ...run_pipeline_container_resolved_wrapper.py | 7 +- .../test_run_pipeline_container_workflow.py | 7 +- .../test_run_pipeline_info_container.py | 4 +- tests/commands/test_build.py | 334 +++--------------- tests/commands/test_config.py | 64 ++-- tests/commands/test_contract.py | 8 +- tests/commands/test_integration_split.py | 6 +- 27 files changed, 370 insertions(+), 462 deletions(-) diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index b845e832..df6f81d0 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -1419,18 +1419,18 @@ Also drop `config.build.env` from the comments that call the env-file "task_executor secrets". Constraint from the manifest: no worktree handling anywhere on the surface — no flag, no guard, no worktree-related rejection. -- [ ] **Declaration**: Task 17 — host launcher surface -- [ ] **Contract tests**: in `tests/commands/build/test_build.py` — `--worktree`/`--skip-finalize` are unknown options (exit 2 + message); the step-2.2 guard message names `build.agent` (expected to fail at this stage) -- [ ] **Code**: apply the three deltas + flag removals to `goga/commands/build/build.py` per the trace -- [ ] **Code**: update `tests/commands/conftest.py` shared config-writing helpers to the two-part schema (`build: {agent: …}`); drop the `worktree`/`skip_finalize`/`codex_review`/`review_executor` lines -- [ ] **Code**: update `tests/commands/test_build.py` — replace the `worktree`-option assertion with the removed-surface assertion (unknown option, exit 2); repoint the `task_executor` config fixture to `build.agent` -- [ ] **Interface verification**: `pytest tests/commands/ -x -q` — contract tests pass -- [ ] **Logic tests**: `test_host_command_surface_and_env_file` (click runner `CliRunner`; tmp config with two-part build; existing host fixtures; invoke `goga build plan.md` and with `--base-ref x --review-patience 2 --skip-review` → `--worktree`/`--skip-finalize` unknown options (exit 2 + message); guard message names `build.agent`; forwarded args contain `--base-ref x` / `--review-patience 2` / `--skip-review` only when set; the written env-file contains home/git/cli env keys and NOT the `build.env` values (secret boundary); docker args carry `-m goga.build `) -- [ ] **Code**: update the four integration files to the two-part schema and the env-file assertions (the task env is no longer written into the env-file): `tests/commands/build/test_build_home_integration.py`, `test_build_proxy_hosts_update.py`, `test_build_runtime_isolation_integration.py`, `test_build_credential_mount_integration.py` -- [ ] **Debugging**: `pytest tests/commands/ -x -q` — fix implementation code until all tests pass -- [ ] **Contract re-verification**: `grep -rn -e "--worktree" -e "--skip-finalize" goga/commands/` empty; host does not resolve the tri-state or base-ref precedence (forwarding only) -- [ ] **Lint**: `ruff check goga/commands tests/commands` — fix formatting if necessary -- [ ] **Completion**: mark all checkboxes of this task complete +- [x] **Declaration**: Task 17 — host launcher surface +- [x] **Contract tests**: in `tests/commands/build/test_build.py` — `--worktree`/`--skip-finalize` are unknown options (exit 2 + message); the step-2.2 guard message names `build.agent` (expected to fail at this stage) +- [x] **Code**: apply the three deltas + flag removals to `goga/commands/build/build.py` per the trace +- [x] **Code**: update `tests/commands/conftest.py` shared config-writing helpers to the two-part schema (`build: {agent: …}`); drop the `worktree`/`skip_finalize`/`codex_review`/`review_executor` lines +- [x] **Code**: update `tests/commands/test_build.py` — replace the `worktree`-option assertion with the removed-surface assertion (unknown option, exit 2); repoint the `task_executor` config fixture to `build.agent` +- [x] **Interface verification**: `pytest tests/commands/ -x -q` — contract tests pass +- [x] **Logic tests**: `test_host_command_surface_and_env_file` (click runner `CliRunner`; tmp config with two-part build; existing host fixtures; invoke `goga build plan.md` and with `--base-ref x --review-patience 2 --skip-review` → `--worktree`/`--skip-finalize` unknown options (exit 2 + message); guard message names `build.agent`; forwarded args contain `--base-ref x` / `--review-patience 2` / `--skip-review` only when set; the written env-file contains home/git/cli env keys and NOT the `build.env` values (secret boundary); docker args carry `-m goga.build `) +- [x] **Code**: update the four integration files to the two-part schema and the env-file assertions (the task env is no longer written into the env-file): `tests/commands/build/test_build_home_integration.py`, `test_build_proxy_hosts_update.py`, `test_build_runtime_isolation_integration.py`, `test_build_credential_mount_integration.py` +- [x] **Debugging**: `pytest tests/commands/ -x -q` — fix implementation code until all tests pass +- [x] **Contract re-verification**: `grep -rn -e "--worktree" -e "--skip-finalize" goga/commands/` empty; host does not resolve the tri-state or base-ref precedence (forwarding only) +- [x] **Lint**: `ruff check goga/commands tests/commands` — fix formatting if necessary +- [x] **Completion**: mark all checkboxes of this task complete ### Task 18: Onboarding two-part build emission — `goga/onboarding/generator` (TDD coding) diff --git a/goga/commands/build/build.py b/goga/commands/build/build.py index 9262a0aa..b75b346e 100644 --- a/goga/commands/build/build.py +++ b/goga/commands/build/build.py @@ -110,15 +110,11 @@ def _cli_flags_to_args(cli_flags: dict[str, bool | str | int | None]) -> list[st cli_flags: Build flags forwarded to the in-container entrypoint. Returns: - A flat list of CLI argument tokens (e.g. ``["--worktree", "--wait", "5m"]``). + A flat list of CLI argument tokens (e.g. ``["--dry-run", "--wait", "5m"]``). """ args: list[str] = [] if cli_flags.get("dry_run"): args.append("--dry-run") - if cli_flags.get("worktree"): - args.append("--worktree") - if cli_flags.get("skip_finalize"): - args.append("--skip-finalize") if cli_flags.get("skip_manifest_check"): args.append("--skip-manifest-check") @@ -212,19 +208,22 @@ def _cleanup_ralphex_in_project(project_dir: Path) -> None: @click.command() @click.argument("plan") @click.option("--dry-run", is_flag=True, help="Show command without executing") -@click.option("--worktree", is_flag=True, help="Enable ralph-loop worktree mode") -@click.option("--skip-finalize", is_flag=True, help="Skip finalization") @click.option("--skip-manifest-check", is_flag=True, help="Skip CODEMANIFEST uncommitted check") @click.option("--session-timeout", type=str, default=None, help="Session timeout") @click.option("--idle-timeout", type=str, default=None, help="Idle timeout") @click.option("--wait", type=str, default=None, help="Wait time") @click.option("--max-iterations", type=int, default=None, help="Max iterations") -@click.option("--review-patience", type=int, default=None, help="Review patience") +@click.option( + "--review-patience", + type=int, + default=None, + help="External review patience (consecutive unchanged rounds); addresses build.review.additional.patience", +) @click.option( "--base-ref", type=str, default=None, - help="Review diff base (branch name or commit hash); overrides build.review_executor.base_ref", + help="Review diff base (branch name or commit hash); addresses build.review.base_ref", ) @click.option("-e", "--env", "extra_env", multiple=True, help="Pass env var to container (KEY=VALUE)") @click.option("--proxy", type=str, default=None, help="HTTP/HTTPS proxy URL; overrides config.build.proxy") @@ -255,15 +254,13 @@ def _cleanup_ralphex_in_project(project_dir: Path) -> None: "skip_review", default=None, help="Skip the review phase (--skip-review) or force the full cycle (--no-skip-review); " - "overrides build.review_executor.skip in .goga/config.yml", + "overrides build.review.skip in .goga/config.yml", ) @click.pass_context def build( # noqa: PLR0913, C901, PLR0915, PLR0912, PLR0917 ctx: click.Context, plan: str, dry_run: bool, - worktree: bool, - skip_finalize: bool, skip_manifest_check: bool, session_timeout: str | None, idle_timeout: str | None, @@ -281,10 +278,12 @@ def build( # noqa: PLR0913, C901, PLR0915, PLR0912, PLR0917 """Build code via a ralph-loop by launching goga.build inside a Docker container. Home (machine-wide) config from ``~/.goga/config.yml`` is applied up front: - ``home.env`` is the lowest-priority container environment layer (project env + ``home.env`` is the lowest-priority container environment layer (git identity and CLI ``-e`` win on conflict), ``home.docker.run`` is forwarded to every ``docker run``, and ``home.docker.build`` is forwarded to image builds. An - absent home file is ignored. + absent home file is ignored. The task env (``build.env``) is NOT part of the + container env-file — it reaches the container through the mounted + ``.goga/config.yml`` and is applied in-container as the tasks-pass layer. """ if not _check_docker(): raise click.ClickException("docker not found in PATH") @@ -314,40 +313,14 @@ def build( # noqa: PLR0913, C901, PLR0915, PLR0912, PLR0917 if config.build is None: raise click.ClickException("build section is required in .goga/config.yml to run 'goga build'") - # Step 2.2 — agent None-guard: build.task_executor.agent is optional at the - # loader level (None when absent/empty), but `goga build` resolves it into - # the in-container wrapper path and cannot run without it. Raise a clean - # ClickException BEFORE any agent access to avoid a downstream TypeError. - if config.build.task_executor.agent is None: - raise click.ClickException("build.task_executor.agent is required in .goga/config.yml to run 'goga build'") - - # Step 2.3 — two-pass x worktree guard: a review executor whose agent - # differs from the task executor, or that declares a non-empty review env, - # makes the in-container build run two passes (tasks, then `ralphex - # --review`). ralphex review mode cannot follow a --worktree branch, so - # the combination is rejected here — BEFORE the docker command is - # assembled (before the env-file write, before DockerRunner), so no - # container is ever launched for a run that is doomed to lose the review - # pass. The condition is the config-level projection of the two_pass - # formula in resolve_review_options; it is skip-independent — the host - # does not resolve the tri-state --skip-review (that belongs to the - # in-container build, which also owns the env-without-agent gate). - review_exec = config.build.review_executor - worktree_active = worktree or bool(config.build.worktree) - - if ( - review_exec is not None - and review_exec.agent is not None - and (review_exec.agent != config.build.task_executor.agent or bool(review_exec.env)) - and worktree_active - ): - raise click.ClickException( - "build.review_executor two-pass review (differing agent or review env) cannot follow a --worktree branch" - ) + # Step 2.2 — agent None-guard: build.agent is optional at the loader level + # (None when absent/empty), but `goga build` resolves it into the in-container + # wrapper path and cannot run without it. Raise a clean ClickException BEFORE + # any agent access to avoid a downstream TypeError. + if config.build.agent is None: + raise click.ClickException("build.agent is required in .goga/config.yml to run 'goga build'") cli_flags = { - "worktree": worktree, - "skip_finalize": skip_finalize, "skip_manifest_check": skip_manifest_check, "skip_review": skip_review, "session_timeout": session_timeout, @@ -372,14 +345,18 @@ def build( # noqa: PLR0913, C901, PLR0915, PLR0912, PLR0917 merged_hosts[host] = ip # Reject the missing-image case before creating any temp files: the env file - # (written below) carries git identity and task_executor secrets and is only + # (written below) carries git identity and CLI -e secrets and is only # unlinked by the finally of the try block below, so creating it here and then # raising would leak it on disk. if config.image is None: raise click.ClickException("image in .goga/config.yml is not set") git_env = _read_git_config() - env = {**home.env, **git_env, **config.build.task_executor.env} + # Step 7 — base layers only: home.env < git identity < CLI -e (the raw extra + # channel appended last inside _write_env_file). The task env (config.build.env) + # is NOT written into the env-file — it reaches the container only through the + # mounted .goga/config.yml and is applied in-container as the tasks-pass layer. + env = {**home.env, **git_env} # When a proxy is resolved (CLI or config), populate the standard proxy env # vars. NO_PROXY is fixed at localhost,127.0.0.1 — there is no --no-proxy. @@ -391,7 +368,7 @@ def build( # noqa: PLR0913, C901, PLR0915, PLR0912, PLR0917 # Resolve and prepare the host ralphex runtime directory BEFORE writing the # secret-bearing env file: mkdir/clean can raise (read-only home, permission # denied), and the env file is only unlinked by the finally below — so - # writing it first would leak git identity and task_executor secrets on disk + # writing it first would leak git identity and CLI -e secrets on disk # if the runtime-dir setup raised. Docker may also refuse to bind-mount a # non-existent host path, so the directory must exist before docker run. # When --clean is set, wipe and recreate it so ralphex starts from a fresh @@ -412,8 +389,8 @@ def _on_signal(signum: int, _frame: object) -> None: # try below — so a signal (or any exception) raised in the window that spans # the env-file write, the docker_update build, and the DockerRunner launch # propagates through the finally, which unlinks the env file. Writing the env - # file before the handlers are installed would leak git identity and - # task_executor secrets on disk if a signal arrived in that window. The + # file before the handlers are installed would leak git identity and CLI -e + # secrets on disk if a signal arrived in that window. The # runner later installs its own handler that NESTS under these (saving and # restoring them), so the restores below return to the originals. _prev_term = signal.signal(signal.SIGTERM, _on_signal) @@ -476,7 +453,7 @@ def _on_signal(signum: int, _frame: object) -> None: finally: # Unlink the env file only if it was created: a pre-write failure or a # dry_run ctx.exit before the write leaves env_file None. The env file - # carries git identity and task_executor secrets. + # carries git identity and CLI -e secrets. if env_file is not None: env_file.unlink(missing_ok=True) # Remove the Docker-created empty ``.ralphex/`` mount point from the diff --git a/tests/commands/build/test_build.py b/tests/commands/build/test_build.py index 519a88a1..37442fc8 100644 --- a/tests/commands/build/test_build.py +++ b/tests/commands/build/test_build.py @@ -15,6 +15,7 @@ from unittest import mock import pytest +import yaml from click.testing import CliRunner from goga.commands import build as build_cmd from goga.commands.build.build import ( @@ -22,18 +23,18 @@ clean_build_runtime_dir, resolve_build_runtime_dir, ) -from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import BuildConfig, PipelineConfig, ProjectConfig _build_mod = __import__("goga.commands.build.build", fromlist=["build"]) -def _valid_config(*, image: str | None = "qarium/goga:latest") -> ProjectConfig: - """Return a minimal valid ProjectConfig for the build flow.""" +def _valid_config(*, image: str | None = "qarium/goga:latest", agent: str | None = "claude") -> ProjectConfig: + """Return a minimal valid ProjectConfig for the build flow (two-part build).""" return ProjectConfig( lang="python", image=image, dockerfile=None, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent=agent), pipeline=PipelineConfig(agent="claude"), ) @@ -107,6 +108,170 @@ def test_build_has_no_skip_option(self) -> None: assert not any(p.name == "skip" for p in build_cmd.params) +class TestRetiredFlagSurfaceContract: + """The retired flags are unknown options: click rejects them with exit 2. + + ``--worktree`` and ``--skip-finalize`` were removed with no replacement + (major-version window, no compatibility shims). Parsing either flag must + fail at the argparse/click layer — the rejection message names the option, + and no docker run ever happens for such an invocation. + """ + + @pytest.mark.parametrize("retired_flag", ["--worktree", "--skip-finalize"]) + def test_retired_flag_is_unknown_option(self, retired_flag: str, tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + result = CliRunner().invoke(build_cmd, ["plan.md", retired_flag]) + + assert result.exit_code == 2 + assert "No such option" in result.output + assert retired_flag in result.output + + @pytest.mark.parametrize("retired_flag", ["--worktree", "--skip-finalize"]) + def test_no_worktree_or_skip_finalize_param_declared(self, retired_flag: str) -> None: + param_names = {p.name for p in build_cmd.params} + retired_name = retired_flag.removeprefix("--").replace("-", "_") + + assert retired_name not in param_names + + def test_no_worktree_handling_on_the_surface(self) -> None: + """No flag, no guard, no worktree-related rejection: the callback + signature and the cli_flags channel carry no worktree/skip_finalize keys.""" + import inspect + + from goga.commands.build.build import _cli_flags_to_args + + param_names = set(inspect.signature(build_cmd.callback).parameters) + assert "worktree" not in param_names + assert "skip_finalize" not in param_names + # The forwarding helper renders nothing for the retired keys either. + assert _cli_flags_to_args({"worktree": True, "skip_finalize": True, "dry_run": False}) == [] + + +class TestBuildAgentGuardContract: + """Step 2.2 — the host-side agent guard names the two-part key ``build.agent``. + + ``build.agent`` is optional at the loader level (None when absent/empty), + but ``goga build`` cannot run without it; the guard fires before any agent + access, the env-file write, and the docker launch. + """ + + def test_guard_message_names_build_agent(self, tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + with ( + mock.patch.object(_build_mod, "_check_docker", return_value=True), + mock.patch.object(_build_mod, "load_project_config", return_value=_valid_config(agent=None)), + mock.patch.object(_build_mod, "DockerRunner") as mock_runner, + ): + result = CliRunner().invoke(build_cmd, ["plan.md"]) + + assert result.exit_code == 1 + assert "build.agent is required in .goga/config.yml to run 'goga build'" in result.output + mock_runner.return_value.run.assert_not_called() + + +class TestHostCommandSurfaceAndEnvFile: + """Logic test — the host surface forwards, the env-file carries base layers only. + + The forwarded container args carry ``-m goga.build `` plus the review + knobs exactly when set (the host performs no precedence resolution — neither + the tri-state nor base-ref); the env-file receives home/git/CLI layers and + NEVER the ``build.env`` values (secret boundary: the task env reaches the + container through the mounted config, applied in-container). + """ + + @staticmethod + def _write_two_part_config(tmp_path: Path, *, build_env: dict[str, str] | None = None) -> None: + build_block: dict = {"agent": "claude"} + if build_env is not None: + build_block["env"] = build_env + (tmp_path / ".goga").mkdir(exist_ok=True) + (tmp_path / ".goga" / "config.yml").write_text( + yaml.dump( + { + "language": "python", + "image": "qarium/goga:latest", + "build": build_block, + "pipeline": {"agent": "claude"}, + } + ) + ) + + def test_host_command_surface_and_env_file(self, tmp_path: Path, monkeypatch) -> None: + self._write_two_part_config(tmp_path, build_env={"API_KEY": "task-secret"}) + + # home.env base layer under the isolated HOME (autouse _isolate_home). + home_goga = Path.home() / ".goga" + home_goga.mkdir(parents=True, exist_ok=True) + (home_goga / "config.yml").write_text(yaml.dump({"env": {"HOME_KEY": "home-val"}})) + + monkeypatch.chdir(tmp_path) + captured: dict = {} + + def _fake_write_env(env, extra_env): + captured["env"] = dict(env) + captured["extra"] = tuple(extra_env) + return tmp_path / "env" + + with ( + mock.patch.object(_build_mod, "_check_docker", return_value=True), + mock.patch.object(_build_mod, "_read_git_config", return_value={"GIT_AUTHOR_NAME": "User"}), + mock.patch.object(_build_mod, "_write_env_file", side_effect=_fake_write_env), + mock.patch.object(_build_mod, "docker_build_if_not_exist"), + mock.patch.object(_build_mod, "DockerRunner") as mock_runner, + ): + mock_runner.return_value.run.return_value = 0 + result = CliRunner().invoke( + build_cmd, + [ + "--skip-manifest-check", + "--base-ref", + "x", + "--review-patience", + "2", + "--skip-review", + "-e", + "CLI_KEY=cli-val", + "plan.md", + ], + ) + + assert result.exit_code == 0, result.output + + # docker args carry -m goga.build and the set review knobs only. + args = mock_runner.return_value.run.call_args.args[0] + assert args[:3] == ["-m", "goga.build", "plan.md"] + assert "--base-ref" in args + assert "x" in args + assert "--review-patience" in args + assert "2" in args + assert "--skip-review" in args + assert "--no-skip-review" not in args + + # env-file layers: home.env, git identity, CLI -e — never build.env. + assert captured["env"]["HOME_KEY"] == "home-val" + assert captured["env"]["GIT_AUTHOR_NAME"] == "User" + assert "CLI_KEY=cli-val" in captured["extra"] + assert "API_KEY" not in captured["env"] + assert "task-secret" not in captured["env"].values() + assert not any("task-secret" in pair for pair in captured["extra"]) + + # Unset knobs forward nothing: the bare invocation adds no review tokens. + with ( + mock.patch.object(_build_mod, "_check_docker", return_value=True), + mock.patch.object(_build_mod, "_read_git_config", return_value={}), + mock.patch.object(_build_mod, "_write_env_file", side_effect=_fake_write_env), + mock.patch.object(_build_mod, "docker_build_if_not_exist"), + mock.patch.object(_build_mod, "DockerRunner") as mock_runner, + ): + mock_runner.return_value.run.return_value = 0 + result = CliRunner().invoke(build_cmd, ["--skip-manifest-check", "plan.md"]) + + assert result.exit_code == 0, result.output + args = mock_runner.return_value.run.call_args.args[0] + for token in ("--base-ref", "--review-patience", "--skip-review", "--no-skip-review"): + assert token not in args + + # --- Logic tests (positive) --- diff --git a/tests/commands/build/test_build_credential_mount_integration.py b/tests/commands/build/test_build_credential_mount_integration.py index d00a494c..726f07d1 100644 --- a/tests/commands/build/test_build_credential_mount_integration.py +++ b/tests/commands/build/test_build_credential_mount_integration.py @@ -20,11 +20,11 @@ def _write_goga_yml(tmp_path: Path) -> None: - """Write a minimal .goga/config.yml with an image set.""" + """Write a minimal .goga/config.yml with an image set (two-part build).""" data: dict = { "language": "python", "image": "qarium/goga:latest", - "build": {"task_executor": {"agent": "claude"}}, + "build": {"agent": "claude"}, "pipeline": {"agent": "claude"}, } (tmp_path / ".goga").mkdir(exist_ok=True) diff --git a/tests/commands/build/test_build_home_integration.py b/tests/commands/build/test_build_home_integration.py index e428f20c..617df66e 100644 --- a/tests/commands/build/test_build_home_integration.py +++ b/tests/commands/build/test_build_home_integration.py @@ -26,18 +26,18 @@ def _write_project_yml( tmp_path: Path, *, - task_executor_env: dict[str, str] | None = None, + build_env: dict[str, str] | None = None, dockerfile: str | None = None, ) -> None: - """Write a minimal .goga/config.yml, optionally with task_executor.env/dockerfile.""" + """Write a minimal .goga/config.yml, optionally with build.env/dockerfile (two-part build).""" data: dict = { "language": "python", "image": "qarium/goga:latest", - "build": {"task_executor": {"agent": "claude"}}, + "build": {"agent": "claude"}, "pipeline": {"agent": "claude"}, } - if task_executor_env is not None: - data["build"]["task_executor"]["env"] = task_executor_env + if build_env is not None: + data["build"]["env"] = build_env if dockerfile is not None: data["dockerfile"] = dockerfile (tmp_path / ".goga").mkdir(exist_ok=True) @@ -102,8 +102,9 @@ def test_extra_args_forwarded_as_separate_keyword( class TestHomeEnvLayering: - """home.env is the lowest-priority layer: project config wins on conflict, - home.env survives where unconflicted.""" + """home.env is the env-file base layer; the task env (build.env) never + joins it — build.env reaches the container through the mounted config and + is applied in-container as the tasks-pass layer.""" @mock.patch.object(_build_mod, "_check_docker", return_value=True) @mock.patch.object(_build_mod, "_read_git_config", return_value={}) @@ -111,7 +112,7 @@ class TestHomeEnvLayering: def test_build_command_layers_home_env_as_base( self, mock_env, mock_git, mock_docker, tmp_path, monkeypatch ) -> None: - _write_project_yml(tmp_path, task_executor_env={"API_KEY": "proj"}) + _write_project_yml(tmp_path, build_env={"API_KEY": "proj"}) _write_home_yml(Path.home(), {"env": {"API_KEY": "home", "EXTRA": "home"}}) mock_env.return_value = Path("/tmp/env") @@ -123,10 +124,11 @@ def test_build_command_layers_home_env_as_base( _run_build_in_tmp(tmp_path, monkeypatch, ["plan.md"]) env_dict = mock_env.call_args[0][0] - # Project task_executor env wins on key conflict. - assert env_dict["API_KEY"] == "proj" - # home.env survives where unconflicted (it is the base layer). + # home.env is the env-file body — the project task env (build.env) is + # NOT written into the file (secret boundary; in-container layer). + assert env_dict["API_KEY"] == "home" assert env_dict["EXTRA"] == "home" + assert "proj" not in env_dict.values() class TestExtraArgsForwarding: diff --git a/tests/commands/build/test_build_proxy_hosts_update.py b/tests/commands/build/test_build_proxy_hosts_update.py index 41dc6706..17634cb4 100644 --- a/tests/commands/build/test_build_proxy_hosts_update.py +++ b/tests/commands/build/test_build_proxy_hosts_update.py @@ -23,7 +23,7 @@ def _write_goga_yml( data: dict = { "language": "python", "image": "qarium/goga:latest", - "build": {"task_executor": {"agent": "claude"}}, + "build": {"agent": "claude"}, "pipeline": {"agent": "claude"}, } if no_image: @@ -86,9 +86,9 @@ def test_build_update_has_short_flag(self) -> None: update_param = next(p for p in build_cmd.params if p.name == "update") assert "-u" in update_param.opts - def test_build_sixteen_options(self) -> None: + def test_build_fourteen_options(self) -> None: options = [p for p in build_cmd.params if isinstance(p, click.Option)] - assert len(options) == 16 + assert len(options) == 14 def test_help_lists_new_options(self) -> None: runner = CliRunner() diff --git a/tests/commands/build/test_build_runtime_isolation_integration.py b/tests/commands/build/test_build_runtime_isolation_integration.py index a4826970..17384136 100644 --- a/tests/commands/build/test_build_runtime_isolation_integration.py +++ b/tests/commands/build/test_build_runtime_isolation_integration.py @@ -27,18 +27,18 @@ from click.testing import CliRunner from goga.commands import build as build_cmd from goga.commands.build.build import resolve_build_runtime_dir -from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import BuildConfig, PipelineConfig, ProjectConfig _build_mod = __import__("goga.commands.build.build", fromlist=["build"]) def _valid_config(*, image: str | None = "qarium/goga:latest") -> ProjectConfig: - """Return a minimal valid ProjectConfig for the build flow.""" + """Return a minimal valid ProjectConfig for the build flow (two-part build).""" return ProjectConfig( lang="python", image=image, dockerfile=None, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig(agent="claude"), ) @@ -165,7 +165,7 @@ def _fake_popen(cmd, *args, **kwargs): def test_runtime_setup_failure_does_not_write_secret_env_file(self, tmp_path: Path, monkeypatch) -> None: """A runtime-dir setup failure must not leave the secret env file on disk. - The env file carries git identity and ``task_executor`` secrets and is + The env file carries git identity and CLI ``-e`` secrets and is only unlinked by the finally block, so it must not be created before the runtime-dir setup — which can raise on a read-only home or a permission error. Regression guard for the prepare-runtime-before-env-file ordering. diff --git a/tests/commands/conftest.py b/tests/commands/conftest.py index 4f4e5519..2cb17cd2 100644 --- a/tests/commands/conftest.py +++ b/tests/commands/conftest.py @@ -5,19 +5,19 @@ @pytest.fixture def minimal_config(tmp_path: Path) -> Path: - """Minimal .goga/config.yml in tmp_path.""" + """Minimal .goga/config.yml in tmp_path (two-part build root).""" goga_dir = tmp_path / ".goga" goga_dir.mkdir() config_file = goga_dir / "config.yml" config_file.write_text( - "language: python\nbuild:\n task_executor:\n agent: claude\npipeline:\n agent: claude\n" + "language: python\nbuild:\n agent: claude\npipeline:\n agent: claude\n" ) return tmp_path @pytest.fixture def full_config(tmp_path: Path) -> Path: - """Full .goga/config.yml with all options.""" + """Full .goga/config.yml with all options (two-part build + build.review).""" goga_dir = tmp_path / ".goga" goga_dir.mkdir() config_file = goga_dir / "config.yml" @@ -25,23 +25,22 @@ def full_config(tmp_path: Path) -> Path: "language: python\n" "commands:\n test: pytest\n" "build:\n" - " task_executor:\n" - " agent: claude\n" - " env:\n" - " API_KEY: sk-xxx\n" - " MODEL: claude-sonnet-4-6\n" - " worktree: true\n" - " skip_finalize: false\n" + " agent: claude\n" + " env:\n" + " API_KEY: sk-xxx\n" + " MODEL: claude-sonnet-4-6\n" " session_timeout: '30m'\n" " idle_timeout: '1h'\n" " wait: '5m'\n" " max_iterations: 10\n" - " review_executor:\n" - " base_ref: origin/1.2.x\n" - " patience: 3\n" " prompts_dir: /custom/prompts\n" " agents_dir: /custom/agents\n" - " codex_review: true\n" + " review:\n" + " skip: true\n" + " strategy: short\n" + " base_ref: origin/1.2.x\n" + " additional:\n" + " patience: 3\n" "pipeline:\n" " agent: claude\n" " env:\n" diff --git a/tests/commands/pipeline/test_integration_launcher_tmpfile.py b/tests/commands/pipeline/test_integration_launcher_tmpfile.py index 5812f6f0..379140f3 100644 --- a/tests/commands/pipeline/test_integration_launcher_tmpfile.py +++ b/tests/commands/pipeline/test_integration_launcher_tmpfile.py @@ -28,7 +28,7 @@ from goga.commands.pipeline.run_pipeline_container import ( run_pipeline_container as rpc, ) -from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import BuildConfig, PipelineConfig, ProjectConfig from goga.docker import DockerRunner # Resolve the real submodule directly: the package __init__ re-exports the @@ -52,7 +52,7 @@ def _make_config( lang="python", image=image, dockerfile=None, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig(agent=pipeline_agent, env=pipeline_env or {}), ) diff --git a/tests/commands/pipeline/test_pipeline.py b/tests/commands/pipeline/test_pipeline.py index caeed071..4a1d2185 100644 --- a/tests/commands/pipeline/test_pipeline.py +++ b/tests/commands/pipeline/test_pipeline.py @@ -10,7 +10,7 @@ from click.testing import CliRunner from goga.commands.pipeline import pipeline from goga.commands.pipeline.pipeline import pipeline as pipeline_cmd -from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import BuildConfig, PipelineConfig, ProjectConfig # goga.commands.pipeline.pipeline is shadowed in the package __init__ by the # pipeline Click command, so a string-based mock.patch path walking through it @@ -24,7 +24,7 @@ def _make_config() -> ProjectConfig: lang="python", image="qarium/goga:latest", dockerfile=None, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig(agent="claude"), ) diff --git a/tests/commands/pipeline/test_pipeline_command.py b/tests/commands/pipeline/test_pipeline_command.py index f482ae08..7215d403 100644 --- a/tests/commands/pipeline/test_pipeline_command.py +++ b/tests/commands/pipeline/test_pipeline_command.py @@ -57,8 +57,7 @@ def _write_config(tmp_path: Path, *, with_pipeline: bool = True) -> None: "language: python", "image: qarium/goga:latest", "build:", - " task_executor:", - " agent: claude", + " agent: claude", ] if with_pipeline: lines += [ @@ -262,8 +261,7 @@ def _write_config_without_pipeline(tmp_path: Path) -> None: "language: python", "image: qarium/goga:latest", "build:", - " task_executor:", - " agent: claude", + " agent: claude", ] (goga_dir / "config.yml").write_text("\n".join(lines) + "\n") @@ -326,8 +324,7 @@ def _write_config_without_pipeline_agent(tmp_path: Path) -> None: "language: python", "image: qarium/goga:latest", "build:", - " task_executor:", - " agent: claude", + " agent: claude", "pipeline: {}", ] (goga_dir / "config.yml").write_text("\n".join(lines) + "\n") diff --git a/tests/commands/pipeline/test_pipeline_contract.py b/tests/commands/pipeline/test_pipeline_contract.py index c414354d..7e5604c7 100644 --- a/tests/commands/pipeline/test_pipeline_contract.py +++ b/tests/commands/pipeline/test_pipeline_contract.py @@ -50,8 +50,7 @@ def _write_config(tmp_path: Path) -> None: "language: python", "image: qarium/goga:latest", "build:", - " task_executor:", - " agent: claude", + " agent: claude", "pipeline:", " agent: claude", ] diff --git a/tests/commands/pipeline/test_pipeline_credential_mount_integration.py b/tests/commands/pipeline/test_pipeline_credential_mount_integration.py index 75c36bb8..62a6df5d 100644 --- a/tests/commands/pipeline/test_pipeline_credential_mount_integration.py +++ b/tests/commands/pipeline/test_pipeline_credential_mount_integration.py @@ -15,7 +15,7 @@ from unittest import mock from goga.commands.pipeline.run_pipeline_container import run_pipeline_container -from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import BuildConfig, PipelineConfig, ProjectConfig # goga.commands.pipeline.run_pipeline_container is the real submodule; resolve # it via sys.modules so string-based mock.patch paths walk the actual module. @@ -28,7 +28,7 @@ def _make_config() -> ProjectConfig: lang="python", image="qarium/goga:latest", dockerfile=None, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig(agent="claude", env={}), ) diff --git a/tests/commands/pipeline/test_pipeline_dispatch.py b/tests/commands/pipeline/test_pipeline_dispatch.py index d5c63037..7f216407 100644 --- a/tests/commands/pipeline/test_pipeline_dispatch.py +++ b/tests/commands/pipeline/test_pipeline_dispatch.py @@ -42,7 +42,7 @@ from click.testing import CliRunner from goga.commands.pipeline import pipeline from goga.commands.pipeline.pipeline import pipeline as pipeline_cmd -from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import BuildConfig, PipelineConfig, ProjectConfig from goga.history import current_year from goga.topics import board as topics_board from goga.topics import creation as topics_creation @@ -66,7 +66,7 @@ def _make_config( lang="python", image="qarium/goga:latest", dockerfile=None, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig( agent="claude", proxy=pipeline_proxy, diff --git a/tests/commands/pipeline/test_pipeline_home_integration.py b/tests/commands/pipeline/test_pipeline_home_integration.py index bed0fb9c..81b2b119 100644 --- a/tests/commands/pipeline/test_pipeline_home_integration.py +++ b/tests/commands/pipeline/test_pipeline_home_integration.py @@ -26,7 +26,7 @@ import pytest import yaml from goga.commands.pipeline.run_pipeline_container import run_pipeline_container as rpc -from goga.config import BuildConfig, HomeConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import BuildConfig, HomeConfig, PipelineConfig, ProjectConfig # Resolve the real submodule via sys.modules (the package __init__ binds the # function name `run_pipeline_container`, which would shadow string-based @@ -44,7 +44,7 @@ def _make_config( lang="python", image="qarium/goga:latest", dockerfile=dockerfile, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig(agent="claude", env=pipeline_env or {}), ) diff --git a/tests/commands/pipeline/test_pipeline_workflow_flags.py b/tests/commands/pipeline/test_pipeline_workflow_flags.py index ad735794..d66e57d1 100644 --- a/tests/commands/pipeline/test_pipeline_workflow_flags.py +++ b/tests/commands/pipeline/test_pipeline_workflow_flags.py @@ -44,8 +44,7 @@ def _write_config(tmp_path: Path) -> None: "language: python", "image: qarium/goga:latest", "build:", - " task_executor:", - " agent: claude", + " agent: claude", "pipeline:", " agent: claude", ] diff --git a/tests/commands/pipeline/test_run_pipeline_container.py b/tests/commands/pipeline/test_run_pipeline_container.py index 81317d43..dd05cc47 100644 --- a/tests/commands/pipeline/test_run_pipeline_container.py +++ b/tests/commands/pipeline/test_run_pipeline_container.py @@ -18,7 +18,6 @@ HomeConfig, PipelineConfig, ProjectConfig, - TaskExecutorConfig, ) # Resolve the real submodule via sys.modules (the package __init__ binds the @@ -38,7 +37,7 @@ def _make_config( lang="python", image=image, dockerfile=None, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig(agent=pipeline_agent, env=pipeline_env or {}), ) @@ -325,8 +324,9 @@ def capture(env: dict[str, str], extra_env: tuple[str, ...] = ()) -> Path: def test_pipeline_env_overrides_git_on_conflict(self, tmp_path: Path, monkeypatch) -> None: """config.pipeline.env wins over git identity when the same key is set in both. - Mirrors goga/commands/build where task_executor.env overrides git env - (env = {**git_env, **config.pipeline.env}). + The pipeline command keeps its own env-file layering + (env = {**git_env, **config.pipeline.env}); build does not fold the task + env into its env-file (in-container tasks-pass layer instead). """ config = _make_config(pipeline_env={"GIT_AUTHOR_NAME": "from-pipeline"}) monkeypatch.setattr(_rpc_mod, "_read_git_config", lambda: {"GIT_AUTHOR_NAME": "from-git"}) diff --git a/tests/commands/pipeline/test_run_pipeline_container_afm_config.py b/tests/commands/pipeline/test_run_pipeline_container_afm_config.py index 6a390c38..1159a60d 100644 --- a/tests/commands/pipeline/test_run_pipeline_container_afm_config.py +++ b/tests/commands/pipeline/test_run_pipeline_container_afm_config.py @@ -14,7 +14,7 @@ from goga.commands.pipeline.run_pipeline_container import ( run_pipeline_container as rpc, ) -from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import BuildConfig, PipelineConfig, ProjectConfig # Resolve the real submodule directly: the package __init__ re-exports the # `run_pipeline_container` function, which shadows the submodule name in @@ -38,7 +38,7 @@ def _make_config( lang="python", image=image, dockerfile=None, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig(agent=pipeline_agent, env=pipeline_env or {}), ) diff --git a/tests/commands/pipeline/test_run_pipeline_container_contract.py b/tests/commands/pipeline/test_run_pipeline_container_contract.py index 1ba350b1..0c08687f 100644 --- a/tests/commands/pipeline/test_run_pipeline_container_contract.py +++ b/tests/commands/pipeline/test_run_pipeline_container_contract.py @@ -39,13 +39,13 @@ def _make_config(): """Build a minimal ProjectConfig with a pipeline section for run-mode dispatch.""" - from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig + from goga.config import BuildConfig, PipelineConfig, ProjectConfig return ProjectConfig( lang="python", image="qarium/goga:latest", dockerfile=None, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig(agent="claude", env={}), ) diff --git a/tests/commands/pipeline/test_run_pipeline_container_persistent.py b/tests/commands/pipeline/test_run_pipeline_container_persistent.py index 800b058d..9b4c2265 100644 --- a/tests/commands/pipeline/test_run_pipeline_container_persistent.py +++ b/tests/commands/pipeline/test_run_pipeline_container_persistent.py @@ -34,7 +34,7 @@ resolve_pipeline_runtime_dir, run_pipeline_container, ) -from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import BuildConfig, PipelineConfig, ProjectConfig # goga.commands.pipeline.run_pipeline_container is the real submodule; resolve # it via sys.modules so string-based mock.patch paths walk the actual module @@ -53,7 +53,7 @@ def _make_config( lang="python", image=image, dockerfile=None, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig(agent=pipeline_agent, env=pipeline_env or {}), ) diff --git a/tests/commands/pipeline/test_run_pipeline_container_resolved_wrapper.py b/tests/commands/pipeline/test_run_pipeline_container_resolved_wrapper.py index 19e82fc7..1990d093 100644 --- a/tests/commands/pipeline/test_run_pipeline_container_resolved_wrapper.py +++ b/tests/commands/pipeline/test_run_pipeline_container_resolved_wrapper.py @@ -16,7 +16,7 @@ from goga.commands.pipeline.run_pipeline_container import ( run_pipeline_container as rpc, ) -from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import BuildConfig, PipelineConfig, ProjectConfig # goga.commands.pipeline.pipeline is shadowed in the package __init__ by the # pipeline Click command, so a string-based mock.patch path walking through it @@ -39,7 +39,7 @@ def _make_config(*, pipeline_agent: str | None = "claude") -> ProjectConfig: lang="python", image="qarium/goga:latest", dockerfile=None, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig(agent=pipeline_agent, env={}), ) @@ -63,8 +63,7 @@ def _write_config( "pipeline:", f" agent: {agent}", "build:", - " task_executor:", - " agent: claude", + " agent: claude", ] (goga_dir / "config.yml").write_text("\n".join(lines) + "\n") diff --git a/tests/commands/pipeline/test_run_pipeline_container_workflow.py b/tests/commands/pipeline/test_run_pipeline_container_workflow.py index 51f30b61..5f3883ea 100644 --- a/tests/commands/pipeline/test_run_pipeline_container_workflow.py +++ b/tests/commands/pipeline/test_run_pipeline_container_workflow.py @@ -31,7 +31,7 @@ from goga.commands.pipeline.run_pipeline_container import ( run_pipeline_container as rpc, ) -from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import BuildConfig, PipelineConfig, ProjectConfig # Resolve the real submodules via sys.modules (the package __init__ binds the # function/command names, which would shadow string-based mock.patch paths @@ -50,7 +50,7 @@ def _make_config( lang="python", image="qarium/goga:latest", dockerfile=None, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig(agent=pipeline_agent, env=pipeline_env or {}), ) @@ -65,8 +65,7 @@ def _write_config(tmp_path: Path) -> None: "language: python", "image: qarium/goga:latest", "build:", - " task_executor:", - " agent: claude", + " agent: claude", "pipeline:", " agent: claude", ] diff --git a/tests/commands/pipeline/test_run_pipeline_info_container.py b/tests/commands/pipeline/test_run_pipeline_info_container.py index 327227aa..682d9983 100644 --- a/tests/commands/pipeline/test_run_pipeline_info_container.py +++ b/tests/commands/pipeline/test_run_pipeline_info_container.py @@ -33,7 +33,7 @@ from goga.commands.pipeline.run_pipeline_info_container import ( run_pipeline_info_container as rpic, ) -from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import BuildConfig, PipelineConfig, ProjectConfig from goga.docker._flags import translate_params # Resolve the real submodule via sys.modules (the package __init__ will bind the @@ -48,7 +48,7 @@ def _make_config(image: str | None = "goga:test") -> ProjectConfig: lang="python", image=image, dockerfile=None, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig(agent="claude", env={}), ) diff --git a/tests/commands/test_build.py b/tests/commands/test_build.py index 191acc4d..a78071c6 100644 --- a/tests/commands/test_build.py +++ b/tests/commands/test_build.py @@ -15,11 +15,11 @@ def _write_goga_yml(tmp_path: Path, extra: dict | None = None, *, no_image: bool = False) -> None: - """Write a minimal .goga/config.yml in the new schema (top-level image, pipeline block).""" + """Write a minimal .goga/config.yml in the new schema (top-level image, two-part build).""" data: dict = { "language": "python", "image": "qarium/goga:latest", - "build": {"task_executor": {"agent": "claude"}}, + "build": {"agent": "claude"}, "pipeline": {"agent": "claude"}, } if no_image: @@ -77,26 +77,28 @@ def test_build_plan_is_required(self, tmp_path, monkeypatch) -> None: assert result.exit_code == 2 assert "Missing argument" in result.output - def test_build_has_sixteen_options(self) -> None: + def test_build_has_fourteen_options(self) -> None: options = [p for p in build_cmd.params if isinstance(p, click.Option)] - assert len(options) == 16 + assert len(options) == 14 def test_build_has_dry_run_option(self) -> None: param_names = [p.name for p in build_cmd.params] assert "dry_run" in param_names - def test_build_has_worktree_option(self) -> None: - param_names = [p.name for p in build_cmd.params] - assert "worktree" in param_names + def test_build_removed_flags_are_unknown_options(self, tmp_path, monkeypatch) -> None: + """--worktree/--skip-finalize were removed with no replacement: click + rejects them as unknown options (exit 2) — no forwarding, no shim.""" + _write_goga_yml(tmp_path) + for retired_flag in ("--worktree", "--skip-finalize"): + result = _run_build_in_tmp(tmp_path, monkeypatch, ["plan.md", retired_flag], skip_manifest_check=False) + assert result.exit_code == 2 + assert "No such option" in result.output + assert retired_flag in result.output def test_build_has_extra_env_option(self) -> None: param_names = [p.name for p in build_cmd.params] assert "extra_env" in param_names - def test_build_has_skip_finalize_option(self) -> None: - param_names = [p.name for p in build_cmd.params] - assert "skip_finalize" in param_names - def test_build_has_skip_manifest_check_option(self) -> None: param_names = [p.name for p in build_cmd.params] assert "skip_manifest_check" in param_names @@ -142,8 +144,6 @@ def test_help_contains_all_options(self) -> None: output = result.output for opt in ( "--dry-run", - "--worktree", - "--skip-finalize", "--skip-manifest-check", "--session-timeout", "--idle-timeout", @@ -156,6 +156,9 @@ def test_help_contains_all_options(self) -> None: "-e", ): assert opt in output, f"Option {opt} not found in help output" + # The retired flags are absent from the help surface entirely. + assert "--worktree" not in output + assert "--skip-finalize" not in output # --- Docker check tests --- @@ -504,7 +507,7 @@ def test_build_command_no_leak_env_file_when_build_section_absent( """The guard runs BEFORE the env-file write, so no secret env file leaks on disk. Ordering invariant (step 2b before step 10): the env file carries git - identity and ``task_executor`` secrets and is only unlinked by the + identity and CLI ``-e`` secrets and is only unlinked by the ``finally`` of the try block — so the None-guard must run before ``_write_env_file`` to guarantee the raise cannot leak it. """ @@ -517,11 +520,11 @@ def test_build_command_no_leak_env_file_when_build_section_absent( mock_env.assert_not_called() -# --- Build task_executor.agent None-guard (step 2c) tests --- +# --- Build agent None-guard (step 2.2) tests --- class TestBuildAgentGuard: - """Step 2c — host-side None-guard: ClickException when build.task_executor.agent + """Step 2.2 — host-side None-guard: ClickException when build.agent is absent/empty. The agent is optional at the loader level (None when unset), but `goga build` needs it to resolve the in-container wrapper path, so the guard runs before any agent access to avoid a downstream TypeError. @@ -529,11 +532,11 @@ class TestBuildAgentGuard: @staticmethod def _write_config_without_build_agent(tmp_path: Path) -> None: - """Write a valid config with a build section but NO task_executor.agent.""" + """Write a valid config with a build section but NO build.agent.""" data = { "language": "python", "image": "qarium/goga:latest", - "build": {"task_executor": {}}, + "build": {}, "pipeline": {"agent": "claude"}, } (tmp_path / ".goga").mkdir(exist_ok=True) @@ -546,7 +549,7 @@ def test_build_command_raises_click_exception_when_agent_absent(self, mock_docke result = _run_build_in_tmp(tmp_path, monkeypatch, ["plan.md"]) assert result.exit_code == 1 - assert "build.task_executor.agent is required" in result.output + assert "build.agent is required" in result.output # docker run never starts on an agent-less config. mock_runner.return_value.run.assert_not_called() @@ -558,7 +561,7 @@ class TestBuildNegativeCases: @mock.patch.object(_build_mod, "_check_docker", return_value=True) def test_build_invalid_goga_config_raises_config_error(self, mock_docker, tmp_path, monkeypatch) -> None: data = { - "build": {"task_executor": {"agent": "claude"}}, + "build": {"agent": "claude"}, } (tmp_path / ".goga").mkdir(exist_ok=True) (tmp_path / ".goga" / "config.yml").write_text(yaml.dump(data)) @@ -572,19 +575,6 @@ def test_build_invalid_goga_config_raises_config_error(self, mock_docker, tmp_pa class TestCLIFlagForwarding: - @mock.patch.object(_build_mod, "_check_docker", return_value=True) - @mock.patch.object(_build_mod, "_write_env_file") - def test_worktree_forwarded(self, mock_env, mock_docker, tmp_path, monkeypatch) -> None: - _write_goga_yml(tmp_path) - mock_env.return_value = Path("/tmp/env") - - with mock.patch.object(_build_mod, "DockerRunner") as mock_runner: - mock_runner.return_value.run.return_value = 0 - _run_build_in_tmp(tmp_path, monkeypatch, ["plan.md", "--worktree"]) - - args = mock_runner.return_value.run.call_args.args[0] - assert "--worktree" in args - @mock.patch.object(_build_mod, "_check_docker", return_value=True) @mock.patch.object(_build_mod, "_write_env_file") def test_session_timeout_forwarded(self, mock_env, mock_docker, tmp_path, monkeypatch) -> None: @@ -599,18 +589,14 @@ def test_session_timeout_forwarded(self, mock_env, mock_docker, tmp_path, monkey assert "--session-timeout" in args assert "30m" in args - @mock.patch.object(_build_mod, "_check_docker", return_value=True) - @mock.patch.object(_build_mod, "_write_env_file") - def test_skip_finalize_forwarded(self, mock_env, mock_docker, tmp_path, monkeypatch) -> None: - _write_goga_yml(tmp_path) - mock_env.return_value = Path("/tmp/env") + def test_retired_flags_never_forwarded(self) -> None: + """No input makes the launcher emit the retired flags: even a cli_flags + map carrying stale keys renders no token for them.""" + from goga.commands.build.build import _cli_flags_to_args - with mock.patch.object(_build_mod, "DockerRunner") as mock_runner: - mock_runner.return_value.run.return_value = 0 - _run_build_in_tmp(tmp_path, monkeypatch, ["plan.md", "--skip-finalize"]) + args = _cli_flags_to_args({"worktree": True, "skip_finalize": True, "dry_run": False}) - args = mock_runner.return_value.run.call_args.args[0] - assert "--skip-finalize" in args + assert args == [] # --- _cli_flags_to_args base_ref forwarding tests --- @@ -706,7 +692,7 @@ class TestGitConfigMergedInBuild: @mock.patch.object(_build_mod, "_write_env_file") @mock.patch.object(_build_mod, "_read_git_config") def test_git_env_merged_into_env_file(self, mock_git, mock_env, mock_docker, tmp_path, monkeypatch) -> None: - _write_goga_yml(tmp_path, extra={"task_executor": {"agent": "claude", "env": {"API_KEY": "secret"}}}) + _write_goga_yml(tmp_path, extra={"env": {"API_KEY": "secret"}}) mock_git.return_value = { "GIT_AUTHOR_NAME": "User", "GIT_AUTHOR_EMAIL": "u@e.com", @@ -721,15 +707,23 @@ def test_git_env_merged_into_env_file(self, mock_git, mock_env, mock_docker, tmp call_args = mock_env.call_args env_dict = call_args[0][0] - assert env_dict["API_KEY"] == "secret" assert env_dict["GIT_AUTHOR_NAME"] == "User" assert env_dict["GIT_COMMITTER_EMAIL"] == "u@e.com" + # The task env (build.env) is NOT written into the env-file — it reaches + # the container through the mounted config only (secret boundary). + assert "API_KEY" not in env_dict + assert "secret" not in env_dict.values() @mock.patch.object(_build_mod, "_check_docker", return_value=True) @mock.patch.object(_build_mod, "_write_env_file") @mock.patch.object(_build_mod, "_read_git_config") - def test_task_executor_env_has_priority(self, mock_git, mock_env, mock_docker, tmp_path, monkeypatch) -> None: - _write_goga_yml(tmp_path, extra={"task_executor": {"agent": "claude", "env": {"GIT_AUTHOR_NAME": "override"}}}) + def test_build_env_does_not_override_git_identity( + self, mock_git, mock_env, mock_docker, tmp_path, monkeypatch + ) -> None: + """A build.env key colliding with git identity stays out of the env-file: + the git identity layer passes through unmodified (the task env is applied + in-container as the tasks-pass layer, not here).""" + _write_goga_yml(tmp_path, extra={"env": {"GIT_AUTHOR_NAME": "override"}}) mock_git.return_value = {"GIT_AUTHOR_NAME": "GitUser"} mock_env.return_value = Path("/tmp/env") @@ -738,17 +732,17 @@ def test_task_executor_env_has_priority(self, mock_git, mock_env, mock_docker, t _run_build_in_tmp(tmp_path, monkeypatch, ["plan.md"]) env_dict = mock_env.call_args[0][0] - assert env_dict["GIT_AUTHOR_NAME"] == "override" + assert env_dict["GIT_AUTHOR_NAME"] == "GitUser" @mock.patch.object(_build_mod, "_check_docker", return_value=True) @mock.patch.object(_build_mod, "_write_env_file") @mock.patch.object(_build_mod, "_read_git_config", return_value={}) def test_review_env_not_in_container_env_file(self, mock_git, mock_env, mock_docker, tmp_path, monkeypatch) -> None: - """The review env layer reaches ONLY the pass-2 subprocess — it is never + """The review env layer reaches ONLY the review-pass subprocess — it is never part of the container env-file, so the tasks pass cannot see it.""" _write_goga_yml( tmp_path, - extra={"review_executor": {"agent": "codex", "env": {"ANTHROPIC_MODEL": "reviewer"}}}, + extra={"review": {"agent": "codex", "env": {"ANTHROPIC_MODEL": "reviewer"}}}, ) mock_env.return_value = Path("/tmp/env") @@ -871,12 +865,12 @@ def test_build_uses_top_level_config_image(self, mock_git, mock_docker, tmp_path @mock.patch.object(_build_mod, "_check_docker", return_value=True) @mock.patch.object(_build_mod, "_read_git_config") @mock.patch.object(_build_mod, "_write_env_file") - def test_build_env_file_task_executor_overrides_git( + def test_build_env_file_carries_git_only( self, mock_env, mock_git, mock_docker, tmp_path, monkeypatch ) -> None: _write_goga_yml( tmp_path, - extra={"task_executor": {"agent": "claude", "env": {"GIT_AUTHOR_NAME": "from-task"}}}, + extra={"env": {"GIT_AUTHOR_NAME": "from-task"}}, ) mock_git.return_value = {"GIT_AUTHOR_NAME": "from-git", "GIT_AUTHOR_EMAIL": "x@y"} mock_env.return_value = Path("/tmp/env") @@ -886,8 +880,9 @@ def test_build_env_file_task_executor_overrides_git( _run_build_in_tmp(tmp_path, monkeypatch, ["plan.md"]) env_dict = mock_env.call_args[0][0] - # task_executor env takes precedence over git identity env. - assert env_dict["GIT_AUTHOR_NAME"] == "from-task" + # The env-file carries the git identity layer; the task env (build.env) + # stays out of it — it is applied in-container as the tasks-pass layer. + assert env_dict["GIT_AUTHOR_NAME"] == "from-git" assert env_dict["GIT_AUTHOR_EMAIL"] == "x@y" @mock.patch.object(_build_mod, "_check_docker", return_value=True) @@ -928,7 +923,7 @@ def test_build_raises_clickexception_when_config_image_is_none(self, mock_docker def test_build_image_none_does_not_write_env_file(self, mock_env, mock_docker, tmp_path, monkeypatch) -> None: """When image is None, the env file is never written — no secret leak on disk. - The env file holds git identity plus ``task_executor`` env (potential + The env file holds git identity plus CLI ``-e`` values (potential secrets) and is only unlinked by the ``finally`` of the try block in ``build``. The ``config.image is None`` check must therefore run before ``_write_env_file`` so the raise cannot leak the file (mirrors the @@ -948,7 +943,7 @@ def test_build_image_none_does_not_write_env_file(self, mock_env, mock_docker, t def test_build_works_when_git_config_absent(self, mock_env, mock_git, mock_docker, tmp_path, monkeypatch) -> None: _write_goga_yml( tmp_path, - extra={"task_executor": {"agent": "claude", "env": {"FOO": "1"}}}, + extra={"env": {"FOO": "1"}}, ) mock_env.return_value = Path("/tmp/env") @@ -958,231 +953,12 @@ def test_build_works_when_git_config_absent(self, mock_env, mock_git, mock_docke assert result.exit_code == 0 env_dict = mock_env.call_args[0][0] - # With git config absent, only the task_executor env reaches the file. - assert env_dict == {"FOO": "1"} - - -# --- Review-phase control: guard 2.3 (two-pass x worktree) + flag forwarding --- - - -class TestTwoPassWorktreeGuard: - """Step 2.3 — host-side guard: a review executor that differs from the task - executor means a two-pass run (tasks pass, then a review pass). ralphex - ``--review`` mode cannot follow a worktree branch, so the combination is - rejected BEFORE the docker command is assembled (right after guards 2.1/2.2, - before the env-file write and DockerRunner launch). - """ - - @staticmethod - def _write_two_pass_config(tmp_path: Path, *, worktree: bool | None = None) -> None: - data: dict = { - "language": "python", - "image": "qarium/goga:latest", - "build": { - "task_executor": {"agent": "claude"}, - "review_executor": {"agent": "codex"}, - }, - "pipeline": {"agent": "claude"}, - } - - if worktree is not None: - data["build"]["worktree"] = worktree - (tmp_path / ".goga").mkdir(exist_ok=True) - (tmp_path / ".goga" / "config.yml").write_text(yaml.dump(data)) - - def test_host_guard_two_pass_worktree_conflict_cli_flag(self, tmp_path, monkeypatch) -> None: - """Worktree activated via the CLI --worktree flag → guard fires, no docker run.""" - self._write_two_pass_config(tmp_path) - with ( - mock.patch.object(_build_mod, "_check_docker", return_value=True), - mock.patch.object(_build_mod, "DockerRunner") as mock_runner, - ): - result = _run_build_in_tmp(tmp_path, monkeypatch, ["plan.md", "--worktree"]) + # With git config absent and no home env, the env-file body is empty — + # the task env (build.env) is not part of it (in-container layer). + assert env_dict == {} - assert result.exit_code == 1 - assert "review_executor" in result.output - assert "worktree" in result.output - mock_runner.return_value.run.assert_not_called() - def test_host_guard_two_pass_worktree_conflict_config_flag(self, tmp_path, monkeypatch) -> None: - """Worktree activated via build.worktree: true in config (no CLI flag) → guard fires too.""" - self._write_two_pass_config(tmp_path, worktree=True) - with ( - mock.patch.object(_build_mod, "_check_docker", return_value=True), - mock.patch.object(_build_mod, "DockerRunner") as mock_runner, - ): - result = _run_build_in_tmp(tmp_path, monkeypatch, ["plan.md"]) - - assert result.exit_code == 1 - assert "review_executor" in result.output - assert "worktree" in result.output - mock_runner.return_value.run.assert_not_called() - - @mock.patch.object(_build_mod, "_check_docker", return_value=True) - @mock.patch.object(_build_mod, "_read_git_config", return_value={}) - @mock.patch.object(_build_mod, "_write_env_file") - def test_host_guard_negative_control_no_worktree( - self, mock_env, mock_git, mock_docker, tmp_path, monkeypatch - ) -> None: - """Explicit build.worktree: false + no --worktree → guard silent, runner launched - (a two-pass run without worktree is legal).""" - self._write_two_pass_config(tmp_path, worktree=False) - mock_env.return_value = Path("/tmp/env") - - with mock.patch.object(_build_mod, "DockerRunner") as mock_runner: - mock_runner.return_value.run.return_value = 0 - result = _run_build_in_tmp(tmp_path, monkeypatch, ["plan.md"]) - - assert result.exit_code == 0 - mock_runner.return_value.run.assert_called_once() - - @mock.patch.object(_build_mod, "_check_docker", return_value=True) - @mock.patch.object(_build_mod, "_read_git_config", return_value={}) - @mock.patch.object(_build_mod, "_write_env_file") - def test_host_guard_same_agents_with_worktree_passes( - self, mock_env, mock_git, mock_docker, tmp_path, monkeypatch - ) -> None: - """review_executor.agent == task agent → single-pass run, worktree is fine.""" - _write_goga_yml( - tmp_path, - extra={"review_executor": {"agent": "claude"}}, - ) - mock_env.return_value = Path("/tmp/env") - - with mock.patch.object(_build_mod, "DockerRunner") as mock_runner: - mock_runner.return_value.run.return_value = 0 - result = _run_build_in_tmp(tmp_path, monkeypatch, ["plan.md", "--worktree"]) - - assert result.exit_code == 0 - mock_runner.return_value.run.assert_called_once() - - @mock.patch.object(_build_mod, "_check_docker", return_value=True) - @mock.patch.object(_build_mod, "_read_git_config", return_value={}) - @mock.patch.object(_build_mod, "_write_env_file") - def test_host_guard_inactive_worktree_config_false( - self, mock_env, mock_git, mock_docker, tmp_path, monkeypatch - ) -> None: - """review_executor present but WITHOUT an agent → the "agent is set" condition - fails, so --worktree alone does not trip the guard.""" - _write_goga_yml( - tmp_path, - extra={"review_executor": {"skip": True}}, - ) - mock_env.return_value = Path("/tmp/env") - - with mock.patch.object(_build_mod, "DockerRunner") as mock_runner: - mock_runner.return_value.run.return_value = 0 - result = _run_build_in_tmp(tmp_path, monkeypatch, ["plan.md", "--worktree"]) - - assert result.exit_code == 0 - mock_runner.return_value.run.assert_called_once() - - def test_host_guard_fires_even_with_explicit_skip_review(self, tmp_path, monkeypatch) -> None: - """--skip-review does not defuse the guard — pinned semantics. - - The guard is config-driven by contract (step 2.3): it never consults - the tri-state, because the host must not resolve skip against the - config — resolution belongs to the in-container build. Even a run that - would skip the review phase entirely is rejected here when the config - declares differing executors AND worktree. - """ - self._write_two_pass_config(tmp_path) - with ( - mock.patch.object(_build_mod, "_check_docker", return_value=True), - mock.patch.object(_build_mod, "DockerRunner") as mock_runner, - ): - result = _run_build_in_tmp(tmp_path, monkeypatch, ["plan.md", "--worktree", "--skip-review"]) - - assert result.exit_code == 1 - assert "review_executor" in result.output - assert "worktree" in result.output - mock_runner.return_value.run.assert_not_called() - - @staticmethod - def _write_env_induced_config(tmp_path: Path, *, review_env: dict | None, agent: str | None = "claude") -> None: - """Same task/review agents; only a non-empty review env induces two-pass.""" - review_executor: dict = {} - - if agent is not None: - review_executor["agent"] = agent - - if review_env is not None: - review_executor["env"] = review_env - _write_goga_yml(tmp_path, extra={"review_executor": review_executor}) - - def test_host_guard_env_induced_two_pass_worktree_conflict_cli(self, tmp_path, monkeypatch) -> None: - """Same agents + non-empty review env → two-pass is induced by env alone; - combined with --worktree the guard fires BEFORE any docker call.""" - self._write_env_induced_config(tmp_path, review_env={"M": "r"}) - with ( - mock.patch.object(_build_mod, "_check_docker", return_value=True), - mock.patch.object(_build_mod, "DockerRunner") as mock_runner, - ): - result = _run_build_in_tmp(tmp_path, monkeypatch, ["plan.md", "--worktree"]) - - assert result.exit_code == 1 - assert "review_executor" in result.output - assert "worktree" in result.output - mock_runner.return_value.run.assert_not_called() - - def test_host_guard_env_conflict_skip_independent(self, tmp_path, monkeypatch) -> None: - """The env-induced conflict is skip-independent: the guard never reads - the skip tri-state (resolution belongs to the in-container build).""" - self._write_env_induced_config(tmp_path, review_env={"M": "r"}) - with ( - mock.patch.object(_build_mod, "_check_docker", return_value=True), - mock.patch.object(_build_mod, "DockerRunner") as mock_runner, - ): - result = _run_build_in_tmp(tmp_path, monkeypatch, ["plan.md", "--worktree", "--skip-review"]) - - assert result.exit_code == 1 - assert "review_executor" in result.output - assert "worktree" in result.output - mock_runner.return_value.run.assert_not_called() - - def test_host_guard_env_induced_two_pass_worktree_conflict_config_flag(self, tmp_path, monkeypatch) -> None: - """The config-driven worktree variant: `build.worktree: true` with a - non-empty review env is the same rejected combination — the guard is a - config-level projection, not a CLI-flag check.""" - _write_goga_yml( - tmp_path, - extra={"worktree": True, "review_executor": {"agent": "claude", "env": {"M": "r"}}}, - ) - with ( - mock.patch.object(_build_mod, "_check_docker", return_value=True), - mock.patch.object(_build_mod, "DockerRunner") as mock_runner, - ): - result = _run_build_in_tmp(tmp_path, monkeypatch, ["plan.md"]) - - assert result.exit_code == 1 - assert "review_executor" in result.output - assert "worktree" in result.output - mock_runner.return_value.run.assert_not_called() - - @mock.patch.object(_build_mod, "_check_docker", return_value=True) - @mock.patch.object(_build_mod, "_read_git_config", return_value={}) - @mock.patch.object(_build_mod, "_write_env_file") - @pytest.mark.parametrize( - ("review_env", "agent"), - [ - ({"M": "r"}, None), # env without an agent stays a container-side concern - ({}, "claude"), # empty env → single-pass even under --worktree - ], - ) - def test_host_guard_env_empty_and_env_without_agent_no_conflict( # noqa: PLR0913, PLR0917 - self, mock_env, mock_git, mock_docker, tmp_path, monkeypatch, review_env, agent - ) -> None: - """(a) empty review env with a matching agent, (b) env without any agent — - neither trips the guard; the run proceeds to docker.""" - self._write_env_induced_config(tmp_path, review_env=review_env, agent=agent) - mock_env.return_value = Path("/tmp/env") - - with mock.patch.object(_build_mod, "DockerRunner") as mock_runner: - mock_runner.return_value.run.return_value = 0 - result = _run_build_in_tmp(tmp_path, monkeypatch, ["plan.md", "--worktree"]) - - assert result.exit_code == 0 - mock_runner.return_value.run.assert_called_once() +# --- Review-phase control: tri-state flag forwarding --- class TestSkipReviewPairForwarding: diff --git a/tests/commands/test_config.py b/tests/commands/test_config.py index cf976d86..fa2fe871 100644 --- a/tests/commands/test_config.py +++ b/tests/commands/test_config.py @@ -38,14 +38,14 @@ def test_config_requires_option_argument(self) -> None: assert result.exit_code != 0 def test_config_multiple_options_output_headers_and_values(self, full_config) -> None: - result = _run_with_config(full_config, ["language", "build.task_executor.agent", "build.worktree"]) + result = _run_with_config(full_config, ["language", "build.agent", "build.review.strategy"]) assert result.exit_code == 0 assert "# language\npython\n" in result.output - assert "# build.task_executor.agent\nclaude\n" in result.output - assert "# build.worktree\nTrue\n" in result.output + assert "# build.agent\nclaude\n" in result.output + assert "# build.review.strategy\nshort\n" in result.output # Check separators (blank lines between options) lines = result.output.split("\n") - # After "python" there should be a blank line before "# build.task_executor.agent" + # After "python" there should be a blank line before "# build.agent" idx = lines.index("python") assert lines[idx + 1] == "" @@ -58,16 +58,16 @@ def test_config_language_returns_str(self, full_config) -> None: assert result.exit_code == 0 assert result.output == "# language\npython\n" - def test_config_build_task_executor_agent_returns_str(self, full_config) -> None: - result = _run_with_config(full_config, ["build.task_executor.agent"]) + def test_config_build_agent_returns_str(self, full_config) -> None: + result = _run_with_config(full_config, ["build.agent"]) assert result.exit_code == 0 - assert result.output == "# build.task_executor.agent\nclaude\n" + assert result.output == "# build.agent\nclaude\n" def test_config_build_returns_yaml(self, full_config) -> None: result = _run_with_config(full_config, ["build"]) assert result.exit_code == 0 assert result.output.startswith("# build\n") - assert "task_executor:" in result.output + assert "review:" in result.output assert "agent: claude" in result.output def test_config_commands_returns_yaml_dict(self, full_config) -> None: @@ -77,19 +77,19 @@ def test_config_commands_returns_yaml_dict(self, full_config) -> None: assert "test: pytest" in result.output def test_config_none_value_outputs_null(self, minimal_config) -> None: - result = _run_with_config(minimal_config, ["build.worktree"]) + result = _run_with_config(minimal_config, ["build.review"]) assert result.exit_code == 0 - assert result.output == "# build.worktree\nnull\n" + assert result.output == "# build.review\nnull\n" def test_config_bool_value_outputs_true_false(self, full_config) -> None: - result = _run_with_config(full_config, ["build.worktree"]) + result = _run_with_config(full_config, ["build.review.skip"]) assert result.exit_code == 0 - assert result.output == "# build.worktree\nTrue\n" + assert result.output == "# build.review.skip\nTrue\n" - def test_config_build_task_executor_env_returns_yaml(self, full_config) -> None: - result = _run_with_config(full_config, ["build.task_executor.env"]) + def test_config_build_env_returns_yaml(self, full_config) -> None: + result = _run_with_config(full_config, ["build.env"]) assert result.exit_code == 0 - assert result.output.startswith("# build.task_executor.env\n") + assert result.output.startswith("# build.env\n") assert "API_KEY: sk-xxx" in result.output assert "MODEL: claude-sonnet-4-6" in result.output @@ -99,12 +99,12 @@ def test_config_str_optional_field_value(self, full_config) -> None: assert result.output == "# build.session_timeout\n30m\n" def test_config_dict_key_traversal(self, full_config) -> None: - result = _run_with_config(full_config, ["build.task_executor.env.API_KEY"]) + result = _run_with_config(full_config, ["build.env.API_KEY"]) assert result.exit_code == 0 - assert result.output == "# build.task_executor.env.API_KEY\nsk-xxx\n" + assert result.output == "# build.env.API_KEY\nsk-xxx\n" def test_config_dict_key_not_found(self, full_config) -> None: - result = _run_with_config(full_config, ["build.task_executor.env.NONEXISTENT"]) + result = _run_with_config(full_config, ["build.env.NONEXISTENT"]) assert result.exit_code == 1 assert "Option not found" in result.output @@ -116,22 +116,22 @@ def test_config_commands_dict_key(self, full_config) -> None: def test_config_multiple_mixed_types(self, full_config) -> None: result = _run_with_config( full_config, - ["build.session_timeout", "build", "build.task_executor.agent"], + ["build.session_timeout", "build", "build.agent"], ) assert result.exit_code == 0 assert "# build.session_timeout\n30m\n" in result.output assert "# build\n" in result.output - assert "# build.task_executor.agent\nclaude\n" in result.output + assert "# build.agent\nclaude\n" in result.output # Verify separators between options lines = result.output.split("\n") # Find "30m" and check blank line follows idx = lines.index("30m") assert lines[idx + 1] == "" - def test_config_skip_finalize_option(self, full_config) -> None: - result = _run_with_config(full_config, ["build.skip_finalize"]) + def test_config_review_additional_patience_option(self, full_config) -> None: + result = _run_with_config(full_config, ["build.review.additional.patience"]) assert result.exit_code == 0 - assert result.output == "# build.skip_finalize\nFalse\n" + assert result.output == "# build.review.additional.patience\n3\n" class TestNegative: @@ -200,22 +200,22 @@ def test_config_int_value(self, full_config) -> None: assert result.exit_code == 0 assert result.output == "# build.max_iterations\n10\n" - def test_config_codex_review_false(self, tmp_path) -> None: + def test_config_review_strategy_str_value(self, tmp_path) -> None: goga_dir = tmp_path / ".goga" goga_dir.mkdir() config_file = goga_dir / "config.yml" config_file.write_text( - "language: python\nbuild:\n task_executor:\n agent: claude\n codex_review: false\n" + "language: python\nbuild:\n agent: claude\n review:\n strategy: short\n" "pipeline:\n agent: claude\n" ) - result = _run_with_config(tmp_path, ["build.codex_review"]) + result = _run_with_config(tmp_path, ["build.review.strategy"]) assert result.exit_code == 0 - assert result.output == "# build.codex_review\nFalse\n" + assert result.output == "# build.review.strategy\nshort\n" def test_config_private_attribute_rejected(self, full_config) -> None: - result = _run_with_config(full_config, ["build._task_executor"]) + result = _run_with_config(full_config, ["build._agent"]) assert result.exit_code == 1 - assert "Option not found: build._task_executor" in result.output + assert "Option not found: build._agent" in result.output def test_config_scalar_traversal_returns_not_found(self, full_config) -> None: result = _run_with_config(full_config, ["language.foo"]) @@ -225,11 +225,11 @@ def test_config_scalar_traversal_returns_not_found(self, full_config) -> None: def test_config_bool_value_output_format(self, full_config) -> None: result = _run_with_config( full_config, - ["build.worktree", "build.codex_review"], + ["build.review.skip", "build.session_timeout"], ) assert result.exit_code == 0 - assert "# build.worktree\nTrue\n" in result.output - assert "# build.codex_review\nTrue\n" in result.output + assert "# build.review.skip\nTrue\n" in result.output + assert "# build.session_timeout\n30m\n" in result.output # Verify separator lines = result.output.split("\n") idx = lines.index("True") diff --git a/tests/commands/test_contract.py b/tests/commands/test_contract.py index e3d4aa59..7752a10a 100644 --- a/tests/commands/test_contract.py +++ b/tests/commands/test_contract.py @@ -26,7 +26,7 @@ def _write_codemanifest(directory: Path, content: str) -> None: def _write_goga_yml(directory: Path) -> None: (directory / ".goga").mkdir(exist_ok=True) (directory / ".goga" / "config.yml").write_text( - "language: python\nbuild:\n task_executor:\n agent: claude\npipeline:\n agent: claude\n" + "language: python\nbuild:\n agent: claude\npipeline:\n agent: claude\n" ) @@ -417,7 +417,7 @@ def test_contract_lang_from_config(tmp_path) -> None: def test_contract_lang_cli_overrides_config(tmp_path) -> None: (tmp_path / ".goga").mkdir(exist_ok=True) (tmp_path / ".goga" / "config.yml").write_text( - "language: go\nbuild:\n task_executor:\n agent: claude\npipeline:\n agent: claude\n" + "language: go\nbuild:\n agent: claude\npipeline:\n agent: claude\n" ) cell = tmp_path / "cell_one" cell.mkdir() @@ -447,7 +447,7 @@ def test_contract_config_missing(tmp_path) -> None: def test_contract_config_invalid_language(tmp_path) -> None: (tmp_path / ".goga").mkdir(exist_ok=True) - (tmp_path / ".goga" / "config.yml").write_text('language: ""\nbuild:\n task_executor:\n agent: claude\n') + (tmp_path / ".goga" / "config.yml").write_text('language: ""\nbuild:\n agent: claude\n') cell = tmp_path / "cell_one" cell.mkdir() _write_codemanifest(cell, ENTITY_CODEMANIFEST) @@ -556,7 +556,7 @@ def test_contract_golang_lang_cli(tmp_path) -> None: def test_contract_default_lang_from_config_golang(tmp_path) -> None: (tmp_path / ".goga").mkdir(exist_ok=True) (tmp_path / ".goga" / "config.yml").write_text( - "language: golang\nbuild:\n task_executor:\n agent: claude\npipeline:\n agent: claude\n", + "language: golang\nbuild:\n agent: claude\npipeline:\n agent: claude\n", encoding="utf-8", ) cell = tmp_path / "cell_one" diff --git a/tests/commands/test_integration_split.py b/tests/commands/test_integration_split.py index f8990a7c..794e6d23 100644 --- a/tests/commands/test_integration_split.py +++ b/tests/commands/test_integration_split.py @@ -106,7 +106,7 @@ def _write_goga_yml(tmp_path: Path) -> None: (tmp_path / ".goga").mkdir(exist_ok=True) (tmp_path / ".goga" / "config.yml").write_text( "language: python\nimage: qarium/goga:latest\n" - "build:\n task_executor:\n agent: claude\n" + "build:\n agent: claude\n" "pipeline:\n agent: claude\n" ) @@ -177,8 +177,6 @@ def test_build_passes_all_cli_options(self, tmp_path: Path) -> None: build_cli, [ "--skip-manifest-check", - "--worktree", - "--skip-finalize", "--session-timeout", "30m", "--idle-timeout", @@ -195,8 +193,6 @@ def test_build_passes_all_cli_options(self, tmp_path: Path) -> None: assert result.exit_code == 0 args = mock_runner.return_value.run.call_args.args[0] - assert "--worktree" in args - assert "--skip-finalize" in args assert "--session-timeout" in args assert "30m" in args assert "--idle-timeout" in args From f0117819cf4932eacba9f1b11d92a58f118a7eb2 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 21:42:32 +0000 Subject: [PATCH 101/205] feat: onboarding two-part build emission generator.py (Task 18) --- .goga/history/2026/add-hooks-to-build/plan.md | 18 +++++------ goga/onboarding/generator/generator.py | 4 +-- tests/onboarding/generator/test_generator.py | 32 +++++++++++++++++-- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index df6f81d0..956ca7cd 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -1462,15 +1462,15 @@ Current stale lines: `generator.py:59–60` (`_executor_block` assembling a generator would otherwise emit a silently-disabled build section under the new loader. -- [ ] **Declaration**: Task 18 — onboarding two-part build emission -- [ ] **Contract tests**: in `tests/onboarding/generator/test_generator.py` — the generated `build` block has `agent`/`env` at the root, no `task_executor` nesting (expected to fail at this stage) -- [ ] **Code**: update `goga/onboarding/generator/generator.py` per the trace (emission + `_executor_block` docstring reword) -- [ ] **Interface verification**: `pytest tests/onboarding/generator/test_generator.py -x -q` — contract tests pass -- [ ] **Logic tests**: `test_onboarding_generator_emits_two_part_build` (onboarding answers `build: {agent: "claude", env: {API_KEY: "secret"}}` — existing fixture pattern; input `generate_goga_config(answers)` → load the written file with `load_project_config` → `cfg["build"] == {"agent": "claude", "env": {"API_KEY": "secret"}}` (no `task_executor` nesting); `config.build.agent == "claude"` — the generated file actually drives a build) -- [ ] **Debugging**: `pytest tests/onboarding/ -x -q` — fix implementation code until all tests pass -- [ ] **Contract re-verification**: round-trip — generated file passes `load_project_config` with the two-part extraction -- [ ] **Lint**: `ruff check goga/onboarding tests/onboarding` — fix formatting if necessary -- [ ] **Completion**: mark all checkboxes of this task complete +- [x] **Declaration**: Task 18 — onboarding two-part build emission +- [x] **Contract tests**: in `tests/onboarding/generator/test_generator.py` — the generated `build` block has `agent`/`env` at the root, no `task_executor` nesting (expected to fail at this stage) +- [x] **Code**: update `goga/onboarding/generator/generator.py` per the trace (emission + `_executor_block` docstring reword) +- [x] **Interface verification**: `pytest tests/onboarding/generator/test_generator.py -x -q` — contract tests pass +- [x] **Logic tests**: `test_onboarding_generator_emits_two_part_build` (onboarding answers `build: {agent: "claude", env: {API_KEY: "secret"}}` — existing fixture pattern; input `generate_goga_config(answers)` → load the written file with `load_project_config` → `cfg["build"] == {"agent": "claude", "env": {"API_KEY": "secret"}}` (no `task_executor` nesting); `config.build.agent == "claude"` — the generated file actually drives a build) +- [x] **Debugging**: `pytest tests/onboarding/ -x -q` — fix implementation code until all tests pass +- [x] **Contract re-verification**: round-trip — generated file passes `load_project_config` with the two-part extraction +- [x] **Lint**: `ruff check goga/onboarding tests/onboarding` — fix formatting if necessary +- [x] **Completion**: mark all checkboxes of this task complete ### Task 19: Integration tests for the build cycle and the end-to-end flows (integration tests) diff --git a/goga/onboarding/generator/generator.py b/goga/onboarding/generator/generator.py index 82f53d9b..31f11f44 100644 --- a/goga/onboarding/generator/generator.py +++ b/goga/onboarding/generator/generator.py @@ -57,7 +57,7 @@ class CreatedFile: def _executor_block(section: dict) -> dict | None: - """Assemble a build.task_executor / pipeline content dict. + """Assemble a build root / pipeline content dict. Keys are emitted in field order (``agent``, then ``env``). The block is omitted entirely when it carries no content (no agent and no/empty env). @@ -158,7 +158,7 @@ def _build_config_document(snapshot: dict) -> dict: build_block = _executor_block(snapshot.get("build") or {}) if build_block is not None: - data["build"] = {"task_executor": build_block} + data["build"] = build_block pipeline_block = _executor_block(snapshot.get("pipeline") or {}) if pipeline_block is not None: diff --git a/tests/onboarding/generator/test_generator.py b/tests/onboarding/generator/test_generator.py index c8db4aa2..eb12704f 100644 --- a/tests/onboarding/generator/test_generator.py +++ b/tests/onboarding/generator/test_generator.py @@ -50,6 +50,17 @@ def test_generator_methods_callable_on_the_instance(self) -> None: for name in ("generate", "generate_goga_config", "generate_tool_configs"): assert callable(getattr(generator, name)) + def test_generated_build_block_carries_agent_at_the_root(self) -> None: + answers = SessionAnswers() + answers.record("language", "python") + answers.record("build", {"agent": "claude", "env": {"API_KEY": "secret"}}) + + FileGenerator().generate_goga_config(answers) + + cfg = yaml.safe_load(Path(".goga/config.yml").read_text(encoding="utf-8")) + assert cfg["build"] == {"agent": "claude", "env": {"API_KEY": "secret"}} + assert "task_executor" not in cfg["build"] + class TestLogic: """Logic tests for the snapshot-driven generator — `_clean_cwd` filesystem.""" @@ -169,7 +180,7 @@ def test_generate_maps_the_whole_snapshot_in_field_order(self) -> None: "tools", "usages", ] - assert cfg["build"] == {"task_executor": {"agent": "claude", "env": {"API_KEY": "secret"}}} + assert cfg["build"] == {"agent": "claude", "env": {"API_KEY": "secret"}} assert cfg["pipeline"] == {"agent": "codex", "env": {"CODEX_MODEL": "x"}} assert cfg["codemanifest"]["usages"] == {"custom": ".goga/usages/custom.md"} assert cfg["codemanifest"]["annotations"] == "Use conventions for code writing rules.\n" @@ -232,11 +243,28 @@ def test_written_config_passes_the_project_config_loader(self) -> None: assert config.lang == "python" assert config.image == "my-app:latest" assert config.build is not None - assert config.build.task_executor.agent == "claude" + assert config.build.agent == "claude" assert config.tools == {"my-tool": "latest"} assert config.usages is not None assert config.usages["cell"]["dep"].git == "https://example.com/repo.git" + def test_onboarding_generator_emits_two_part_build(self) -> None: + answers = SessionAnswers() + answers.record("language", "python") + answers.record("build", {"agent": "claude", "env": {"API_KEY": "secret"}}) + + FileGenerator().generate_goga_config(answers) + + cfg = yaml.safe_load(Path(".goga/config.yml").read_text(encoding="utf-8")) + assert cfg["build"] == {"agent": "claude", "env": {"API_KEY": "secret"}} + + config = load_project_config() + + assert config.build is not None + assert config.build.agent == "claude" + assert config.build.env == {"API_KEY": "secret"} + assert config.build.review is None + def test_generate_tool_configs_noop_on_empty_list(self) -> None: assert FileGenerator().generate_tool_configs([]) is None assert not Path(".goga").exists() From e9a93566b6049aa4c15c1043e8c0ecbb150e95d6 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 21:52:22 +0000 Subject: [PATCH 102/205] feat: integration tests for build cycle and end-to-end flows (Task 19) --- .goga/history/2026/add-hooks-to-build/plan.md | 38 +-- tests/build/test_build.py | 223 ++++++++++++++++++ tests/integration/test_base_ref_end_to_end.py | 135 +++++++---- .../test_docker_update_launch_integration.py | 6 +- tests/integration/test_parallel_flow.py | 4 +- tests/integration/test_pipeline_cli.py | 4 +- .../integration/test_resolved_wrapper_flow.py | 135 +++++++++-- tests/integration/test_runtime_isolation.py | 4 +- .../test_skip_review_end_to_end.py | 119 +++++++--- tests/integration/test_workflow_entity.py | 4 +- 10 files changed, 537 insertions(+), 135 deletions(-) diff --git a/.goga/history/2026/add-hooks-to-build/plan.md b/.goga/history/2026/add-hooks-to-build/plan.md index 956ca7cd..56df976d 100644 --- a/.goga/history/2026/add-hooks-to-build/plan.md +++ b/.goga/history/2026/add-hooks-to-build/plan.md @@ -1490,12 +1490,12 @@ design's General Setup verbatim (zone fixtures re-exported by **CRITICAL: `CODEMANIFEST` files — read-only contract definitions. Do NOT modify them. If implementation does not match the contract, fix the implementation — never fix the contract.** -- [ ] Rewrite `tests/integration/test_base_ref_end_to_end.py` onto the two-part config and the always-two-pass cycle (base_ref flows CLI > `build.review.base_ref` > omit) -- [ ] Rewrite `tests/integration/test_skip_review_end_to_end.py` onto the two-part config and the always-two-pass cycle (the skip form: exactly one tasks pass) -- [ ] Rewrite `tests/integration/test_resolved_wrapper_flow.py` onto the two-part config and the always-two-pass cycle (wrapper resolution per pass; the additional wrapper under short) -- [ ] Add to `tests/build/test_build.py` the orchestration integration scenarios: `test_notifications_carry_completion_facts` (one tool subscribing all four soft actions with hooks recording `context` via `self`; orchestration as in the two-pass test with the tasks pass returning 0 and the review pass returning 2 → recorded contexts expose `PassCompleted.exit_code == 2` for the review facts; `BuildCompleted.exit_code == 2`; `stages == ["tasks", "review"]`; a crashing notification hook — separate variant — warns and the return code stays 2); `test_build_dry_run_rehearses_event_structure` (two-pass setup with `dry_run=True`; `run_build_pass` NOT patched at the pass level — patch `goga.ralphex.run_ralphex.run_ralphex` to assert it is called with `dry_run=True` → both passes "ran" (launcher called twice, both dry); the plan file still at its original path; `BuildCompleted.relocation.moved is False`; recorded notification `moment.dry_run is True`); `test_registry_built_once_across_checkpoints` (one tool subscribing `validate_build` + `build_started` + `build_completed`; pin the enumeration boundary mock and count reads → the `packages_distributions` boundary read exactly once across a full `build(...)` run) -- [ ] Test edge case: second run sees an edited hook (SC10 — registration re-reads; assert a second `build(...)` run in the same process picks up a hook edit between runs) -- [ ] Run validation: `pytest tests/integration/ tests/build/ -x -q`, then the full suite `pytest tests/ -x` +- [x] Rewrite `tests/integration/test_base_ref_end_to_end.py` onto the two-part config and the always-two-pass cycle (base_ref flows CLI > `build.review.base_ref` > omit) +- [x] Rewrite `tests/integration/test_skip_review_end_to_end.py` onto the two-part config and the always-two-pass cycle (the skip form: exactly one tasks pass) +- [x] Rewrite `tests/integration/test_resolved_wrapper_flow.py` onto the two-part config and the always-two-pass cycle (wrapper resolution per pass; the additional wrapper under short) +- [x] Add to `tests/build/test_build.py` the orchestration integration scenarios: `test_notifications_carry_completion_facts` (one tool subscribing all four soft actions with hooks recording `context` via `self`; orchestration as in the two-pass test with the tasks pass returning 0 and the review pass returning 2 → recorded contexts expose `PassCompleted.exit_code == 2` for the review facts; `BuildCompleted.exit_code == 2`; `stages == ["tasks", "review"]`; a crashing notification hook — separate variant — warns and the return code stays 2); `test_build_dry_run_rehearses_event_structure` (two-pass setup with `dry_run=True`; `run_build_pass` NOT patched at the pass level — patch `goga.ralphex.run_ralphex.run_ralphex` to assert it is called with `dry_run=True` → both passes "ran" (launcher called twice, both dry); the plan file still at its original path; `BuildCompleted.relocation.moved is False`; recorded notification `moment.dry_run is True`); `test_registry_built_once_across_checkpoints` (one tool subscribing `validate_build` + `build_started` + `build_completed`; pin the enumeration boundary mock and count reads → the `packages_distributions` boundary read exactly once across a full `build(...)` run) +- [x] Test edge case: second run sees an edited hook (SC10 — registration re-reads; assert a second `build(...)` run in the same process picks up a hook edit between runs) +- [x] Run validation: `pytest tests/integration/ tests/build/ -x -q`, then the full suite `pytest tests/ -x` --- @@ -1517,35 +1517,35 @@ All commands run in the `.venv` virtualenv from the repo root. ## Completion Criteria -- [ ] Every contract entity is implemented in the correct `location` (13 zone +- [x] Every contract entity is implemented in the correct `location` (13 zone types across `facts.py`/`contexts.py`/`events.py`; `run_settings.py`, `pass_options.py` created; all re-signatured routines updated) -- [ ] Every contract entity is accessible from its facade +- [x] Every contract entity is accessible from its facade (`goga.build.hooks` 13 names; `goga.config` embeddings; `goga.build.build`) -- [ ] Properties and methods match the declared API (kw_only dataclasses; +- [x] Properties and methods match the declared API (kw_only dataclasses; frozen where the contract says frozen — `RunSettings`/`PassSettings`/ `ReviewPassSettings` and the config model — non-frozen zone facts/contexts) -- [ ] Descriptions are reflected in behavior (checkpoint order, inheritance +- [x] Descriptions are reflected in behavior (checkpoint order, inheritance rules, zero-valued external flags, veto semantics, secret boundaries) -- [ ] Contract dependencies are met (imports from `goga/hooks`, `goga/history`, +- [x] Contract dependencies are met (imports from `goga/hooks`, `goga/history`, `goga/agents`, `goga/ralphex`, `goga/docker`, `goga/config` resolve as declared) -- [ ] Re-exports are accessible from the facade (`ReviewConfig`, +- [x] Re-exports are accessible from the facade (`ReviewConfig`, `AdditionalReviewConfig` from `goga.config`; retired names gone) -- [ ] Every coding task followed the TDD workflow (contract tests → code → +- [x] Every coding task followed the TDD workflow (contract tests → code → verification → logic tests → debugging → re-verification → lint) -- [ ] Contract tests and logic tests cover facade, API, and behavior within each +- [x] Contract tests and logic tests cover facade, API, and behavior within each coding task — 39 named scenarios (26 positive, 6 negative, 7 edge) plus the rewritten existing suites -- [ ] Integration tests exist where cross-entity scenarios require them +- [x] Integration tests exist where cross-entity scenarios require them (Task 19: three end-to-end rewrites + notifications/dry-run/registry-once orchestration scenarios) -- [ ] No package boundary was expanded (no new cells beyond the contract-declared +- [x] No package boundary was expanded (no new cells beyond the contract-declared `goga/build/hooks`; internal helpers only within existing cells) -- [ ] `CODEMANIFEST` files were not modified (contract is read-only); +- [x] `CODEMANIFEST` files were not modified (contract is read-only); `.goga/config.yml` was not touched (already migrated) -- [ ] All validation commands pass (`pytest tests/ -x`, ruff, facade checks, +- [x] All validation commands pass (`pytest tests/ -x`, ruff, facade checks, absence greps, `goga lint` 79 cells / 0 errors, `goga schema` 13 types) -- [ ] Every Usages entry is mentioned in at least one task (calibration table: +- [x] Every Usages entry is mentioned in at least one task (calibration table: `conventions`/`convention` all tasks; `ralphex` 3/9/11/12/13; `agent-wrappers` 10/12/15; `checkpoints` 7/15/19; `topic-paths`/ `topic-statuses` 15; `resolve-wrapper-path` 10/12/15; `run-ralphex` diff --git a/tests/build/test_build.py b/tests/build/test_build.py index 54614b35..4e9a0473 100644 --- a/tests/build/test_build.py +++ b/tests/build/test_build.py @@ -1203,3 +1203,226 @@ def test_build_dry_run_two_pass_no_env_in_output(self, tmp_path, monkeypatch, ca # A dry run relocates nothing. assert (tmp_path / "plan.md").is_file() assert not (tmp_path / "completed").exists() + + +# --- Orchestration integration scenarios (Task 19) --- + + +class TestOrchestrationIntegrationScenarios: + """Cross-entity scenarios joining goga/build with goga/build/hooks and the + fake tool packages over the platform boundary fixtures: completion facts on + the notifications, the dry-run rehearsal of the event structure, and the + enumeration-once invariant of the run registry.""" + + def test_notifications_carry_completion_facts( + self, + tmp_path: Path, + monkeypatch, + pin_package_environment, + install_tool_package, + ) -> None: + """The notification sequence carries the actual facts: pass completions + with their real exit codes, and the completion event with the final + code, the executed stages, and the failed-run relocation outcome.""" + recorded: list[tuple[str, object]] = [] + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + _install_recording_tool(install_tool_package, recorded) + + with mock.patch("goga.build.build.run_build_pass", side_effect=[0, 2]): + result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options=dict(_FULL_CLI_OPTIONS)) + + assert result == 2 + + # The full notification sequence of a two-pass run with a failed review. + assert [action for action, _context in recorded] == [ + "validate_build", + "build_started", + "pass_started", + "pass_completed", + "pass_started", + "pass_completed", + "build_completed", + ] + + # Completion is a fact, not a success claim: the review pass's + # completion carries its actual non-zero code. + completions = [context for action, context in recorded if action == "pass_completed"] + assert [context.exit_code for context in completions] == [0, 2] + assert completions[1].facts.stage == "review" + + completed = next(context for action, context in recorded if action == "build_completed") + assert completed.exit_code == 2 + assert completed.stages == ["tasks", "review"] + assert completed.relocation.moved is False + + # The failed final pass keeps the plan in place for a resumable re-run. + assert (tmp_path / "plan.md").is_file() + + def test_crashing_notification_hook_warns_and_run_unaffected( + self, + tmp_path: Path, + monkeypatch, + caplog, + pin_package_environment, + install_tool_package, + ) -> None: + """A crashing soft hook warns inside the platform; the exit code of the + run is never affected (SC4).""" + + def register(hooks: object) -> None: + def broken(self: object, context: object) -> None: + raise RuntimeError("notify boom") + + def make(action: str): + def hook(self: object, context: object) -> None: + pass + + return hook + + hooks.subscribe("build", "build_started", "broken", broken) # type: ignore[attr-defined] + for action in ("pass_started", "pass_completed", "build_completed"): + hooks.subscribe("build", action, action, make(action)) # type: ignore[attr-defined] + + pin_package_environment({"goga_tool_crash": ["crash-dist"]}) + install_tool_package("goga_tool_crash", register_hooks=register) + + with ( + caplog.at_level(logging.WARNING, logger="goga.hooks.dispatch.emit"), + mock.patch("goga.build.build.run_build_pass", side_effect=[0, 2]), + ): + result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options=dict(_FULL_CLI_OPTIONS)) + + assert result == 2 + warnings = [record for record in caplog.records if record.levelno == logging.WARNING] + assert any( + record.getMessage() == "hook broken of tool crash failed on build.build_started: notify boom" + for record in warnings + ) + + def test_build_dry_run_rehearses_event_structure( + self, + tmp_path: Path, + monkeypatch, + pin_package_environment, + install_tool_package, + ) -> None: + """A dry run rehearses the identical event structure: the gate runs, both + passes reach the launcher dry, the plan stays, the relocation outcome is + not-moved, and every delivered moment carries dry_run=True (SC6). + + run_build_pass stays real here — only the launcher seam is stubbed. The + patch lands at the consumer's import point (goga.build.build_pass), + because the facade re-export shadows the submodule path named by the + design (per [[feedback_mock_patch_module_shadowing]]). + """ + recorded: list[tuple[str, object]] = [] + pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + _install_recording_tool(install_tool_package, recorded) + + with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_launch: + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + cli_options={**_FULL_CLI_OPTIONS, "dry_run": True}, + ) + + assert result == 0 + + # Both passes "ran" — the real pass executor delegated each to the + # launcher with dry_run=True (third positional of run_ralphex). + assert mock_launch.call_count == 2 + assert all(call.args[2] is True for call in mock_launch.call_args_list) + + # The identical event structure fired, gate included. + assert [action for action, _context in recorded] == [ + "validate_build", + "build_started", + "pass_started", + "pass_completed", + "pass_started", + "pass_completed", + "build_completed", + ] + + # Nothing executed and nothing relocated: the plan file is still at its + # original path and the completion facts say so. + assert (tmp_path / "plan.md").is_file() + completed = next(context for action, context in recorded if action == "build_completed") + assert completed.relocation.moved is False + assert completed.moment.dry_run is True + + started = next(context for action, context in recorded if action == "build_started") + assert started.moment.dry_run is True + + def test_registry_built_once_across_checkpoints( + self, + tmp_path: Path, + monkeypatch, + pin_package_environment, + install_tool_package, + ) -> None: + """One HookRegistry per run: the packages_distributions boundary is read + exactly once across a full build() run reaching several checkpoints.""" + recorded: list[tuple[str, object]] = [] + boundary = pin_package_environment({"goga_tool_demo": ["demo-dist"]}) + + def register(hooks: object) -> None: + def make(action: str): + def hook(self: object, context: object) -> None: + recorded.append((action, context)) + + return hook + + for action in ("validate_build", "build_started", "build_completed"): + hooks.subscribe("build", action, action, make(action)) # type: ignore[attr-defined] + + install_tool_package("goga_tool_demo", register_hooks=register) + + with mock.patch("goga.build.build.run_build_pass", return_value=0): + result = _run_build_in_tmp(tmp_path, monkeypatch, cli_options=dict(_FULL_CLI_OPTIONS)) + + assert result == 0 + assert [action for action, _context in recorded] == ["validate_build", "build_started", "build_completed"] + assert boundary.call_count == 1 + + def test_second_run_sees_edited_hook( + self, + tmp_path: Path, + monkeypatch, + pin_package_environment, + install_tool_package, + ) -> None: + """SC10 — no caching across runs: registration re-reads, so a second + build() run in the same process picks up a hook edit made between the + runs.""" + recorded: list[str] = [] + pin_package_environment({"goga_tool_edit": ["edit-dist"]}) + module = install_tool_package("goga_tool_edit") + + def register_v1(hooks: object) -> None: + def hook(self: object, context: object) -> None: + recorded.append("v1") + + hooks.subscribe("build", "validate_build", "guard", hook) # type: ignore[attr-defined] + + module.register_hooks = register_v1 + + with mock.patch("goga.build.build.run_build_pass", return_value=0): + first = _run_build_in_tmp(tmp_path, monkeypatch, cli_options=dict(_FULL_CLI_OPTIONS)) + + # The edit: the same installed package now registers a different hook + # on a different action — only the registration callback changes. + def register_v2(hooks: object) -> None: + def hook(self: object, context: object) -> None: + recorded.append("v2") + + hooks.subscribe("build", "build_started", "notified", hook) # type: ignore[attr-defined] + + module.register_hooks = register_v2 + + with mock.patch("goga.build.build.run_build_pass", return_value=0): + second = _run_build_in_tmp(tmp_path, monkeypatch, cli_options=dict(_FULL_CLI_OPTIONS)) + + assert first == 0 + assert second == 0 + assert recorded == ["v1", "v2"] diff --git a/tests/integration/test_base_ref_end_to_end.py b/tests/integration/test_base_ref_end_to_end.py index 81c4a527..b9460977 100644 --- a/tests/integration/test_base_ref_end_to_end.py +++ b/tests/integration/test_base_ref_end_to_end.py @@ -1,26 +1,26 @@ """End-to-end integration tests for the review-scoped ``base_ref`` option. -These stitch together the cross-cell path introduced by the -``add-ref-for-review`` change: +These stitch together the cross-cell path of the two-part build model +(``add-hooks-to-build``): host container goga/commands/build (click value goga/build/__main__ (argparse value option --base-ref) option --base-ref) -> cli_flags -> docker run args -> cli_options["base_ref"] - -> resolve_review_options step 6-7 - .goga/config.yml build.review_executor (CLI > review_executor > omit) - -> load_project_config (loader step 7) - -> ReviewOptions.base_ref/.patience - -> _review_scoped_options -> pass composition (review-carrying - passes only) -> run_ralphex options keys base_ref / - review_patience -> ralphex flags --base-ref / --review-patience + -> resolve_run_settings step 6 + .goga/config.yml build.review.base_ref (CLI > build.review.base_ref > omit) + -> load_project_config (two-part loader) + -> ReviewPassSettings.base_ref + -> compose_pass_options("review") -> run_ralphex options key + base_ref -> ralphex flag --base-ref Three seams only hold end-to-end and are verified here: the value survives the host->container handoff as the exact docker-run token pair and is parsed back by the real in-container argparse wiring; an unset option forwards no token and still lands as a present-but-None ``cli_options`` key (the tri-state that lets -the resolver defer to the config); and a config-declared review base reaches -the ralphex argv of the review pass only — never the tasks pass. +the resolver defer to the config); and the resolved base reaches the ralphex +argv of the review pass only — never the tasks pass — under the full +CLI > ``build.review.base_ref`` > omit precedence. Mocks live only on the external boundaries per the project conventions: the DockerRunner (docker binary), ``run_ralphex`` (ralphex binary), the vendored @@ -35,6 +35,7 @@ from pathlib import Path from unittest import mock +import pytest import yaml from click.testing import CliRunner from goga.build.__main__ import main as container_main @@ -73,12 +74,12 @@ ) -def _write_goga_yml(tmp_path: Path, review_executor: dict | None = None) -> None: - """Materialize a .goga/config.yml with the optional build.review_executor section.""" - build_section: dict = {"task_executor": {"agent": "claude"}} +def _write_goga_yml(tmp_path: Path, review: dict | None = None) -> None: + """Materialize a .goga/config.yml with the optional build.review section.""" + build_section: dict = {"agent": "claude"} - if review_executor is not None: - build_section["review_executor"] = review_executor + if review is not None: + build_section["review"] = review data = { "language": "python", @@ -123,8 +124,8 @@ class TestBaseRefSurvivesHostToContainer: option parses those same tokens back into one dest. Any lossy conversion on either side (the host resolving None against the config, or the token pair being dropped in ``_cli_flags_to_args``) would break the CLI > - ``build.review_executor.*`` > omit precedence that lives in - ``resolve_review_options``. + ``build.review.base_ref`` > omit precedence that lives in + ``resolve_run_settings``. """ def test_base_ref_survives_host_to_container(self, tmp_path: Path, monkeypatch) -> None: @@ -182,7 +183,7 @@ def test_base_ref_unset_forwards_no_token(self, tmp_path: Path, monkeypatch) -> assert "--base-ref" not in container_args # The tri-state survives: the key is present in cli_options with value - # None, so the resolver falls through to build.review_executor.base_ref. + # None, so the resolver falls through to build.review.base_ref. monkeypatch.setenv("GOGA_DOCKER", "1") monkeypatch.setattr(sys, "argv", ["goga.build", "plan.md", *forwarded]) @@ -198,56 +199,96 @@ def test_base_ref_unset_forwards_no_token(self, tmp_path: Path, monkeypatch) -> class TestConfigBaseReachesRalphexFlag: - """A config-declared review base reaches the ralphex argv of the review pass only. + """The resolved review base reaches the ralphex argv of the review pass only. - Loader (step 7) -> resolve_review_options (steps 6-7) -> pass composition - (review-scoped fragment joined onto the review-carrying pass only) -> + Two-part loader -> ``resolve_run_settings`` step 6 (CLI > + ``build.review.base_ref`` > omit) -> ``compose_pass_options("review")`` -> ``_build_command`` mapping the composed option keys to the ralphex flags. """ - def test_config_base_reach_ralphex_flag_on_review_pass(self, tmp_path: Path, monkeypatch) -> None: + @pytest.mark.parametrize( + ("cli_base_ref", "config_base_ref", "expected"), + [ + (None, "origin/1.2.x", "origin/1.2.x"), + ("cli/1.3.x", "origin/1.2.x", "cli/1.3.x"), + ("cli/1.3.x", None, "cli/1.3.x"), + (None, None, None), + ], + ) + def test_base_ref_precedence_on_review_pass( + self, + tmp_path: Path, + monkeypatch, + cli_base_ref: str | None, + config_base_ref: str | None, + expected: str | None, + ) -> None: + review: dict = {"agent": "codex", "additional": {"patience": 3}} + + if config_base_ref is not None: + review["base_ref"] = config_base_ref + monkeypatch.chdir(tmp_path) - _write_goga_yml( - tmp_path, - review_executor={"agent": "codex", "base_ref": "origin/1.2.x", "patience": 3}, - ) + _write_goga_yml(tmp_path, review=review) Path("plan.md").write_text("# plan\n") review_wrapper = tmp_path / "codex-as-claude.sh" review_wrapper.write_text("#!/bin/sh\n") config = load_project_config() + cli_options: dict = {"skip_manifest_check": True} + + if cli_base_ref is not None: + cli_options["base_ref"] = cli_base_ref with ( _mock_vendored_sources(tmp_path), mock.patch("goga.build.review_config.resolve_wrapper_path", return_value=str(review_wrapper)), mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run, ): - result = build("plan.md", config, {"skip_manifest_check": True}) + result = build("plan.md", config, cli_options) assert result == 0 - assert mock_run.call_count == 2 - # The review-carrying pass alone carries the review-scoped flags: the - # options keys base_ref / review_patience map onto the ralphex value - # flags --base-ref / --review-patience. The full argv is pinned (not - # just flag membership) so a flag/value transposition or a stray token - # fails the test. - second_cmd = _build_command("plan.md", mock_run.call_args_list[1].args[1]) - assert second_cmd == [ - "ralphex", - "plan.md", - "--config-dir", - ".ralphex/", - "--review", - "--review-patience", - "3", - "--base-ref", - "origin/1.2.x", - ] + # The always-two-pass cycle: tasks-only first, review second. + assert mock_run.call_count == 2 + first_options = mock_run.call_args_list[0].args[1] + second_options = mock_run.call_args_list[1].args[1] + assert first_options["tasks_only"] is True + assert second_options["review"] is True + + # The review-carrying pass alone carries the review-scoped flags; the + # full argv is pinned (not just flag membership) so a flag/value + # transposition or a stray token fails the test. + second_cmd = _build_command("plan.md", second_options) + + if expected is None: + # Omit arm: neither source set the base — no --base-ref token. + assert "base_ref" not in second_options + assert second_cmd == [ + "ralphex", + "plan.md", + "--config-dir", + ".ralphex/", + "--review", + "--review-patience", + "3", + ] + else: + assert second_cmd == [ + "ralphex", + "plan.md", + "--config-dir", + ".ralphex/", + "--review", + "--review-patience", + "3", + "--base-ref", + expected, + ] # The tasks pass carries the universal options only — a diff base on the # task pass would scope the wrong phase of the run. - first_cmd = _build_command("plan.md", mock_run.call_args_list[0].args[1]) + first_cmd = _build_command("plan.md", first_options) assert first_cmd == [ "ralphex", "plan.md", diff --git a/tests/integration/test_docker_update_launch_integration.py b/tests/integration/test_docker_update_launch_integration.py index 891e1a7a..a415c123 100644 --- a/tests/integration/test_docker_update_launch_integration.py +++ b/tests/integration/test_docker_update_launch_integration.py @@ -51,7 +51,7 @@ from goga.commands.pipeline.run_pipeline_container import ( run_pipeline_container as rpc, ) -from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import BuildConfig, PipelineConfig, ProjectConfig # Resolve the real submodules via __import__/sys.modules: the package __init__ # binds the function names, which shadow string-based mock.patch paths walking @@ -71,7 +71,7 @@ def _write_goga_yml( data: dict = { "language": "python", "image": image, - "build": {"task_executor": {"agent": "claude"}}, + "build": {"agent": "claude"}, "pipeline": {"agent": "claude"}, } if dockerfile is not None: @@ -91,7 +91,7 @@ def _make_config( lang="python", image=image, dockerfile=dockerfile, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig(agent=pipeline_agent, env={}), ) diff --git a/tests/integration/test_parallel_flow.py b/tests/integration/test_parallel_flow.py index 05393d94..3ba9fc00 100644 --- a/tests/integration/test_parallel_flow.py +++ b/tests/integration/test_parallel_flow.py @@ -28,7 +28,7 @@ from click.testing import CliRunner from goga.cli import app -from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import BuildConfig, PipelineConfig, ProjectConfig from goga.pipeline import pipeline_cli from goga.pipeline.compiler import ( BodyFormat, @@ -57,7 +57,7 @@ def _make_config() -> ProjectConfig: lang="python", image="qarium/goga:latest", dockerfile=None, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig(agent="claude"), ) diff --git a/tests/integration/test_pipeline_cli.py b/tests/integration/test_pipeline_cli.py index 929525f5..8eedf222 100644 --- a/tests/integration/test_pipeline_cli.py +++ b/tests/integration/test_pipeline_cli.py @@ -42,7 +42,7 @@ import pytest from click.testing import CliRunner from goga.cli import app -from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import BuildConfig, PipelineConfig, ProjectConfig from goga.pipeline import pipeline_cli from goga.pipeline.compiler import ( BodyFormat, @@ -80,7 +80,7 @@ def _make_config() -> ProjectConfig: lang="python", image="qarium/goga:latest", dockerfile=None, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig(agent="claude"), ) diff --git a/tests/integration/test_resolved_wrapper_flow.py b/tests/integration/test_resolved_wrapper_flow.py index abfa1ed0..325c2eb8 100644 --- a/tests/integration/test_resolved_wrapper_flow.py +++ b/tests/integration/test_resolved_wrapper_flow.py @@ -1,33 +1,33 @@ """End-to-end integration tests for the resolved wrapper path flow. -These stitch together the cross-cell path introduced by the -``unified-agent-wrappers-resolution`` migration. A single leaf routine — -``resolve_wrapper_path`` in ``goga/agents/wrapper`` — is re-exported through the -``goga.agents`` facade and consumed by both host-side launchers: +These stitch together the cross-cell path of the ``unified-agent-wrappers- +resolution`` migration on the two-part build model. A single leaf routine — +``resolve_wrapper_path`` in ``goga/agents/wrapper`` — is re-exported through +the ``goga.agents`` facade and consumed by both host-side launchers: goga/agents/wrapper/resolve.py (leaf) -> goga/agents/__init__.py (facade re-export) - -> goga/build/build.py (writes .ralphex/config claude_command) + -> goga/build/build.py (per-pass executor wrappers) -> goga/commands/pipeline/... (writes afm-config tmpfile client.command) The integration boundary is the facade import -``from goga.agents import resolve_wrapper_path``: each consumer resolves the bare -agent name from its own config block (``build.task_executor.agent`` / -``pipeline.agent``) and writes the resulting absolute path into a different -config surface. These tests verify both consumers receive the exact value -``resolve_wrapper_path`` produces for the same agent, and that the two surfaces -agree with each other. +``from goga.agents import resolve_wrapper_path``: each consumer resolves the +bare agent name from its own config block (``build.agent`` / +``build.review.agent`` / ``pipeline.agent``) and writes the resulting absolute +path into a different config surface. The build consumer resolves one wrapper +per pass of the always-two-pass cycle — the tasks pass under the root agent, +the review pass under the review agent, or under the additional agent when the +strategy is short — so the per-pass wrapper sequence and the final +``.ralphex/config`` are verified together with the pipeline-side surface. The docker/subprocess boundary is mocked per ``[[feedback_mock_patch_module_shadowing]]``: the package ``__init__`` re-exports -submodule functions, which shadows string-based ``mock.patch`` paths on Python -3.10, so the real modules are resolved via ``sys.modules`` and patched by -attribute. +submodule functions, which shadows string-based ``mock.patch`` paths, so the +real modules are resolved via ``sys.modules`` and patched by attribute. """ from __future__ import annotations -import shutil import subprocess import sys from contextlib import contextmanager @@ -35,12 +35,15 @@ from unittest import mock import pytest +import yaml from goga.agents import resolve_wrapper_path from goga.build import build +from goga.build.build_pass import write_ralphex_config as _real_write_ralphex_config from goga.commands.pipeline.run_pipeline_container import ( run_pipeline_container as rpc, ) from goga.config import load_project_config +from goga.ralphex.run_ralphex import _build_command # goga.commands.pipeline.run_pipeline_container shadows its submodule name in the # package __init__, so resolve the real module via sys.modules for @@ -50,20 +53,30 @@ _AFM_MOUNT_SUFFIX = ":/home/goga/.afm/config.yaml:ro" -def _write_config(tmp_path: Path, *, agent: str, image: str = "goga:latest") -> None: - """Materialize a .goga/config.yml with both consumer agent blocks set to agent.""" +def _write_config(tmp_path: Path, *, agent: str, image: str = "goga:latest", review: dict | None = None) -> None: + """Materialize a .goga/config.yml with the two-part build block set to agent.""" goga_dir = tmp_path / ".goga" goga_dir.mkdir(parents=True, exist_ok=True) + build_block: dict = {"agent": agent} + + if review is not None: + build_block["review"] = review + lines = [ "language: python", f"image: {image}", "pipeline:", f" agent: {agent}", "build:", - " task_executor:", - f" agent: {agent}", + f" agent: {agent}", ] + + if review is not None: + # yaml.dump indents nested mappings readably; splice it under build:. + review_text = yaml.dump({"review": review}, default_flow_style=False) + lines.extend(" " + line for line in review_text.splitlines()) + (goga_dir / "config.yml").write_text("\n".join(lines) + "\n") @@ -123,7 +136,7 @@ def popen_side_effect(cmd, *args, **kwargs): return popen_side_effect -# --- build consumer: .ralphex/config claude_command --- +# --- build consumer: per-pass executor wrappers and .ralphex/config --- class TestBuildResolvedPathFlow: @@ -137,10 +150,12 @@ def test_build_resolved_path_matches_resolve_wrapper_path( """build() writes the resolve_wrapper_path(agent) value into claude_command.""" _write_config(tmp_path, agent=agent) config = _load_config(tmp_path, monkeypatch) - monkeypatch.setattr(shutil, "which", lambda *_: True) cli_options = {"dry_run": True, "skip_manifest_check": True} - with _mock_vendored_sources(tmp_path): + with ( + _mock_vendored_sources(tmp_path), + mock.patch("goga.build.build_pass.run_ralphex", return_value=0), + ): result = build("plan.md", config, cli_options) assert result == 0 @@ -155,6 +170,76 @@ def test_build_resolved_path_matches_resolve_wrapper_path( return pytest.fail("claude_command line not found in .ralphex/config") + @pytest.mark.parametrize( + ("review_section", "expected_wrappers"), + [ + # medium (the default strategy): tasks pass under the root agent, + # review pass under the review agent. + ( + {"agent": "codex"}, + ["/home/goga/bin/claude-as-claude.sh", "/home/goga/bin/codex-as-claude.sh"], + ), + # short: the review pass is the external-only pass carried by the + # additional agent's wrapper. + ( + { + "agent": "codex", + "strategy": "short", + "additional": {"agent": "cursor", "patience": 2}, + }, + ["/home/goga/bin/claude-as-claude.sh", "/home/goga/bin/cursor-as-claude.sh"], + ), + ], + ) + def test_wrapper_resolved_per_pass( + self, + tmp_path: Path, + monkeypatch, + review_section: dict, + expected_wrappers: list[str], + ) -> None: + """Each pass resolves its own executor wrapper; the final .ralphex/config + carries the last pass's wrapper.""" + _write_config(tmp_path, agent="claude", review=review_section) + config = _load_config(tmp_path, monkeypatch) + Path("plan.md").write_text("# plan\n") + + review_wrapper = tmp_path / "codex-as-claude.sh" + review_wrapper.write_text("#!/bin/sh\n") + wrappers: list[str] = [] + + def _record(settings, wrapper_path): + wrappers.append(wrapper_path) + return _real_write_ralphex_config(settings, wrapper_path) + + with ( + _mock_vendored_sources(tmp_path), + mock.patch("goga.build.review_config.resolve_wrapper_path", return_value=str(review_wrapper)), + mock.patch("goga.build.build_pass.write_ralphex_config", side_effect=_record), + mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run, + ): + result = build("plan.md", config, {"skip_manifest_check": True}) + + assert result == 0 + + # The always-two-pass cycle writes one config per pass, wrapper of that + # pass; the sequence follows the per-stage agent resolution. + assert mock_run.call_count == 2 + assert wrappers == expected_wrappers + + # The mode flags follow the strategy: --review for medium, -e for short. + second_options = mock_run.call_args_list[1].args[1] + if review_section.get("strategy") == "short": + assert second_options["external_only"] is True + assert "review" not in second_options + assert _build_command("plan.md", second_options)[4:6] == ["-e", "--review-patience"] + else: + assert second_options["review"] is True + + # The final pass config carries the last pass's executor wrapper. + config_text = (tmp_path / ".ralphex" / "config").read_text() + assert f"claude_command = {expected_wrappers[-1]}" in config_text + # --- pipeline consumer: afm-config tmpfile client.command --- @@ -209,9 +294,11 @@ def test_resolved_path_consistent_between_build_and_pipeline( config = _load_config(tmp_path, monkeypatch) # --- build side: capture .ralphex/config claude_command --- - monkeypatch.setattr(shutil, "which", lambda *_: True) build_options = {"dry_run": True, "skip_manifest_check": True} - with _mock_vendored_sources(tmp_path): + with ( + _mock_vendored_sources(tmp_path), + mock.patch("goga.build.build_pass.run_ralphex", return_value=0), + ): build_result = build("plan.md", config, build_options) assert build_result == 0 build_config_text = (tmp_path / ".ralphex" / "config").read_text() diff --git a/tests/integration/test_runtime_isolation.py b/tests/integration/test_runtime_isolation.py index 408c006e..9f43bfce 100644 --- a/tests/integration/test_runtime_isolation.py +++ b/tests/integration/test_runtime_isolation.py @@ -46,7 +46,7 @@ resolve_pipeline_runtime_dir, run_pipeline_container, ) -from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import BuildConfig, PipelineConfig, ProjectConfig from goga.runtime import normalize_project_path, resolve_runtime_dir # The two consumer modules shadow their submodule names in their package @@ -61,7 +61,7 @@ def _valid_config(*, image: str | None = "qarium/goga:latest") -> ProjectConfig: lang="python", image=image, dockerfile=None, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig(agent="claude"), ) diff --git a/tests/integration/test_skip_review_end_to_end.py b/tests/integration/test_skip_review_end_to_end.py index 0df320fa..171fe437 100644 --- a/tests/integration/test_skip_review_end_to_end.py +++ b/tests/integration/test_skip_review_end_to_end.py @@ -1,24 +1,26 @@ """End-to-end integration tests for the review-phase control flow. -These stitch together the cross-entity path introduced by the -``skip-review-on-build`` change: +These stitch together the cross-entity path of the two-part build model +(``add-hooks-to-build``): host container goga/commands/build (click pair) goga/build/__main__ (argparse pair) -> cli_flags -> docker run args -> cli_options["skip_review"] - -> build() (Algorithm 0-9) - .goga/config.yml build.review_executor - -> load_project_config (loader step 6.5) - -> resolve_review_options -> validate_review_config + -> build() (Algorithm 0-11) + .goga/config.yml build.review + -> load_project_config (two-part loader) + -> resolve_run_settings (tri-state skip: CLI > build.review.skip + > False) -> validate_review_config -> sync_ralphex_defaults (role filtering) -> run_build_pass xN -> .ralphex/config + ralphex flags -> move_completed_plan -Three seams only hold end-to-end and are verified here: the tri-state flag +Four seams only hold end-to-end and are verified here: the tri-state flag survives the host->container handoff undistorted (click pair -> forwarded args --> argparse pair -> cli_options); a real ``build.review_executor`` YAML section -flows through the loader into two ralphex passes with role-filtered prompts and -a codex ``claude_command``; the two-pass x worktree guard fires before the -env-file write and the DockerRunner launch. +-> argparse pair -> cli_options); a real ``build.review`` YAML section flows +through the loader into two ralphex passes with role-filtered prompts and a +codex ``claude_command``; the skip form yields exactly one tasks pass; and the +``build.agent`` host guard fires before the env-file write and the DockerRunner +launch. Mocks live only on the external boundaries per the project conventions: the DockerRunner (docker binary), ``run_ralphex`` (ralphex binary), the vendored @@ -71,12 +73,12 @@ ) -def _write_goga_yml(tmp_path: Path, review_executor: dict | None = None) -> None: - """Materialize a .goga/config.yml with the optional build.review_executor section.""" - build_section: dict = {"task_executor": {"agent": "claude"}} +def _write_goga_yml(tmp_path: Path, review: dict | None = None, *, agent: str = "claude") -> None: + """Materialize a .goga/config.yml with the optional build.review section.""" + build_section: dict = {"agent": agent} - if review_executor is not None: - build_section["review_executor"] = review_executor + if review is not None: + build_section["review"] = review data = { "language": "python", @@ -119,8 +121,8 @@ class TestTriStateSurvivesHostToContainer: forwarded verbatim into the docker run args; the container argparse pair parses those same tokens back into one dest. Any lossy conversion on either side (e.g. the host resolving None against the config, or the container - defaulting to False) would break the CLI > ProjectConfig > omit precedence - that lives in ``resolve_review_options``. + defaulting to False) would break the CLI > ``build.review.skip`` > False + precedence that lives in ``resolve_run_settings``. """ @pytest.mark.parametrize( @@ -132,7 +134,11 @@ class TestTriStateSurvivesHostToContainer: ], ) def test_tri_state_survives_host_to_container( - self, tmp_path: Path, monkeypatch, host_flag: str | None, expected: bool | None + self, + tmp_path: Path, + monkeypatch, + host_flag: str | None, + expected: bool | None, ) -> None: monkeypatch.chdir(tmp_path) _write_goga_yml(tmp_path) @@ -170,19 +176,16 @@ def test_tri_state_survives_host_to_container( class TestConfigYamlFlowsToRalphex: - """A real build.review_executor YAML section drives the full container flow. + """A real build.review YAML section drives the full container flow. - Loader (step 6.5) -> resolve_review_options -> validate_review_config -> + Two-part loader -> resolve_run_settings -> validate_review_config -> sync_ralphex_defaults (role filtering) -> two run_build_pass calls (tasks, then review) -> the final .ralphex/config carrying the review wrapper. """ - def test_config_yaml_review_executor_flows_to_ralphex_flags(self, tmp_path: Path, monkeypatch) -> None: + def test_config_yaml_review_flows_to_ralphex_flags(self, tmp_path: Path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) - _write_goga_yml( - tmp_path, - review_executor={"skip": False, "agent": "codex", "roles": ["quality"]}, - ) + _write_goga_yml(tmp_path, review={"skip": False, "agent": "codex", "roles": ["quality"]}) Path("plan.md").write_text("# plan\n") review_wrapper = tmp_path / "codex-as-claude.sh" review_wrapper.write_text("#!/bin/sh\n") @@ -197,6 +200,8 @@ def test_config_yaml_review_executor_flows_to_ralphex_flags(self, tmp_path: Path result = build("plan.md", config, {"skip_manifest_check": True}) assert result == 0 + + # The always-two-pass cycle of a non-skipped run. assert mock_run.call_count == 2 first_options = mock_run.call_args_list[0].args[1] second_options = mock_run.call_args_list[1].args[1] @@ -223,12 +228,60 @@ def test_config_yaml_review_executor_flows_to_ralphex_flags(self, tmp_path: Path assert "move_plan_on_completion = false" in config_text -class TestGuardFiresBeforeDockerAssembly: - """The two-pass x worktree guard fires before any docker-side side effect.""" +class TestSkipFormSingleTasksPass: + """The skip form of the always-two-pass cycle: exactly one tasks pass. + + Both skip sources land in the same shape: the config-declared + ``build.review.skip`` and the CLI tri-state override. + """ + + @pytest.mark.parametrize( + ("review_section", "cli_options"), + [ + ({"skip": True}, {"skip_manifest_check": True}), + ({"skip": True, "agent": "codex"}, {"skip_manifest_check": True}), + ({"agent": "codex"}, {"skip_manifest_check": True, "skip_review": True}), + (None, {"skip_manifest_check": True, "skip_review": True}), + ], + ) + def test_skip_yields_exactly_one_tasks_pass( + self, + tmp_path: Path, + monkeypatch, + review_section: dict | None, + cli_options: dict, + ) -> None: + monkeypatch.chdir(tmp_path) + _write_goga_yml(tmp_path, review=review_section) + Path("plan.md").write_text("# plan\n") + + config = load_project_config() + + with ( + _mock_vendored_sources(tmp_path), + mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run, + ): + result = build("plan.md", config, cli_options) + + assert result == 0 + + # Exactly one pass — tasks-only; the review pass never launches. + assert mock_run.call_count == 1 + options = mock_run.call_args.args[1] + assert options["tasks_only"] is True + assert "review" not in options + + # The single successful pass relocates the plan. + assert not (tmp_path / "plan.md").exists() + assert (tmp_path / "completed" / "plan.md").read_text() == "# plan\n" + + +class TestAgentGuardFiresBeforeDockerAssembly: + """The build.agent host guard fires before any docker-side side effect.""" def test_guard_fires_before_docker_assembly(self, tmp_path: Path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) - _write_goga_yml(tmp_path, review_executor={"agent": "codex"}) + _write_goga_yml(tmp_path, agent="") runner = CliRunner() with ( @@ -236,13 +289,11 @@ def test_guard_fires_before_docker_assembly(self, tmp_path: Path, monkeypatch) - mock.patch.object(_build_cmd_mod, "_write_env_file") as mock_env, mock.patch.object(_build_cmd_mod, "DockerRunner") as mock_runner, ): - result = runner.invoke(build_cmd, ["plan.md", "--worktree"]) + result = runner.invoke(build_cmd, ["plan.md"]) assert result.exit_code == 1 - assert "review_executor" in result.output - assert "worktree" in result.output + assert "build.agent is required" in result.output mock_env.assert_not_called() - mock_runner.return_value.run.assert_not_called() assert not mock_runner.called @@ -251,7 +302,7 @@ class TestTwoPassFailureKeepsPlan: def test_two_pass_failure_keeps_plan_for_resume(self, tmp_path: Path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) - _write_goga_yml(tmp_path, review_executor={"agent": "codex"}) + _write_goga_yml(tmp_path, review={"agent": "codex"}) Path("plan.md").write_text("# plan\n") review_wrapper = tmp_path / "codex-as-claude.sh" review_wrapper.write_text("#!/bin/sh\n") diff --git a/tests/integration/test_workflow_entity.py b/tests/integration/test_workflow_entity.py index 69144d17..7ef0c069 100644 --- a/tests/integration/test_workflow_entity.py +++ b/tests/integration/test_workflow_entity.py @@ -36,7 +36,7 @@ import yaml from click.testing import CliRunner from goga.commands.pipeline import pipeline -from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.config import BuildConfig, PipelineConfig, ProjectConfig from goga.pipeline.compiler import StructuralError, compile_flow from goga.pipeline.workflow import WorkflowDocument, WorkflowStage, parse_workflow @@ -435,7 +435,7 @@ def _make_config( lang="python", image="qarium/goga:latest", dockerfile=None, - build=BuildConfig(task_executor=TaskExecutorConfig(agent="claude")), + build=BuildConfig(agent="claude"), pipeline=PipelineConfig(agent=pipeline_agent, env={}), ) From 8abc16c9d128f3a3e8a6c63b48f679990ee02933 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Mon, 21 Sep 2026 22:18:27 +0000 Subject: [PATCH 103/205] fix: address code review findings - Restore the two-part build form in .goga/config.yml (commit 927f009 had reverted it mid-implementation; at HEAD the loader silently ignored the retired task_executor/review_executor blocks, so goga build failed with 'build.agent is required' on this repository) - Repoint retired dot-paths (build.task_executor.agent, build.worktree) in live usage examples to the two-part keys (commands/config, config/home, agents/resolve-wrapper-path) - Drop dead None-fallbacks on the non-optional ReviewPassSettings.additional in ralphex_config.py and review_config.py - Add tests: gate collects both violations of two vetoing tools; gate delivers a write-protected view; completion statuses fall through to [] when the hosted topic is absent from the year listing; loader type-error parametrizations for root session knobs, prompts_dir/agents_dir, and review session knobs - Update README Build/Tools sections and the docs site pages (build configuration/cli/api/hooks/index, hooks catalog, configuration pages, workflow) from the retired schema to the two-part model, the always two-pass cycle, and the five build hook actions --- .goga/config.yml | 22 ++---- README.md | 22 +++--- docs/configuration/agents.md | 6 +- docs/configuration/cli.md | 9 ++- docs/configuration/project.md | 26 ++++--- docs/features/build/api.md | 35 ++++++---- docs/features/build/cli.md | 27 ++++---- docs/features/build/configuration.md | 63 +++++++++-------- docs/features/build/hooks.md | 56 ++++++++++++++- docs/features/build/index.md | 5 +- docs/features/hooks/hooks.md | 4 +- docs/features/hooks/index.md | 2 +- docs/features/pipelines/configuration.md | 2 +- docs/workflow/build.md | 9 ++- goga/agents/.usages/resolve-wrapper-path.md | 2 +- goga/build/ralphex_config.py | 2 +- goga/build/review_config.py | 2 +- goga/commands/config/.usages/config.md | 8 +-- goga/config/.usages/home-configuration.md | 2 +- tests/build/hooks/test_events.py | 76 +++++++++++++++++++++ tests/build/test_build.py | 50 ++++++++++++++ tests/config/test_loader.py | 16 +++++ 22 files changed, 319 insertions(+), 127 deletions(-) diff --git a/.goga/config.yml b/.goga/config.yml index b4af2301..34b81dd7 100644 --- a/.goga/config.yml +++ b/.goga/config.yml @@ -8,29 +8,17 @@ dockerfile: Dockerfile ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic" build: - task_executor: - agent: claude - env: - <<: *claude-env - ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" - review_executor: + agent: claude + env: + <<: *claude-env + ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" + review: agent: claude base_ref: release/2.0.0 env: <<: *claude-env ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3[1m]" -# agent: claude -# env: -# <<: *claude-env -# ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" -# review: -# agent: claude -# base_ref: release/2.0.0 -# env: -# <<: *claude-env -# ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3[1m]" - pipeline: agent: claude env: diff --git a/README.md b/README.md index b5c4de05..9cf81b84 100644 --- a/README.md +++ b/README.md @@ -317,16 +317,18 @@ goga build plan.md -e ENV_VAR=value # forward an extra env var into the co goga build plan.md --skip-review # run tasks only, skip the review phase ``` -The review phase is configurable beyond the on/off flag through a `build.review_executor` section in `.goga/config.yml`: +The build configuration in `.goga/config.yml` is two-part: the `build` root carries the tasks-pass settings (`agent`, `env`, iteration and session knobs), and its `review` sub-block carries the review-pass settings. A run with review on is always two passes — a tasks pass on the root agent's wrapper, then a review pass on the review agent's wrapper (`review.agent` inherits `build.agent` when unset); `--skip-review` or `review.skip: true` collapses the cycle to the tasks pass alone, and a failed tasks pass skips the review. The review pass is configured through the sub-block: -- hand review to a different agent (`agent: codex` runs a second, review-only pass on the codex wrapper); -- skip it by default (`skip: true` — `--no-skip-review` forces the full cycle); -- select the reviewer composition (`roles: [quality, testing]`); -- layer environment variables onto the review pass alone (`env: {ANTHROPIC_MODEL: reviewer}` — the variables overlay the container environment for the review subprocess only; the tasks pass never sees them, the values never reach logs or dry-run output, and like a differing agent a non-empty `env` forces a two-pass run, so it cannot be combined with a worktree); -- bound the review diff to an explicit base (`base_ref: origin/main` — a branch name or commit hash that overrides ralphex's default-branch detection; `--base-ref` on the command line wins); -- stop the external review after N unchanged rounds (`patience: 3`, or `--review-patience` — the setting moved from the top-level `build.review_patience` key, which is no longer parsed). +- hand review to a different agent (`review.agent: codex` runs the review pass on the codex wrapper; unset inherits `build.agent`); +- skip it by default (`review.skip: true` — `--no-skip-review` forces the full cycle); +- select the reviewer composition (`review.roles: [quality, testing]`); +- layer environment variables onto the review pass alone (`review.env: {ANTHROPIC_MODEL: reviewer}` — the variables overlay the container environment for the review subprocess only; the tasks pass never sees them, the review env never inherits the root env, and the values never reach logs or dry-run output); +- bound the review diff to an explicit base (`review.base_ref: origin/main` — a branch name or commit hash that overrides ralphex's default-branch detection; `--base-ref` on the command line wins); +- pick the review strategy (`review.strategy: full | medium | short`, default `medium` — `medium` disables the external reviewer; `full` keeps it; `short` runs the external review alone, on the additional agent's wrapper); +- set a finalize prompt (`review.finalize: |` — a user-authored final review prompt materialized into ralphex's finalize step); +- stop the external review after N unchanged rounds (`review.additional.patience: 3`, or `--review-patience`). -Both review bounds apply to review-carrying passes only: the single full-cycle pass, or the review pass of a two-pass run. After a successful run the plan file itself moves to `completed/` inside its own topic directory (`.goga/history///completed/`). +Before the first pass a validation gate runs: tools subscribed to `build/validate_build` read the resolved run facts and may veto the run (see [Tools](#tools) below). After a successful run the plan file itself moves to `completed/` inside its own topic directory (`.goga/history///completed/`). A running build executes inside a Docker container, where its run-state and logs are written to a persistent host directory and survive across runs of the same project on the same branch — so an interrupted build can be resumed. Pass `--clean` (or `-c`) to wipe that state before launch for a fresh run. After the build, test the implementation manually. @@ -443,7 +445,7 @@ A valid tool **must**: A tool **may** additionally expose an `install(user: str | None = None)` callable in its facade package: `goga install` calls it after a successful pip, passing the initiating user (`SUDO_USER` when goga itself runs under sudo, else the current OS user) only when the parameter is declared keyword-capable. A missing or non-callable `install` is skipped quietly. -A tool **may** also expose a `register_hooks(hooks)` callable to extend goga domains with its own hooks — today, the topic status scale, the onboarding session (`declare_session`/`amend_config`, reached via `goga init -t `), the seven topic-lifecycle checkpoints of `topics` (two content amendments and five notifications; see [Topics — Hooks](https://qarium.github.io/goga/features/topics/hooks/)), and the three pipeline checkpoints of `pipeline` (the workflow amendment `amend_workflow` and the two run notifications `run_created`/`run_completed`; see [Pipelines — Hooks](https://qarium.github.io/goga/features/pipelines/hooks/)). goga calls it when a command first reaches a hook checkpoint of the run, or when you inspect the registry with `goga hooks`; commands that use no hooks never call it: +A tool **may** also expose a `register_hooks(hooks)` callable to extend goga domains with its own hooks — today, the topic status scale, the onboarding session (`declare_session`/`amend_config`, reached via `goga init -t `), the seven topic-lifecycle checkpoints of `topics` (two content amendments and five notifications; see [Topics — Hooks](https://qarium.github.io/goga/features/topics/hooks/)), the three pipeline checkpoints of `pipeline` (the workflow amendment `amend_workflow` and the two run notifications `run_created`/`run_completed`; see [Pipelines — Hooks](https://qarium.github.io/goga/features/pipelines/hooks/)), and the five build checkpoints of `build` (the validation gate `validate_build`, delivered before the first pass with the resolved run facts, plus the four run notifications `build_started`, `pass_started`, `pass_completed`, `build_completed`; see [Build — Hooks](https://qarium.github.io/goga/features/build/hooks/)). goga calls it when a command first reaches a hook checkpoint of the run, or when you inspect the registry with `goga hooks`; commands that use no hooks never call it: ```python def register_hooks(hooks): @@ -454,7 +456,7 @@ def register_published(context): context.register("published", "mkdocs/published.md", after="planned") ``` -The hook receives the delivered status registry through `context` — read and call freely, attribute assignment is blocked. The name is shown qualified as `.` (here `mkdocs.published`); the tool identity is the package name with the `goga_tool_` prefix dropped and underscores turned into hyphens, so `goga_tool_hello_world` registers `hello-world.*`. The filepath is relative to the topic directory (nested paths allowed), and `before=`/`after=` anchor the entry to an existing scale entry — at least one anchor is required, both define a range. Built-in entries are immutable. A bad registration — an unknown anchor, an invalid range, or a crashed hook — is skipped with a warning on stderr and never aborts the command; only a package that fails to import is fatal. That skip-with-a-warning rule covers the **soft** actions; `pipeline/amend_workflow` is the platform's first **hard** action — a hook of it that raises (or contributes a malformed document) aborts `goga pipeline` before any launch with a clean error naming the hook, the tool, and the action. Run [`goga hooks`](https://qarium.github.io/goga/features/hooks/cli/) to inspect what is registered. The removed `register_topic_statuses(statuses)` callback is no longer called — a package still carrying it loses its statuses silently after the update. +The hook receives the delivered status registry through `context` — read and call freely, attribute assignment is blocked. The name is shown qualified as `.` (here `mkdocs.published`); the tool identity is the package name with the `goga_tool_` prefix dropped and underscores turned into hyphens, so `goga_tool_hello_world` registers `hello-world.*`. The filepath is relative to the topic directory (nested paths allowed), and `before=`/`after=` anchor the entry to an existing scale entry — at least one anchor is required, both define a range. Built-in entries are immutable. A bad registration — an unknown anchor, an invalid range, or a crashed hook — is skipped with a warning on stderr and never aborts the command; only a package that fails to import is fatal. That skip-with-a-warning rule covers the **soft** actions; the platform's **hard** actions are `pipeline/amend_workflow` and `build/validate_build` — a hook of the former that raises (or contributes a malformed document) aborts `goga pipeline` before any launch with a clean error naming the hook, the tool, and the action; the build gate instead lets every subscribed tool's hooks run to completion, collects their `context.veto(reason)` calls (a raising hook counts as its tool's veto, with the crash reason), and merges all vetoes into one error that stops `goga build` before any pass. Run [`goga hooks`](https://qarium.github.io/goga/features/hooks/cli/) to inspect what is registered. The removed `register_topic_statuses(statuses)` callback is no longer called — a package still carrying it loses its statuses silently after the update. After publication, install into any project: diff --git a/docs/configuration/agents.md b/docs/configuration/agents.md index ed2d0608..0277ce6e 100644 --- a/docs/configuration/agents.md +++ b/docs/configuration/agents.md @@ -1,6 +1,6 @@ # Agents -Wherever you set `agent: ` — in `.goga/config.yml` (`build.task_executor.agent`, `build.review_executor.agent`, `pipeline.agent`) or in a workflow-file (`workflow.stages..agent`, `workflow.extend..agent`) — goga resolves that name into a wrapper script **inside the Docker container**. The wrapper is what actually runs the AI agent during `goga build` and `goga pipeline`; it presents the agent's CLI in a uniform shape so goga does not care which concrete agent is underneath. +Wherever you set `agent: ` — in `.goga/config.yml` (`build.agent`, `build.review.agent`, `build.review.additional.agent`, `pipeline.agent`) or in a workflow-file (`workflow.stages..agent`, `workflow.extend..agent`) — goga resolves that name into a wrapper script **inside the Docker container**. The wrapper is what actually runs the AI agent during `goga build` and `goga pipeline`; it presents the agent's CLI in a uniform shape so goga does not care which concrete agent is underneath. Resolution is pure string concatenation — there is no whitelist and no validation. A missing wrapper surfaces as a runtime error when the container tries to invoke it, not from goga itself. The full mechanic, baseline wrappers, per-agent env variables, and the custom-agent path are covered below. @@ -12,7 +12,7 @@ Resolution invariant: → /home/goga/bin/-as-claude.sh ``` -The `agent` field is **optional** in `build.task_executor`, `build.review_executor`, and `pipeline`: at config load, an absent / YAML-null / empty / whitespace-only value resolves to `None` (it is not an error). `resolve_wrapper_path` is invoked only for a non-`None` value — it strips surrounding whitespace and forwards the result verbatim (no case-folding or other normalization), so an empty value never reaches resolution. What `None` means differs by consumer: `goga build` raises a `ClickException` (the build needs an agent), whereas `goga pipeline` carries `None` through and lets a per-stage workflow agent or the pipeline's own default cover the absent global agent. A `None` (or same-as-task) `build.review_executor.agent` means the review phase runs on the task executor's wrapper in the same pass — unless `build.review_executor.env` is non-empty, which also induces a second, review-only pass (on the task executor's wrapper, with the review env layered over the container environment); a differing agent runs a second, review-only pass on that agent's wrapper (its existence is validated in-container before the pass). +The `agent` field is **optional** in `build`, `build.review`, `build.review.additional`, and `pipeline`: at config load, an absent / YAML-null / empty / whitespace-only value resolves to `None` (it is not an error). `resolve_wrapper_path` is invoked only for a non-`None` value — it strips surrounding whitespace and forwards the result verbatim (no case-folding or other normalization), so an empty value never reaches resolution. What `None` means differs by consumer: `goga build` raises a `ClickException` (the build needs `build.agent`), whereas `goga pipeline` carries `None` through and lets a per-stage workflow agent or the pipeline's own default cover the absent global agent. A `None` `build.review.agent` inherits `build.agent` — the review pass always runs as its own pass on the review agent's wrapper (its existence is validated in-container before the pass), with the review env (`build.review.env`) layered over the container environment for that subprocess only; `build.review.additional.agent` inherits the review agent the same way and carries the external review (under `strategy: short` the review pass itself runs on its wrapper). Edge cases: @@ -131,4 +131,4 @@ If `agent: myname` is set but the wrapper is not `COPY`'d into the image or is n ## Relationship to `goga connect` -> **Two different `agent` concepts.** The runtime `agent` (this section) picks which CLI binary runs **inside the goga Docker container** during `goga build` / `goga pipeline`. [`goga connect`](../features/connect/cli.md) is a separate, host-side mechanism that installs goga skills and commands **into** an AI agent (claude/codex/cursor/opencode/qwen) as a target. They are orthogonal: you can run `goga connect claude codex` to get goga skills inside both of your host-installed CLIs, and still set `build.task_executor.agent: codex` — in that case the codex wrapper runs inside the container, not your host-side CLI. +> **Two different `agent` concepts.** The runtime `agent` (this section) picks which CLI binary runs **inside the goga Docker container** during `goga build` / `goga pipeline`. [`goga connect`](../features/connect/cli.md) is a separate, host-side mechanism that installs goga skills and commands **into** an AI agent (claude/codex/cursor/opencode/qwen) as a target. They are orthogonal: you can run `goga connect claude codex` to get goga skills inside both of your host-installed CLIs, and still set `build.agent: codex` — in that case the codex wrapper runs inside the container, not your host-side CLI. diff --git a/docs/configuration/cli.md b/docs/configuration/cli.md index d2eff8cd..c2c13839 100644 --- a/docs/configuration/cli.md +++ b/docs/configuration/cli.md @@ -44,10 +44,10 @@ Read the entire build configuration: goga config build ``` -Read the top-level image and the task executor agent: +Read the top-level image and the build executor agent: ```bash -goga config image build.task_executor.agent +goga config image build.agent ``` Use the `language` alias: @@ -64,9 +64,8 @@ Values are read from `.goga/config.yml`. A minimal configuration: language: python image: qarium/goga-python-3.12:1.3 # top-level image, shared by build and pipeline (build.image is rejected) build: - task_executor: - agent: claude # optional at the loader level; goga build raises a ClickException when it is None - env: {} + agent: claude # optional at the loader level; goga build raises a ClickException when it is None + env: {} ``` ## Exit Codes diff --git a/docs/configuration/project.md b/docs/configuration/project.md index 5149b4f5..684e095f 100644 --- a/docs/configuration/project.md +++ b/docs/configuration/project.md @@ -21,25 +21,23 @@ language: python image: qarium/goga-python-3.14:1.3 # dockerfile: .goga/Dockerfile # optional — when set, `--update` builds from this Dockerfile instead of pulling -build: - task_executor: - agent: claude - env: - ANTHROPIC_API_KEY: sk-ant-... - - worktree: false - skip_finalize: false +build: # two-part: the root is the tasks-pass settings + agent: claude # the tasks-pass executor + env: + ANTHROPIC_API_KEY: sk-ant-... session_timeout: 30m idle_timeout: 10m max_iterations: 10 - # review_executor: # optional review-phase control + # review: # optional review-pass settings # skip: false # true → tasks-only run - # agent: codex # differing agent → two-pass run (tasks, then --review) + # agent: codex # review executor (inherits build.agent when unset) # roles: [quality, testing] # reviewer composition; absent/[] → full default set # env: # review-pass env layer (requires agent when non-empty) # ANTHROPIC_MODEL: reviewer # base_ref: origin/1.2.x # review diff base — branch name or commit hash - # patience: 3 # stop the external review after N unchanged rounds + # strategy: medium # full | medium | short + # additional: + # patience: 3 # stop the external review after N unchanged rounds # proxy: http://corp:3123 # optional HTTP/HTTPS proxy URL for the build container # hosts: # optional docker run --add-host entries # foo.local: 127.0.0.1 @@ -110,7 +108,7 @@ Each domain-owned section is documented in full — every field, typing rule, an | Section | Domain | Consumed by | |---|---|---| -| `build` (incl. `task_executor`, `review_executor`) | [Build](../features/build/configuration.md) | `goga build` | +| `build` (incl. `review`) | [Build](../features/build/configuration.md) | `goga build` | | `pipeline` | [Pipelines](../features/pipelines/configuration.md) | `goga pipeline` | | `tools` | [Install](../features/install/configuration.md) | `goga install` (bulk mode) | | `usages` | [Usages](../features/usages/configuration.md) | `goga usages sync` / `goga usages status` | @@ -145,8 +143,8 @@ The config loader raises specific exceptions for invalid configuration: | Error | Cause | |-------|-------| | `FileNotFoundError` | `.goga/config.yml` does not exist or is empty | -| `KeyError` | Missing required field (`language`, or `build.task_executor` when `build` is present) | -| `ValueError` | Invalid field value (wrong type, empty string, non-mapping where mapping expected), or the deprecated `build.image` field is present. `build.review_executor` adds: non-mapping section (`build.review_executor must be a mapping`), non-bool `skip` (a YAML `1` is rejected), non-string `agent`, `roles` that is not a list of strings, a non-mapping `env` (`build.review_executor.env must be a mapping in .goga/config.yml`), `env` with non-string keys/values (`build.review_executor.env must have string keys and values`), a non-string `base_ref` (`build.review_executor.base_ref must be a string in .goga/config.yml`), or a non-int `patience`, including a YAML boolean (`build.review_executor.patience must be an int in .goga/config.yml`). `topics` adds: a non-mapping section (`'topics' must be a mapping in .goga/config.yml`) or a non-string field (`topics.base_ref must be a string in .goga/config.yml`, `topics.publish_commit must be a string in .goga/config.yml`) | +| `KeyError` | Missing required field (`language`) | +| `ValueError` | Invalid field value (wrong type, empty string, non-mapping where mapping expected), or the deprecated `build.image` field is present. `build` adds: a non-string `agent` / session knob / `prompts_dir` / `agents_dir` / review string field (`build.agent must be a string in .goga/config.yml`, and the same pattern for every session knob and `build.review.*` string field), a non-int `max_iterations` including a YAML boolean, a non-mapping `env` (`build.env must be a mapping in .goga/config.yml`, `build.review.env` likewise), `env` with non-string keys/values (`build.env must have string keys and values`), a non-mapping `build.review` (`build.review must be a mapping in .goga/config.yml`), a non-bool `build.review.skip` (a YAML `1` is rejected), `roles` that is not a list of strings, a non-mapping `build.review.additional`, or a non-int `patience`/`max_iterations` of the additional block including a YAML boolean. The retired keys (`worktree`, `skip_finalize`, `codex_review`, `task_executor`, `review_executor`) raise nothing — they are silently ignored. `topics` adds: a non-mapping section (`'topics' must be a mapping in .goga/config.yml`) or a non-string field (`topics.base_ref must be a string in .goga/config.yml`, `topics.publish_commit must be a string in .goga/config.yml`) | ## Implementation details diff --git a/docs/features/build/api.md b/docs/features/build/api.md index 349cd021..73769ede 100644 --- a/docs/features/build/api.md +++ b/docs/features/build/api.md @@ -1,6 +1,6 @@ # Build — API -The facade of the domain package **`goga.build`** — the host-side orchestration of a plan execution in a Docker container. +The facade of the domain package **`goga.build`** — the two-pass orchestration of a plan execution through ralphex. The signatures below are the CODEMANIFEST contract of the cell. @@ -11,32 +11,37 @@ build(plan: str, config: ProjectConfig, cli_options: dict) -> int main() -> int ``` -`build` is the full orchestration — precondition checks (Docker, config, uncommitted manifests), agent wrapper resolution, engine defaults sync, optional image refresh, and the container launch; the exit code is returned. `main` is the console entry point. The `cli_options` dict carries the CLI-surface values (timeouts, `--update`, review flags, …) resolved by the command layer. +`build` is the full cycle — manifest pre-check, settings resolution with the pre-side-effect validations, the `build/validate_build` hooks gate, the tasks pass, the review pass (skipped on `skip` or a failed tasks pass), plan relocation, and the completion notification; the exit code of the last executed pass is returned. `main` is the in-container console entry point. The `cli_options` dict carries the in-container CLI-surface values (tri-state `skip_review`, `base_ref`, the session knobs, `review_patience`, …). -## Review options +## Run settings ```python -resolve_review_options(config: BuildConfig, cli_options: dict) -> ReviewOptions -validate_review_config(config: BuildConfig, review: ReviewOptions) -> None -ReviewOptions(skip: bool, review_agent: str | None, roles: list[str] | None, - two_pass: bool, review_env: dict[str, str], - base_ref: str | None, patience: int | None) +resolve_run_settings(config: BuildConfig, cli_options: dict) -> RunSettings +validate_review_config(settings: RunSettings) -> None +compose_pass_options(settings: RunSettings, stage: str) -> dict[str, str | int | bool] +RunSettings(skip: bool, tasks: PassSettings, review: ReviewPassSettings) +PassSettings(agent, env, max_iterations, session_timeout, idle_timeout, wait) +ReviewPassSettings(PassSettings, roles, base_ref, strategy, finalize, additional) ``` -`resolve_review_options` composes the review-scoped settings with the precedence CLI > `build.review_executor.*` > omit. `validate_review_config` enforces the host-side guards — among them the rejection of a two-pass review form combined with an active worktree. +`resolve_run_settings` is the pure resolver of the two-part configuration with the precedence CLI > config > default > omit (root→review inheritance applied; the review env never inherits the root env; the additional block is always constructed, its agent inheriting the review agent). `validate_review_config` runs the semantic checks (roles whitelist, the env-requires-agent gate, agent wrapper existence, strategy whitelist) before any side effect. `compose_pass_options` maps the resolved settings onto the ralphex options of one pass — exactly one mode flag (`tasks_only`, `review`, or `external_only` under `strategy: short`); the agent and env never appear in the composition. ## Run plumbing ```python -sync_ralphex_defaults(config: BuildConfig, review: ReviewOptions) -> None -write_ralphex_config(config: BuildConfig, wrapper_path: str) -> None -run_build_pass(plan: str, config: BuildConfig, options: dict[str, str | int | bool], +sync_ralphex_defaults(config: BuildConfig, settings: RunSettings) -> None +write_ralphex_config(settings: RunSettings, wrapper_path: str) -> None +run_build_pass(plan: str, settings: RunSettings, options: dict[str, str | int | bool], wrapper_path: str, dry_run: bool, env: dict[str, str] | None = None) -> int -move_completed_plan(plan: str, outcome: bool, dry_run: bool) -> None +move_completed_plan(plan: str, outcome: bool, dry_run: bool) -> RelocationOutcome ``` -`sync_ralphex_defaults` rewrites `.ralphex/prompts/` and `.ralphex/agents/` from the configured or vendored defaults (filtering review prompts to the selected `roles`); `write_ralphex_config` writes the engine config with the resolved wrapper. `run_build_pass` launches one container pass (tasks or review) — `dry_run=True` prints the assembled command. `move_completed_plan` moves the plan into the topic's `completed/` directory after the run. +`sync_ralphex_defaults` rewrites `.ralphex/prompts/` and `.ralphex/agents/` from the configured or vendored defaults (filtering review prompts to the selected `roles`, materializing the finalize step when `review.finalize` is set). `write_ralphex_config` writes the engine config for one pass — the executor wrapper of the current pass plus the external-review surface derived from the review part (strategy `medium` disables the external review; `full`/`short` with an additional agent set it to that agent's wrapper). `run_build_pass` launches one ralphex pass (tasks or review) — `dry_run=True` prints the assembled command; the env layer reaches the subprocess only, never options or logs. `move_completed_plan` relocates a successfully completed plan into `/completed/` and returns the `RelocationOutcome` that feeds the completion event. + +## Hooks + +The `goga.build.hooks` facade exports the checkpoint surface of the build domain — `BuildHooks` (the `validate_build` gate and the four notifications) plus the fact types (`BuildMoment`, `StageFacts`, `WorkIdentity`, `GateVerdict`, `Violation`, `RelocationOutcome`, `AdditionalFacts`, and the five context types). See [Hooks](hooks.md). ## Example @@ -44,5 +49,5 @@ move_completed_plan(plan: str, outcome: bool, dry_run: bool) -> None from goga.build import build from goga.config import load_project_config -exit_code = build("plan.md", load_project_config(), {"update": False, "dry_run": False}) +exit_code = build("plan.md", load_project_config(), {"dry_run": False, "skip_review": None}) ``` diff --git a/docs/features/build/cli.md b/docs/features/build/cli.md index 2363b07b..a2e7cb2a 100644 --- a/docs/features/build/cli.md +++ b/docs/features/build/cli.md @@ -17,11 +17,11 @@ The build pipeline performs these steps: 1. **Docker check** -- Verifies Docker is installed and accessible. 2. **Config loading** -- Reads `.goga/config.yml` for build settings. The optional machine-wide home config (`~/.goga/config.yml`) layers underneath: `home.env` is the base (lowest-priority) layer of the container env-file, `home.docker.run` is appended to every `docker run`, `home.docker.build` is forwarded to image builds. Git identity env (`GIT_AUTHOR_NAME/EMAIL`, `GIT_COMMITTER_NAME/EMAIL`) is layered in, tolerating absent git config. 3. **Uncommitted manifest check** -- Scans `git status` for uncommitted `CODEMANIFEST` files (can be skipped). -4. **Agent preconditions** -- Resolves the configured agent to its in-container wrapper (the wrappers ship inside the image). A review executor with a set `agent` that differs from the task executor, or that declares a non-empty `env`, combined with an active worktree (`--worktree` or `build.worktree: true`) is rejected host-side with exit 1, before any container launch — review execution cannot follow a worktree branch. The guard is config-level and skip-independent: `--skip-review` does not bypass it. -5. **Defaults copy** -- Fully rewrites the engine's prompt and agent defaults from the configured `build.prompts_dir`/`build.agents_dir`, or from the defaults shipped with goga. When `build.review_executor.roles` is set, the review prompts are filtered to the selected roles. +4. **Agent preconditions** -- Resolves the configured agent to its in-container wrapper (the wrappers ship inside the image). Requires a `build` section and a resolvable `build.agent` — a missing section or a `None` agent is rejected host-side with exit 1, before any container launch. The review agent's wrapper (`build.review.agent`) and, when the strategy engages the external review, the additional agent's wrapper are validated in-container before their pass. +5. **Defaults copy** -- Fully rewrites the engine's prompt and agent defaults from the configured `build.prompts_dir`/`build.agents_dir`, or from the defaults shipped with goga. When `build.review.roles` is set, the review prompts are filtered to the selected roles; when `build.review.finalize` is set, the finalize step's ralphex files are materialized from the prompt. 6. **Image refresh (optional)** -- When `--update`/`-u` is set, the image is refreshed: if a top-level `dockerfile` is declared in `.goga/config.yml`, `docker build` runs against it (build failure is fatal — exit 1); otherwise `docker pull` runs (a pull failure is logged as a warning and the build proceeds with the locally available image). By default no refresh happens and the local image is used as-is. 7. **First-run safety net** -- Runs unconditionally at launch entry: when the configured image is absent locally AND a project `dockerfile` is declared, the image is built once before launch (fatal on failure — the launch is skipped). This closes the corner case where a Dockerfile is declared but the image was never built and `--update` is not passed. -8. **Docker execution** -- Launches the build inside the configured Docker image, after the shared pre-launch host–image version check (see [Runtime — Pre-launch version check](../pipelines/runtime.md#pre-launch-version-check)). Credential files for claude, codex, and opencode are detected on the host and bind-mounted read-only into the container automatically (no flag). Persistent build state is isolated from the project directory (see [Runtime state isolation](#runtime-state-isolation)). The pass structure: one full-cycle pass by default; `--skip-review` runs a single tasks-only pass; a review executor agent differing from the task agent, or a non-empty `build.review_executor.env`, runs two passes — tasks, then the review pass with the review env overlaid for that subprocess only (a failed first pass skips the second). +8. **Docker execution** -- Launches the build inside the configured Docker image, after the shared pre-launch host–image version check (see [Runtime — Pre-launch version check](../pipelines/runtime.md#pre-launch-version-check)). Credential files for claude, codex, and opencode are detected on the host and bind-mounted read-only into the container automatically (no flag). Persistent build state is isolated from the project directory (see [Runtime state isolation](#runtime-state-isolation)). The pass structure: a run with review on is always two passes — a tasks pass (`--tasks-only`) on the `build.agent` wrapper, then a review pass (`--review`, or `--external-only` under `strategy: short`) on the review-stage agent's wrapper with the review env (`build.review.env`) overlaid for that subprocess only; a failed tasks pass skips the review; `--skip-review` (or `build.review.skip: true`) collapses the run to the tasks pass alone. Before the first pass, the `build/validate_build` hooks gate runs — a vetoed run stops with exit 1 and no pass (see [Hooks](hooks.md)). 9. **Plan relocation** -- After a successful final pass the plan file moves to `/completed/` (atomic replace, idempotent by name). A failed run or a dry run leaves the plan in place so the build can resume. ## Arguments @@ -35,16 +35,14 @@ The build pipeline performs these steps: | Option | Type | Default | Description | |---|---|---|---| | `--dry-run` | flag | off | Print the assembled command without executing | -| `--worktree` | flag | off | Run the build on an isolated git worktree branch | -| `--skip-finalize` | flag | off | Skip finalization step | | `--skip-manifest-check` | flag | off | Skip check for uncommitted CODEMANIFEST files | -| `--skip-review` / `--no-skip-review` | bool pair (tri-state) | unset | Skip the review phase (`--skip-review` — a tasks-only run) or force the full cycle (`--no-skip-review`). Overrides `build.review_executor.skip` in `.goga/config.yml`; when neither is given, the config decides | +| `--skip-review` / `--no-skip-review` | bool pair (tri-state) | unset | Skip the review phase (`--skip-review` — a tasks-only run) or force the full cycle (`--no-skip-review`). Overrides `build.review.skip` in `.goga/config.yml`; when neither is given, the config decides | | `--session-timeout` | string | config | Session timeout duration | | `--idle-timeout` | string | config | Idle timeout duration | | `--wait` | string | config | Wait time before starting | -| `--max-iterations` | int | config | Maximum number of build iterations | -| `--review-patience` | int | config | Review patience count | -| `--base-ref` | string | config | Review diff base (branch name or commit hash); overrides `build.review_executor.base_ref` | +| `--max-iterations` | int | config | Maximum number of build iterations (tasks pass) | +| `--review-patience` | int | config | External-review patience — addresses `build.review.additional.patience` in `.goga/config.yml`; forwarded to the container only when set | +| `--base-ref` | string | config | Review diff base (branch name or commit hash); overrides `build.review.base_ref`; reaches ralphex as `--base-ref` on the review pass only | | `-e`, `--env` | string (repeatable) | -- | Additional environment variable (`KEY=VALUE`, repeatable) | | `--proxy` | string | config | HTTP/HTTPS proxy URL; overrides `build.proxy`. Adds `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` to the container env-file | | `--add-host` | string (repeatable) | -- | Add a `docker run --add-host HOST:IP` entry; merges on top of `build.hosts` (CLI wins on key conflict) | @@ -53,7 +51,7 @@ The build pipeline performs these steps: Timeout and iteration options fall back to values in `.goga/config.yml` when not provided on the command line. -`--review-patience` and `--base-ref` are review-scoped: they resolve with precedence CLI > `build.review_executor.*` in `.goga/config.yml` > omit, and they apply to review-carrying passes only — the single full-cycle pass, or the review pass of a two-pass run; a tasks-only run carries neither. The legacy `build.review_patience` key is not parsed (the setting moved to `build.review_executor.patience`). +`--review-patience` and `--base-ref` are review-scoped: they resolve with precedence CLI > `build.review.*` in `.goga/config.yml` > omit, and they apply to the review pass only — a skipped run carries neither. The patience setting lives at `build.review.additional.patience` (the legacy `build.review_patience` and `build.review_executor.patience` keys are not parsed). ### Proxy and hosts @@ -153,19 +151,18 @@ image: qarium/goga-python-3.12:1.3 pipeline: agent: claude build: - task_executor: - agent: claude - env: {} + agent: claude + env: {} proxy: http://corp:3128 # optional HTTP/HTTPS proxy URL for the build container hosts: # optional docker run --add-host entries foo.local: 127.0.0.1 ``` -Only `language` is required by the loader. `goga build` additionally requires a `build` section (it exits with a `ClickException` when `build` is absent), a non-`None` `build.task_executor.agent` (optional at the loader level — absent/empty/whitespace resolves to `None`; the command raises a `ClickException` when it is `None`, since the build needs an agent to resolve the in-container wrapper), and the top-level `image` field must be set; otherwise the command exits with an error. The deprecated `build.image` field is rejected — use the top-level `image` field. The optional top-level `dockerfile` field (when set) makes `--update` build the image locally from that Dockerfile instead of pulling it. The optional `build.proxy` and `build.hosts` fields are overridden/augmented by the `--proxy` and `--add-host` CLI options respectively. +Only `language` is required by the loader. `goga build` additionally requires a `build` section (it exits with a `ClickException` when `build` is absent), a non-`None` `build.agent` (optional at the loader level — absent/empty/whitespace resolves to `None`; the command raises a `ClickException` when it is `None`, since the build needs an agent to resolve the in-container wrapper), and the top-level `image` field must be set; otherwise the command exits with an error. The deprecated `build.image` field is rejected — use the top-level `image` field, and the retired keys (`worktree`, `skip_finalize`, `codex_review`, and the `task_executor` / `review_executor` block names) are silently ignored — see [Configuration](configuration.md). The optional top-level `dockerfile` field (when set) makes `--update` build the image locally from that Dockerfile instead of pulling it. The optional `build.proxy` and `build.hosts` fields are overridden/augmented by the `--proxy` and `--add-host` CLI options respectively. ## Exit Codes | Code | Meaning | |---|---| | `0` | Build completed successfully | -| `1` | Build failed (Docker not found, config error, precondition failure, invalid review configuration, two-pass review combined with worktree, an execution-engine error — a missing engine binary inside the image or a rejected launch surfaces as a clean one-line message with exit code 1 — a fatal `docker build` under `--update`, or the pre-launch version check refusing the launch: a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version — see [Runtime — Pre-launch version check](../pipelines/runtime.md#pre-launch-version-check)) | +| `1` | Build failed (Docker not found, config error, precondition failure, invalid review configuration, a hook veto at the `build/validate_build` gate, an execution-engine error — a missing engine binary inside the image or a rejected launch surfaces as a clean one-line message with exit code 1 — a fatal `docker build` under `--update`, or the pre-launch version check refusing the launch: a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version — see [Runtime — Pre-launch version check](../pipelines/runtime.md#pre-launch-version-check)) | diff --git a/docs/features/build/configuration.md b/docs/features/build/configuration.md index 28fdf695..8dee24c0 100644 --- a/docs/features/build/configuration.md +++ b/docs/features/build/configuration.md @@ -1,54 +1,59 @@ # Build — Configuration -The build domain reads one section of `.goga/config.yml` — `build`. The section is optional at the loader level; `goga build` raises a `ClickException` when it is absent. +The build domain reads one section of `.goga/config.yml` — `build`. The section is two-part: the `build` root carries the **tasks-pass** settings, and its optional `review` sub-mapping carries the **review-pass** settings. The section is optional at the loader level; `goga build` raises a `ClickException` when it is absent, and again when `build.agent` resolves to `None`. ```yaml image: qarium/goga-python-3.12:1.3 # top-level image, shared with pipelines (build.image is rejected) build: - task_executor: - agent: claude # the agent that runs the build inside the container - env: {} - review_executor: - agent: codex # optional: a separate review-pass agent + agent: claude # the agent that runs the tasks pass inside the container + env: {} + review: # optional review-pass settings + agent: codex # inherits build.agent when unset roles: [quality, testing] base_ref: origin/1.3.x # review diff base - patience: 3 + strategy: medium # full | medium | short (default medium) + additional: + patience: 3 ``` -### `build` +A run with review on is always two passes — a tasks pass on the root agent's wrapper, then a review pass on the review agent's wrapper (`review.agent` inherits `build.agent` when unset). A skipped review (`review.skip: true` or `--skip-review`) collapses the cycle to the tasks pass alone; a failed tasks pass skips the review. The retired keys `worktree`, `skip_finalize`, `codex_review` and the retired block names `task_executor` / `review_executor` are silently ignored — a config still carrying them loses its build settings (`goga build` fails with `build.agent is required`). + +### `build` root (the tasks-pass settings) | Field | Type | Required | Description | |---|---|---|---| -| `task_executor` | mapping | Yes | AI agent configuration — see [build.task_executor](#buildtask_executor) | -| `worktree` | `bool` | No | Use an isolated git worktree for builds | -| `skip_finalize` | `bool` | No | Skip the finalization step | -| `session_timeout` | `string` | No | Session timeout (a duration string, e.g. `30m`, `1h`) | -| `idle_timeout` | `string` | No | Idle timeout (a duration string, e.g. `10m`) | -| `wait` | `string` | No | Wait time on rate limit (a duration string, e.g. `5m`) | -| `max_iterations` | `int` | No | Maximum task iterations | -| `prompts_dir` | `string` | No | Path to custom build prompts | -| `agents_dir` | `string` | No | Path to custom build agent definitions | -| `codex_review` | `bool` | No | Enable external codex review | +| `agent` | `string` | No | AI executor that runs the tasks pass inside the container. Optional at the loader level — absent/YAML-null/empty/whitespace resolves to `None`; `goga build` raises a `ClickException` when it is `None`. Resolved to `/home/goga/bin/-as-claude.sh` — no whitelist; any name whose wrapper file exists in the image works. Baseline wrappers: `claude`, `codex`, `cursor`, `opencode`, `qwen`. See [Agents](../../configuration/agents.md) | +| `env` | mapping | No | Tasks-pass environment layer (`{str: str}`). Keys and values must be strings. Defaults to `{}`; an empty mapping means pure inheritance — the layer reaches the container solely as the in-container tasks-pass env layer, never the env-file, and the values never reach logs or dry-run output | +| `session_timeout` | `string` | No | Session timeout (a duration string, e.g. `30m`, `1h`) — the tasks-pass value; the review knobs inherit it when their own is unset | +| `idle_timeout` | `string` | No | Idle timeout (a duration string, e.g. `10m`) — inherited by the review part the same way | +| `wait` | `string` | No | Wait time on rate limit (a duration string, e.g. `5m`) — inherited by the review part the same way | +| `max_iterations` | `int` | No | Maximum task iterations — root-only, never resolves onto the review part | +| `prompts_dir` | `string` | No | Path to custom build prompts (copied as-is, without role filtering) | +| `agents_dir` | `string` | No | Path to custom build agent definitions (copied as-is) | | `proxy` | `string` | No | HTTP/HTTPS proxy URL for the build container. When set, `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` are written to the container env-file. Overridden by the `--proxy` CLI option | | `hosts` | mapping | No | Host→IP mapping for `docker run --add-host`. Defaults to `{}`. Augmented by the repeatable `--add-host` CLI option (CLI wins on key conflict) | -| `review_executor` | mapping | No | Review-phase configuration — see [build.review_executor](#buildreview_executor) | +| `review` | mapping | No | Review-pass configuration — see [build.review](#buildreview) | -### `build.task_executor` +### `build.review` | Field | Type | Required | Description | |---|---|---|---| -| `agent` | `string` | No | AI executor that runs the build inside the container. Optional at the loader level — absent/YAML-null/empty/whitespace resolves to `None`; `goga build` raises a `ClickException` when it is `None`. Resolved to `/home/goga/bin/-as-claude.sh` — no whitelist; any name whose wrapper file exists in the image works. Baseline wrappers: `claude`, `codex`, `cursor`, `opencode`, `qwen`. See [Agents](../../configuration/agents.md) | -| `env` | mapping | No | Environment variables passed to the agent. Keys and values must be strings. Defaults to `{}` | +| `skip` | `bool` | No | Skip the review phase entirely — the run executes the tasks pass only. Absent/YAML-null means "not set" (the CLI flag decides); must be a real bool — a YAML `1` is rejected | +| `agent` | `string` | No | Review executor agent name (same resolution mechanic as `build.agent`; inherits `build.agent` when unset, and its wrapper must exist in the image — validated in-container before the pass). The review pass always runs as its own pass on this agent's wrapper | +| `roles` | list of `string` | No | Reviewer composition for the review prompts: keeps only the `{{agent:X}}` lines of the selected roles and adapts the counters of the accompanying text. Whitelist: `quality`, `implementation`, `testing`, `simplification`, `documentation`. Absent or `[]` means the full default set (prompts stay byte-identical to the vendored defaults) | +| `env` | mapping of `string` | No | Review-pass environment layer (`{str: str}`) — never inherits the root env. Keys overlay same-named container variables for the review-pass subprocess only — the tasks pass and the container env-file are unaffected, and the values never reach logs or dry-run output. Absent/YAML-null/`{}` all resolve to `{}`; a non-empty layer requires `agent`; a skipped run ignores the layer entirely | +| `base_ref` | `string` | No | Review diff base — a branch name or commit hash, stored verbatim (no resolvability or format check; an unresolvable ref is reported at run time). Reaches ralphex as `--base-ref` on the review pass only. Overridden by the `--base-ref` CLI option | +| `strategy` | `string` | No | Review strategy: `full` (external review enabled; with an additional agent it runs on that agent's wrapper), `medium` (the default — external review explicitly disabled, internal reviewers only), or `short` (the external review alone, executed on the additional agent's wrapper) | +| `finalize` | `string` | No | A user-authored final review prompt; when set, the ralphex finalize step is materialized from this text and enabled. Unset leaves the step at the ralphex default (off) | +| `session_timeout` / `idle_timeout` / `wait` | `string` | No | Review-pass session knobs — inherit the corresponding `build` root values when unset; the CLI flags win over both | +| `additional` | mapping | No | External-review block — see [build.review.additional](#buildreviewadditional) | -### `build.review_executor` +### `build.review.additional` | Field | Type | Required | Description | |---|---|---|---| -| `skip` | `bool` | No | Skip the review phase entirely — the run executes tasks only. Absent/YAML-null means "not set" (the CLI flag decides); must be a real bool — a YAML `1` is rejected | -| `agent` | `string` | No | Review executor agent name (same resolution mechanic as `build.task_executor.agent`; its wrapper must exist in the image). When it differs from `task_executor.agent`, **or when a non-empty `env` is declared alongside it**, the build runs two passes: tasks with the task wrapper, then the review pass with the review wrapper. Combining either two-pass form with an active worktree (`--worktree` or `build.worktree: true`) is rejected with exit 1 on the host | -| `roles` | list of `string` | No | Reviewer composition for the review prompts: keeps only the `{{agent:X}}` lines of the selected roles and adapts the counters of the accompanying text. Whitelist: `quality`, `implementation`, `testing`, `simplification`, `documentation`. Absent or `[]` means the full default set (prompts stay byte-identical to the vendored defaults) | -| `env` | mapping of `string` | No | Review-pass environment layer (`{str: str}`). Keys overlay same-named container variables for the review-pass subprocess only — the tasks pass and the container env-file are unaffected, and the values never reach logs or dry-run output. Absent/YAML-null/`{}` all resolve to `{}`. A non-empty `env` induces a two-pass run like a differing agent does, and requires `agent`; a skipped run ignores the layer entirely | -| `base_ref` | `string` | No | Review diff base — a branch name or commit hash, stored verbatim (no resolvability or format check; an unresolvable ref is reported at run time). Overrides the detected default review base on review-carrying passes. Overridden by the `--base-ref` CLI option | -| `patience` | `int` | No | Stop the external review after N consecutive unchanged rounds. Absent/YAML-null resolves to `None`; a YAML boolean is rejected. Overridden by the `--review-patience` CLI option | +| `agent` | `string` | No | The external-review agent (inherits `review.agent` when unset). Under `strategy: short` the review pass itself runs on this agent's wrapper; under `full` it carries the external review. Its wrapper must exist when the strategy engages the external review | +| `patience` | `int` | No | Stop the external review after N consecutive unchanged rounds; `0` disables the bound and passes verbatim. Absent/YAML-null resolves to `None` (unset); a YAML boolean is rejected. Overridden by the `--review-patience` CLI option | +| `max_iterations` | `int` | No | External-review iteration cap; `0` passes verbatim to ralphex's auto. Absent resolves to `None` (unset) | The image itself is configured at the top level (`image`, `dockerfile`) — shared with [Pipelines](../pipelines/configuration.md). The general file location, loading rules, and the shared example live in [Project Configuration](../../configuration/project.md); the validation errors of the section are listed there (see [validation errors](../../configuration/project.md#validation-errors)). diff --git a/docs/features/build/hooks.md b/docs/features/build/hooks.md index 05970104..a8044876 100644 --- a/docs/features/build/hooks.md +++ b/docs/features/build/hooks.md @@ -1,5 +1,57 @@ # Build — Hooks -The build domain exposes **no hook actions** for tool packages today. +The build domain exposes **five hook actions** for tool packages: one hard validation gate delivered before the first pass, and four soft notifications carrying the run's facts. -The build's extension surface is configuration-shaped instead: custom build prompts (`build.prompts_dir`), custom agent definitions (`build.agents_dir`), and any CLI agent whose wrapper exists in the image (see [Configuration](configuration.md)). The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). +A tool subscribes through its `register_hooks` callable — no goga code changes are needed (see the [registration contract](../hooks/hooks.md)): + +```python +def register_hooks(hooks): + hooks.subscribe("build", "validate_build", "policy", enforce_policy) + hooks.subscribe("build", "build_completed", "reporter", report_build) +``` + +## The events + +| Address | Error class | Fires | +|---|---|---| +| `build / validate_build` | **hard** | After goga's own pre-checks (manifest check, settings resolution, review-config validation, ralphex defaults sync) and before the first pass launch — including dry-run runs | +| `build / build_started` | soft | Immediately after the gate passes, before the first pass launch | +| `build / pass_started` | soft | Before each pass launch — tasks and review | +| `build / pass_completed` | soft | On every pass return — zero, non-zero, and spawn-failure codes alike, carrying the actual exit code | +| `build / build_completed` | soft | On every return of a started build — after the relocation attempt and the status recompute | + +A failing moment fires nothing: goga pre-launch failures (uncommitted manifests, invalid review config, unavailable defaults, missing build section or agent) return before any checkpoint. A blocked (vetoed) run fires nothing after the gate. + +## The gate view + +`validate_build` delivers a `BuildValidation` view per tool: `moment` (plan, work identity, `dry_run`), `tasks` and `review` — the resolved stage facts (the executor agent, env presence as names, the option facts; review adds roles, `base_ref`, strategy, the additional facts, and the finalize prompt text), and `skip`. + +```python +def enforce_policy(context): + if violates(context): + context.veto("reason") +``` + +- `veto(reason)` buffers your tool's single veto; a repeat call replaces the reason whole. +- Your tool's hooks all run even when another tool already vetoed — verdict collection requires every tool's outcome; the walk never stops between tools. +- A crashing hook counts as your tool's veto with the crash reason — never a raw traceback. +- All vetoes merge into one clean error (tool, hook, reason); the run stops before any pass: exit code 1, the plan stays in place, no started/pass/completed events fire. + +## The notifications + +All four deliver read-only facts; a failing hook warns naming your tool, the action, and the reason — the run's outcome is never affected. + +- `build_started` — `BuildStarted`: the same facts as the gate. +- `pass_started` — `PassStarted`: the stage facts of the pass about to launch. +- `pass_completed` — `PassCompleted`: the stage facts plus the actual `exit_code`. Completion is a fact, not a success claim. +- `build_completed` — `BuildCompleted`: the final `exit_code`, `stages` (the executed sequence), `relocation` (moved + destination), `statuses` (the work's current history statuses recomputed after the relocation attempt — empty in the branch-only form), and `moment`. + +Env values are never delivered — presence as names only, in every context. + +## Integration scenarios + +- **Build reporting, automation, external notifications** — subscribe to the four notifications; read the stage facts, the exit codes, the relocation outcome, `dry_run`; keep state in your `self` context. +- **Artifact → history-status on completion** — subscribe to `build_completed`; read `relocation` and `work`; register your status on the statuses domain keyed by your artifact. +- **Policy enforcement** — subscribe to `validate_build`; inspect the resolved facts; `context.veto(reason)` when policy is violated — or stay silent to use the gate as a pre-start notification. + +The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md); the build's configuration-shaped extension surface (custom prompts, custom agent definitions) is covered in [Configuration](configuration.md). diff --git a/docs/features/build/index.md b/docs/features/build/index.md index fb406090..20b642f1 100644 --- a/docs/features/build/index.md +++ b/docs/features/build/index.md @@ -6,8 +6,9 @@ The build domain is the headless execution surface: a plan file (the output of t - **Run plans unattended** — `goga build plan.md` prepares the environment, validates preconditions (Docker, config, agent wrappers), and delegates to the build engine running in-container. - **Keep state persistent** — the build state survives across runs of the same plan on the same branch; `--clean` wipes it for a fresh run. -- **Separate the reviewer from the executor** — `build.review_executor` configures a second agent (and an env layer) for the review pass: the build runs tasks with one wrapper, then the review with another. -- **Scope the review diff** — `base_ref` overrides the review's default-branch detection; `patience` stops the external review after N unchanged rounds. +- **Separate the reviewer from the executor** — a run with review on is always two passes: tasks on the `build.agent` wrapper, then review on the review agent's wrapper (`build.review.agent`, inheriting `build.agent` when unset, with its own env layer under `build.review.env` that never inherits the root env). +- **Scope the review diff** — `build.review.base_ref` overrides the review's default-branch detection; `build.review.additional.patience` stops the external review after N unchanged rounds. +- **Gate and observe runs** — tools subscribed to `build/validate_build` read the resolved run facts and may veto the run before any pass; four notifications (`build_started`, `pass_started`, `pass_completed`, `build_completed`) carry the run's facts to reporting tools (see [Hooks](hooks.md)). The interactive, stage-by-stage counterpart of this domain is [Pipelines](../pipelines/index.md); the SDD cycle that produces the plans is covered in [Workflow](../../workflow/index.md). diff --git a/docs/features/hooks/hooks.md b/docs/features/hooks/hooks.md index 8562732e..9255ea72 100644 --- a/docs/features/hooks/hooks.md +++ b/docs/features/hooks/hooks.md @@ -16,7 +16,7 @@ def register_published(context): `hooks.subscribe(domain, action, name, hook)` registers one hook: -- `domain` + `action` — the action address: the semantic owner domain and the action name within it (`"statuses"` / `"register_statuses"` is the topic-status action — see [History — Hooks](../history/hooks.md); `"onboarding"` / `"declare_session"` and `"onboarding"` / `"amend_config"` are the onboarding-session actions a tool is invited into via `goga init -t ` — see [Init — Hooks](../init/hooks.md); the seven `"topics"` addresses — `amend_creation`, `amend_todo_entry`, `topic_created`, `topic_published`, `topic_switched`, `topic_todo_entered`, `topic_deleted`, all soft — are the topic-lifecycle checkpoints: two amendments before the content is fixed and five notifications after their moments, see [Topics — Hooks](../topics/hooks.md); the three `"pipeline"` addresses — `amend_workflow` (**hard**), `run_created`, `run_completed` (soft) — are the pipeline checkpoints: the workflow amendment before compilation in both the run and the card form, and the two notifications around a run's launch, see [Pipelines — Hooks](../pipelines/hooks.md)). +- `domain` + `action` — the action address: the semantic owner domain and the action name within it (`"statuses"` / `"register_statuses"` is the topic-status action — see [History — Hooks](../history/hooks.md); `"onboarding"` / `"declare_session"` and `"onboarding"` / `"amend_config"` are the onboarding-session actions a tool is invited into via `goga init -t ` — see [Init — Hooks](../init/hooks.md); the seven `"topics"` addresses — `amend_creation`, `amend_todo_entry`, `topic_created`, `topic_published`, `topic_switched`, `topic_todo_entered`, `topic_deleted`, all soft — are the topic-lifecycle checkpoints: two amendments before the content is fixed and five notifications after their moments, see [Topics — Hooks](../topics/hooks.md); the three `"pipeline"` addresses — `amend_workflow` (**hard**), `run_created`, `run_completed` (soft) — are the pipeline checkpoints: the workflow amendment before compilation in both the run and the card form, and the two notifications around a run's launch, see [Pipelines — Hooks](../pipelines/hooks.md); the five `"build"` addresses — `validate_build` (**hard**, with verdict collection), `build_started`, `pass_started`, `pass_completed`, `build_completed` (soft) — are the build checkpoints: the validation gate over the resolved run facts before the first pass and the four run notifications, see [Build — Hooks](../build/hooks.md)). - `name` — the hook name, unique per tool per address; registrations appear in the [`goga hooks`](cli.md) tree under their tool line. - `hook` — the callable executed when the action fires. @@ -33,7 +33,7 @@ The declaration order does not matter; names you did not declare receive nothing ## Error classes and diagnostics -Each action in the catalog fixes how a failing hook is treated. The topic-status, the onboarding, the topics, and the two pipeline notification actions are **soft**: a failing hook is skipped with a stderr warning naming the tool, the action, and the reason, and the command continues. A **hard** action stops the command at the first failing hook with a clean error — `pipeline/amend_workflow` is the existing hard action (see [Pipelines — Hooks](../pipelines/hooks.md)); the class is chosen by the owner domain when it declares the action. +Each action in the catalog fixes how a failing hook is treated. The topic-status, the onboarding, the topics, the two pipeline notification, and the four build notification actions are **soft**: a failing hook is skipped with a stderr warning naming the tool, the action, and the reason, and the command continues. A **hard** action stops the command — `pipeline/amend_workflow` stops at the first failing hook with a clean error (see [Pipelines — Hooks](../pipelines/hooks.md)), while `build/validate_build` lets every subscribed tool's hooks run to completion and merges their vetoes into one error that stops the build before any pass (see [Build — Hooks](../build/hooks.md)); the class is chosen by the owner domain when it declares the action. At registration: a wrong address, an empty name, or a repeated name on the same address is refused with a stderr warning naming the tool, the action, and the reason — the registration is skipped, the rest apply. A crashing callback is a warning; the registrations made before the crash survive. A broken package import is the only fatal case: a clean error naming the package. diff --git a/docs/features/hooks/index.md b/docs/features/hooks/index.md index 1083b562..284dbd6a 100644 --- a/docs/features/hooks/index.md +++ b/docs/features/hooks/index.md @@ -8,7 +8,7 @@ The hooks domain is the mechanism behind every domain extension: a domain declar - **Tool packages extend domains with no goga code changes** — a package registers its hooks at run time; registration is never cached, so package edits apply from the next run without reinstall. - **Inspection** — `goga hooks` assembles the registry once and prints it as a tree: tool, domain, action — the fact of registration, including every refused registration with its reason. -The declared actions today: the status-scale registration of the [History](../history/hooks.md) domain, the two onboarding actions of the [Init](../init/hooks.md) domain (`onboarding/declare_session`, `onboarding/amend_config`, both soft — a tool reaches them via `goga init -t `), the seven lifecycle actions of the [Topics](../topics/hooks.md) domain (`topics/amend_creation`, `topics/amend_todo_entry`, `topics/topic_created`, `topics/topic_published`, `topics/topic_switched`, `topics/topic_todo_entered`, `topics/topic_deleted`, all soft — two amendments before the content is fixed, five notifications after their moments), and the three actions of the [Pipelines](../pipelines/hooks.md) domain (`pipeline/amend_workflow` — the platform's first **hard** action, the workflow amendment delivered before compilation in both the run and the card form — plus the soft `pipeline/run_created` / `pipeline/run_completed` around a run's launch). The authoring side — how a tool package writes its `register_hooks` callback — is the [registration contract](hooks.md). +The declared actions today: the status-scale registration of the [History](../history/hooks.md) domain, the two onboarding actions of the [Init](../init/hooks.md) domain (`onboarding/declare_session`, `onboarding/amend_config`, both soft — a tool reaches them via `goga init -t `), the seven lifecycle actions of the [Topics](../topics/hooks.md) domain (`topics/amend_creation`, `topics/amend_todo_entry`, `topics/topic_created`, `topics/topic_published`, `topics/topic_switched`, `topics/topic_todo_entered`, `topics/topic_deleted`, all soft — two amendments before the content is fixed, five notifications after their moments), the three actions of the [Pipelines](../pipelines/hooks.md) domain (`pipeline/amend_workflow` — a **hard** action, the workflow amendment delivered before compilation in both the run and the card form — plus the soft `pipeline/run_created` / `pipeline/run_completed` around a run's launch), and the five actions of the [Build](../build/hooks.md) domain (`build/validate_build` — a **hard** action with verdict collection, the validation gate over the resolved run facts delivered before the first pass — plus the soft `build/build_started`, `build/pass_started`, `build/pass_completed`, `build/build_completed` carrying the run's facts). The authoring side — how a tool package writes its `register_hooks` callback — is the [registration contract](hooks.md). ## Model diff --git a/docs/features/pipelines/configuration.md b/docs/features/pipelines/configuration.md index 943b397d..2c4a0001 100644 --- a/docs/features/pipelines/configuration.md +++ b/docs/features/pipelines/configuration.md @@ -12,7 +12,7 @@ pipeline: | Field | Type | Required | Description | |---|---|---|---| -| `pipeline.agent` | `string` | No | AI agent that runs the pipeline stages inside the container. Optional at the loader level — absent/YAML-null/empty/whitespace resolves to `None`. When `None`, the agent may be supplied by a per-stage workflow override, or the stage runs with the pipeline's default agent, so `goga pipeline` does not require it. Same resolution mechanic and baseline set as `build.task_executor.agent` — see [Agents](../../configuration/agents.md) | +| `pipeline.agent` | `string` | No | AI agent that runs the pipeline stages inside the container. Optional at the loader level — absent/YAML-null/empty/whitespace resolves to `None`. When `None`, the agent may be supplied by a per-stage workflow override, or the stage runs with the pipeline's default agent, so `goga pipeline` does not require it. Same resolution mechanic and baseline set as `build.agent` — see [Agents](../../configuration/agents.md) | | `pipeline.env` | mapping | No | Environment variables passed into the pipeline container. Keys and values must be strings. Defaults to `{}` | | `pipeline.proxy` | `string` | No | HTTP/HTTPS proxy URL for the pipeline container. When set, `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` are written to the container env-file. Overridden by the `--proxy` CLI option | | `pipeline.hosts` | mapping | No | Host→IP mapping for `docker run --add-host`. Defaults to `{}`. Augmented by the repeatable `--add-host` CLI option (CLI wins on key conflict) | diff --git a/docs/workflow/build.md b/docs/workflow/build.md index d659ca21..ef9fc412 100644 --- a/docs/workflow/build.md +++ b/docs/workflow/build.md @@ -38,9 +38,12 @@ step-by-step algorithm — lives in the `/completed/`. A failed run leaves it in place for resumption. -A single full-cycle pass runs by default. `--skip-review` runs a -tasks-only pass; a review executor that differs from the task executor -(or declares its own `env`) runs two passes — tasks, then review. +A run with review on is always two passes — a tasks pass on the +`build.agent` wrapper, then a review pass on the review agent's wrapper +(`build.review.agent`, inheriting `build.agent` when unset). `--skip-review` +(or `build.review.skip: true`) runs a tasks-only pass; a failed tasks +pass skips the review. Before the first pass, the `build/validate_build` +hooks gate may veto the run. ## When to use diff --git a/goga/agents/.usages/resolve-wrapper-path.md b/goga/agents/.usages/resolve-wrapper-path.md index 68620849..064676b7 100644 --- a/goga/agents/.usages/resolve-wrapper-path.md +++ b/goga/agents/.usages/resolve-wrapper-path.md @@ -3,7 +3,7 @@ ## Domain Resolution of agent names declared in `.goga/config.yml` -(`build.task_executor.agent`, `pipeline.agent`) to the absolute in-container +(`build.agent`, `pipeline.agent`) to the absolute in-container path of the corresponding `*-as-claude.sh` wrapper script. Target audience: goga cells that write the resolved path into a downstream diff --git a/goga/build/ralphex_config.py b/goga/build/ralphex_config.py index 27573d2c..901e0b9e 100644 --- a/goga/build/ralphex_config.py +++ b/goga/build/ralphex_config.py @@ -54,7 +54,7 @@ def write_ralphex_config(settings: RunSettings, wrapper_path: str) -> None: "move_plan_on_completion = false", ] - additional_agent = review.additional.agent if review.additional is not None else None + additional_agent = review.additional.agent if review.strategy == "medium": config_lines.append("codex_enabled = false") diff --git a/goga/build/review_config.py b/goga/build/review_config.py index afe7c159..7c58840c 100644 --- a/goga/build/review_config.py +++ b/goga/build/review_config.py @@ -61,7 +61,7 @@ def validate_review_config(settings: RunSettings) -> None: if not Path(wrapper).is_file(): raise ValueError(f"review agent wrapper not found: {wrapper} (agent {review.agent!r})") - additional_agent = review.additional.agent if review.additional is not None else None + additional_agent = review.additional.agent if review.strategy == "short" or (review.strategy == "full" and additional_agent is not None): additional_wrapper = resolve_wrapper_path(additional_agent) diff --git a/goga/commands/config/.usages/config.md b/goga/commands/config/.usages/config.md index a1e86927..18ebdd46 100644 --- a/goga/commands/config/.usages/config.md +++ b/goga/commands/config/.usages/config.md @@ -14,7 +14,7 @@ goga config