Imports:
  - Types:
      - compile_flow
      - translate_role
    Usages:
      - compile-flow
      - parse-dsl
      - serialize-flow
    From: goga/pipeline/compiler
  - Types:
      - run_flow
    Usages:
      - run-flow
    From: goga/afm
  - Types:
      - ensure_in_docker
    Usages:
      - ensure-in-docker
    From: goga/docker
  - Types:
      - parse_workflow
      - WorkflowDocument
      - WorkflowStage
    Usages:
      - parse-workflow
    From: goga/pipeline/workflow
  - Types:
      - resolve_project_name
    From: goga/config

Usages:
  conventions: .goga/usages/conventions.md
  argparse: |
    Use the standard library argparse module for in-container CLI parsing.
    Two subcommands: list (no args) and run NAME --port PORT [--parallel N].
  cli_entrypoint: |
    The in-container CLI is launched through the package runpy entrypoint
    (python -m goga.pipeline). The package __main__ module MUST stay a thin
    wrapper: it imports `pipeline_cli` from the local cli module and calls
    it with process argv under the standard __main__ guard. The
    `pipeline_cli` implementation itself MUST live in the cli module —
    never in the __main__ module — so that importing the goga.pipeline
    package does not pull __main__ into sys.modules and trigger a runpy
    RuntimeWarning about __main__ being pre-imported. The single piece of
    logic permitted in __main__.py besides the runpy delegation is the
    in-container docker guard (`ensure_in_docker`, per the
    `ensure-in-docker` practice): it is invoked as the very first
    statement of the __main__ guard block, before `pipeline_cli` is called,
    so host-side invocations of python -m goga.pipeline fail loudly before
    any pipeline work. The guard does NOT move into `pipeline_cli` itself
    — `pipeline_cli` stays a pure parse-and-dispatch routine whose
    contract is unchanged by the guard. The guard invocation MUST be
    covered by tests for both branches: the success path (GOGA_DOCKER=1)
    proceeds to `pipeline_cli`, and the refusal path (marker unset or not
    "1") writes to stderr and exits with code 1 before `pipeline_cli` is
    reached.
  default_prompts: |
    The four default agent prompt files ship inside the installed goga
    package at
    goga/assets/afm/prompts/{planning,implementation,review,summary}.md.
    The directory contains exactly four files; the file stem matches the
    canonical afm agent name. Three of them (planning, implementation,
    review) correspond to the overridable DSL roles planner/executor/
    reviewer (the role-field-name → stem mapping is resolved via
    `translate_role`, imported from goga/pipeline/compiler); summary
    is NOT overridable from the DSL — summary.md is always materialized
    from the default. Resolution from the installed package location is an
    implementation detail — the consumer may use any standard mechanism
    (e.g. importlib.resources or Path(__file__) composition) that yields
    the absolute path to the package's goga/assets/afm/prompts/
    directory at runtime. All four files are expected to exist in a
    properly installed image — when a default is missing AND no inline
    override is supplied for the corresponding role, materialization
    fails with a readable error before launch.

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 entire pipeline workflow: discovery of *.yml pipeline
  files across the project and user pipeline directories, the pipeline-file
  entity model, run coordination, and the in-container CLI entrypoint
  pipeline_cli. It delegates subprocess execution to `run_flow` from
  goga/afm (per the `run-flow` practice) — afm run is invoked inside the
  container via `run_flow`, never directly from this cell.

  Use the standard library dataclasses module (NOT pydantic) for
  `PipelineEntry`, per project convention — pydantic is treated as tech debt.
  Use the standard library enum module for `PipelineSource` (str-backed).
  Use the `argparse` practice for the in-container CLI entrypoint.
  Use the `cli_entrypoint` practice to keep the __main__ module a thin
  wrapper and to ensure `pipeline_cli` is defined in the cli module — never
  in __main__ — so that the python -m goga.pipeline invocation does not emit
  a runpy RuntimeWarning about __main__ being pre-imported into sys.modules.
  Use pathlib.Path for directory and path resolution.

  The pipeline directories are <cwd>/.goga/pipelines/ (project-level,
  user-authored) and ~/.goga/pipelines/ (user-level, populated by
  goga connect). On a name conflict, the project source wins.

  The workflow directory is <cwd>/.goga/workflows/ (project-level,
  user-authored). Workflows are an OPTIONAL extension layer applied during
  pipeline compilation. Workflow resolution is environment-driven and
  happens INSIDE the container in `run_pipeline` (per the `parse-workflow`
  practice): GOGA_WORKFLOW_DISABLED=1 forces no workflow;
  GOGA_WORKFLOW_NAME=<wf-name> overrides the workflow name (file path
  resolves to <cwd>/.goga/workflows/<wf-name>.yml); when neither env var is
  set, `run_pipeline` falls back to the basename of the pipeline name
  (<cwd>/.goga/workflows/<pipeline_name>.yml) — silent miss if that file
  does not exist (no error, workflow = None). When a workflow path resolves
  and exists, `run_pipeline` calls `parse_workflow` to obtain a
  `WorkflowDocument`, which is passed to `compile_flow` as the optional
  workflow argument.

  GOGA_SKIP_STAGES=<csv> carries the CLI --skip/-s names: `run_pipeline`
  reads them and applies via `apply_skip_stages` as in-memory `WorkflowStage`
  skip directives merged onto the resolved workflow (or a freshly constructed
  `WorkflowDocument` when no workflow resolved, e.g. --no-workflow or no
  auto-match), BEFORE `compile_flow`. The compiler then removes the skipped
  stages and transparently reconnects their dependents' depends_on. Unset/empty
  = no skip. The host performs NO stage-name validation — unknown names surface
  as the compiler's structural error in-container.

  This 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).

  Compilation step: `run_pipeline` invokes `compile_flow` (per the
  `compile-flow` practice) to transform the discovered pipeline-file from
  goga DSL into an afm flow-file at runtime, inside the container. When a
  workflow is resolved, the parsed `WorkflowDocument` is forwarded to
  `compile_flow` so the compiler can reconstruct the body per the workflow
  instructions before serializing the flow-file. The output path is the
  flow.yml file inside the directory pointed to by the AFM_DIR environment
  variable. Use the `parse-dsl` and `serialize-flow` practices to understand
  the intermediate stages of the compilation pipeline when a lower-level
  view is required.

  Prompt materialization step: after `compile_flow`, `run_pipeline`
  materializes the four default agent prompt files from the installed goga
  package (goga/assets/afm/prompts/, per the `default_prompts` practice)
  into the runtime directory at <AFM_DIR>/prompts/, then applies a
  per-role override — for each non-None role field of the pipeline
  document header (planner/executor/reviewer) of the documents tuple
  returned by `compile_flow` (per the `compile-flow` practice), the
  corresponding <stem>.md file — stem resolved via `translate_role`
  (imported from goga/pipeline/compiler) — is overwritten with the inline
  prompt text. summary has no override field; summary.md is always
  copied from the default. The override is a full
  replacement (no merge, no concatenation). All four files are written
  unconditionally — when a default is missing from the package AND no
  inline override is supplied for that key, materialization fails with a
  readable error before `run_flow` is invoked. The compiled flow-file is
  unaffected — inline prompts are a goga-side artifact, not part of the
  afm flow-file contract.

