///|
fn main {
  let argv = @sys.get_cli_args()
  let cmd = build_cli()
  // argv[0] is the program path; argparse wants only the trailing args.
  let args = if argv.length() > 0 { argv[1:] } else { argv[:] }
  let parsed : Result[@argparse.Matches, Error] = try? cmd.parse(argv=args)
  match parsed {
    Err(e) => {
      println("\{e}")
      @sys.exit(2)
    }
    Ok(matches) => {
      let dispatched : Result[Unit, Error] = try? dispatch(matches)
      match dispatched {
        Ok(_) => ()
        Err(e) => {
          println("error: \{e}")
          @sys.exit(1)
        }
      }
    }
  }
}

///|
fn no_render_flag() -> @argparse.FlagArg {
  @argparse.FlagArg::new(
    "no-render",
    long="no-render",
    about="Skip automatic refresh of .vmap/vibe-map.html.",
  )
}

///|
/// Build the `vmap` argparse command tree.
fn build_cli() -> @argparse.Command {
  @argparse.Command::new(
    "vmap",
    about="DAG-based progress visualization for vibe coding.",
    version=@core.version,
    arg_required_else_help=true,
    subcommand_required=true,
    subcommands=[
      @argparse.Command::new(
        "init",
        about="Create a new vibe-map.json in the current directory.",
        flags=[no_render_flag()],
        options=[
          @argparse.OptionArg::new("name", long="name", about="Project name"),
          @argparse.OptionArg::new(
            "file",
            long="file",
            default_values=[@core.default_path],
            about="Path to the DAG file",
          ),
        ],
      ),
      @argparse.Command::new(
        "add",
        about="Add a goal or task.",
        subcommand_required=true,
        subcommands=[
          @argparse.Command::new(
            "goal",
            about="Add a goal (a user-visible capability).",
            flags=[
              @argparse.FlagArg::new(
                "regression-testable",
                long="regression-testable",
                negatable=true,
                about="Mark as backed by regression tests (or use --no-regression-testable).",
              ),
              @argparse.FlagArg::new(
                "focus",
                long="focus",
                negatable=true,
                about="Mark this goal as a current focus region.",
              ),
              @argparse.FlagArg::new(
                "archived",
                long="archived",
                negatable=true,
                about="Mark this goal as archived.",
              ),
              no_render_flag(),
            ],
            options=[
              @argparse.OptionArg::new("id", long="id", required=true),
              @argparse.OptionArg::new("title", long="title", required=true),
              @argparse.OptionArg::new("description", long="description"),
              @argparse.OptionArg::new(
                "deps",
                long="deps",
                about="Comma-separated goal ids this depends on",
              ),
              @argparse.OptionArg::new(
                "tests",
                long="tests",
                about="Comma-separated test file paths/globs",
              ),
              @argparse.OptionArg::new(
                "docs",
                long="docs",
                about="Comma-separated documentation paths",
              ),
              @argparse.OptionArg::new(
                "closure",
                long="closure",
                about="Region closure stage (default: seed).",
              ),
              @argparse.OptionArg::new(
                "formal",
                long="formal",
                about="Automation/evidence level (default: none or checked).",
              ),
              @argparse.OptionArg::new(
                "product",
                long="product",
                about="Product key from config.products (default: default).",
              ),
              @argparse.OptionArg::new("milestone", long="milestone"),
              @argparse.OptionArg::new("owner", long="owner"),
              @argparse.OptionArg::new(
                "issue-count",
                long="issue-count",
                about="Issue/task count used for node size.",
              ),
              @argparse.OptionArg::new(
                "promoted-at",
                long="promoted-at",
                about="Comma-separated promotion timeline, e.g. scoped=2026-05-01,public=2026-05-10.",
              ),
              @argparse.OptionArg::new(
                "gh-query",
                long="gh-query",
                about="GitHub search query used by drift tooling.",
              ),
              @argparse.OptionArg::new(
                "probes",
                long="probes",
                about="Comma-separated list of regression probe commands.",
              ),
              @argparse.OptionArg::new("file", long="file", default_values=[
                @core.default_path,
              ]),
            ],
          ),
          @argparse.Command::new(
            "task",
            about="Add a task (an implementation task under a goal).",
            flags=[
              @argparse.FlagArg::new(
                "regression-testable",
                long="regression-testable",
                negatable=true,
                about="Mark as backed by regression tests (or use --no-regression-testable).",
              ),
              no_render_flag(),
            ],
            options=[
              @argparse.OptionArg::new("id", long="id", required=true),
              @argparse.OptionArg::new("goal", long="goal", required=true),
              @argparse.OptionArg::new("title", long="title", required=true),
              @argparse.OptionArg::new(
                "deps",
                long="deps",
                about="Comma-separated task ids this depends on",
              ),
              @argparse.OptionArg::new("notes", long="notes"),
              @argparse.OptionArg::new(
                "tests",
                long="tests",
                about="Comma-separated test file paths/globs",
              ),
              @argparse.OptionArg::new(
                "docs",
                long="docs",
                about="Comma-separated documentation paths",
              ),
              @argparse.OptionArg::new("file", long="file", default_values=[
                @core.default_path,
              ]),
            ],
          ),
        ],
      ),
      @argparse.Command::new(
        "update",
        about="Update fields on a goal or task. Flags that are not passed leave the existing value alone.",
        positionals=[@argparse.PositionArg::new("id")],
        flags=[
          @argparse.FlagArg::new(
            "regression-testable",
            long="regression-testable",
            negatable=true,
            about="Mark/unmark as backed by regression tests.",
          ),
          @argparse.FlagArg::new(
            "focus",
            long="focus",
            negatable=true,
            about="Mark/unmark a goal as current focus.",
          ),
          @argparse.FlagArg::new(
            "archived",
            long="archived",
            negatable=true,
            about="Mark/unmark a goal as archived.",
          ),
          no_render_flag(),
        ],
        options=[
          @argparse.OptionArg::new(
            "status",
            long="status",
            about="todo | in-progress | blocked | done",
          ),
          @argparse.OptionArg::new("title", long="title"),
          @argparse.OptionArg::new(
            "description",
            long="description",
            about="Goal description (empty string clears). Goals only.",
          ),
          @argparse.OptionArg::new(
            "notes",
            long="notes",
            about="Task notes (empty string clears). Tasks only.",
          ),
          @argparse.OptionArg::new(
            "tests",
            long="tests",
            about="Replace the test list (comma-separated; empty string clears).",
          ),
          @argparse.OptionArg::new(
            "add-test",
            long="add-test",
            about="Append one or more test paths (comma-separated).",
          ),
          @argparse.OptionArg::new(
            "remove-test",
            long="remove-test",
            about="Remove one or more test paths (comma-separated).",
          ),
          @argparse.OptionArg::new(
            "docs",
            long="docs",
            about="Replace the docs list (comma-separated; empty string clears).",
          ),
          @argparse.OptionArg::new(
            "add-doc",
            long="add-doc",
            about="Append one or more doc paths (comma-separated).",
          ),
          @argparse.OptionArg::new(
            "remove-doc",
            long="remove-doc",
            about="Remove one or more doc paths (comma-separated).",
          ),
          @argparse.OptionArg::new(
            "deps",
            long="deps",
            about="Replace the deps list (comma-separated; empty string clears).",
          ),
          @argparse.OptionArg::new(
            "add-dep",
            long="add-dep",
            about="Append one or more deps (comma-separated).",
          ),
          @argparse.OptionArg::new(
            "remove-dep",
            long="remove-dep",
            about="Remove one or more deps (comma-separated).",
          ),
          @argparse.OptionArg::new(
            "goal",
            long="goal",
            about="Move a task to another goal (task only).",
          ),
          @argparse.OptionArg::new("closure", long="closure"),
          @argparse.OptionArg::new("formal", long="formal"),
          @argparse.OptionArg::new("product", long="product"),
          @argparse.OptionArg::new("milestone", long="milestone"),
          @argparse.OptionArg::new("owner", long="owner"),
          @argparse.OptionArg::new("issue-count", long="issue-count"),
          @argparse.OptionArg::new("promoted-at", long="promoted-at"),
          @argparse.OptionArg::new("gh-query", long="gh-query"),
          @argparse.OptionArg::new(
            "probes",
            long="probes",
            about="Replace the probes list (comma-separated; empty string clears).",
          ),
          @argparse.OptionArg::new("file", long="file", default_values=[
            @core.default_path,
          ]),
        ],
      ),
      @argparse.Command::new(
        "rm",
        about="Remove a goal or task.",
        positionals=[@argparse.PositionArg::new("id")],
        flags=[no_render_flag()],
        options=[
          @argparse.OptionArg::new("file", long="file", default_values=[
            @core.default_path,
          ]),
        ],
      ),
      @argparse.Command::new(
        "render",
        about="Render the DAG to a self-contained HTML page.",
        options=[
          @argparse.OptionArg::new(
            "out",
            long="out",
            default_values=["dag.html"],
            about="Output path",
          ),
          @argparse.OptionArg::new("file", long="file", default_values=[
            @core.default_path,
          ]),
        ],
      ),
      @argparse.Command::new(
        "status",
        about="Show a summary of the DAG. With --json, emit machine-readable output. With --release , restrict to one release lane.",
        flags=[
          @argparse.FlagArg::new(
            "json",
            long="json",
            about="Emit a single JSON document instead of a text summary.",
          ),
        ],
        options=[
          @argparse.OptionArg::new(
            "release",
            long="release",
            about="Filter to goals assigned to this release key.",
          ),
          @argparse.OptionArg::new("file", long="file", default_values=[
            @core.default_path,
          ]),
        ],
      ),
      @argparse.Command::new(
        "audit",
        about="Check the DAG for missing tests / docs. Exit code 3 on violations.",
        flags=[
          @argparse.FlagArg::new(
            "json",
            long="json",
            about="Emit a single JSON document (`{ ok, violations: [...] }`).",
          ),
        ],
        options=[
          @argparse.OptionArg::new(
            "release",
            long="release",
            about="Filter violations to goals/tasks associated with this release.",
          ),
          @argparse.OptionArg::new("file", long="file", default_values=[
            @core.default_path,
          ]),
        ],
      ),
      @argparse.Command::new(
        "backfill",
        about="Synthesize goals (per package) and tasks (per source file) from an existing source tree. Merges into the DAG; existing ids are left alone. Auto-detects the template when --template / --template-file is omitted.",
        flags=[
          @argparse.FlagArg::new(
            "json",
            long="json",
            about="Emit the BackfillReport as JSON.",
          ),
          no_render_flag(),
        ],
        options=[
          @argparse.OptionArg::new(
            "src",
            long="src",
            default_values=["."],
            about="Source tree root to scan (default: current dir).",
          ),
          @argparse.OptionArg::new(
            "template",
            long="template",
            about="Built-in template name: moonbit | typescript | dotnet.",
          ),
          @argparse.OptionArg::new(
            "template-file",
            long="template-file",
            about="Path to a JSON template file (overrides --template).",
          ),
          @argparse.OptionArg::new("file", long="file", default_values=[
            @core.default_path,
          ]),
        ],
      ),
      @argparse.Command::new(
        "import",
        about="Validate a hand-crafted vibe-map.json (ids unique, deps resolve, no cycles) and write it to the target file. Useful for AI bulk-loading.",
        flags=[no_render_flag()],
        options=[
          @argparse.OptionArg::new(
            "in",
            long="in",
            required=true,
            about="Path to the source JSON file.",
          ),
          @argparse.OptionArg::new("file", long="file", default_values=[
            @core.default_path,
          ]),
        ],
      ),
      @argparse.Command::new(
        "plan",
        about="Scan markdown files and synthesize nodes. --format selects the parser: `checklist` (default) for `- [ ]` items, `tlist` for `## T — title` + `**Status:**` lines.",
        flags=[
          @argparse.FlagArg::new(
            "json",
            long="json",
            about="Emit the PlanReport as JSON.",
          ),
          no_render_flag(),
        ],
        options=[
          @argparse.OptionArg::new(
            "docs",
            long="docs",
            required=true,
            about="Comma-separated list of files or directories to scan.",
          ),
          @argparse.OptionArg::new(
            "format",
            long="format",
            default_values=["checklist"],
            about="Parser to use: checklist | tlist.",
          ),
          @argparse.OptionArg::new("file", long="file", default_values=[
            @core.default_path,
          ]),
        ],
      ),
      @argparse.Command::new(
        "release",
        about="Manage release lanes (versioned goal groupings).",
        subcommand_required=true,
        subcommands=[
          @argparse.Command::new(
            "add",
            about="Register a new release lane.",
            positionals=[@argparse.PositionArg::new("key")],
            flags=[no_render_flag()],
            options=[
              @argparse.OptionArg::new(
                "label-en",
                long="label-en",
                about="English label.",
              ),
              @argparse.OptionArg::new(
                "label-zh",
                long="label-zh",
                about="Chinese label.",
              ),
              @argparse.OptionArg::new(
                "target",
                long="target",
                about="ISO target date, e.g. 2026-06-15.",
              ),
              @argparse.OptionArg::new(
                "status",
                long="status",
                default_values=["planned"],
                about="planned | open | closed (default: planned).",
              ),
              @argparse.OptionArg::new(
                "notes",
                long="notes",
                about="Free-form notes.",
              ),
              @argparse.OptionArg::new("file", long="file", default_values=[
                @core.default_path,
              ]),
            ],
          ),
          @argparse.Command::new(
            "list",
            about="List all registered releases with member counts.",
            flags=[
              @argparse.FlagArg::new(
                "json",
                long="json",
                about="Emit a JSON array.",
              ),
            ],
            options=[
              @argparse.OptionArg::new("file", long="file", default_values=[
                @core.default_path,
              ]),
            ],
          ),
          @argparse.Command::new(
            "assign",
            about="Assign one or more goals to a release lane.",
            positionals=[@argparse.PositionArg::new("key")],
            flags=[no_render_flag()],
            options=[
              @argparse.OptionArg::new(
                "goals",
                long="goals",
                required=true,
                about="Comma-separated goal ids to assign.",
              ),
              @argparse.OptionArg::new("file", long="file", default_values=[
                @core.default_path,
              ]),
            ],
          ),
          @argparse.Command::new(
            "rm",
            about="Remove a release lane. Refuses if goals still point at it unless --force.",
            positionals=[@argparse.PositionArg::new("key")],
            flags=[
              @argparse.FlagArg::new(
                "force",
                long="force",
                about="Clear milestone on member goals before removing.",
              ),
              no_render_flag(),
            ],
            options=[
              @argparse.OptionArg::new("file", long="file", default_values=[
                @core.default_path,
              ]),
            ],
          ),
          @argparse.Command::new(
            "update",
            about="Modify release fields. Status transitions only move forward (planned → open → closed).",
            positionals=[@argparse.PositionArg::new("key")],
            flags=[no_render_flag()],
            options=[
              @argparse.OptionArg::new("label-en", long="label-en"),
              @argparse.OptionArg::new("label-zh", long="label-zh"),
              @argparse.OptionArg::new("target", long="target"),
              @argparse.OptionArg::new(
                "status",
                long="status",
                about="planned | open | closed",
              ),
              @argparse.OptionArg::new("notes", long="notes"),
              @argparse.OptionArg::new("file", long="file", default_values=[
                @core.default_path,
              ]),
            ],
          ),
          @argparse.Command::new(
            "unassign",
            about="Clear `milestone` on goals currently pointing at this release. Without --goals, clears all members.",
            positionals=[@argparse.PositionArg::new("key")],
            flags=[no_render_flag()],
            options=[
              @argparse.OptionArg::new(
                "goals",
                long="goals",
                about="Comma-separated goal ids to unassign (default: all members).",
              ),
              @argparse.OptionArg::new("file", long="file", default_values=[
                @core.default_path,
              ]),
            ],
          ),
          @argparse.Command::new(
            "status",
            about="Show a progress snapshot of a release.",
            positionals=[@argparse.PositionArg::new("key")],
            flags=[
              @argparse.FlagArg::new(
                "json",
                long="json",
                about="Emit a JSON snapshot.",
              ),
            ],
            options=[
              @argparse.OptionArg::new("file", long="file", default_values=[
                @core.default_path,
              ]),
            ],
          ),
          @argparse.Command::new(
            "close",
            about="Close a release lane if all member goals are committed or shipped.",
            positionals=[@argparse.PositionArg::new("key")],
            flags=[no_render_flag()],
            options=[
              @argparse.OptionArg::new(
                "date",
                long="date",
                about="Date override (YYYY-MM-DD). If omitted, retrieves system date.",
              ),
              @argparse.OptionArg::new("file", long="file", default_values=[
                @core.default_path,
              ]),
            ],
          ),
        ],
      ),
      @argparse.Command::new(
        "show",
        about="Show full details of one goal or task. With --json, emit machine-readable output.",
        positionals=[@argparse.PositionArg::new("id")],
        flags=[
          @argparse.FlagArg::new(
            "json",
            long="json",
            about="Emit the node as a JSON document.",
          ),
        ],
        options=[
          @argparse.OptionArg::new("file", long="file", default_values=[
            @core.default_path,
          ]),
        ],
      ),
      @argparse.Command::new(
        "list",
        about="List goals or tasks with filter flags.",
        subcommand_required=true,
        subcommands=[
          @argparse.Command::new(
            "goals",
            about="List goals. Filter flags compose.",
            flags=[
              @argparse.FlagArg::new(
                "json",
                long="json",
                about="Emit a JSON array.",
              ),
              @argparse.FlagArg::new(
                "focus",
                long="focus",
                about="Only goals with focus=true.",
              ),
              @argparse.FlagArg::new(
                "archived",
                long="archived",
                about="Only goals with archived=true.",
              ),
              @argparse.FlagArg::new(
                "no-tests",
                long="no-tests",
                about="Only goals with empty tests list.",
              ),
              @argparse.FlagArg::new(
                "no-docs",
                long="no-docs",
                about="Only goals with empty docs list.",
              ),
            ],
            options=[
              @argparse.OptionArg::new(
                "release",
                long="release",
                about="Filter to one release key.",
              ),
              @argparse.OptionArg::new(
                "closure",
                long="closure",
                about="Filter to one closure tier.",
              ),
              @argparse.OptionArg::new(
                "owner",
                long="owner",
                about="Filter to one owner.",
              ),
              @argparse.OptionArg::new(
                "product",
                long="product",
                about="Filter to one product key.",
              ),
              @argparse.OptionArg::new("file", long="file", default_values=[
                @core.default_path,
              ]),
            ],
          ),
          @argparse.Command::new(
            "tasks",
            about="List tasks. Filter flags compose.",
            flags=[
              @argparse.FlagArg::new(
                "json",
                long="json",
                about="Emit a JSON array.",
              ),
            ],
            options=[
              @argparse.OptionArg::new(
                "goal",
                long="goal",
                about="Filter to tasks under one goal.",
              ),
              @argparse.OptionArg::new(
                "status",
                long="status",
                about="Filter to one status: todo | in-progress | blocked | done.",
              ),
              @argparse.OptionArg::new("file", long="file", default_values=[
                @core.default_path,
              ]),
            ],
          ),
        ],
      ),
      @argparse.Command::new(
        "deps",
        about="Show dependency graph for a node: --upstream (what it depends on), --downstream (what depends on it), or both (default).",
        positionals=[@argparse.PositionArg::new("id")],
        flags=[
          @argparse.FlagArg::new(
            "upstream",
            long="upstream",
            about="Show transitive upstream only.",
          ),
          @argparse.FlagArg::new(
            "downstream",
            long="downstream",
            about="Show transitive downstream only.",
          ),
          @argparse.FlagArg::new("json", long="json", about="Emit JSON."),
        ],
        options=[
          @argparse.OptionArg::new("file", long="file", default_values=[
            @core.default_path,
          ]),
        ],
      ),
      @argparse.Command::new(
        "context",
        about="Emit the minimal deterministic context slice for a goal or task as JSON: { files, ancestorGoals }.",
        positionals=[@argparse.PositionArg::new("id")],
        options=[
          @argparse.OptionArg::new("file", long="file", default_values=[
            @core.default_path,
          ]),
        ],
      ),
      @argparse.Command::new(
        "rename",
        about="Rename a goal or task id; rewrites all references (deps + task.goal).",
        positionals=[
          @argparse.PositionArg::new("old"),
          @argparse.PositionArg::new("new"),
        ],
        flags=[no_render_flag()],
        options=[
          @argparse.OptionArg::new("file", long="file", default_values=[
            @core.default_path,
          ]),
        ],
      ),
      @argparse.Command::new(
        "version",
        about="Print the vmap version string. With --check, also print the command to check for newer releases.",
        flags=[
          @argparse.FlagArg::new(
            "json",
            long="json",
            about="Emit a JSON document.",
          ),
          @argparse.FlagArg::new(
            "check",
            long="check",
            about="Also print the curl command that checks for a newer release on GitHub.",
          ),
        ],
      ),
      @argparse.Command::new(
        "upgrade",
        about="Print the upgrade command. Pipe through bash to actually upgrade: `vmap upgrade | bash`.",
      ),
      @argparse.Command::new(
        "coverage",
        about="Report how much of the project's docs / source / GitHub issues is referenced from the DAG. Use as a bootstrap gate: <70% means the DAG probably missed major capabilities.",
        flags=[
          @argparse.FlagArg::new(
            "json",
            long="json",
            about="Emit a single CoverageReport JSON document.",
          ),
          @argparse.FlagArg::new(
            "docs-only",
            long="docs-only",
            about="Only run the docs dimension.",
          ),
          @argparse.FlagArg::new(
            "code-only",
            long="code-only",
            about="Only run the code dimension.",
          ),
          @argparse.FlagArg::new(
            "issues-only",
            long="issues-only",
            about="Only run the issues dimension (requires --issues-file).",
          ),
        ],
        options=[
          @argparse.OptionArg::new(
            "docs-root",
            long="docs-root",
            about="Comma-separated paths to scan for markdown (default: `.`).",
          ),
          @argparse.OptionArg::new(
            "src-root",
            long="src-root",
            about="Top-level source dir whose subdirs are checked against goal/task ids (default: auto-detect src | lib | app | packages).",
          ),
          @argparse.OptionArg::new(
            "issues-file",
            long="issues-file",
            about="Path to JSON file produced by `gh issue list --json number,title,state,url --limit 500 --state all`. If omitted, the issues dimension is skipped.",
          ),
          @argparse.OptionArg::new(
            "max-examples",
            long="max-examples",
            default_values=["50"],
            about="Cap the number of unreferenced/unmatched examples shown per dimension.",
          ),
          @argparse.OptionArg::new("file", long="file", default_values=[
            @core.default_path,
          ]),
        ],
      ),
      @argparse.Command::new(
        "doctor",
        about="Aggregate health warnings: density anomalies, release-lane imbalance, orphan goals, codebase↔DAG mismatches. Advisory only — always exits 0.",
        flags=[
          @argparse.FlagArg::new(
            "json",
            long="json",
            about="Emit a single DoctorReport JSON document.",
          ),
        ],
        options=[
          @argparse.OptionArg::new(
            "scan-root",
            long="scan-root",
            about="Root path for the markdown scan that feeds the density check (default: `.`).",
          ),
          @argparse.OptionArg::new(
            "src-root",
            long="src-root",
            about="Top-level source dir for the codebase-mismatch check (default: auto-detect).",
          ),
          @argparse.OptionArg::new("file", long="file", default_values=[
            @core.default_path,
          ]),
        ],
      ),
      @argparse.Command::new(
        "regression",
        about="AI-assisted regression fixing utilities.",
        subcommand_required=true,
        subcommands=[
          @argparse.Command::new(
            "prompt",
            about="Generate a diagnostic prompt for an AI agent to fix a goal's regression.",
            options=[
              @argparse.OptionArg::new(
                "id",
                long="id",
                required=true,
                about="Goal ID to generate prompt for",
              ),
              @argparse.OptionArg::new(
                "error-file",
                long="error-file",
                about="Path to file containing test failure output",
              ),
              @argparse.OptionArg::new(
                "file",
                long="file",
                default_values=[@core.default_path],
                about="Path to the vibe-map.json file",
              ),
            ],
          ),
          @argparse.Command::new(
            "run",
            about="Run regression probes for goals and record status.",
            flags=[
              @argparse.FlagArg::new(
                "all",
                long="all",
                about="Run probes for all goals, even if not completed.",
              ),
              no_render_flag(),
            ],
            options=[
              @argparse.OptionArg::new(
                "id",
                long="id",
                about="Run probes only for a specific goal",
              ),
              @argparse.OptionArg::new(
                "file",
                long="file",
                default_values=[@core.default_path],
                about="Path to the vibe-map.json file",
              ),
            ],
          ),
        ],
      ),
    ],
  )
}

