Imports:
  - Types:
      - compile_flow
      - translate_role
      - parse_dsl
      - FlowStage
    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:
  convention: .goga/usages/conventions.md
  argparse: |
    Use the standard library argparse module for in-container CLI parsing.
    Two subcommands: list (with an optional --info flag) and run (a required
    name, an optional --info flag, optional workflow flags, --port required
    only without --info, and an optional --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 `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 pipeline workflow surface: discovery of *.yml pipeline
  files across the project and user pipeline directories, the pipeline-file
  entity model, the informational surface (overview and card), run
  coordination, and the in-container CLI entrypoint `pipeline_cli`.
  Subprocess execution is delegated to `run_flow` — afm run is invoked inside
  the container, never directly.

  Build the data models `PipelineEntry`, `PipelineSummary`, `PipelineCard`,
  and `CardStage` with the standard library dataclasses module (kw_only=True
  per `convention`); `PipelineSource` with the standard library enum module
  (str-backed). 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).

  Workflow resolution is one rule set shared by run coordination and the
  card: an explicit workflow name, forced disabling, and the basename
  auto-match of the pipeline name are resolved by `resolve_workflow`, so the
  card always reflects the workflow a run with the same flags would apply.
  Use `parse-workflow` for the accepted workflow-file structure.

  GOGA_SKIP_STAGES=<csv> carries the CLI skip names in run coordination:
  applied in-memory onto the resolved workflow before compilation. The CLI
  performs no stage-name validation — unknown names surface as the
  compiler's structural error.

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

  Use the `argparse` practice for the in-container CLI parsing and the
  `cli_entrypoint` practice for the thin __main__ delegation with the
  in-container docker guard (`ensure-in-docker`).

  Compilation step: run coordination invokes `compile_flow` (see
  `compile-flow`) to transform the discovered pipeline-file into an afm
  flow-file at runtime inside the container; use `parse-dsl` and
  `serialize-flow` for the intermediate stages when a lower-level view is
  required.

  Prompt materialization step: after compilation, run coordination
  materializes the four default agent prompt files (per `default_prompts`)
  into <AFM_DIR>/prompts/ and applies per-role inline overrides.

  Informational surface: the overview lists every discovered pipeline with
  its description from the DSL header; the card reports the name, the
  description, and the post-workflow stage composition produced by the same
  compilation machine a run uses. The card reads no run-only
  GOGA_SKIP_STAGES environment variable — the CLI skip channel is a run
  concern — while workflow-file skip directives apply through the shared
  compilation machine (they are part of the composition a run with the same
  workflow flags executes). The card writes nothing into the project or
  runtime directories.

---

"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 `convention`)
    - `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 `convention` practice).

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

"PipelineSummary(name: str, source: PipelineSource, description: str, display_name: str = \"\")":
  location: pipeline_summary.py
  annotations: |
    Describe one row of the overview: the discovered pipeline name, its
    origin, its DSL header description, and the authored header name.

    `name`: pipeline name without extension
    `source`: origin of the pipeline — `PipelineSource` value
    `description`: pipeline description taken from the DSL header
    `display_name`: authored pipeline name from the DSL header; may differ
                    from `name`; defaults to an empty string

    Build with the standard library dataclasses module and
    @dataclass(kw_only=True) (per `convention`).

    Requirements:
    - Use @dataclass(kw_only=True)
    - `name` follows the `PipelineEntry` validation rules — non-empty, no
      path separators, no .yml suffix
    - `display_name` defaults to an empty string — constructions without it
      remain valid

  properties:
    "name -> str": |
      Pipeline name without extension.
    "source -> PipelineSource": |
      Origin of the pipeline.
    "description -> str": |
      Description from the DSL header.
    "display_name -> str": |
      Authored pipeline name from the DSL header; may differ from the
      discovered stem.

"PipelineCard(name: str, description: str, stages: list[CardStage])":
  location: pipeline_card.py
  annotations: |
    Describe the card of a single pipeline: the authored name and
    description plus the ordered stage rows.

    `name`: pipeline name from the DSL header
    `description`: pipeline description from the DSL header
    `stages`: stage rows in execution order — one per compiled stage

    Build with the standard library dataclasses module and
    @dataclass(kw_only=True) (per `convention`).

    Requirements:
    - Use @dataclass(kw_only=True)

  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.

"CardStage(id: str, title: str)":
  location: pipeline_card.py
  annotations: |
    Describe one stage row of the card.

    `id`: stage identifier
    `title`: stage display title

    Build with the standard library dataclasses module and
    @dataclass(kw_only=True) (per `convention`).

    Requirements:
    - Use @dataclass(kw_only=True)

  properties:
    "id -> str": |
      Stage identifier.
    "title -> str": |
      Stage display title — the display name of the compiled `FlowStage`.