---

"PipelineEntry(name: str, source: PipelineSource)":
  location: pipeline_entry.py
  annotations: |
    Describe a single pipeline-file discovered by `list_pipelines`: its `name`
    and where it comes from (`source`).

    `name`: pipeline name without extension (e.g. "deploy"); the .yml extension
            is implied and never stored here
    `source`: origin of the pipeline — `PipelineSource` enum value

    Build the data model with the standard library dataclasses module (NOT
    pydantic, per project convention; pydantic is treated as tech debt). Use
    @dataclass(kw_only=True) and validate `name` at construction time
    (raising ValueError on invalid input).

    Requirements:
    - Use @dataclass(kw_only=True) (per `conventions`)
    - `name` must not contain path separators ("/", "\\") or the .yml extension
    - `name` must not be empty

  properties:
    "name -> str": |
      Pipeline name without extension.
    "source -> PipelineSource": |
      Origin of the pipeline: `PipelineSource`.PROJECT for project-level
      <cwd>/.goga/pipelines/, `PipelineSource`.USER for user-level ~/.goga/pipelines/.

"PipelineSource()":
  location: pipeline_entry.py
  annotations: |
    str-backed Enum declaring the origin of a pipeline-file.

    Modeled via the standard library enum module; str-mixin so values
    serialize as plain strings (per the `conventions` practice).

  properties:
    "PROJECT = \"project\"": |
      Origin = project-level <cwd>/.goga/pipelines/.
    "USER = \"user\"": |
      Origin = user-level ~/.goga/pipelines/.