///|
fn dispatch(matches : @argparse.Matches) -> Unit raise {
  let (name, sub) = matches.subcommand.unwrap()
  match name {
    "init" => cmd_init(sub)
    "add" => {
      let (sub_name, sub_sub) = sub.subcommand.unwrap()
      match sub_name {
        "goal" => cmd_add_goal(sub_sub)
        "task" => cmd_add_task(sub_sub)
        _ => fail("unknown add subcommand: \{sub_name}")
      }
    }
    "update" => cmd_update(sub)
    "rm" => cmd_rm(sub)
    "render" => cmd_render(sub)
    "status" => cmd_status(sub)
    "audit" => cmd_audit(sub)
    "backfill" => cmd_backfill(sub)
    "import" => cmd_import(sub)
    "plan" => cmd_plan(sub)
    "release" => {
      let (sub_name, sub_sub) = sub.subcommand.unwrap()
      match sub_name {
        "add" => cmd_release_add(sub_sub)
        "list" => cmd_release_list(sub_sub)
        "assign" => cmd_release_assign(sub_sub)
        "rm" => cmd_release_rm(sub_sub)
        "update" => cmd_release_update(sub_sub)
        "unassign" => cmd_release_unassign(sub_sub)
        "status" => cmd_release_status(sub_sub)
        "close" => cmd_release_close(sub_sub)
        _ => fail("unknown release subcommand: \{sub_name}")
      }
    }
    "show" => cmd_show(sub)
    "list" => {
      let (sub_name, sub_sub) = sub.subcommand.unwrap()
      match sub_name {
        "goals" => cmd_list_goals(sub_sub)
        "tasks" => cmd_list_tasks(sub_sub)
        _ => fail("unknown list subcommand: \{sub_name}")
      }
    }
    "deps" => cmd_deps(sub)
    "context" => cmd_context(sub)
    "rename" => cmd_rename(sub)
    "version" => cmd_version(sub)
    "upgrade" => cmd_upgrade(sub)
    "coverage" => cmd_coverage(sub)
    "doctor" => cmd_doctor(sub)
    "regression" => {
      let (sub_name, sub_sub) = sub.subcommand.unwrap()
      match sub_name {
        "prompt" => cmd_regression_prompt(sub_sub)
        "run" => cmd_regression_run(sub_sub)
        _ => fail("unknown regression subcommand: \{sub_name}")
      }
    }
    _ => fail("unknown subcommand: \{name}")
  }
}