"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 `convention` 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)

"describe_pipelines(project_dir: Path, user_dir: Path) -> summaries: list[PipelineSummary]":
  location: describe_pipelines.py
  annotations: |
    Compose the overview of every discovered pipeline: each entry paired with
    the authored name and description from its DSL header.

    `project_dir`: project-level pipelines directory (absolute)
    `user_dir`: user-level pipelines directory (absolute)
    `summaries`: one `PipelineSummary` per discovered pipeline, in discovery
                 order

    Apply `convention` for filesystem reading, logging, and docstring style.

    Algorithm:
    1. Discover entries via `list_pipelines`
    2. For each entry: compose the pipeline-file path inside its source
       directory, read the file, parse it via `parse_dsl`, and take the header
       name and description
    3. Build a `PipelineSummary` from the entry name, entry source, the
       header description, and the authored header name (the summary
       display_name field)
    4. Return the summaries in discovery order

    Requirements:
    - The description and the authored header name (summary display_name
      field) come from the DSL header; the name is the discovered stem
    - A damaged pipeline file (unreadable or structurally invalid) aborts the
      whole overview with a readable error — no partial list, no silent
      skips, no placeholder markers
    - Read the filesystem only; write nothing

    Constraints:
    - Do not sort or filter the summaries — discovery order is preserved
    - Do not compile pipeline files — the overview reads headers only
    - Do not extend the discovery contract — names and sources come from
      `list_pipelines` unchanged

"resolve_workflow(pipeline_name: str, workflow_name: str | None, no_workflow: bool) -> workflow: WorkflowDocument | None":
  location: resolve_workflow.py
  annotations: |
    Resolve the optional workflow for a pipeline: the single rule set shared
    by run coordination and the card.

    `pipeline_name`: pipeline name without extension (the basename auto-match
                     key)
    `workflow_name`: optional explicit workflow name (without the .yml
                     extension)
    `no_workflow`: when True, workflow application is disabled
    `workflow`: the parsed `WorkflowDocument`, or None when no workflow applies

    Apply `parse-workflow` for the accepted structure and the parse contract.
    Apply `convention` for docstring style.

    Algorithm:
    1. When `no_workflow` is True — return None
    2. Compose the workflow-file path under <cwd>/.goga/workflows/: named
       `workflow_name`.yml when provided (an empty string counts as not
       provided), otherwise the pipeline basename `pipeline_name`.yml
    3. Containment guard: when the composed path resolves outside the
       <cwd>/.goga/workflows/ directory (a ".." segment or an absolute
       prefix in the name) — return None (silent miss; workflow files are
       project-only, never resolved into the wider filesystem)
    4. When the composed file exists — parse it via `parse_workflow` and
       return the resulting `WorkflowDocument`; structural errors propagate
       with their readable messages
    5. Otherwise — return None (silent miss; an absent workflow-file is not an
       error)

    Requirements:
    - The resolution rules are exactly: disabled → None; explicit name → that
      file (an empty string counts as no name — the basename auto-match
      applies); otherwise basename auto-match; a name escaping the workflows
      directory → None; missing file → None
    - Workflow files are project-only — the directory resolves from the
      current working directory, and a composed path outside it is a silent
      miss, never a traversal into the wider filesystem

    Constraints:
    - Do not treat a missing workflow-file as an error — the auto-match is
      opt-in
    - Do not validate workflow contents beyond what `parse_workflow`
      enforces
    - Do not read environment variables — callers own their flag sources

"order_stages(stages: list[FlowStage]) -> ordered: list[FlowStage]":
  location: order_stages.py
  annotations: |
    Order flow stages for execution: a topological sort over the depends_on
    references of each `FlowStage`.

    `stages`: flow stages in declaration order
    `ordered`: the same stages ordered for execution

    Apply `convention` for docstring style.

    Algorithm:
    1. Build the ordering graph from the depends_on references of each
       `FlowStage`
    2. Repeatedly emit, among the stages whose references are all already
       emitted, the one earliest in declaration order
    3. Treat a reference naming no stage in the list as satisfied — dangling
       references are the flow executor's concern, not an ordering error
    4. When no remaining stage qualifies (a reference cycle), append the
       remaining stages in declaration order

    Requirements:
    - Deterministic — declaration order is the tie-break at every step and
      the fallback under cycles
    - The result contains every input stage exactly once — none dropped,
      none duplicated
    - Pure — the input list and its stages are not mutated

    Constraints:
    - Do not validate references for dangling ids, cycles, or duplicates
    - Do not rewrite depends_on — ordering only