"list_pipelines(project_dir: Path, user_dir: Path) -> entries: list[PipelineEntry]":
  location: list_pipelines.py
  annotations: |
    Discover pipeline files across two source directories and return them as
    `entries`: a list of `PipelineEntry`-s.

    `project_dir`: project-level pipelines directory (typically <cwd>/.goga/pipelines/)
    `user_dir`: user-level pipelines directory (typically ~/.goga/pipelines/)
    `entries`: list of `PipelineEntry`-s, one per unique pipeline name

    Algorithm:
    1. Scan flat *.yml files (non-recursive) in `project_dir`; for each valid
       stem, record a `PipelineEntry` with the name set to the stem and source
       set to `PipelineSource`.PROJECT. Skip stems that fail `PipelineEntry`
       validation (invalid chars, .yml suffix, empty) silently.
    2. Scan flat *.yml files (non-recursive) in `user_dir`; for each valid stem
       not already present from step 1, record a `PipelineEntry` with source
       set to `PipelineSource`.USER.
    3. Return the combined `entries` list.

    Apply the `conventions` practice for the filesystem scanning code
    (relative imports, logging, docstring style).

    Requirements:
    - Scan only the top level of each directory — do not descend into subdirectories
    - Drop the .yml extension when forming the entry name
    - A missing source directory is treated as empty (no error)
    - Skip stems that fail `PipelineEntry` validation silently — they are not pipelines

    Constraints:
    - Do not parse or validate the contents of pipeline files — only their names
      matter here
    - When a name exists in both sources, the project source wins (no duplicate
      entries)