///|
/// Read the first value bound to `key`, or `default` if it was omitted.
fn opt(m : @argparse.Matches, key : String, default? : String = "") -> String {
  match m.values.get(key) {
    Some(arr) => if arr.length() > 0 { arr[0] } else { default }
    None => default
  }
}

///|
/// True if the user explicitly passed `--key` on argv (not a default).
fn provided(m : @argparse.Matches, key : String) -> Bool {
  m.sources.get(key) is Some(@argparse.Argv)
}

///|
/// Parse `--deps a,b,c` (or empty string) into an array, dropping blanks.
fn parse_csv(raw : String) -> Array[String] {
  if raw == "" {
    return []
  }
  let out = []
  for part in raw.split(",") {
    let trimmed = part.trim().to_owned()
    if trimmed != "" {
      out.push(trimmed)
    }
  }
  out
}

///|
fn parse_int_arg(raw : String, name : String) -> Int raise {
  @string.parse_int(raw) catch {
    _ => fail("invalid \{name}: \{raw} (want integer)")
  }
}

///|
fn parse_optional_int_arg(
  raw : String,
  name : String,
  default? : Int = 0,
) -> Int raise {
  if raw == "" {
    default
  } else {
    parse_int_arg(raw, name)
  }
}

///|
fn parse_kv_csv(raw : String, name : String) -> Map[String, String] raise {
  let out : Map[String, String] = {}
  if raw == "" {
    return out
  }
  for part in raw.split(",") {
    let trimmed = part.trim().to_owned()
    if trimmed == "" {
      continue
    }
    let pieces = trimmed.split("=").to_array()
    if pieces.length() != 2 {
      fail("invalid \{name} item: \{trimmed} (want key=value)")
    }
    let key = pieces[0].trim().to_owned()
    let value = pieces[1].trim().to_owned()
    if key == "" || value == "" {
      fail("invalid \{name} item: \{trimmed} (key and value are required)")
    }
    out[key] = value
  }
  out
}