"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.

    `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

    Apply `compile-flow` for the compilation contract and the documents tuple.
    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. 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
       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

    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
    - 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 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`

"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 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
    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
    `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.

    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 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
       <AFM_DIR>/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`,
        and max_parallel=`parallel`
    11. 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
    - A missing workflow-file is a silent miss, not an error
    - Skip merges onto any resolved workflow and applies to a workflow-less
      pipeline; unknown skip names surface as the compiler's structural
      error
    - <AFM_DIR>/prompts/ contains exactly four files after step 9 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 write prompts inside the project directory or /workspace —
      always <AFM_DIR>/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

"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
    - 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
      raises a structural error on a name absent from the pipeline body; this
      routine stays declarative
    - Apply `convention` for code style and docstring formatting

    Constraints:
    - Do not delete stages or rewrite depends_on here — the compiler does both
    - 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 one of four operations: the flat listing
    (`list_pipelines`), the overview (`describe_pipelines`), the card
    (`describe_pipeline`), or run coordination (`run_pipeline`). Invoked
    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 operation
                 failure

    Apply the `argparse` practice for parser construction.
    Apply the `cli_entrypoint` practice: this routine is defined in the cli
    module; __main__ only delegates to it.
    Apply `convention` for docstring style and intra-package imports.

    Algorithm:
    1. Build the parser with two subcommands:
       - list: an optional --info/-i flag
       - run: a required name positional; an optional --info/-i flag; optional
         --workflow/-w NAME and --no-workflow flags; a --port PORT integer
         required only when --info is absent; an optional --parallel N integer
    2. Parse argv; on argparse error exit with code 2
    3. Resolve the project pipelines directory (<cwd>/.goga/pipelines/) and
       the user pipelines directory (~/.goga/pipelines/)
    4. Dispatch:
       - list without --info — call `list_pipelines`; print the flat list:
         one bullet line per entry — the marker line "* <name>" (with the
         " (project)" suffix for project source entries); return 0
       - list with --info — call `describe_pipelines`; print the overview: one
         bullet block per pipeline — the marker line "* <name>" (with the
         " (project)" suffix for project source entries) followed by indented
         "name:" and "description:" field lines; return 0
       - run with --info — call `describe_pipeline` with the name and the
         parsed workflow flags; print the card: "name:" and "description:"
         field lines, a blank line, a "---" separator, a blank line, then one
         bullet block per ordered stage — the marker line "* <id>:" and an
         indented "title:" field line; return 0
       - run without --info — call `run_pipeline` with name, port, and
         parallel; return its exit code
    5. Render an operation failure as a clean readable message to stderr (no
       traceback) and return a non-zero exit code

    Requirements:
    - The run subcommand accepts --info/-i, --workflow/-w NAME,
      --no-workflow, --parallel N
    - --port is an integer option required only when --info is absent; a
      missing --port without --info is an argparse-style error (exit 2);
      --port is ignored in info mode
    - Long and short flag forms behave identically
    - The flat list template: one marker line "* <name>" per pipeline (with
      the " (project)" suffix for project-source entries) and no header line
      (example: "* deploy (project)" / "* rollback")
    - The overview template: the marker line "* <name>" (with the
      " (project)" suffix for project-source entries) followed by "name:"
      and "description:" field lines indented by four spaces; the name field
      carries the authored header name, the description field the header
      description (example: "* deploy (project)" / "    name: Deploy" /
      "    description: Deploy the service")
    - The card template: a "name:" line, a "description:" line, a blank line,
      a "---" separator, a blank line, then per ordered stage the marker line
      "* <id>:" and a "title:" line indented by four spaces; the separator
      block is printed even when the card carries no stages
    - 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
    - Card stage bullet blocks follow execution order; loop-expanded copies
      appear as separate blocks
    - Overview and card output stays within terminal width — no horizontal
      scrolling

    Constraints:
    - Do not allocate a port inside this CLI — run mode receives it from the
      host-side launcher
    - Do not validate workflow flag combinations — the host-side launcher
      owns that validation
    - Do not print tracebacks for operation failures — clean stderr messages
    - Do not import this cell from the host side — invoke via docker only

---

Author: Goga
CreatedAt: 19/08/26
Description: |
  Cell that owns the pipeline workflow: discovery of *.yml pipeline files
  across the project and user pipeline directories, the pipeline-file entity
  model, the informational surface (overview and card), run coordination,
  and the in-container CLI entrypoint pipeline_cli.