"run_pipeline(name: str, project_dir: Path, user_dir: Path, port: int, parallel: int | None = None) -> exit_code: int":
  location: run_pipeline.py
  annotations: |
    Resolve a pipeline name to an absolute file path via `list_pipelines`,
    resolve an optional workflow-file per the workflow environment contract
    (see the cell-level annotation) and parse it via `parse_workflow` when
    applicable, compile the pipeline-file (optionally extended by the
    workflow) from goga DSL 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`.

    `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 by `pipeline_cli`; run
                mode only.
    `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.

    Algorithm:
    1. Discover pipelines via `list_pipelines` and find the `PipelineEntry`
       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. If AFM_DIR is not set — raise an error with
       the readable message "AFM_DIR not set". Resolve the value to an
       absolute path (via pathlib.Path.resolve) to guarantee the directory
       is absolute regardless of whether the env value is relative
    5. Compose the output flow path inside that directory
    6. Resolve an optional workflow per the workflow environment contract:
       a. Read GOGA_WORKFLOW_DISABLED from the environment. When its value
          is "1" — set workflow = None (workflow disabled by the host
          launcher via --no-workflow)
       b. Otherwise determine the workflow-file path:
          - When GOGA_WORKFLOW_NAME is set in the environment — compose
            workflow_path as Path.cwd() / ".goga" / "workflows" /
            <GOGA_WORKFLOW_NAME>.yml (= /workspace/.goga/workflows/
            <wf-name>.yml inside the container). The CWD-based path is
            mandatory: project_dir itself is /workspace/.goga/pipelines,
            so project_dir.parent is /workspace/.goga and a parent-based
            composition would produce a double .goga. Workflows are
            project-only by design.
          - Otherwise (GOGA_WORKFLOW_NAME not set) — compose workflow_path
            via the basename fallback: Path.cwd() / ".goga" /
            "workflows" / <name>.yml (the same basename as the pipeline
            being run)
       c. When workflow_path exists — call `parse_workflow`
          (per the `parse-workflow` practice) on workflow_path to obtain a
          `WorkflowDocument`. Structural errors from `parse_workflow`
          propagate unchanged with their readable messages
       d. When workflow_path does not exist — set workflow = None (silent
          miss; this is the opt-in path — the absence of a workflow-file
          is not an error)
    6e. Read GOGA_SKIP_STAGES from the process environment. When unset or
        empty — skip_stages = []. Otherwise split on ',' and drop empty
        fragments to obtain skip_stages. Then call `apply_skip_stages` with the
        resolved workflow and skip_stages — when skip_stages is non-empty, this
        merges `WorkflowStage` skip directives into the resolved workflow (or
        constructs a new `WorkflowDocument` when workflow is None, e.g.
        --no-workflow or no resolved workflow-file); when empty, the workflow
        is unchanged. The result is forwarded to `compile_flow` at step 7.
    7. Resolve the in-container project name via `resolve_project_name` (None when the git origin remote is unavailable — mirroring the root_dir
       pattern, never raises). Compile the pipeline-file via `compile_flow`
       (per the `compile-flow` practice) with the resolved workflow as the
       optional workflow argument, the in-container project root (Path.cwd(),
       which resolves to /workspace inside the goga container — the single
       source of truth mirroring the host-side mount decision) as the optional
       root_dir argument, and project_name=resolve_project_name() — receive the
       documents tuple as the return value (per the `compile-flow` practice).
       When workflow is None, no workflow is applied. The root_dir is always
       supplied (computed from Path.cwd()), so the compiled flow-file carries a
       top-level root_dir directive; when project_name is None the compiled flow
       description carries no prefix. Structural DSL errors propagate
       unchanged; structural workflow errors propagate unchanged from
       `parse_workflow`
    8. Materialize agent prompts inline (atomic: validate-all, then wipe,
       then write):
       a. Resolve the default prompts directory in the installed goga
          package (per the `default_prompts` practice) —
          goga/assets/afm/prompts/
       b. Validate (atomic, before wipe): for each overridable role
          (planner, executor, reviewer) — stem = `translate_role`(role);
          either the documents tuple returned by `compile_flow` carries a
          non-None inline override for that role (per the
          `compile-flow` practice), or the corresponding default prompt
          file (defaults_dir / "<stem>.md") exists. summary has no
          override field — its default file (defaults_dir / "summary.md")
          MUST exist. A role with neither override nor default, or a
          missing summary default, is a fatal error raised BEFORE the
          directory is wiped or any prompt file is written — this is the
          atomicity guarantee (no partial state on disk)
       c. After step 8b succeeds, reset <AFM_DIR>/prompts/ to a clean
          state (rmtree + mkdir) so the directory contains exactly four
          files after step 8 succeeds, regardless of any leftover files
          from previous runs or manual edits
       d. For each overridable role (planner, executor, reviewer):
          stem = `translate_role`(role); write the inline prompt override
          for that role to <AFM_DIR>/prompts/<stem>.md when the
          documents tuple carries one, otherwise copy the corresponding
          (already-validated) default prompt from the installed package.
          summary.md is always copied from the default (never overridden
          from the DSL). By construction (step 8b) every source exists.
       e. After step 8d succeeds, <AFM_DIR>/prompts/ contains exactly
          four files (planning.md, implementation.md, review.md,
          summary.md)
    9. Launch afm via `run_flow` (per the `run-flow` practice) with the
       compiled flow-file path, `port`, AND max_parallel=`parallel` (None ⇒
       run_flow omits --max-parallel)
    10. Return the exit code returned by `run_flow`

    Apply `conventions` for error-handling style and docstring formatting.
    Apply `parse-workflow` for the contract of `parse_workflow`
    and the resulting `WorkflowDocument`.
    Apply `compile-flow` for the compilation step contract (including the
    new optional workflow parameter and the documents-tuple return value).
    Apply `default_prompts` for resolving the path to the four packaged
    default prompt files.
    Apply `run-flow` for the subprocess launch contract.

    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` — never omit it
    - Forward `parallel` to `run_flow` as its max_parallel argument — None
      propagates (no --max-parallel flag)
    - Read AFM_DIR directly from the process environment
    - Resolve AFM_DIR to an absolute path (via pathlib.Path.resolve) before
      composing flow_path (so a relative env value still yields an absolute
      flow_path that satisfies `compile_flow`'s absolute-paths precondition)
    - Apply the `run-flow` practice's error-handling rules verbatim (as
      propagated through `run_flow`)
    - Apply the `compile-flow` practice's contract verbatim
    - Always accept the return value of `compile_flow` (the documents
      tuple); never re-invoke the parse-dsl routine to obtain inline
      prompt overrides
    - Read inline prompt overrides exclusively from the pipeline document
      header's roles block (planner/executor/reviewer) of the documents tuple
      returned by `compile_flow` (per the `compile-flow` practice — the
      compiled flow-file carries no roles data)
    - Resolve each overridable role to its prompt-file stem via
      `translate_role` (imported from goga/pipeline/compiler);
      planner→planning, executor→implementation, reviewer→review
    - summary.md is always copied from the default — summary has no
      override field and is not overridable from the DSL
    - <AFM_DIR>/prompts/ must contain exactly four files after step 8
      succeeds; partial state (fewer files) is an error condition that must
      surface before `run_flow` is invoked. Wipe the directory clean in
      step 8c (after validation in 8b) so leftover files from a previous
      run or manual edits do not accumulate — idempotent end state: the
      same four files after every run
    - Default prompt files are read from the installed package location
      (per `default_prompts`); the source path must NOT be derived from
      AFM_DIR, CWD, or any environment variable
    - The override is a full file replacement — no merge, no concatenation
      with the default prompt text
    - A missing default prompt (file absent in the package) for a key with
      no inline override is a fatal error: raise with a readable message
      naming the key in step 8b (validation phase), BEFORE the directory
      is wiped or any prompt file is written. <AFM_DIR>/prompts/ remains in
      its previous state on error — atomicity is guaranteed by validate-all
      before wipe
    - A missing default prompt for a key WITH an inline override is NOT an
      error — the file is written from the override; the default's absence
      is ignored for that key
    - Read GOGA_WORKFLOW_DISABLED and GOGA_WORKFLOW_NAME directly from the
      process environment; the host-side launcher sets them via the env-file
    - Workflow path resolution is CWD-based:
      Path.cwd() / ".goga" / "workflows" / "<name>.yml" — the
      project_dir.parent IS NOT the project root; project_dir itself is
      /workspace/.goga/pipelines, so Path.cwd() (= /workspace in-container)
      is the project root. Workflows are project-only.
    - root_dir resolution is CWD-based: the in-container project root
      (Path.cwd(), which resolves to /workspace inside the goga container
      because the host-side launcher sets workdir=/workspace and
      bind-mounts the project there) is forwarded to `compile_flow` as
      the root_dir argument. Path.cwd() is the single source of truth —
      it mirrors the host-side mount decision rather than re-declaring
      the literal, so a future change to the mount target propagates
      automatically. The value is resolved via Path.cwd().resolve() to
      guarantee an absolute path regardless of how the container's CWD
      was set.
    - A non-existent workflow-file is a silent miss (workflow = None), NOT
      an error — the host-side launcher performs explicit --workflow
      existence validation; the basename fallback is opt-in
    - GOGA_WORKFLOW_DISABLED="1" takes precedence over GOGA_WORKFLOW_NAME —
      when both are set, workflow = None
    - Read GOGA_SKIP_STAGES from the process environment (comma-separated;
      unset/empty = no skip); apply via `apply_skip_stages` BEFORE
      `compile_flow`
    - --skip merges onto any resolved workflow and is NOT mutually exclusive
      with --workflow; it also applies to a workflow-less pipeline
      (--no-workflow / no auto-match) — `apply_skip_stages` constructs a
      `WorkflowDocument` carrying only the skip entries then
    - Unknown skip names surface as the compiler's structural error
      "unknown stage name in workflow.stages: <name>" (raised in-container by
      `compile_flow` step 4pre), propagated as a non-zero exit code

    Constraints:
    - Do not invoke afm directly outside `run_flow`
    - Do not invoke the compiler outside `compile_flow`
    - Do not allocate the port — the caller allocates it
    - Do not default `parallel` — None ⇒ unbounded; the caller (pipeline_cli)
      decides based on --parallel presence
    - Do not copy pipeline files into any project-level directory
    - Do not modify or parse pipeline-file contents directly — that is
      `compile_flow`'s job
    - Do not accept relative `project_dir` or `user_dir` — both must already
      be absolute when passed in
    - Do not mask or wrap exceptions from `compile_flow` — structural DSL
      errors propagate with their readable messages
    - Do not write prompts/ inside the project directory or /workspace —
      always write to <AFM_DIR>/prompts/ (the in-container persistent state
      directory)
    - Do not write inline prompt overrides into the compiled flow-file —
      they are a goga-side artifact, not part of the afm flow-file contract
    - Do not silently skip a missing default when no inline override is
      supplied for that key
    - Do not perform any prompt-related work outside step 8 — the host
      launcher writes prompts_dir into the afm config.yaml tmpfile but
      does not know whether the pipeline-file contains a roles block
    - Do not mutate the documents tuple returned by `compile_flow` — read
      the inline prompt overrides from it as-is
    - Do not invoke the workflow parser outside `parse_workflow`
    - Do not modify or parse workflow-file contents directly — that is
      `parse_workflow`'s job
    - Do not treat a missing workflow-file as an error when neither
      GOGA_WORKFLOW_NAME nor GOGA_WORKFLOW_DISABLED="1" is set — the
      basename fallback is opt-in
    - Do not validate the workflow-file path on the host — workflow
      resolution happens inside the container only; the host-side launcher
      performs explicit --workflow existence validation before launch
      when the user passes --workflow
    - Do not delete skipped stages or rewrite depends_on in `run_pipeline` —
      `compile_flow` does both; `run_pipeline` only prepares the workflow via
      `apply_skip_stages`
    - Do not write or generate a workflow-file for skip — the merge is
      in-memory only
    - project_name is derived in-container via `resolve_project_name`
      (mirroring the root_dir pattern — derived inside the container, not read
      from config); None when the git origin remote is unavailable → the
      compiled flow description carries no [<project-name>] prefix