///|
fn parse_status(raw : String) -> @core.Status raise {
  match raw {
    "todo" => @core.Todo
    "in-progress" => @core.InProgress
    "blocked" => @core.Blocked
    "done" => @core.Done
    _ => fail("invalid status: \{raw} (want todo|in-progress|blocked|done)")
  }
}

///|
/// After a mutating command saves the DAG, refresh the rendered HTML at
/// `.vmap/vibe-map.html` so the human-facing view tracks every CLI edit.
/// Honors `--no-render` on commands that declare the flag.
fn maybe_render(dag : @core.Dag, m : @argparse.Matches) -> Unit raise {
  if m.flags.get("no-render").unwrap_or(false) {
    return
  }
  let html = @render.render(dag)
  @core.write_text(@core.default_render_path, html)
}

///|
fn cmd_init(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let name = opt(m, "name", default="untitled")
  let dag = @core.Dag::empty(name)
  @core.save(dag, path)
  maybe_render(dag, m)
  println("created \{path} (project: \{name})")
  if m.flags.get("no-render").unwrap_or(false) {
    println("render skipped (--no-render)")
  } else {
    println("rendered -> \{@core.default_render_path}")
  }
  println("")
  println(
    "tip: add `.vmap/` to .gitignore — the json + rendered html are local working state, not source.",
  )
}

///|
fn cmd_add_goal(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let id = opt(m, "id")
  let title = opt(m, "title")
  let probes = if provided(m, "probes") {
    Some(parse_csv(opt(m, "probes")))
  } else {
    None
  }
  let dag = @core.load(path, default_name="untitled")
  dag.add_goal(
    id~,
    title~,
    description=opt(m, "description"),
    deps=parse_csv(opt(m, "deps")),
    closure=opt(m, "closure"),
    formal=opt(m, "formal"),
    product=opt(m, "product"),
    milestone=opt(m, "milestone"),
    owner=opt(m, "owner"),
    issue_count=parse_optional_int_arg(opt(m, "issue-count"), "issue-count"),
    focus=m.flags.get("focus").unwrap_or(false),
    archived=m.flags.get("archived").unwrap_or(false),
    promoted_at=parse_kv_csv(opt(m, "promoted-at"), "promoted-at"),
    gh_query=opt(m, "gh-query"),
    regression_testable=m.flags.get("regression-testable").unwrap_or(false),
    tests=parse_csv(opt(m, "tests")),
    docs=parse_csv(opt(m, "docs")),
    probes~,
  )
  @core.save(dag, path)
  maybe_render(dag, m)
  println("+ goal \{id}: \{title}")
}

///|
fn cmd_add_task(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let id = opt(m, "id")
  let goal = opt(m, "goal")
  let title = opt(m, "title")
  let dag = @core.load(path, default_name="untitled")
  dag.add_task(
    id~,
    goal~,
    title~,
    deps=parse_csv(opt(m, "deps")),
    notes=opt(m, "notes"),
    regression_testable=m.flags.get("regression-testable").unwrap_or(false),
    tests=parse_csv(opt(m, "tests")),
    docs=parse_csv(opt(m, "docs")),
  )
  @core.save(dag, path)
  maybe_render(dag, m)
  println("+ task \{id} under \{goal}: \{title}")
}

///|
fn cmd_update(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let id = opt(m, "id")
  let dag = @core.load(path, default_name="untitled")
  let mut touched = false
  if provided(m, "status") {
    let s = opt(m, "status")
    dag.update_status(id, parse_status(s))
    println("~ \{id} status -> \{s}")
    touched = true
  }
  if provided(m, "title") {
    let t = opt(m, "title")
    dag.update_title(id, t)
    println("~ \{id} title -> \{t}")
    touched = true
  }
  if provided(m, "regression-testable") {
    let v = m.flags.get("regression-testable").unwrap_or(false)
    dag.set_regression_testable(id, v)
    println("~ \{id} regression_testable -> \{v}")
    touched = true
  }
  if provided(m, "tests") {
    let ts = parse_csv(opt(m, "tests"))
    dag.set_tests(id, ts)
    println("~ \{id} tests -> [\{ts.length()}]")
    touched = true
  }
  if provided(m, "docs") {
    let ds = parse_csv(opt(m, "docs"))
    dag.set_docs(id, ds)
    println("~ \{id} docs -> [\{ds.length()}]")
    touched = true
  }
  if provided(m, "closure") {
    let value = opt(m, "closure")
    dag.set_goal_closure(id, value)
    println("~ \{id} closure -> \{value}")
    touched = true
  }
  if provided(m, "formal") {
    let value = opt(m, "formal")
    dag.set_goal_formal(id, value)
    println("~ \{id} formal -> \{value}")
    touched = true
  }
  if provided(m, "product") {
    let value = opt(m, "product")
    dag.set_goal_product(id, value)
    println("~ \{id} product -> \{value}")
    touched = true
  }
  if provided(m, "milestone") {
    let value = opt(m, "milestone")
    dag.set_goal_milestone(id, value)
    println("~ \{id} milestone -> \{value}")
    touched = true
  }
  if provided(m, "owner") {
    let value = opt(m, "owner")
    dag.set_goal_owner(id, value)
    println("~ \{id} owner -> \{value}")
    touched = true
  }
  if provided(m, "issue-count") {
    let value = parse_int_arg(opt(m, "issue-count"), "issue-count")
    dag.set_goal_issue_count(id, value)
    println("~ \{id} issue_count -> \{value}")
    touched = true
  }
  if provided(m, "focus") {
    let value = m.flags.get("focus").unwrap_or(false)
    dag.set_goal_focus(id, value)
    println("~ \{id} focus -> \{value}")
    touched = true
  }
  if provided(m, "archived") {
    let value = m.flags.get("archived").unwrap_or(false)
    dag.set_goal_archived(id, value)
    println("~ \{id} archived -> \{value}")
    touched = true
  }
  if provided(m, "promoted-at") {
    let value = parse_kv_csv(opt(m, "promoted-at"), "promoted-at")
    dag.set_goal_promoted_at(id, value)
    println("~ \{id} promoted_at -> [\{value.length()}]")
    touched = true
  }
  if provided(m, "gh-query") {
    let value = opt(m, "gh-query")
    dag.set_goal_gh_query(id, value)
    println("~ \{id} gh_query -> \{value}")
    touched = true
  }
  if provided(m, "deps") {
    let ds = parse_csv(opt(m, "deps"))
    dag.set_deps(id, ds)
    println("~ \{id} deps -> [\{ds.length()}]")
    touched = true
  }
  if provided(m, "add-dep") {
    for d in parse_csv(opt(m, "add-dep")) {
      dag.add_dep(id, d)
      println("~ \{id} +dep \{d}")
    }
    touched = true
  }
  if provided(m, "remove-dep") {
    for d in parse_csv(opt(m, "remove-dep")) {
      dag.remove_dep(id, d)
      println("~ \{id} -dep \{d}")
    }
    touched = true
  }
  if provided(m, "goal") {
    let value = opt(m, "goal")
    dag.set_task_goal(id, value)
    println("~ \{id} goal -> \{value}")
    touched = true
  }
  if provided(m, "notes") {
    let value = opt(m, "notes")
    dag.set_task_notes(id, value)
    println("~ \{id} notes -> \{value}")
    touched = true
  }
  if provided(m, "description") {
    let value = opt(m, "description")
    dag.set_description(id, value)
    println("~ \{id} description -> \{value}")
    touched = true
  }
  if provided(m, "add-test") {
    for p in parse_csv(opt(m, "add-test")) {
      dag.add_test(id, p)
      println("~ \{id} +test \{p}")
    }
    touched = true
  }
  if provided(m, "remove-test") {
    for p in parse_csv(opt(m, "remove-test")) {
      dag.remove_test(id, p)
      println("~ \{id} -test \{p}")
    }
    touched = true
  }
  if provided(m, "probes") {
    let raw = opt(m, "probes")
    let probes = if raw == "" { None } else { Some(parse_csv(raw)) }
    dag.set_goal_probes(id, probes)
    println("~ \{id} probes -> \{raw}")
    touched = true
  }
  if provided(m, "add-doc") {
    for p in parse_csv(opt(m, "add-doc")) {
      dag.add_doc(id, p)
      println("~ \{id} +doc \{p}")
    }
    touched = true
  }
  if provided(m, "remove-doc") {
    for p in parse_csv(opt(m, "remove-doc")) {
      dag.remove_doc(id, p)
      println("~ \{id} -doc \{p}")
    }
    touched = true
  }
  if !touched {
    fail("update needs at least one changed field (see `vmap update --help`)")
  }
  @core.save(dag, path)
  maybe_render(dag, m)
}

///|
fn cmd_rm(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let id = opt(m, "id")
  let dag = @core.load(path, default_name="untitled")
  dag.remove(id)
  @core.save(dag, path)
  maybe_render(dag, m)
  println("- \{id}")
}

///|
fn cmd_render(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let out = opt(m, "out", default="dag.html")
  let dag = @core.load(path, default_name="untitled")
  let html = @render.render(dag)
  @core.write_text(out, html)
  println("rendered -> \{out}")
}

///|
fn cmd_status(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let dag = @core.load(path, default_name="untitled")
  let release_filter = opt(m, "release")
  if release_filter != "" && !dag.has_release(release_filter) {
    fail("unknown release: \{release_filter}")
  }
  if m.flags.get("json").unwrap_or(false) {
    println(status_json(dag, release=release_filter).stringify(indent=2))
    return
  }
  println("project: \{dag.project.name}")
  if dag.project.description != "" {
    println("  \{dag.project.description}")
  }
  if release_filter != "" {
    println("filter: release \{release_filter}")
  }
  let filtered_goals = if release_filter == "" {
    dag.goals
  } else {
    dag.release_members(release_filter)
  }
  if filtered_goals.length() == 0 {
    if release_filter == "" {
      println("(no goals yet — try `vmap add goal --id m1 --title …`)")
    } else {
      println("(no goals assigned to release \{release_filter} yet)")
    }
    return
  }
  for goal in filtered_goals {
    let (done, total) = dag.progress(goal.id)
    let pct = if total > 0 { done * 100 / total } else { 0 }
    println("• [\{goal.id}] \{goal.title}  (\{done}/\{total} · \{pct}%)")
    for task in dag.tasks_of(goal.id) {
      let mark = match task.status {
        @core.Done => "✓"
        @core.InProgress => "…"
        @core.Blocked => "!"
        @core.Todo => "·"
      }
      println("    \{mark} [\{task.id}] \{task.title}")
    }
  }
}

///|
/// Build the JSON shape returned by `vmap status --json`: the raw DAG plus a
/// `progress` field derived per goal, so AI agents don't have to recompute.
/// When `release` is non-empty, the `dag.goals` (and their `tasks`) are
/// filtered to that lane's members. The DAG is serialized via its derived
/// ToJson so Option field encoding matches the on-disk format exactly.
fn status_json(dag : @core.Dag, release? : String = "") -> Json {
  let view = if release == "" {
    dag
  } else {
    let goals = dag.release_members(release)
    let goal_ids : Map[String, Bool] = {}
    for g in goals {
      goal_ids[g.id] = true
    }
    let tasks = []
    for t in dag.tasks {
      if goal_ids.contains(t.goal) {
        tasks.push(t)
      }
    }
    { ..dag, goals, tasks }
  }
  let progress : Map[String, Json] = {}
  for m in view.goals {
    let (done, total) = dag.progress(m.id)
    progress[m.id] = { "done": done.to_json(), "total": total.to_json() }
  }
  let payload : Map[String, Json] = {
    "dag": view.to_json(),
    "progress": progress.to_json(),
  }
  if release != "" {
    payload["release"] = release.to_json()
  }
  payload.to_json()
}

///|
fn cmd_audit(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let dag = @core.load(path, default_name="untitled")
  let raw_report = dag.audit()
  let release_filter = match m.values.get("release") {
    Some([r, ..]) => r
    _ => ""
  }
  let report = if release_filter != "" {
    let filtered = raw_report.violations.filter(fn(v) {
      if v.node_kind == "goal" {
        match dag.find_goal(v.node_id) {
          Some((_, goal)) => goal.milestone == Some(release_filter)
          None => false
        }
      } else if v.node_kind == "task" {
        match dag.find_task(v.node_id) {
          Some((_, task)) =>
            match dag.find_goal(task.goal) {
              Some((_, goal)) => goal.milestone == Some(release_filter)
              None => false
            }
          None => false
        }
      } else {
        false
      }
    })
    let r : @core.AuditReport = {
      ok: filtered.length() == 0,
      violations: filtered,
    }
    r
  } else {
    raw_report
  }
  if m.flags.get("json").unwrap_or(false) {
    println(report.to_json().stringify(indent=2))
  } else if report.ok {
    println("audit: clean (0 violations)")
  } else {
    println("audit: \{report.violations.length()} violation(s)")
    for v in report.violations {
      println("  ! [\{v.node_kind} \{v.node_id}] \{v.kind}: \{v.message}")
    }
  }
  if !report.ok {
    @sys.exit(3)
  }
}

///|
fn cmd_import(m : @argparse.Matches) -> Unit raise {
  let in_path = opt(m, "in")
  let target = opt(m, "file", default=@core.default_path)
  let dag = @core.import_dag(in_path)
  @core.save(dag, target)
  maybe_render(dag, m)
  println(
    "imported \{dag.goals.length()} goal(s), \{dag.tasks.length()} task(s) -> \{target}",
  )
}

///|
fn cmd_backfill(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let src = opt(m, "src", default="src")
  let template_name = opt(m, "template")
  let template_file = opt(m, "template-file")
  let template = if template_file != "" {
    @core.load_template_file(template_file)
  } else if template_name != "" {
    match @core.find_template_by_name(template_name) {
      Some(t) => t
      None =>
        fail(
          "unknown template: \{template_name} (built-in names: moonbit | typescript | dotnet)",
        )
    }
  } else {
    match @core.auto_detect_template(src) {
      Some(t) => t
      None =>
        fail(
          "could not auto-detect a template at \{src}; pass --template  or --template-file ",
        )
    }
  }
  let dag = @core.load(path, default_name="untitled")
  let report = dag.backfill(src, template)
  @core.save(dag, path)
  maybe_render(dag, m)
  if m.flags.get("json").unwrap_or(false) {
    println(report.to_json().stringify(indent=2))
  } else {
    println(
      "backfill[\{report.template}] from \{src}: +\{report.added_goals.length()} goal(s), +\{report.added_tasks.length()} task(s)",
    )
    if report.skipped_goals.length() > 0 || report.skipped_tasks.length() > 0 {
      println(
        "  (skipped \{report.skipped_goals.length()} goal(s) and \{report.skipped_tasks.length()} task(s) that already existed)",
      )
    }
  }
}