"apply_skip_stages(workflow: WorkflowDocument | None, skip_stages: list[str]) -> workflow: WorkflowDocument | None":
  location: apply_skip_stages.py
  annotations: |
    Pure in-memory merge of CLI skip directives into a workflow document.
    Each name in `skip_stages` is applied as a `WorkflowStage` carrying
    skip=True over the workflow's stages map, so the downstream `compile_flow`
    removes those stages and transparently reconnects their dependents'
    depends_on. This routine does NOT delete stages or rewrite depends_on — it
    only prepares the declarative workflow that the compiler consumes.

    `workflow`: optional `WorkflowDocument` resolved by `run_pipeline` (parsed
                from a workflow-file via `parse_workflow`, or None when no
                workflow resolved)
    `skip_stages`: stage names to skip (from the comma-split GOGA_SKIP_STAGES
                   container env var); an empty list is a no-op
    `workflow`: the resulting `WorkflowDocument` carrying the skip directives,
                or the input unchanged when `skip_stages` is empty (None stays
                None — `compile_flow` then runs with no workflow)

    Algorithm:
    1. When `skip_stages` is empty — return `workflow` unchanged (None stays
       None; no skip applied)
    2. Build a new stages map: start from a copy of the stages map of `workflow`
       when `workflow` is not None, otherwise an empty dict
    3. For each name in `skip_stages` — set stages[name] = a `WorkflowStage`
       carrying skip=True (skip wins over any pre-existing entry for that name;
       the compiler removes the stage before applying overrides)
    4. Return a NEW `WorkflowDocument`: prompt = the prompt of `workflow` when
       `workflow` is not None else None; stages = the new map; extend = a copy
       of the extend map of `workflow` when `workflow` is not None else the
       default empty map. The input `workflow` and its maps are NOT mutated

    Requirements:
    - Empty `skip_stages` is a no-op — return the input unchanged
    - Skip always wins — a name present in both the workflow stages and
      `skip_stages` is replaced with a `WorkflowStage` carrying skip=True
    - Do not mutate the input `workflow` or its stages/extend maps — build a
      new `WorkflowDocument` and a new stages map (per the run-pipeline
      anti-pattern against mutating `parse_workflow` output)
    - When `workflow` is None and `skip_stages` is non-empty — construct a
      `WorkflowDocument` whose stages map carries only the skip entries (prompt
      None, extend empty); skip applies to a workflow-less pipeline
    - Construct `WorkflowStage` with skip=True and all other fields at their
      defaults
    - Stage-name validation is NOT performed here — the compiler's strict check
      (`compile_flow` step 4pre) raises a structural error on a name absent
      from the pipeline body; this routine stays declarative
    - Apply `conventions` for code style and docstring formatting

    Constraints:
    - Do not delete stages or rewrite depends_on here — the compiler does both
      (`compile_flow` step 4skip)
    - Do not validate stage names against any pipeline here
    - Do not write, read, or generate any workflow-file — the merge operates
      purely on in-memory Python objects
    - Do not mutate the input `workflow` object or its maps