///|
fn cmd_plan(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let docs = parse_csv(opt(m, "docs"))
  let format = opt(m, "format", default="checklist")
  if docs.length() == 0 {
    fail("plan needs --docs ")
  }
  let dag = @core.load(path, default_name="untitled")
  let report = dag.plan_from_docs(docs, format~)
  @core.save(dag, path)
  maybe_render(dag, m)
  if m.flags.get("json").unwrap_or(false) {
    println(report.to_json().stringify(indent=2))
  } else {
    println(
      "plan from \{docs.length()} path(s): +\{report.added_goals.length()} goal(s), +\{report.added_tasks.length()} task(s)",
    )
    if report.skipped_goals.length() > 0 || report.skipped_tasks.length() > 0 {
      println(
        "  (skipped \{report.skipped_goals.length()} goal(s) and \{report.skipped_tasks.length()} task(s) that already existed)",
      )
    }
  }
}

///|
fn cmd_release_add(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let key = opt(m, "key")
  if key == "" {
    fail(
      "release add needs a positional release key, e.g. `vmap release add 0.4.0`",
    )
  }
  let label_en = opt(m, "label-en")
  let label_zh = opt(m, "label-zh")
  let target = opt(m, "target")
  let status = opt(m, "status", default="planned")
  let notes = opt(m, "notes")
  let dag = @core.load(path, default_name="untitled")
  dag.add_release(key~, label_en~, label_zh~, target~, status~, notes~)
  @core.save(dag, path)
  maybe_render(dag, m)
  let label_suffix = if label_en != "" { " — " + label_en } else { "" }
  println("+ release \{key} [\{status}]\{label_suffix}")
}

///|
fn cmd_release_list(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let dag = @core.load(path, default_name="untitled")
  let releases = match dag.releases {
    Some(arr) => arr
    None => []
  }
  if m.flags.get("json").unwrap_or(false) {
    let out = []
    for r in releases {
      let members = dag.release_member_ids(r.key)
      let entry : Map[String, Json] = {
        "key": r.key.to_json(),
        "label": r.label.to_json(),
        "status": r.status.to_json(),
        "members": members.to_json(),
      }
      match r.target {
        Some(t) => entry["target"] = t.to_json()
        None => ()
      }
      match r.notes {
        Some(n) => entry["notes"] = n.to_json()
        None => ()
      }
      match r.closed_at {
        Some(c) => entry["closed_at"] = c.to_json()
        None => ()
      }
      out.push(entry)
    }
    println(out.to_json().stringify(indent=2))
    return
  }
  if releases.length() == 0 {
    println(
      "(no releases yet — try `vmap release add  --label-en … --label-zh …`)",
    )
    return
  }
  for r in releases {
    let members = dag.release_member_ids(r.key)
    let target_str = match r.target {
      Some(t) => " · target \{t}"
      None => ""
    }
    println(
      "• \{r.key} [\{r.status}]\{target_str}  (\{members.length()} goal(s))",
    )
    if r.label.en != "" || r.label.zh != "" {
      println("    \{r.label.en} / \{r.label.zh}")
    }
  }
}

///|
fn cmd_release_assign(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let key = opt(m, "key")
  if key == "" {
    fail(
      "release assign needs a positional release key, e.g. `vmap release assign 0.4.0 --goals g1,g2`",
    )
  }
  let goal_ids = parse_csv(opt(m, "goals"))
  if goal_ids.length() == 0 {
    fail("release assign needs --goals ")
  }
  let dag = @core.load(path, default_name="untitled")
  for gid in goal_ids {
    dag.assign_release(gid, key)
    println("~ \{gid} -> release \{key}")
  }
  @core.save(dag, path)
  maybe_render(dag, m)
}

///|
fn cmd_release_rm(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let key = opt(m, "key")
  if key == "" {
    fail("release rm needs a positional release key")
  }
  let force = m.flags.get("force").unwrap_or(false)
  let dag = @core.load(path, default_name="untitled")
  dag.remove_release(key, force~)
  @core.save(dag, path)
  maybe_render(dag, m)
  println("- release \{key}")
}

///|
fn cmd_release_update(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let key = opt(m, "key")
  if key == "" {
    fail("release update needs a positional release key")
  }
  let dag = @core.load(path, default_name="untitled")
  let mut touched = false
  let label_en = if provided(m, "label-en") {
    touched = true
    opt(m, "label-en")
  } else {
    ""
  }
  let label_zh = if provided(m, "label-zh") {
    touched = true
    opt(m, "label-zh")
  } else {
    ""
  }
  let target = if provided(m, "target") {
    touched = true
    opt(m, "target")
  } else {
    ""
  }
  let status = if provided(m, "status") {
    touched = true
    opt(m, "status")
  } else {
    ""
  }
  let notes = if provided(m, "notes") {
    touched = true
    opt(m, "notes")
  } else {
    ""
  }
  if !touched {
    fail(
      "release update needs at least one field (--label-en/--label-zh/--target/--status/--notes)",
    )
  }
  dag.update_release(key, label_en~, label_zh~, target~, status~, notes~)
  @core.save(dag, path)
  maybe_render(dag, m)
  println("~ release \{key} updated")
}

///|
fn cmd_release_unassign(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let key = opt(m, "key")
  if key == "" {
    fail("release unassign needs a positional release key")
  }
  let only = parse_csv(opt(m, "goals"))
  let dag = @core.load(path, default_name="untitled")
  let affected = if only.length() == 0 {
    dag.release_member_ids(key)
  } else {
    only
  }
  dag.unassign_release(key, only)
  @core.save(dag, path)
  maybe_render(dag, m)
  for gid in affected {
    println("~ \{gid} -release \{key}")
  }
}

///|
fn goal_to_json(g : @core.Goal) -> Json {
  g.to_json()
}

///|
fn task_to_json(t : @core.Task) -> Json {
  t.to_json()
}

///|
fn cmd_show(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let id = opt(m, "id")
  if id == "" {
    fail("show needs a positional id")
  }
  let dag = @core.load(path, default_name="untitled")
  let json_mode = m.flags.get("json").unwrap_or(false)
  match dag.find_goal(id) {
    Some((_, g)) => {
      if json_mode {
        let payload : Map[String, Json] = {
          "kind": "goal".to_json(),
          "goal": goal_to_json(g),
          "tasks": dag.tasks_of(id).to_json(),
          "upstream": dag.upstream(id).to_json(),
          "downstream": dag.downstream(id).to_json(),
        }
        let (done, total) = dag.progress(id)
        payload["progress"] = {
          "done": done.to_json(),
          "total": total.to_json(),
        }
        println(payload.to_json().stringify(indent=2))
        return
      }
      let (done, total) = dag.progress(id)
      println("goal \{id}: \{g.title}  (\{done}/\{total} tasks done)")
      if g.description != "" {
        println("  \{g.description}")
      }
      let closure_s = g.closure.unwrap_or("?")
      let formal_s = g.formal.unwrap_or("?")
      let product_s = g.product.unwrap_or("?")
      println("  closure=\{closure_s} formal=\{formal_s} product=\{product_s}")
      let milestone_s = g.milestone.unwrap_or("(none)")
      let owner_s = g.owner.unwrap_or("(none)")
      let issue_count = g.issue_count.unwrap_or(0)
      println(
        "  milestone=\{milestone_s}  owner=\{owner_s}  issue_count=\{issue_count}",
      )
      let focus_v = g.focus.unwrap_or(false)
      let archived_v = g.archived.unwrap_or(false)
      println(
        "  focus=\{focus_v}  archived=\{archived_v}  regression_testable=\{g.regression_testable}",
      )
      if g.deps.length() > 0 {
        println("  deps: \{@debug.to_repr(g.deps)}")
      }
      if g.tests.length() > 0 {
        println("  tests: \{@debug.to_repr(g.tests)}")
      }
      if g.docs.length() > 0 {
        println("  docs: \{@debug.to_repr(g.docs)}")
      }
      let up = dag.upstream(id)
      if up.length() > 0 {
        println("  upstream (transitive): \{@debug.to_repr(up)}")
      }
      let down = dag.downstream(id)
      if down.length() > 0 {
        println("  downstream (transitive): \{@debug.to_repr(down)}")
      }
      let tasks = dag.tasks_of(id)
      if tasks.length() > 0 {
        println("  tasks:")
        for t in tasks {
          let mark = match t.status {
            @core.Done => "✓"
            @core.InProgress => "…"
            @core.Blocked => "!"
            @core.Todo => "·"
          }
          println("    \{mark} [\{t.id}] \{t.title}")
        }
      }
      return
    }
    None => ()
  }
  match dag.find_task(id) {
    Some((_, t)) => {
      if json_mode {
        let payload : Map[String, Json] = {
          "kind": "task".to_json(),
          "task": task_to_json(t),
          "upstream": dag.upstream(id).to_json(),
          "downstream": dag.downstream(id).to_json(),
        }
        println(payload.to_json().stringify(indent=2))
        return
      }
      let mark = match t.status {
        @core.Done => "✓"
        @core.InProgress => "…"
        @core.Blocked => "!"
        @core.Todo => "·"
      }
      println("task \{id}: \{mark} \{t.title}  (under \{t.goal})")
      if t.notes != "" {
        println("  notes: \{t.notes}")
      }
      println("  regression_testable=\{t.regression_testable}")
      if t.deps.length() > 0 {
        println("  deps: \{@debug.to_repr(t.deps)}")
      }
      if t.tests.length() > 0 {
        println("  tests: \{@debug.to_repr(t.tests)}")
      }
      if t.docs.length() > 0 {
        println("  docs: \{@debug.to_repr(t.docs)}")
      }
      let up = dag.upstream(id)
      if up.length() > 0 {
        println("  upstream (transitive): \{@debug.to_repr(up)}")
      }
      let down = dag.downstream(id)
      if down.length() > 0 {
        println("  downstream (transitive): \{@debug.to_repr(down)}")
      }
    }
    None => fail("unknown id: \{id}")
  }
}

///|
fn cmd_list_goals(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let dag = @core.load(path, default_name="untitled")
  let release_f = opt(m, "release")
  let closure_f = opt(m, "closure")
  let owner_f = opt(m, "owner")
  let product_f = opt(m, "product")
  let only_focus = m.flags.get("focus").unwrap_or(false)
  let only_archived = m.flags.get("archived").unwrap_or(false)
  let only_no_tests = m.flags.get("no-tests").unwrap_or(false)
  let only_no_docs = m.flags.get("no-docs").unwrap_or(false)
  if release_f != "" && !dag.has_release(release_f) {
    fail("unknown release: \{release_f}")
  }
  let filtered : Array[@core.Goal] = []
  for g in dag.goals {
    if release_f != "" {
      match g.milestone {
        Some(m2) => if m2 != release_f { continue }
        None => continue
      }
    }
    if closure_f != "" {
      match g.closure {
        Some(c) => if c != closure_f { continue }
        None => continue
      }
    }
    if owner_f != "" {
      match g.owner {
        Some(o) => if o != owner_f { continue }
        None => continue
      }
    }
    if product_f != "" {
      match g.product {
        Some(p) => if p != product_f { continue }
        None => continue
      }
    }
    if only_focus && !g.focus.unwrap_or(false) {
      continue
    }
    if only_archived && !g.archived.unwrap_or(false) {
      continue
    }
    if only_no_tests && g.tests.length() > 0 {
      continue
    }
    if only_no_docs && g.docs.length() > 0 {
      continue
    }
    filtered.push(g)
  }
  if m.flags.get("json").unwrap_or(false) {
    let out = []
    for g in filtered {
      out.push(goal_to_json(g))
    }
    println(out.to_json().stringify(indent=2))
    return
  }
  if filtered.length() == 0 {
    println("(0 goals matched)")
    return
  }
  for g in filtered {
    let (done, total) = dag.progress(g.id)
    let pct = if total > 0 { done * 100 / total } else { 0 }
    let closure_s = g.closure.unwrap_or("?")
    let milestone_s = g.milestone.unwrap_or("-")
    let owner_s = g.owner.unwrap_or("-")
    println(
      "• [\{g.id}] \{g.title}  closure=\{closure_s} milestone=\{milestone_s} owner=\{owner_s}  (\{done}/\{total} · \{pct}%)",
    )
  }
}

///|
fn cmd_list_tasks(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let dag = @core.load(path, default_name="untitled")
  let goal_f = opt(m, "goal")
  let status_f = opt(m, "status")
  if goal_f != "" && !dag.has_goal(goal_f) {
    fail("unknown goal: \{goal_f}")
  }
  let status_filter : @core.Status? = if status_f == "" {
    None
  } else {
    Some(parse_status(status_f))
  }
  let filtered : Array[@core.Task] = []
  for t in dag.tasks {
    if goal_f != "" && t.goal != goal_f {
      continue
    }
    match status_filter {
      Some(s) => if t.status != s { continue }
      None => ()
    }
    filtered.push(t)
  }
  if m.flags.get("json").unwrap_or(false) {
    let out = []
    for t in filtered {
      out.push(task_to_json(t))
    }
    println(out.to_json().stringify(indent=2))
    return
  }
  if filtered.length() == 0 {
    println("(0 tasks matched)")
    return
  }
  for t in filtered {
    let mark = match t.status {
      @core.Done => "✓"
      @core.InProgress => "…"
      @core.Blocked => "!"
      @core.Todo => "·"
    }
    println("\{mark} [\{t.id}] \{t.title}  (under \{t.goal})")
  }
}

///|
fn cmd_deps(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let id = opt(m, "id")
  if id == "" {
    fail("deps needs a positional id")
  }
  let dag = @core.load(path, default_name="untitled")
  if dag.kind_of(id) is None {
    fail("unknown id: \{id}")
  }
  let want_up = m.flags.get("upstream").unwrap_or(false)
  let want_down = m.flags.get("downstream").unwrap_or(false)
  let show_both = !want_up && !want_down
  let up = if want_up || show_both { dag.upstream(id) } else { [] }
  let down = if want_down || show_both { dag.downstream(id) } else { [] }
  if m.flags.get("json").unwrap_or(false) {
    let payload : Map[String, Json] = {
      "id": id.to_json(),
      "upstream": up.to_json(),
      "downstream": down.to_json(),
    }
    println(payload.to_json().stringify(indent=2))
    return
  }
  println("id: \{id}")
  if want_up || show_both {
    println("upstream (transitive, \{up.length()}):")
    for x in up {
      println("  ← \{x}")
    }
  }
  if want_down || show_both {
    println("downstream (transitive, \{down.length()}):")
    for x in down {
      println("  → \{x}")
    }
  }
}

///|
fn cmd_context(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let id = opt(m, "id")
  if id == "" {
    fail("context needs a positional id")
  }
  let dag = @core.load(path, default_name="untitled")
  let slice = dag.context(id)
  println(slice.to_json().stringify(indent=2))
}

///|
fn cmd_rename(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let old_id = opt(m, "old")
  let new_id = opt(m, "new")
  if old_id == "" || new_id == "" {
    fail("rename needs two positionals:  ")
  }
  let dag = @core.load(path, default_name="untitled")
  dag.rename(old_id, new_id)
  @core.save(dag, path)
  maybe_render(dag, m)
  println("~ \{old_id} -> \{new_id}")
}

///|
fn cmd_version(m : @argparse.Matches) -> Unit {
  let check = m.flags.get("check").unwrap_or(false)
  let json_mode = m.flags.get("json").unwrap_or(false)
  let check_url = "https://api.github.com/repos/eanzhao/vibe-map/releases/latest"
  if json_mode {
    let payload : Map[String, Json] = { "version": @core.version.to_json() }
    if check {
      payload["latest_check_url"] = check_url.to_json()
      payload["upgrade_hint"] = "vmap upgrade | bash".to_json()
    }
    println(payload.to_json().stringify())
    return
  }
  println("vmap \{@core.version}")
  if check {
    println("")
    println("# To check the latest release tag on GitHub:")
    println("curl -fsSL \{check_url} | grep -m1 tag_name | cut -d'\"' -f4")
    println("")
    println("# To upgrade if needed:")
    println("vmap upgrade | bash")
  }
}

///|
/// `vmap coverage` — how much of docs / src / issues the DAG actually
/// references. Auto-runs all three dimensions unless `--*-only` narrows it.
fn cmd_coverage(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let dag = @core.load(path, default_name="untitled")
  let docs_only = m.flags.get("docs-only").unwrap_or(false)
  let code_only = m.flags.get("code-only").unwrap_or(false)
  let issues_only = m.flags.get("issues-only").unwrap_or(false)
  let any_only = docs_only || code_only || issues_only
  let run_docs = if any_only { docs_only } else { true }
  let run_code = if any_only { code_only } else { true }
  let run_issues = if any_only { issues_only } else { true }
  let scan_roots = parse_csv(opt(m, "docs-root"))
  let src_root = opt(m, "src-root")
  let issues_file = opt(m, "issues-file")
  let max_examples = parse_optional_int_arg(
    opt(m, "max-examples"),
    "max-examples",
    default=50,
  )
  if issues_only && issues_file == "" {
    fail(
      "coverage --issues-only requires --issues-file  (produce it with: `gh issue list --json number,title,state,url --limit 500 --state all > /tmp/issues.json`)",
    )
  }
  let report = dag.coverage(
    scan_roots,
    src_root,
    issues_file,
    run_docs,
    run_code,
    run_issues,
    max_examples~,
  )
  if m.flags.get("json").unwrap_or(false) {
    println(report.to_json().stringify(indent=2))
    return
  }
  print_coverage_text(report, issues_file~)
}