"pipeline_cli(argv: list[str]) -> exit_code: int":
  location: cli.py
  annotations: |
    In-container CLI implementation for python -m goga.pipeline. Parses argv
    via argparse and dispatches to `list_pipelines` (discovery) or
    `run_pipeline` (run). This routine is invoked by the host-side docker
    launcher in goga/commands/pipeline through the runpy entrypoint in
    __main__.py — never imported by Python from the host side (runtime
    docker boundary, no Imports).

    `argv`: argument list (typically the process argv minus the program name)
    `exit_code`: 0 on success, 2 on argparse error, non-zero on pipeline failure
                 (propagated from `run_pipeline` / `list_pipelines`)

    Algorithm:
    1. Build an argparse parser with two subcommands: list and run
       - list takes no positional arguments
       - run takes a required name positional argument,
         a required --port PORT integer option, AND an optional
         --parallel N integer option (None when absent)
    2. Parse argv; on argparse error — exit code 2 (argparse default)
    3. Resolve the project pipelines directory (project working directory's
       .goga/pipelines/) and the user pipelines directory (user home's
       .goga/pipelines/)
    4. Dispatch:
       - list → call `list_pipelines`(project_dir, user_dir), print
         "Available pipelines:" header followed by one entry per line
         (project source suffixed with " (project)"), return 0
       - run NAME --port PORT [--parallel N] → call `run_pipeline`(NAME,
         project_dir, user_dir, PORT, parallel=args.parallel) and return its
         exit code (args.parallel is None when --parallel is absent; delegates
         name resolution, compilation, and the .yml extension to `run_pipeline`)
    5. Return the resulting exit code

    Apply the `conventions` practice for docstring style and intra-package imports.
    Apply the `argparse` practice for parser construction.
    Apply the `cli_entrypoint` practice: this routine MUST be defined in the
    cli module, and __main__ MUST only delegate to it — never define
    `pipeline_cli` inside __main__.

    Requirements:
    - run subcommand must accept --port PORT as an integer (required)
    - run subcommand accepts an optional --parallel N integer (None when
      absent)
    - list subcommand takes no arguments
    - Echo the header BEFORE the list in list mode (predictable UX)

    Constraints:
    - Do not allocate a port inside this CLI — --port is required and supplied
      by the host-side launcher
    - Do not import this cell from the host side — invoke via docker only
      (docker run ... python -m goga.pipeline {list,run})

---

Author: Goga
CreatedAt: 29/06/26
Description: |
  Cell that owns the entire pipeline workflow: discovery of *.yml pipeline
  files across the project and user pipeline directories, the pipeline-file
  entity model, run coordination (including optional workflow resolution),
  and the in-container CLI entrypoint pipeline_cli.