///|
/// Pretty text view for `vmap coverage`. Mirrors the JSON one-for-one so
/// agents reading either get the same picture.
fn print_coverage_text(
  report : @core.CoverageReport,
  issues_file? : String = "",
) -> Unit {
  match report.docs {
    Some(d) => {
      println(
        "docs coverage:    \{format_pct_str(d.pct)}% (\{d.referenced} / \{d.total} markdown files referenced)",
      )
      if d.unreferenced_examples.length() > 0 {
        let suffix = if d.unreferenced_total > d.unreferenced_examples.length() {
          " (showing \{d.unreferenced_examples.length()} of \{d.unreferenced_total} — `--json` for the full list)"
        } else {
          ""
        }
        println("  unreferenced examples\{suffix}:")
        for p in d.unreferenced_examples {
          println("    \{p}")
        }
      }
    }
    None => ()
  }
  match report.code {
    Some(c) => {
      println(
        "code coverage:    \{format_pct_str(c.pct)}% (\{c.matched} / \{c.total} top-level src dirs match a goal/task)",
      )
      if c.unmatched.length() > 0 {
        println("  unmatched dirs:")
        for d in c.unmatched {
          println("    \{d}")
        }
      }
    }
    None => ()
  }
  match report.issues {
    Some(i) => {
      println(
        "issues coverage:  \{format_pct_str(i.pct)}% (\{i.referenced_direct} / \{i.total} issues directly referenced; \{i.open} open / \{i.closed} closed)",
      )
      if i.goals_with_gh_query > 0 {
        println(
          "  fuzzy hint: \{i.goals_with_gh_query} goal(s) have a `gh_query` field — run tools/audit_github.py for strict drift check.",
        )
      }
      if i.unreferenced_examples.length() > 0 {
        let suffix = if i.unreferenced_total > i.unreferenced_examples.length() {
          " (showing \{i.unreferenced_examples.length()} of \{i.unreferenced_total})"
        } else {
          ""
        }
        println("  unreferenced issues\{suffix}:")
        for line in i.unreferenced_examples {
          println("    \{line}")
        }
      }
    }
    None =>
      if issues_file == "" {
        println(
          "issues coverage:  (skipped — pass `--issues-file ` after `gh issue list --json number,title,state,url --state all > `)",
        )
      } else {
        println("issues coverage:  (no issues_file content read)")
      }
  }
  println("")
  println(
    "overall:          weakest dimension is `\{report.weakest}` at \{format_pct_str(report.overall_score)}%",
  )
  if report.weakest != "(none)" && report.overall_score < 70.0 {
    println(
      "  hint: bootstrap-existing-codebase playbook §1.5 sets 70% as the gate before drafting goals.",
    )
  }
}

///|
fn format_pct_str(value : Double) -> String {
  let scaled = (value * 10.0).to_int()
  let whole = scaled / 10
  let frac = scaled % 10
  "\{whole}.\{frac}"
}

///|
/// `vmap doctor` — aggregate health warnings. Always exits 0; this is
/// advisory, not a CI gate.
fn cmd_doctor(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let dag = @core.load(path, default_name="untitled")
  let scan_root = opt(m, "scan-root")
  let src_root = opt(m, "src-root")
  let report = dag.doctor(scan_root, src_root)
  if m.flags.get("json").unwrap_or(false) {
    println(report.to_json().stringify(indent=2))
    return
  }
  println(
    "doctor: \{report.warnings.length()} warning(s)  (goals=\{report.stats.goals}, tasks=\{report.stats.tasks}, releases=\{report.stats.releases}, md=\{report.stats.markdown_files}, code_dirs=\{report.stats.code_dirs})",
  )
  if report.warnings.length() == 0 {
    println("  clean — no anomalies detected.")
    return
  }
  for w in report.warnings {
    let sev_mark = if w.severity == "warn" { "!" } else { "i" }
    let goal_suffix = match w.goal_id {
      Some(id) => " [\{id}]"
      None => ""
    }
    println("  \{sev_mark} \{w.kind}\{goal_suffix}: \{w.message}")
  }
}

///|
fn cmd_upgrade(_m : @argparse.Matches) -> Unit {
  // Two lines:
  //   - leading `#` comment, so `vmap upgrade | bash` runs only the curl
  //   - the actual install.sh invocation, which downloads the latest release
  //     and refreshes ~/.local/bin/vmap and ~/.vmap/skills/.
  println(
    "# vmap upgrade — current version: \{@core.version}. Piping this output through bash will run the line below:",
  )
  println(
    "curl -fsSL https://raw.githubusercontent.com/eanzhao/vibe-map/master/install.sh | bash",
  )
}

///|
fn cmd_release_status(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let key = opt(m, "key")
  if key == "" {
    fail("release status needs a positional release key")
  }
  let dag = @core.load(path, default_name="untitled")
  let progress = dag.release_progress(key)
  let json_mode = m.flags.get("json").unwrap_or(false)
  if json_mode {
    println(progress.to_json().stringify(indent=2))
  } else {
    let r = progress.release
    let target_str = r.target.unwrap_or("none")
    let closed_str = r.closed_at.unwrap_or("none")
    let notes_str = r.notes.unwrap_or("none")
    println("Release: \{r.key}")
    println("  Label (en): \{r.label.en}")
    println("  Label (zh): \{r.label.zh}")
    println("  Status: \{r.status}")
    println("  Target Date: \{target_str}")
    println("  Closed At: \{closed_str}")
    println("  Notes: \{notes_str}")
    println("Goals: \{progress.total_goals}")
    println("  Closure Distribution:")
    let cfg = dag.effective_config()
    for tier in cfg.closure_tiers {
      match progress.by_closure.get(tier.key) {
        Some(count) => println("    \{tier.key}: \{count}")
        None => ()
      }
    }
    println("  Ready to close: \{progress.ready_to_close}")
    if progress.blocked.length() > 0 {
      println("  Blockers (below 'public'):")
      for b in progress.blocked {
        println("    - \{b}")
      }
    }
  }
}

///|
fn cmd_release_close(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let key = opt(m, "key")
  if key == "" {
    fail("release close needs a positional release key")
  }
  let dag = @core.load(path, default_name="untitled")
  let progress = dag.release_progress(key)
  if !progress.ready_to_close {
    let blocker_str = progress.blocked.join(", ")
    fail(
      "release '\{key}' cannot be closed; contains non-public goals: \{blocker_str}",
    )
  }
  let date_opt = opt(m, "date")
  let close_date = if date_opt != "" {
    date_opt
  } else {
    @core.get_current_date()
  }
  dag.update_release(key, status="closed")
  dag.set_release_closed_at(key, close_date)
  @core.save(dag, path)
  maybe_render(dag, m)
  println("~ release \{key} closed at \{close_date}")
}

///|
fn cmd_regression_prompt(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let id = opt(m, "id")
  if id == "" {
    fail("regression prompt needs a goal --id")
  }
  let error_file = opt(m, "error-file")
  let error_content = if error_file != "" {
    if !@fs.path_exists(error_file) {
      fail("error-file not found: \{error_file}")
    }
    let content = @fs.read_file_to_string(error_file) catch {
      err =>
        fail(
          "failed to read error-file '\{error_file}': \{@debug.to_repr(err)}",
        )
    }
    content
  } else {
    ""
  }
  let dag = @core.load(path, default_name="untitled")
  let prompt = dag.regression_prompt(id, error_content~) catch {
    err => fail("failed to generate regression prompt: \{@debug.to_repr(err)}")
  }
  println(prompt)
}

///|
fn cmd_regression_run(m : @argparse.Matches) -> Unit raise {
  let path = opt(m, "file", default=@core.default_path)
  let id = opt(m, "id")
  let all_goals = m.flags.get("all").unwrap_or(false)
  let dag = @core.load(path, default_name="untitled")
  let selected_goals = []

  if id != "" {
    let mut found = false
    for g in dag.goals {
      if g.id == id {
        selected_goals.push(g)
        found = true
        break
      }
    }
    if !found {
      fail("goal not found: \{id}")
    }
  } else {
    for g in dag.goals {
      let has_probes = match g.probes {
        Some(p) => p.length() > 0
        None => false
      }
      if has_probes {
        selected_goals.push(g)
      } else {
        let (done, total) = dag.progress(g.id)
        let is_completed = total > 0 && done == total
        if is_completed && !all_goals {
          println("⚠️ Warning: goal '\{g.id}' is completed but has no probes. Add probes with 'vmap update \{g.id} --probes \"...\"'.")
        }
      }
    }
  }

  if selected_goals.length() == 0 {
    println("No goals selected for regression.")
    return
  }

  let mut any_failed = false
  for g in selected_goals {
    match g.probes {
      Some(cmds) => {
        if cmds.length() > 0 {
          println("Running probes for goal '\{g.id}'...")
          let results = []
          let run_at = Some(@core.get_current_date())
          for cmd in cmds {
            println("  $ \{cmd}")
            let exit_code = @core.run_command(cmd)
            let status = if exit_code == 0 { "passed" } else { "failed" }
            if exit_code != 0 {
              any_failed = true
            }
            let error = if exit_code == 0 { None } else { Some("Exit status: \{exit_code}") }
            results.push(@core.ProbeResult::new(cmd, status, error, run_at))
            println("    -> \{status}")
          }
          dag.set_goal_probe_results(g.id, Some(results))
        }
      }
      None => ()
    }
  }

  @core.save(dag, path)
  maybe_render(dag, m)
  if any_failed {
    fail("Some regression probes failed.")
  } else {
    println("All executed regression probes passed!")
  }
}