agtag CLI v0.1 Scaffold — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Land a single PR that ships agtag v0.1.0 to PyPI — afi-template universals (learn, explain, --version), the issue noun group (post, fetch, reply) wrapping gh with culture.yaml-driven nick signing, three vendored sibling skills, and the steward-style release pipeline.

Architecture: Python 3.12, hatchling build, single-source version in pyproject.toml, agtag/ module with afi-template-derived cli/ + explain/ and original issue/ + nick.py. agtag/issue/_gh.py is the only file that shells out to gh; verb-level tests mock at that boundary. Two GH workflows: tests.yml (test+lint+version-check, replaces existing sonar.yml) and publish.yml (TestPyPI on PR, PyPI on push to main, OIDC trusted publishing).

Tech Stack: Python 3.12, uv (build/install), hatchling (build backend), pyyaml (culture.yaml), pytest + pytest-xdist + pytest-cov, black + isort + flake8 + bandit, markdownlint-cli2, GitHub Actions, SonarCloud (gated on token).

Spec: docs/superpowers/specs/2026-05-09-agtag-cli-scaffold-design.md


Phase 0 — Tracking issue + branch setup

Task 1: File tracking issue (last call to bash post-issue.sh)

Files: none committed; gh API call only.

Write /tmp/agtag-scaffold-brief.md:

Scaffold the v0.1 surface, release pipeline, and vendored skills per the
approved design at
`docs/superpowers/specs/2026-05-09-agtag-cli-scaffold-design.md`.

## Scope

- `pyproject.toml` (hatchling, name=agtag, version=0.1.0).
- `agtag/` — afi-template-derived `cli/`, `explain/`; original `issue/`,
  `nick.py`. CLI verbs: `learn`, `explain`, `issue post|fetch|reply`,
  plus `--version`/`--json` everywhere.
- `culture.yaml` with `suffix: agtag`.
- Vendored skills: `version-bump`, `run-tests`, `pypi-maintainer` (from
  `agentculture/steward`).
- `communicate/post-issue.sh` removed; SKILL.md routes the post-issue
  path through `agtag issue post`.
- Workflows: `tests.yml` (replaces standalone `sonar.yml`) and
  `publish.yml`.
- Coverage gate at 80%.

## Out of scope (deferred)

- v0.2: `agtag message` (migration of `culture agent message`).
- agex-cli (separate repo): owns the eventual migration of
  `cicd/pr-reply.sh` + `cicd/pr-batch.sh`. `cicd` is untouched here.

## Pre-merge gate

Verify (Test)PyPI trusted-publisher records exist for `agtag` under
`agentculture/agtag` workflow `publish.yml` and environments
`testpypi` / `pypi`.

Run from /home/spark/git/agtag:

bash .claude/skills/communicate/scripts/post-issue.sh \
    --repo agentculture/agtag \
    --title "Scaffold agtag CLI v0.1 (afi template + issue verbs + release pipeline)" \
    --body-file /tmp/agtag-scaffold-brief.md

Expected output: a single line https://github.com/agentculture/agtag/issues/<N>.

Save <N> for use in Task 30 (PR body Closes #<N>). No commit.


Task 2: Create branch + cite afi reference + update .gitignore

Files:

cd /home/spark/git/agtag
git checkout -b scaffold/v0.1
afi cli cite .

Expected: prints wrote 11 files under .afi/reference/python-cli/. Verify ls .afi/reference/python-cli/{{slug}}/cli/_errors.py exists.

Append to .gitignore:

# afi-cli reference tree (read-only template; not committed)
.afi/
git add .gitignore
git commit -m "chore: ignore .afi/ reference tree"

Phase 1 — Project foundation

Task 3: pyproject.toml + culture.yaml + CHANGELOG

Files:

[project]
name = "agtag"
version = "0.1.0"
description = "Agent to Agent communication CLI."
readme = "README.md"
license = "MIT"
requires-python = ">=3.12"
authors = [{name = "AgentCulture"}]
classifiers = [
    "Development Status :: 3 - Alpha",
    "Programming Language :: Python :: 3.12",
    "License :: OSI Approved :: MIT License",
    "Topic :: Software Development",
    "Intended Audience :: Developers",
]
dependencies = [
    "pyyaml>=6.0",
]

[project.urls]
Homepage = "https://github.com/agentculture/agtag"
Issues = "https://github.com/agentculture/agtag/issues"

[project.scripts]
agtag = "agtag.cli:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["agtag"]

[dependency-groups]
dev = [
    "pytest>=8.0",
    "pytest-xdist>=3.0",
    "pytest-cov>=4.1",
    "bandit>=1.7.5",
    "flake8>=6.1",
    "isort>=5.12.0",
    "black>=23.7.0",
]

[tool.coverage.run]
source = ["agtag"]
omit = ["agtag/__pycache__/*", "agtag/__main__.py"]

[tool.coverage.report]
fail_under = 80
show_missing = true
exclude_lines = [
    "pragma: no cover",
    "if __name__ == .__main__.",
    "if TYPE_CHECKING:",
]

[tool.isort]
profile = "black"
line_length = 100
known_first_party = ["agtag"]

[tool.black]
line-length = 100
target-version = ["py312"]

[tool.bandit]
exclude_dirs = ["tests"]
skips = ["B101", "B404", "B603"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"
agents:
- suffix: agtag
  backend: claude
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.0] - 2026-05-09

### Added

- Initial scaffold: agent-first CLI with `learn`, `explain`, `--version`,
  `--json`, structured errors, exit-code policy.
- `issue` noun group: `post`, `fetch`, `reply`. Wraps `gh` with auto-signature
  resolved from `culture.yaml` (fallback: repo basename). `--as NICK` overrides.
  Idempotent: re-signing a body that already ends with the signature is a no-op.
- Vendored skills from `agentculture/steward`: `version-bump`, `run-tests`,
  `pypi-maintainer`.
- Removed `communicate/scripts/post-issue.sh`; SKILL.md now routes the
  post-issue path through `agtag issue post`.
- GitHub workflows: `tests.yml` (test+lint+version-check, replaces
  standalone `sonar.yml`) and `publish.yml` (TestPyPI on PR, PyPI on push).
- `culture.yaml` with `suffix: agtag`.
uv sync

Expected: creates .venv/, installs hatchling/pyyaml + dev deps, prints Resolved N packages. No errors.

git add pyproject.toml culture.yaml CHANGELOG.md
git commit -m "feat: pyproject.toml, culture.yaml, CHANGELOG for v0.1.0"

Phase 2 — Core CLI plumbing (TDD where genuine; copy-then-test for stable-contract)

Task 4: Package init + __main__ (TDD)

Files:

tests/test_version.py:

"""Verify __version__ is populated from package metadata."""

from __future__ import annotations


def test_version_is_populated() -> None:
    from agtag import __version__

    assert __version__
    assert __version__ != "0.0.0"
uv run pytest tests/test_version.py -v

Expected: FAIL with ModuleNotFoundError: No module named 'agtag'.

"""agtag — Agent to Agent communication CLI."""

from __future__ import annotations

from importlib.metadata import PackageNotFoundError, version as _pkg_version

try:
    __version__ = _pkg_version("agtag")
except PackageNotFoundError:  # pragma: no cover
    __version__ = "0.0.0"

__all__ = ["__version__"]
"""Entry point for ``python -m agtag``."""

from __future__ import annotations

import sys

from agtag.cli import main

if __name__ == "__main__":
    sys.exit(main())

Create empty tests/__init__.py.

uv pip install -e .
uv run pytest tests/test_version.py -v

Expected: PASS. __version__ == "0.1.0".

git add agtag/__init__.py agtag/__main__.py tests/__init__.py tests/test_version.py
git commit -m "feat: agtag package skeleton with __version__ from metadata"

Task 5: cli/_errors.py + cli/_output.py (stable-contract; verbatim from afi template, renamed)

Files:

tests/test_errors_output.py:

"""Smoke tests for the AgtagError + emit_* contract."""

from __future__ import annotations

import io
import json

import pytest

from agtag.cli._errors import (
    EXIT_ENV_ERROR,
    EXIT_SUCCESS,
    EXIT_USER_ERROR,
    AgtagError,
)
from agtag.cli._output import emit_diagnostic, emit_error, emit_result


def test_exit_codes() -> None:
    assert EXIT_SUCCESS == 0
    assert EXIT_USER_ERROR == 1
    assert EXIT_ENV_ERROR == 2


def test_agtag_error_to_dict() -> None:
    err = AgtagError(code=1, message="bad", remediation="try X")
    assert err.to_dict() == {"code": 1, "message": "bad", "remediation": "try X"}


def test_emit_result_text() -> None:
    s = io.StringIO()
    emit_result("hello", json_mode=False, stream=s)
    assert s.getvalue() == "hello\n"


def test_emit_result_json() -> None:
    s = io.StringIO()
    emit_result({"a": 1}, json_mode=True, stream=s)
    assert json.loads(s.getvalue()) == {"a": 1}


def test_emit_error_text_with_hint() -> None:
    s = io.StringIO()
    emit_error(AgtagError(code=1, message="bad", remediation="try X"), json_mode=False, stream=s)
    assert "error: bad" in s.getvalue()
    assert "hint: try X" in s.getvalue()


def test_emit_error_json() -> None:
    s = io.StringIO()
    emit_error(AgtagError(code=2, message="env"), json_mode=True, stream=s)
    assert json.loads(s.getvalue()) == {"code": 2, "message": "env", "remediation": ""}


def test_emit_diagnostic() -> None:
    s = io.StringIO()
    emit_diagnostic("hello", stream=s)
    assert s.getvalue() == "hello\n"
uv run pytest tests/test_errors_output.py -v

Expected: FAIL with ModuleNotFoundError: No module named 'agtag.cli'.

"""agtag CLI package (parser lands in Task 8)."""
"""AgtagError and exit-code policy (stable-contract — copy verbatim).

Every failure inside agtag raises :class:`AgtagError`. The CLI entry point
catches it and exits with :attr:`AgtagError.code`. Guarantees:

* no Python traceback leaks to stderr;
* every error has shape ``{code, message, remediation}``;
* the exit-code policy is centralised.
"""

from __future__ import annotations

from dataclasses import dataclass

# Exit-code policy (documented in ``agtag learn`` output).
# 0  = success
# 1  = user-input error (bad flag, bad path, missing arg)
# 2  = environment / setup error
# 3+ = reserved
EXIT_SUCCESS = 0
EXIT_USER_ERROR = 1
EXIT_ENV_ERROR = 2


@dataclass
class AgtagError(Exception):
    """Structured error with a remediation hint for agents."""

    code: int
    message: str
    remediation: str = ""

    def __post_init__(self) -> None:
        super().__init__(self.message)

    def to_dict(self) -> dict[str, object]:
        return {
            "code": self.code,
            "message": self.message,
            "remediation": self.remediation,
        }
"""stdout / stderr helpers with a strict split (stable-contract).

Rule: **results go to stdout, diagnostics and errors go to stderr.** Agents
parsing output can rely on this invariant. JSON mode routes structured
payloads to the same streams — never mixes them.
"""

from __future__ import annotations

import json
import sys
from typing import Any, TextIO

from agtag.cli._errors import AgtagError


def emit_result(data: Any, *, json_mode: bool, stream: TextIO | None = None) -> None:
    """Write a command result to stdout (or ``stream``)."""
    s = stream if stream is not None else sys.stdout
    if json_mode:
        json.dump(data, s, ensure_ascii=False)
        s.write("\n")
        return
    text = data if isinstance(data, str) else str(data)
    s.write(text)
    if not text.endswith("\n"):
        s.write("\n")


def emit_error(err: AgtagError, *, json_mode: bool, stream: TextIO | None = None) -> None:
    """Write an :class:`AgtagError` to stderr.

    Text mode renders as two lines when a remediation is present::

        error: <message>
        hint: <remediation>

    The ``hint:`` prefix is required by the agent-first error rubric.
    """
    s = stream if stream is not None else sys.stderr
    if json_mode:
        json.dump(err.to_dict(), s, ensure_ascii=False)
        s.write("\n")
        return
    s.write(f"error: {err.message}\n")
    if err.remediation:
        s.write(f"hint: {err.remediation}\n")


def emit_diagnostic(message: str, *, stream: TextIO | None = None) -> None:
    """Write a human diagnostic (progress, summary) to stderr."""
    s = stream if stream is not None else sys.stderr
    s.write(message if message.endswith("\n") else message + "\n")
uv run pytest tests/test_errors_output.py -v

Expected: 7 PASSED.

git add agtag/cli/__init__.py agtag/cli/_errors.py agtag/cli/_output.py tests/test_errors_output.py
git commit -m "feat: AgtagError + emit_* output helpers (afi stable-contract)"

Task 6: nick.py — culture.yaml resolver (TDD)

Files:

tests/test_nick.py:

"""Tests for nick resolution from culture.yaml."""

from __future__ import annotations

from pathlib import Path

import pytest

from agtag.nick import resolve_nick


def test_resolves_from_culture_yaml(tmp_path: Path) -> None:
    (tmp_path / "culture.yaml").write_text(
        "agents:\n- suffix: testbot\n  backend: claude\n"
    )
    assert resolve_nick(tmp_path) == "testbot"


def test_falls_back_to_basename_when_no_yaml(tmp_path: Path) -> None:
    repo = tmp_path / "myrepo"
    repo.mkdir()
    assert resolve_nick(repo) == "myrepo"


def test_falls_back_on_malformed_yaml(
    tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
    repo = tmp_path / "broken"
    repo.mkdir()
    (repo / "culture.yaml").write_text("not: valid: yaml: [")
    assert resolve_nick(repo) == "broken"
    err = capsys.readouterr().err
    assert "culture.yaml" in err


def test_falls_back_on_empty_agents(tmp_path: Path) -> None:
    repo = tmp_path / "empty"
    repo.mkdir()
    (repo / "culture.yaml").write_text("agents: []\n")
    assert resolve_nick(repo) == "empty"


def test_default_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    (tmp_path / "culture.yaml").write_text("agents:\n- suffix: cwdbot\n")
    monkeypatch.chdir(tmp_path)
    assert resolve_nick() == "cwdbot"
uv run pytest tests/test_nick.py -v

Expected: FAIL with ModuleNotFoundError: No module named 'agtag.nick'.

"""Resolve the signing nick for the current repo.

Reads ``<cwd>/culture.yaml`` and returns ``agents[0].suffix`` if present.
Falls back to the cwd's directory basename. Malformed YAML falls back too,
emitting a stderr diagnostic so the agent sees why.
"""

from __future__ import annotations

from pathlib import Path

import yaml

from agtag.cli._output import emit_diagnostic


def resolve_nick(cwd: Path | None = None) -> str:
    """Resolve the signing nick.

    Order:
      1. ``<cwd>/culture.yaml`` → ``agents[0].suffix`` (if non-empty)
      2. ``Path(cwd).name`` (repo dir basename)

    cwd defaults to ``Path.cwd()``.
    """
    if cwd is None:
        cwd = Path.cwd()
    yaml_path = cwd / "culture.yaml"
    if yaml_path.is_file():
        try:
            data = yaml.safe_load(yaml_path.read_text()) or {}
        except yaml.YAMLError as exc:
            emit_diagnostic(
                f"warning: could not parse {yaml_path}: {exc}; "
                f"falling back to basename"
            )
            return cwd.name
        agents = data.get("agents") or []
        if agents:
            suffix = agents[0].get("suffix") if isinstance(agents[0], dict) else None
            if suffix:
                return str(suffix)
    return cwd.name
uv run pytest tests/test_nick.py -v

Expected: 5 PASSED.

git add agtag/nick.py tests/test_nick.py
git commit -m "feat: nick.py — resolve signing nick from culture.yaml"

Task 7: explain catalog + resolver (TDD; v0.1 entries)

Files:

tests/test_explain_catalog.py:

"""Tests for the explain catalog and resolver."""

from __future__ import annotations

import pytest

from agtag.cli._errors import AgtagError
from agtag.explain import known_paths, resolve
from agtag.explain.catalog import ENTRIES


def test_root_resolves() -> None:
    assert resolve(()).startswith("# agtag")


def test_agtag_alias_resolves_to_root() -> None:
    assert resolve(("agtag",)) == resolve(())


@pytest.mark.parametrize(
    "path",
    [
        ("learn",),
        ("explain",),
        ("issue",),
        ("issue", "post"),
        ("issue", "fetch"),
        ("issue", "reply"),
    ],
)
def test_each_v01_entry_resolves(path: tuple[str, ...]) -> None:
    md = resolve(path)
    assert md.startswith("#")
    assert len(md) > 100


def test_unknown_path_raises() -> None:
    with pytest.raises(AgtagError) as exc:
        resolve(("zzz-not-real",))
    assert exc.value.code == 1
    assert "remediation" in exc.value.to_dict()


def test_known_paths_lists_all_entries() -> None:
    paths = known_paths()
    assert ("learn",) in paths
    assert ("issue", "post") in paths
    assert len(paths) == len(ENTRIES)
uv run pytest tests/test_explain_catalog.py -v

Expected: FAIL with ModuleNotFoundError: No module named 'agtag.explain'.

"""Explain catalog — markdown keyed by command-path tuples (stable-contract)."""

from __future__ import annotations

from agtag.cli._errors import EXIT_USER_ERROR, AgtagError
from agtag.explain.catalog import ENTRIES


def resolve(path: tuple[str, ...]) -> str:
    if path in ENTRIES:
        return ENTRIES[path]
    display = " ".join(path) if path else "<root>"
    raise AgtagError(
        code=EXIT_USER_ERROR,
        message=f"no explain entry for: {display}",
        remediation="list known entries with: agtag explain agtag",
    )


def known_paths() -> list[tuple[str, ...]]:
    return list(ENTRIES.keys())
"""Markdown catalog for ``agtag explain <path>``.

Each entry is verbatim markdown. Keys are command-path tuples. Both ``()``
and ``("agtag",)`` resolve to the root entry.
"""

from __future__ import annotations

_ROOT = """\
# agtag

Agent to Agent communication CLI. Wraps the GitHub `gh` CLI to file, fetch,
and reply to issues across the AgentCulture mesh, with auto-signature
resolved from the local repo's `culture.yaml`.

## Verbs

- `agtag learn` — structured self-teaching prompt.
- `agtag explain <path>` — markdown docs for any noun/verb.
- `agtag issue post|fetch|reply` — cross-repo issue I/O.

## Exit-code policy

- `0` success
- `1` user-input error
- `2` environment / setup error
- `3+` reserved

## See also

- `agtag explain learn`
- `agtag explain issue`
"""

_LEARN = """\
# agtag learn

Prints a structured self-teaching prompt covering agtag's purpose, command
map, exit-code policy, `--json` support, and `explain` pointer.

## Usage

    agtag learn
    agtag learn --json
"""

_EXPLAIN = """\
# agtag explain <path>

Prints markdown documentation for any noun/verb path. Unlike `--help`
(terse, positional), `explain` is global and addressable by path.

## Usage

    agtag explain agtag
    agtag explain learn
    agtag explain issue post
    agtag explain --json <path>
"""

_ISSUE = """\
# agtag issue

Cross-repo GitHub issue I/O. All write verbs (`post`, `reply`) auto-append
`- <nick> (Claude)` where `<nick>` resolves from the local `culture.yaml`
(falling back to repo basename). `--as NICK` overrides. The signature is
idempotent: re-signing a body that already ends with the signature is a
no-op.

## Verbs

- `agtag issue post`  — open a new issue.
- `agtag issue fetch` — read an issue body + comments (no signature).
- `agtag issue reply` — comment on an existing issue.

## Requires

- `gh` on PATH and authenticated.
"""

_ISSUE_POST = """\
# agtag issue post

Open a new GitHub issue with auto-signature.

## Usage

    agtag issue post --repo OWNER/REPO --title TITLE --body BODY [--as NICK] [--json]
    agtag issue post --repo OWNER/REPO --title TITLE --body-file PATH [--as NICK] [--json]

Stdout: the new issue's HTML URL (text mode) or
`{"url": ..., "number": N, "signed_as": "<nick>"}` (json mode).

## Exit codes

- `0` success
- `1` bad repo / missing args
- `2` `gh` missing or unauthenticated
"""

_ISSUE_FETCH = """\
# agtag issue fetch

Read an issue body + comments. No signature.

## Usage

    agtag issue fetch --repo OWNER/REPO --number N [--json]
    agtag issue fetch https://github.com/OWNER/REPO/issues/N [--json]

Text mode: a `## #N: <title>` heading, the body, then per-comment
`### @user — date` blocks. JSON mode:
`{"number": N, "title": ..., "body": ..., "author": ..., "comments": [...]}`.

## Exit codes

- `0` success
- `1` bad URL / missing args / not found
- `2` `gh` missing or unauthenticated
"""

_ISSUE_REPLY = """\
# agtag issue reply

Comment on an existing issue (or PR conversation) with auto-signature.

## Usage

    agtag issue reply --repo OWNER/REPO --number N --body BODY [--as NICK] [--json]
    agtag issue reply --repo OWNER/REPO --number N --body-file PATH [--as NICK] [--json]

Stdout: the new comment's HTML URL (text mode) or
`{"url": ..., "signed_as": "<nick>"}` (json mode).

## Exit codes

- `0` success
- `1` bad repo / missing args / issue not found
- `2` `gh` missing or unauthenticated
"""


ENTRIES: dict[tuple[str, ...], str] = {
    (): _ROOT,
    ("agtag",): _ROOT,
    ("learn",): _LEARN,
    ("explain",): _EXPLAIN,
    ("issue",): _ISSUE,
    ("issue", "post"): _ISSUE_POST,
    ("issue", "fetch"): _ISSUE_FETCH,
    ("issue", "reply"): _ISSUE_REPLY,
}
uv run pytest tests/test_explain_catalog.py -v

Expected: 9 PASSED (5 named + 4 parametrized for issue/post/fetch/reply minus learn/explain).

Wait — recount: 1 root + 1 alias + 6 parametrized + 1 unknown + 1 known_paths = 10 PASSED.

git add agtag/explain/__init__.py agtag/explain/catalog.py tests/test_explain_catalog.py
git commit -m "feat: explain catalog with v0.1 entries (agtag, learn, explain, issue.*)"

Phase 3 — learn and explain commands + CLI parser

Task 8: learn command + cli/_commands skeleton (TDD)

Files:

tests/test_cli.py:

"""Smoke tests for the agtag CLI."""

from __future__ import annotations

import json

import pytest

from agtag import __version__


def test_version_flag(capsys: pytest.CaptureFixture[str]) -> None:
    from agtag.cli import main

    with pytest.raises(SystemExit) as exc:
        main(["--version"])
    assert exc.value.code == 0
    assert __version__ in capsys.readouterr().out


def test_learn_exits_zero(capsys: pytest.CaptureFixture[str]) -> None:
    from agtag.cli import main

    assert main(["learn"]) == 0
    out = capsys.readouterr().out
    assert len(out) >= 200
    for marker in ["purpose", "commands", "exit", "--json", "explain"]:
        assert marker.lower() in out.lower()


def test_learn_json_parseable(capsys: pytest.CaptureFixture[str]) -> None:
    from agtag.cli import main

    assert main(["learn", "--json"]) == 0
    payload = json.loads(capsys.readouterr().out)
    assert payload["tool"] == "agtag"
    assert payload["version"] == __version__


def test_no_args_prints_help_exits_zero(capsys: pytest.CaptureFixture[str]) -> None:
    from agtag.cli import main

    assert main([]) == 0
    out = capsys.readouterr().out
    assert "agtag" in out
    assert "learn" in out
uv run pytest tests/test_cli.py -v

Expected: FAIL with ImportError: cannot import name 'main' from 'agtag.cli' (only the placeholder __init__.py exists).

Create empty file.

"""``agtag learn`` — the learnability affordance."""

from __future__ import annotations

import argparse

from agtag import __version__
from agtag.cli._output import emit_result

_TEXT = """\
agtag — Agent to Agent communication CLI.

Purpose
-------
Cross-repo agent communication: file, fetch, and reply to GitHub issues
across the AgentCulture mesh, with auto-signature from the local
culture.yaml. Mesh transport (`agtag message`) lands in v0.2.

Commands
--------
  agtag learn                   Print this self-teaching prompt. Supports --json.
  agtag explain <path>...       Print markdown docs for any noun/verb path.
                                Supports --json.
  agtag issue post              Open a new issue (auto-signed).
  agtag issue fetch             Read an issue + comments.
  agtag issue reply             Comment on an issue (auto-signed).

Machine-readable output
-----------------------
Every command that produces a listing or report supports --json. Errors in
JSON mode emit {"code", "message", "remediation"} to stderr. Stdout and
stderr are never mixed.

Exit-code policy
----------------
  0 success
  1 user-input error (bad flag, bad path, missing arg)
  2 environment / setup error
  3+ reserved

More detail
-----------
  agtag explain agtag
  agtag explain issue
"""


def _as_json_payload() -> dict[str, object]:
    return {
        "tool": "agtag",
        "version": __version__,
        "purpose": "Agent to Agent communication CLI: file, fetch, and reply to GitHub issues with auto-signature.",
        "commands": [
            {"path": ["learn"], "summary": "Self-teaching prompt."},
            {"path": ["explain"], "summary": "Markdown docs by path."},
            {"path": ["issue", "post"], "summary": "Open a new issue (auto-signed)."},
            {"path": ["issue", "fetch"], "summary": "Read an issue + comments."},
            {"path": ["issue", "reply"], "summary": "Comment on an issue (auto-signed)."},
        ],
        "exit_codes": {
            "0": "success",
            "1": "user-input error",
            "2": "environment/setup error",
        },
        "json_support": True,
        "explain_pointer": "agtag explain <path>",
    }


def cmd_learn(args: argparse.Namespace) -> int:
    if getattr(args, "json", False):
        emit_result(_as_json_payload(), json_mode=True)
    else:
        emit_result(_TEXT, json_mode=False)
    return 0


def register(sub: argparse._SubParsersAction) -> None:
    p = sub.add_parser(
        "learn",
        help="Print a structured self-teaching prompt for agent consumers.",
    )
    p.add_argument("--json", action="store_true", help="Emit structured JSON.")
    p.set_defaults(func=cmd_learn)

Replace placeholder with the full parser (verbatim from afi template, {{module}}agtag, {{project_name}}agtag, AfiErrorAgtagError):

"""Unified CLI entry point for agtag."""

from __future__ import annotations

import argparse
import sys

from agtag import __version__
from agtag.cli._commands import learn as _learn_cmd
from agtag.cli._errors import EXIT_USER_ERROR, AgtagError
from agtag.cli._output import emit_error


class _ArgumentParser(argparse.ArgumentParser):
    """ArgumentParser that emits errors via our structured format."""

    def error(self, message: str) -> None:  # type: ignore[override]
        err = AgtagError(
            code=EXIT_USER_ERROR,
            message=message,
            remediation=f"run '{self.prog} --help' to see valid arguments",
        )
        emit_error(err, json_mode=False)
        raise SystemExit(err.code)


def _build_parser() -> argparse.ArgumentParser:
    parser = _ArgumentParser(
        prog="agtag",
        description="agtag — Agent to Agent communication CLI.",
    )
    parser.add_argument(
        "--version", action="version", version=f"%(prog)s {__version__}"
    )
    sub = parser.add_subparsers(dest="command")
    _learn_cmd.register(sub)
    # Other commands (explain, issue.*) registered in later tasks.
    return parser


def _dispatch(args: argparse.Namespace) -> int:
    json_mode = bool(getattr(args, "json", False))
    try:
        return args.func(args)
    except AgtagError as err:
        emit_error(err, json_mode=json_mode)
        return err.code
    except Exception as err:  # noqa: BLE001 - last-resort
        wrapped = AgtagError(
            code=EXIT_USER_ERROR,
            message=f"unexpected: {err.__class__.__name__}: {err}",
            remediation="file a bug",
        )
        emit_error(wrapped, json_mode=json_mode)
        return wrapped.code


def main(argv: list[str] | None = None) -> int:
    parser = _build_parser()
    args = parser.parse_args(argv)
    if args.command is None:
        parser.print_help()
        return 0
    return _dispatch(args)


if __name__ == "__main__":
    sys.exit(main())
uv run pytest tests/test_cli.py -v

Expected: 4 PASSED.

git add agtag/cli/__init__.py agtag/cli/_commands/__init__.py agtag/cli/_commands/learn.py tests/test_cli.py
git commit -m "feat: agtag CLI parser + learn command"

Task 9: explain command + register (TDD)

Files:

def test_explain_self(capsys: pytest.CaptureFixture[str]) -> None:
    from agtag.cli import main

    assert main(["explain", "agtag"]) == 0
    assert capsys.readouterr().out.startswith("# agtag")


def test_explain_issue_post(capsys: pytest.CaptureFixture[str]) -> None:
    from agtag.cli import main

    assert main(["explain", "issue", "post"]) == 0
    out = capsys.readouterr().out
    assert "issue post" in out


def test_explain_unknown_path_fails_with_hint(capsys: pytest.CaptureFixture[str]) -> None:
    from agtag.cli import main

    rc = main(["explain", "zzz-not-real"])
    assert rc != 0
    err = capsys.readouterr().err
    assert "error:" in err
    assert "hint:" in err


def test_explain_json_mode(capsys: pytest.CaptureFixture[str]) -> None:
    from agtag.cli import main

    assert main(["explain", "--json", "learn"]) == 0
    payload = json.loads(capsys.readouterr().out)
    assert payload["path"] == ["learn"]
    assert payload["markdown"].startswith("#")
uv run pytest tests/test_cli.py -v

Expected: 4 of 8 FAIL (the 4 new ones; argparse will reject explain as unknown subcommand).

"""``agtag explain <path>...`` — global markdown catalog lookup."""

from __future__ import annotations

import argparse

from agtag.cli._output import emit_result
from agtag.explain import resolve


def cmd_explain(args: argparse.Namespace) -> int:
    path = tuple(args.path) if args.path else ()
    markdown = resolve(path)
    json_mode = bool(getattr(args, "json", False))
    if json_mode:
        emit_result({"path": list(path), "markdown": markdown}, json_mode=True)
    else:
        emit_result(markdown, json_mode=False)
    return 0


def register(sub: argparse._SubParsersAction) -> None:
    p = sub.add_parser(
        "explain",
        help="Print markdown docs for a noun/verb path (e.g. 'agtag explain issue post').",
    )
    p.add_argument(
        "path",
        nargs="*",
        help="Command path tokens; empty = root (same as 'agtag').",
    )
    p.add_argument("--json", action="store_true", help="Emit structured JSON.")
    p.set_defaults(func=cmd_explain)

In _build_parser, add the import + register call:

from agtag.cli._commands import explain as _explain_cmd
from agtag.cli._commands import learn as _learn_cmd

And in _build_parser body, after _learn_cmd.register(sub):

    _explain_cmd.register(sub)
uv run pytest tests/test_cli.py -v

Expected: 8 PASSED.

git add agtag/cli/_commands/explain.py agtag/cli/__init__.py tests/test_cli.py
git commit -m "feat: explain command — catalog lookup with --json"

Phase 4 — issue verbs

Task 10: issue/_gh.py — subprocess boundary (TDD)

Files:

tests/test_gh.py:

"""Tests for the gh subprocess wrapper."""

from __future__ import annotations

import subprocess

import pytest

from agtag.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, AgtagError
from agtag.issue import _gh


def test_run_gh_returns_stdout_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
    def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
        return subprocess.CompletedProcess(cmd, 0, stdout="hello\n", stderr="")

    monkeypatch.setattr(_gh.subprocess, "run", fake_run)
    assert _gh._run_gh(["repo", "view"]) == "hello\n"


def test_run_gh_raises_env_error_when_gh_missing(monkeypatch: pytest.MonkeyPatch) -> None:
    def fake_run(*a: object, **k: object) -> object:
        raise FileNotFoundError("No such file or directory: 'gh'")

    monkeypatch.setattr(_gh.subprocess, "run", fake_run)
    with pytest.raises(AgtagError) as exc:
        _gh._run_gh(["repo", "view"])
    assert exc.value.code == EXIT_ENV_ERROR
    assert "gh" in exc.value.message.lower()


def test_run_gh_raises_user_error_on_nonzero_exit(monkeypatch: pytest.MonkeyPatch) -> None:
    def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
        return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="not found\n")

    monkeypatch.setattr(_gh.subprocess, "run", fake_run)
    with pytest.raises(AgtagError) as exc:
        _gh._run_gh(["issue", "view", "--repo", "x/y", "999"])
    assert exc.value.code == EXIT_USER_ERROR
    assert "not found" in exc.value.message
uv run pytest tests/test_gh.py -v

Expected: FAIL with ModuleNotFoundError: No module named 'agtag.issue'.

Create empty file.

"""Thin wrapper around the ``gh`` CLI.

The single subprocess boundary in agtag. Verb-level tests mock
:func:`_run_gh`; this module's tests mock :mod:`subprocess.run`.
"""

from __future__ import annotations

import subprocess

from agtag.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, AgtagError


def _run_gh(args: list[str], *, stdin: str | None = None) -> str:
    """Run ``gh <args>`` and return stdout.

    Raises :class:`AgtagError` with code ``EXIT_ENV_ERROR`` if ``gh`` is
    missing, or ``EXIT_USER_ERROR`` on non-zero exit (e.g. bad repo,
    not-found).
    """
    try:
        result = subprocess.run(
            ["gh", *args],
            input=stdin,
            capture_output=True,
            text=True,
            check=False,
        )
    except FileNotFoundError:
        raise AgtagError(
            code=EXIT_ENV_ERROR,
            message="`gh` not found on PATH",
            remediation="install GitHub CLI: https://cli.github.com/",
        ) from None

    if result.returncode != 0:
        msg = (result.stderr or result.stdout or "").strip() or "gh failed"
        raise AgtagError(
            code=EXIT_USER_ERROR,
            message=msg,
            remediation="check the repo, issue number, and `gh auth status`",
        )
    return result.stdout
uv run pytest tests/test_gh.py -v

Expected: 3 PASSED.

git add agtag/issue/__init__.py agtag/issue/_gh.py tests/test_gh.py
git commit -m "feat: gh subprocess wrapper for issue verbs"

Task 11: issue/post.py + signature helper + CLI verb (TDD)

Files:

tests/test_issue_post.py:

"""Tests for issue post — signature, idempotency, override, env error."""

from __future__ import annotations

from pathlib import Path

import pytest

from agtag.cli._errors import EXIT_ENV_ERROR, AgtagError
from agtag.issue import post as post_mod


@pytest.fixture
def fake_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    (tmp_path / "culture.yaml").write_text("agents:\n- suffix: agtag\n")
    monkeypatch.chdir(tmp_path)
    return tmp_path


def _capture_gh(monkeypatch: pytest.MonkeyPatch) -> dict[str, object]:
    captured: dict[str, object] = {}

    def fake_run_gh(args: list[str], *, stdin: str | None = None) -> str:
        captured["args"] = args
        captured["stdin"] = stdin
        return "https://github.com/foo/bar/issues/42\n"

    monkeypatch.setattr(post_mod, "_run_gh", fake_run_gh)
    return captured


def test_post_appends_signature(fake_repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    captured = _capture_gh(monkeypatch)
    result = post_mod.post(repo="foo/bar", title="t", body="hello")
    assert result["url"] == "https://github.com/foo/bar/issues/42"
    assert result["number"] == 42
    assert result["signed_as"] == "agtag"
    assert captured["stdin"].endswith("hello\n\n- agtag (Claude)")


def test_post_idempotent_signature(
    fake_repo: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    captured = _capture_gh(monkeypatch)
    post_mod.post(repo="foo/bar", title="t", body="hello\n\n- agtag (Claude)")
    body = captured["stdin"]
    assert body.count("- agtag (Claude)") == 1


def test_post_with_as_override(
    fake_repo: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    captured = _capture_gh(monkeypatch)
    result = post_mod.post(repo="foo/bar", title="t", body="hello", nick="steward")
    assert result["signed_as"] == "steward"
    assert "- steward (Claude)" in captured["stdin"]


def test_post_env_error_when_gh_missing(
    fake_repo: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    def boom(*a: object, **k: object) -> str:
        raise AgtagError(code=EXIT_ENV_ERROR, message="`gh` not found on PATH")

    monkeypatch.setattr(post_mod, "_run_gh", boom)
    with pytest.raises(AgtagError) as exc:
        post_mod.post(repo="foo/bar", title="t", body="hello")
    assert exc.value.code == EXIT_ENV_ERROR
uv run pytest tests/test_issue_post.py -v

Expected: FAIL with ModuleNotFoundError: No module named 'agtag.issue.post'.

"""Signature helper — idempotent ``- <nick> (Claude)`` suffix."""

from __future__ import annotations


def sign(body: str, nick: str) -> str:
    """Return ``body`` with a trailing ``\n\n- <nick> (Claude)`` if not present.

    Idempotent: if ``body`` already ends with the exact signature line
    (with or without trailing whitespace), no second copy is appended.
    """
    sig_line = f"- {nick} (Claude)"
    stripped = body.rstrip()
    if stripped.endswith(sig_line):
        return body
    return f"{stripped}\n\n{sig_line}"
"""``issue post`` — open a new GitHub issue with auto-signature."""

from __future__ import annotations

import re
import tempfile
from pathlib import Path

from agtag.issue._gh import _run_gh
from agtag.issue._sign import sign
from agtag.nick import resolve_nick

_ISSUE_URL_RE = re.compile(r"/issues/(\d+)")


def post(*, repo: str, title: str, body: str, nick: str | None = None) -> dict[str, object]:
    """Open a new issue. Returns ``{url, number, signed_as}``."""
    resolved_nick = nick or resolve_nick()
    signed_body = sign(body, resolved_nick)

    # Use --body-file to avoid argv length limits (mirrors post-issue.sh).
    with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as f:
        f.write(signed_body)
        body_path = f.name
    try:
        url = _run_gh(
            ["issue", "create", "--repo", repo, "--title", title, "--body-file", body_path]
        ).strip()
    finally:
        Path(body_path).unlink(missing_ok=True)

    match = _ISSUE_URL_RE.search(url)
    number = int(match.group(1)) if match else 0
    return {"url": url, "number": number, "signed_as": resolved_nick}
"""``agtag issue post`` CLI verb."""

from __future__ import annotations

import argparse
from pathlib import Path

from agtag.cli._errors import EXIT_USER_ERROR, AgtagError
from agtag.cli._output import emit_result
from agtag.issue.post import post


def cmd_issue_post(args: argparse.Namespace) -> int:
    if args.body and args.body_file:
        raise AgtagError(
            code=EXIT_USER_ERROR,
            message="--body and --body-file are mutually exclusive",
            remediation="pass exactly one",
        )
    if not args.body and not args.body_file:
        raise AgtagError(
            code=EXIT_USER_ERROR,
            message="missing body",
            remediation="pass --body or --body-file",
        )
    body = args.body if args.body else Path(args.body_file).read_text()
    result = post(repo=args.repo, title=args.title, body=body, nick=args.as_nick)
    json_mode = bool(getattr(args, "json", False))
    if json_mode:
        emit_result(result, json_mode=True)
    else:
        emit_result(result["url"], json_mode=False)
    return 0


def register(sub: argparse._SubParsersAction) -> None:
    pass  # Registered indirectly via the issue noun group in Task 14.

(The register shim is empty; the issue noun group registers it. Defining register here keeps the file’s surface symmetric with learn / explain for future direct registration if needed.)

uv run pytest tests/test_issue_post.py -v

Expected: 4 PASSED.

git add agtag/issue/_sign.py agtag/issue/post.py agtag/cli/_commands/issue_post.py tests/test_issue_post.py
git commit -m "feat: issue post — gh wrapper + idempotent signature"

Task 12: issue/fetch.py + URL parsing + CLI verb (TDD)

Files:

tests/test_issue_fetch.py:

"""Tests for issue fetch — gh json shape, URL parsing."""

from __future__ import annotations

import json

import pytest

from agtag.cli._errors import EXIT_USER_ERROR, AgtagError
from agtag.issue import fetch as fetch_mod

_CANNED = {
    "number": 7,
    "title": "Wire-format fix",
    "body": "see line 12",
    "author": {"login": "alice"},
    "comments": [
        {"author": {"login": "bob"}, "body": "agreed", "createdAt": "2026-05-01T10:00:00Z"},
    ],
}


def test_fetch_returns_dict_shape(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(fetch_mod, "_run_gh", lambda args, **kw: json.dumps(_CANNED))
    result = fetch_mod.fetch(repo="foo/bar", number=7)
    assert result["number"] == 7
    assert result["title"] == "Wire-format fix"
    assert result["author"] == "alice"
    assert result["comments"][0]["author"] == "bob"


def test_parse_url_extracts_repo_and_number() -> None:
    repo, num = fetch_mod.parse_issue_url(
        "https://github.com/agentculture/agtag/issues/12"
    )
    assert repo == "agentculture/agtag"
    assert num == 12


def test_parse_url_rejects_non_issue_url() -> None:
    with pytest.raises(AgtagError) as exc:
        fetch_mod.parse_issue_url("https://example.com/not/an/issue")
    assert exc.value.code == EXIT_USER_ERROR
uv run pytest tests/test_issue_fetch.py -v

Expected: FAIL with ModuleNotFoundError: No module named 'agtag.issue.fetch'.

"""``issue fetch`` — read an issue body + comments."""

from __future__ import annotations

import json
import re

from agtag.cli._errors import EXIT_USER_ERROR, AgtagError
from agtag.issue._gh import _run_gh

_URL_RE = re.compile(r"^https://github\.com/([^/]+/[^/]+)/issues/(\d+)/?$")


def parse_issue_url(url: str) -> tuple[str, int]:
    """Parse ``https://github.com/OWNER/REPO/issues/N`` → ``(OWNER/REPO, N)``."""
    match = _URL_RE.match(url)
    if not match:
        raise AgtagError(
            code=EXIT_USER_ERROR,
            message=f"not a GitHub issue URL: {url}",
            remediation="pass --repo OWNER/REPO and --number N, or a /issues/N URL",
        )
    return match.group(1), int(match.group(2))


def fetch(*, repo: str, number: int) -> dict[str, object]:
    """Fetch an issue + its comments. Returns a normalized dict."""
    raw = _run_gh(
        [
            "issue",
            "view",
            str(number),
            "--repo",
            repo,
            "--json",
            "number,title,body,author,comments",
        ]
    )
    data = json.loads(raw)
    comments = [
        {
            "author": c["author"]["login"] if c.get("author") else "",
            "body": c.get("body", ""),
            "created_at": c.get("createdAt", ""),
        }
        for c in data.get("comments", [])
    ]
    return {
        "number": data["number"],
        "title": data["title"],
        "body": data.get("body", ""),
        "author": data["author"]["login"] if data.get("author") else "",
        "comments": comments,
    }
"""``agtag issue fetch`` CLI verb."""

from __future__ import annotations

import argparse

from agtag.cli._errors import EXIT_USER_ERROR, AgtagError
from agtag.cli._output import emit_result
from agtag.issue.fetch import fetch, parse_issue_url


def _format_text(result: dict[str, object]) -> str:
    lines = [f"## #{result['number']}: {result['title']}", ""]
    if result.get("body"):
        lines.append(str(result["body"]))
    for c in result.get("comments", []):
        lines.append("")
        lines.append(f"### @{c['author']}{c['created_at']}")
        lines.append("")
        lines.append(c["body"])
    return "\n".join(lines)


def cmd_issue_fetch(args: argparse.Namespace) -> int:
    if args.url:
        repo, number = parse_issue_url(args.url)
    else:
        if not args.repo or not args.number:
            raise AgtagError(
                code=EXIT_USER_ERROR,
                message="missing --repo or --number",
                remediation="pass --repo OWNER/REPO --number N, or a /issues/N URL",
            )
        repo, number = args.repo, args.number
    result = fetch(repo=repo, number=number)
    json_mode = bool(getattr(args, "json", False))
    if json_mode:
        emit_result(result, json_mode=True)
    else:
        emit_result(_format_text(result), json_mode=False)
    return 0


def register(sub: argparse._SubParsersAction) -> None:
    pass  # registered via the issue noun group
uv run pytest tests/test_issue_fetch.py -v

Expected: 3 PASSED.

git add agtag/issue/fetch.py agtag/cli/_commands/issue_fetch.py tests/test_issue_fetch.py
git commit -m "feat: issue fetch — gh json shape + URL positional parsing"

Task 13: issue/reply.py + CLI verb (TDD)

Files:

tests/test_issue_reply.py:

"""Tests for issue reply — signature, idempotency, override, env error."""

from __future__ import annotations

from pathlib import Path

import pytest

from agtag.cli._errors import EXIT_ENV_ERROR, AgtagError
from agtag.issue import reply as reply_mod


@pytest.fixture
def fake_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    (tmp_path / "culture.yaml").write_text("agents:\n- suffix: agtag\n")
    monkeypatch.chdir(tmp_path)
    return tmp_path


def _capture_gh(monkeypatch: pytest.MonkeyPatch) -> dict[str, object]:
    captured: dict[str, object] = {}

    def fake_run_gh(args: list[str], *, stdin: str | None = None) -> str:
        captured["args"] = args
        captured["stdin"] = stdin
        return "https://github.com/foo/bar/issues/42#issuecomment-99\n"

    monkeypatch.setattr(reply_mod, "_run_gh", fake_run_gh)
    return captured


def test_reply_appends_signature(fake_repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    captured = _capture_gh(monkeypatch)
    result = reply_mod.reply(repo="foo/bar", number=42, body="ack")
    assert result["url"].startswith("https://github.com/")
    assert result["signed_as"] == "agtag"
    assert captured["stdin"].endswith("ack\n\n- agtag (Claude)")


def test_reply_idempotent_signature(fake_repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    captured = _capture_gh(monkeypatch)
    reply_mod.reply(repo="foo/bar", number=42, body="ack\n\n- agtag (Claude)")
    assert captured["stdin"].count("- agtag (Claude)") == 1


def test_reply_with_as_override(fake_repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    captured = _capture_gh(monkeypatch)
    result = reply_mod.reply(repo="foo/bar", number=42, body="ack", nick="steward")
    assert result["signed_as"] == "steward"
    assert "- steward (Claude)" in captured["stdin"]


def test_reply_env_error_when_gh_missing(
    fake_repo: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    def boom(*a: object, **k: object) -> str:
        raise AgtagError(code=EXIT_ENV_ERROR, message="`gh` not found on PATH")

    monkeypatch.setattr(reply_mod, "_run_gh", boom)
    with pytest.raises(AgtagError) as exc:
        reply_mod.reply(repo="foo/bar", number=42, body="ack")
    assert exc.value.code == EXIT_ENV_ERROR
uv run pytest tests/test_issue_reply.py -v

Expected: FAIL — agtag.issue.reply not found.

"""``issue reply`` — comment on a GitHub issue with auto-signature."""

from __future__ import annotations

import tempfile
from pathlib import Path

from agtag.issue._gh import _run_gh
from agtag.issue._sign import sign
from agtag.nick import resolve_nick


def reply(*, repo: str, number: int, body: str, nick: str | None = None) -> dict[str, object]:
    """Comment on an issue. Returns ``{url, signed_as}``."""
    resolved_nick = nick or resolve_nick()
    signed_body = sign(body, resolved_nick)

    with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as f:
        f.write(signed_body)
        body_path = f.name
    try:
        url = _run_gh(
            [
                "issue",
                "comment",
                str(number),
                "--repo",
                repo,
                "--body-file",
                body_path,
            ]
        ).strip()
    finally:
        Path(body_path).unlink(missing_ok=True)

    return {"url": url, "signed_as": resolved_nick}
"""``agtag issue reply`` CLI verb."""

from __future__ import annotations

import argparse
from pathlib import Path

from agtag.cli._errors import EXIT_USER_ERROR, AgtagError
from agtag.cli._output import emit_result
from agtag.issue.reply import reply


def cmd_issue_reply(args: argparse.Namespace) -> int:
    if args.body and args.body_file:
        raise AgtagError(
            code=EXIT_USER_ERROR,
            message="--body and --body-file are mutually exclusive",
            remediation="pass exactly one",
        )
    if not args.body and not args.body_file:
        raise AgtagError(
            code=EXIT_USER_ERROR,
            message="missing body",
            remediation="pass --body or --body-file",
        )
    body = args.body if args.body else Path(args.body_file).read_text()
    result = reply(repo=args.repo, number=args.number, body=body, nick=args.as_nick)
    json_mode = bool(getattr(args, "json", False))
    if json_mode:
        emit_result(result, json_mode=True)
    else:
        emit_result(result["url"], json_mode=False)
    return 0


def register(sub: argparse._SubParsersAction) -> None:
    pass  # registered via the issue noun group
uv run pytest tests/test_issue_reply.py -v

Expected: 4 PASSED.

git add agtag/issue/reply.py agtag/cli/_commands/issue_reply.py tests/test_issue_reply.py
git commit -m "feat: issue reply — gh wrapper + idempotent signature"

Task 14: Wire issue noun group into the parser (TDD via test_cli.py extension)

Files:

def test_issue_post_dispatch(
    tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch,
    capsys: pytest.CaptureFixture[str],
) -> None:
    """End-to-end CLI dispatch: `agtag issue post --json` returns the dict."""
    from agtag.cli import main
    from agtag.issue import post as post_mod

    monkeypatch.setattr(
        post_mod, "_run_gh", lambda a, **k: "https://github.com/foo/bar/issues/1\n"
    )
    cwd = tmp_path.mktemp("repo")
    (cwd / "culture.yaml").write_text("agents:\n- suffix: agtag\n")
    monkeypatch.chdir(cwd)
    rc = main(["issue", "post", "--repo", "foo/bar", "--title", "t",
               "--body", "b", "--json"])
    assert rc == 0
    payload = json.loads(capsys.readouterr().out)
    assert payload["number"] == 1
    assert payload["signed_as"] == "agtag"


def test_issue_fetch_dispatch_url_form(
    monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
) -> None:
    from agtag.cli import main
    from agtag.issue import fetch as fetch_mod

    canned = json.dumps(
        {"number": 7, "title": "T", "body": "B", "author": {"login": "a"}, "comments": []}
    )
    monkeypatch.setattr(fetch_mod, "_run_gh", lambda a, **k: canned)
    rc = main(["issue", "fetch", "https://github.com/x/y/issues/7", "--json"])
    assert rc == 0
    payload = json.loads(capsys.readouterr().out)
    assert payload["number"] == 7


def test_issue_reply_dispatch(
    tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch,
    capsys: pytest.CaptureFixture[str],
) -> None:
    from agtag.cli import main
    from agtag.issue import reply as reply_mod

    monkeypatch.setattr(
        reply_mod, "_run_gh", lambda a, **k: "https://github.com/foo/bar/issues/1#c-9\n"
    )
    cwd = tmp_path.mktemp("repo")
    (cwd / "culture.yaml").write_text("agents:\n- suffix: agtag\n")
    monkeypatch.chdir(cwd)
    rc = main(["issue", "reply", "--repo", "foo/bar", "--number", "1",
               "--body", "ack", "--json"])
    assert rc == 0
    payload = json.loads(capsys.readouterr().out)
    assert payload["signed_as"] == "agtag"
uv run pytest tests/test_cli.py -v

Expected: 3 new FAIL — argparse rejects issue as unknown subcommand.

"""``agtag issue`` noun group — registers post/fetch/reply verbs."""

from __future__ import annotations

import argparse

from agtag.cli._commands.issue_fetch import cmd_issue_fetch
from agtag.cli._commands.issue_post import cmd_issue_post
from agtag.cli._commands.issue_reply import cmd_issue_reply


def register(sub: argparse._SubParsersAction) -> None:
    p = sub.add_parser(
        "issue",
        help="Cross-repo GitHub issue I/O (post / fetch / reply).",
    )
    issue_sub = p.add_subparsers(dest="issue_verb", required=True)

    p_post = issue_sub.add_parser("post", help="Open a new issue (auto-signed).")
    p_post.add_argument("--repo", required=True, help="OWNER/REPO")
    p_post.add_argument("--title", required=True)
    p_post.add_argument("--body", default=None, help="inline body")
    p_post.add_argument("--body-file", default=None, help="path to body markdown")
    p_post.add_argument("--as", dest="as_nick", default=None, help="override signing nick")
    p_post.add_argument("--json", action="store_true", help="emit JSON")
    p_post.set_defaults(func=cmd_issue_post)

    p_fetch = issue_sub.add_parser("fetch", help="Read an issue + its comments.")
    p_fetch.add_argument("url", nargs="?", default=None, help="optional issue URL")
    p_fetch.add_argument("--repo", default=None, help="OWNER/REPO (with --number)")
    p_fetch.add_argument("--number", type=int, default=None, help="issue number")
    p_fetch.add_argument("--json", action="store_true", help="emit JSON")
    p_fetch.set_defaults(func=cmd_issue_fetch)

    p_reply = issue_sub.add_parser("reply", help="Comment on an issue (auto-signed).")
    p_reply.add_argument("--repo", required=True, help="OWNER/REPO")
    p_reply.add_argument("--number", type=int, required=True)
    p_reply.add_argument("--body", default=None, help="inline body")
    p_reply.add_argument("--body-file", default=None, help="path to body markdown")
    p_reply.add_argument("--as", dest="as_nick", default=None, help="override signing nick")
    p_reply.add_argument("--json", action="store_true", help="emit JSON")
    p_reply.set_defaults(func=cmd_issue_reply)

Add to imports:

from agtag.cli._commands import issue as _issue_grp

In _build_parser, after _explain_cmd.register(sub):

    _issue_grp.register(sub)
uv run pytest -v

Expected: all tests PASS (test_cli has 11; full suite ~30+).

git add agtag/cli/_commands/issue.py agtag/cli/__init__.py tests/test_cli.py
git commit -m "feat: wire issue noun group (post|fetch|reply) into parser"

Phase 5 — Coverage gate verification

Task 15: Run full coverage; close any gaps

Files: none (or targeted test additions if gaps found)

uv run pytest -n auto --cov=agtag --cov-report=term-missing -v

Expected: all tests PASS; coverage report at the bottom. Look for any module under 80%.

For each gap, decide:

Common likely gaps and their fix tests:

uv run pytest -n auto --cov=agtag --cov-report=term -v

Expected: Required test coverage of 80% reached. Total coverage: ≥80.0%.

git add tests/
git commit -m "test: close coverage gaps to clear 80% gate"

Phase 6 — Vendor sibling skills

Task 16: Vendor version-bump skill

Files:

cp -R /home/spark/git/steward/.claude/skills/version-bump /home/spark/git/agtag/.claude/skills/
chmod +x /home/spark/git/agtag/.claude/skills/version-bump/scripts/*.py 2>/dev/null || true
ls /home/spark/git/agtag/.claude/skills/version-bump/scripts/

Expected: bump.py (and any helpers).

grep -n 'steward/__init__.py\|steward-cli' /home/spark/git/agtag/.claude/skills/version-bump/SKILL.md

Edit any matches: steward/__init__.pyagtag/__init__.py. The bump.py script itself is generic and reads pyproject.toml directly — no script changes needed.

cd /home/spark/git/agtag
python3 .claude/skills/version-bump/scripts/bump.py show

Expected: 0.1.0.

git add .claude/skills/version-bump/
git commit -m "vendor: version-bump skill from agentculture/steward"

Task 17: Vendor run-tests skill

Files:

cp -R /home/spark/git/steward/.claude/skills/run-tests /home/spark/git/agtag/.claude/skills/
chmod +x /home/spark/git/agtag/.claude/skills/run-tests/scripts/*.sh
cd /home/spark/git/agtag
bash .claude/skills/run-tests/scripts/test.sh -p -q

Expected: pytest runs, all PASS.

git add .claude/skills/run-tests/
git commit -m "vendor: run-tests skill from agentculture/steward"

Task 18: Vendor pypi-maintainer skill

Files:

cp -R /home/spark/git/steward/.claude/skills/pypi-maintainer /home/spark/git/agtag/.claude/skills/
chmod +x /home/spark/git/agtag/.claude/skills/pypi-maintainer/scripts/*.sh
bash -n /home/spark/git/agtag/.claude/skills/pypi-maintainer/scripts/switch-source.sh

Expected: no output (no syntax errors).

git add .claude/skills/pypi-maintainer/
git commit -m "vendor: pypi-maintainer skill from agentculture/steward"

Phase 7 — Skill ledger + communicate migration

Task 19: Migrate communicate/post-issue.sh → CLI

Files:

git rm .claude/skills/communicate/scripts/post-issue.sh

Find every block that invokes post-issue.sh (search: post-issue.sh) and replace with the CLI equivalent. The “How to Invoke” section’s “File a new issue” block becomes:

agtag issue post \
    --repo agentculture/<sibling> \
    --title "Vendor portability-lint into <sibling> (unblocks steward 0.7)" \
    --body-file /tmp/brief.md

Plus a stdin variant note: “Pass --body BODY for inline bodies; --body-file PATH for file-staged bodies. Stdin piping is no longer supported (use --body-file - is not implemented in v0.1; write to a tempfile first).”

Also update the “Per-channel signature rules” section: replace “auto-appended by post-issue.sh” with “auto-appended by agtag issue post”. Replace “Each vendor of this skill hard-codes its own issue-signature literal” with “Signing nick resolves from the local culture.yaml (or repo basename fallback) via agtag.nick.resolve_nick.”

The description: frontmatter line that says “Vendored from steward” stays. Add at the end of the description: “Issue post + reply now route through the agtag CLI; install with uv tool install agtag.”

grep -n 'post-issue.sh' .claude/skills/communicate/SKILL.md

Expected: no output.

git add .claude/skills/communicate/SKILL.md .claude/skills/communicate/scripts/post-issue.sh
git commit -m "refactor(communicate): route post-issue through agtag CLI; remove bash script"

Task 20: Update docs/skill-sources.md ledger

Files: Modify docs/skill-sources.md

Read the existing file. Replace the communicate row’s “Local divergence” cell with:

identifier-only: SKILL.md routes the post-issue path through `agtag issue post`; the bash `post-issue.sh` script has been removed (replaced by the CLI). `fetch-issues.sh` and `mesh-message.sh` left verbatim from upstream.

Add three new rows:

SkillUpstreamLocal divergence
version-bumpagentculture/steward (.claude/skills/version-bump/)identifier-only: SKILL.md steward/__init__.py reference replaced with agtag/__init__.py; scripts/bump.py is generic on package name (reads pyproject.toml) and copied verbatim.
run-testsagentculture/steward (.claude/skills/run-tests/)none — scripts/test.sh reads [tool.coverage.run] from pyproject.toml, fully package-agnostic.
pypi-maintaineragentculture/steward (.claude/skills/pypi-maintainer/)none — scripts/switch-source.sh takes the package name as its first argument; copied verbatim.
git add docs/skill-sources.md
git commit -m "docs: update skill-sources ledger for version-bump, run-tests, pypi-maintainer + communicate migration"

Phase 8 — Docs

Task 21: Update README.md

Files: Modify README.md

# agtag

Agent to Agent communication CLI. Wraps the GitHub `gh` CLI to file, fetch,
and reply to issues across the AgentCulture mesh, with auto-signature
resolved from the local repo's `culture.yaml`.

## Install

```bash
uv tool install agtag

Requires gh on PATH and authenticated (gh auth status).

Quickstart

agtag --version
agtag learn                      # self-teaching prompt
agtag explain issue post         # markdown docs for any noun/verb path

agtag issue post \
  --repo agentculture/agtag \
  --title "Wire-format fix" \
  --body-file /tmp/brief.md

agtag issue fetch https://github.com/agentculture/agtag/issues/12
agtag issue reply --repo agentculture/agtag --number 12 --body "ack"

Every command supports --json. Errors emit {"code","message","remediation"} to stderr; stdout and stderr are never mixed.

Develop

uv venv && uv pip install -e ".[dev]"
uv run pytest -n auto -v

See docs/ for purpose, features, full command reference, and the mesh-side culture context.

License

MIT.


- [ ] **Step 2: Commit**

```bash
git add README.md
git commit -m "docs: README — install via uv tool install agtag, quickstart"

Task 22: Write docs/purpose.md, docs/features.md, docs/commands.md, docs/culture.md

Files:

# Purpose

`agtag` is the Agent to Agent communication CLI for the AgentCulture
ecosystem. Its niche is the *CLI surface* on top of cross-repo agent
hand-offs: file an issue on a sibling repo, fetch an issue's body and
comments to inline into a brief, reply to an existing issue. Every
authored body is auto-signed with the originating repo's nick (resolved
from local `culture.yaml`), so cross-repo audit trails always identify
the agent and the repo.

## Where it sits

The AgentCulture stack distributes responsibility across repos:

- **`culture`** — IRC-based agent mesh (peer-to-peer agent collaboration
  over a custom async Python IRCd).
- **`daria`** — autonomous awareness agent that observes conversations
  and investigates topics.
- **`cultureagent`, `steward`, `agentirc`** — other mesh-side components.
- **`agtag`** (this repo) — CLI-shaped communication layer for tracked,
  asynchronous, cross-repo hand-offs.

Mesh transport already exists in `culture` via IRC channels. agtag does
not duplicate it; v0.2 will migrate the existing `culture agent message`
CLI verb into `agtag message`, but v0.1 is GitHub-issue-only.

## Why a separate CLI

Many AgentCulture agents need to file issues on *other* repos as part of
their normal work — vendoring a skill, asking a sibling-repo agent to
fix a wire-format compatibility bug, posting a status. Doing this
through `gh issue create` works but loses two things:

1. The auto-signature that identifies the originating agent and repo.
2. A consistent grammar (`agtag explain issue post`) that other agents
   can introspect to learn the surface.

A shared CLI installed once via `uv tool install agtag` gives every
agent in the workspace the same surface, the same signing convention,
and the same machine-readable output (`--json`).
# Features

## v0.1.0 (2026-05-09)

### Universals (afi-template)

- `agtag --version`
- `agtag learn` — self-teaching prompt for agent consumers; supports `--json`.
- `agtag explain <path>` — markdown documentation for any noun/verb path; supports `--json`.
- Structured errors: every failure emits `{code, message, remediation}` (text or JSON).
- Exit-code policy: `0` success, `1` user-input error, `2` environment/setup error.

### `issue` noun group

- `agtag issue post --repo OWNER/REPO --title T (--body B | --body-file F)` — open a new issue.
- `agtag issue fetch (--repo … --number … | <url>)` — read an issue body + its comments.
- `agtag issue reply --repo OWNER/REPO --number N (--body B | --body-file F)` — comment on an issue.

All write verbs:

- Auto-sign with `- <nick> (Claude)`. `<nick>` resolves from the local
  `culture.yaml` (`agents[0].suffix`), falling back to the repo
  basename. `--as NICK` overrides.
- Are idempotent: re-signing a body that already ends with the
  signature line is a no-op.

`fetch` is read-only and emits no signature.

## Roadmap

### v0.2 — `agtag message` (mesh transport)

Migrate the existing `culture agent message <channel> <body>` CLI into
`agtag message --channel <channel> --body <body>`, so message-by-CLI and
issue-by-CLI share one binary. Mesh messages remain unsigned (the IRC
nick already identifies the speaker).

### Out of scope (separate repo)

PR review-thread tooling — anything currently in
`cicd/scripts/pr-reply.sh` and `cicd/scripts/pr-batch.sh` — belongs to
the planned **agex-cli** repo (agent-experience / dev-experience-for-
agents). agtag's `cicd` skill is left untouched.
# Command reference

The authoritative reference is `agtag explain <path>`; this file mirrors
that catalog for browsing.

## Globals

- **`agtag --version`** — print the installed version.
- **`agtag learn`** — structured self-teaching prompt.
  - `--json` — emit a structured payload (tool, version, purpose, commands, exit codes, json support, explain pointer).
- **`agtag explain <path>`** — markdown documentation for any noun/verb path.
  - `--json` — emit `{path, markdown}`.

Examples:

```bash
agtag explain agtag
agtag explain issue post
agtag explain --json learn

agtag issue

Cross-repo GitHub issue I/O. All write verbs auto-sign.

agtag issue post

agtag issue post --repo OWNER/REPO --title TITLE --body BODY  [--as NICK] [--json]
agtag issue post --repo OWNER/REPO --title TITLE --body-file PATH  [--as NICK] [--json]

Stdout: the new issue’s HTML URL. --json: {url, number, signed_as}.

Exit codes:

agtag issue fetch

agtag issue fetch --repo OWNER/REPO --number N  [--json]
agtag issue fetch https://github.com/OWNER/REPO/issues/N  [--json]

Text mode: ## #N: <title> heading, body, then per-comment ### @user — date blocks. JSON mode: {number, title, body, author, comments: [{author, body, created_at}, …]}.

agtag issue reply

agtag issue reply --repo OWNER/REPO --number N --body BODY  [--as NICK] [--json]
agtag issue reply --repo OWNER/REPO --number N --body-file PATH  [--as NICK] [--json]

Stdout: the new comment’s HTML URL. --json: {url, signed_as}.


- [ ] **Step 4: Write `docs/culture.md`**

```markdown
# Culture

## How agtag fits the AgentCulture mesh

The AgentCulture mesh has two shapes of agent-to-agent communication:

- **Live channels** — the IRC mesh in `culture`. Ephemeral, low-latency,
  unsigned (the IRC nick is the speaker).
- **Tracked artifacts** — GitHub issues. Persistent, cross-repo,
  signed (so a future reader knows which agent posted, and from which
  repo).

agtag is the CLI for the second shape. Every authored body picks up an
auto-signature `- <nick> (Claude)`, where `<nick>` is read from the
local repo's `culture.yaml` (`agents[0].suffix`). When agtag runs from
`steward`, the signature is `- steward (Claude)`. When it runs from
`agtag` itself, it's `- agtag (Claude)`. Same binary; the *cwd* picks
the identity.

## culture.yaml

Each repo in the mesh declares the agents resident in it via
`culture.yaml`:

```yaml
agents:
- suffix: <repo-nick>
  backend: claude

This is the same schema the bash skills under cicd/_resolve-nick.sh use. Both implementations (the python agtag.nick.resolve_nick and the bash _resolve-nick.sh) read the same file, so signatures stay consistent regardless of which path an agent takes.

Cite-don’t-import skill discipline

agtag vendors skills from agentculture/steward under the “cite-don’t-import” pattern: each skill is copied into .claude/skills/<name>/, may be locally adapted, and does not auto-update when its upstream changes. The ledger at docs/skill-sources.md records each skill’s upstream source and divergence shape. To re-sync from upstream, follow the recipe at the top of that file.

This discipline is a deliberate trade — auto-vendoring would couple every consumer to the upstream’s release cadence and block local adaptation. The cost is that broadcasting a skill change to all consumers takes one round-trip per consumer (steward broadcasts via steward announce-skill-update).

Sibling repos

RepoRole
cultureIRC-based agent mesh.
dariaAutonomous awareness agent.
stewardResident-agent alignment + skill source-of-truth.
cultureagent, agentircOther mesh-side components.
(planned) agex-cliAgent / dev experience CLI; will own PR review-thread tooling.

- [ ] **Step 5: Lint markdown**

```bash
markdownlint-cli2 "docs/*.md" "README.md"

Expected: no errors, or fixable warnings only. If warnings: rerun with --fix.

git add docs/purpose.md docs/features.md docs/commands.md docs/culture.md
git commit -m "docs: purpose, features, commands, culture"

Phase 9 — CI workflows

Task 23: Replace sonar.yml with tests.yml

Files:

git rm .github/workflows/sonar.yml
name: Tests

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    # Promote a presence flag (not the secret itself) to job env so the
    # SonarCloud step can gate on it — `secrets.*` isn't allowed in `if:`.
    # The real SONAR_TOKEN is injected only at the scan step's env, keeping
    # it out of pytest's environment in the same job.
    env:
      SONAR_TOKEN_PRESENT: ${{ secrets.SONAR_TOKEN != '' }}
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
        with:
          fetch-depth: 0

      - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4

      - run: uv python install 3.12

      - run: uv sync

      - run: uv run pytest -n auto --cov=agtag --cov-report=xml:coverage.xml --cov-report=term -v

      - name: SonarCloud Scan
        if: env.SONAR_TOKEN_PRESENT == 'true'
        uses: SonarSource/sonarqube-scan-action@fd88b7d7ccbaefd23d8f36f73b59db7a3d246602 # v6
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          SONAR_HOST_URL: https://sonarcloud.io

  lint:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
        with:
          node-version: '20'
      - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
      - run: uv python install 3.12
      - run: uv sync

      - name: black --check
        run: uv run black --check agtag tests

      - name: isort --check
        run: uv run isort --check-only agtag tests

      - name: flake8
        run: uv run flake8 agtag tests

      - name: bandit
        run: uv run bandit -c pyproject.toml -r agtag

      - name: markdownlint-cli2
        run: |
          npm install -g [email protected]
          markdownlint-cli2 "**/*.md" "#node_modules" "#.local"

  version-check:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
        with:
          fetch-depth: 0

      - run: git fetch origin main

      - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
        with:
          python-version: "3.12"

      - name: Check version bump
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          PR_VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
          MAIN_VERSION=$(git show origin/main:pyproject.toml 2>/dev/null | python3 -c "import sys,tomllib; print(tomllib.loads(sys.stdin.read())['project']['version'])" 2>/dev/null || echo "")

          if [ -z "$MAIN_VERSION" ]; then
            echo "No pyproject.toml on main yet — skipping version check (initial scaffold)."
            exit 0
          fi

          if [ "$PR_VERSION" = "$MAIN_VERSION" ]; then
            MARKER="<!-- version-check -->"
            BODY="⚠️ **Version not bumped** — \`pyproject.toml\` still has \`$PR_VERSION\` (same as main). Bump before merging to avoid a failed PyPI publish.

          $MARKER"

            EXISTING=$(gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \
              --jq '.[] | select(.body | contains("<!-- version-check -->")) | .id' | head -1)

            if [ -n "$EXISTING" ]; then
              gh api repos/${{ github.repository }}/issues/comments/$EXISTING \
                -X PATCH -f body="$BODY" > /dev/null
            else
              gh pr comment ${{ github.event.pull_request.number }} --body "$BODY" || true
            fi

            echo "::error::Version $PR_VERSION matches main. Bump before merging."
            exit 1
          else
            echo "Version bumped: $MAIN_VERSION -> $PR_VERSION"
          fi
python3 -c "import yaml; yaml.safe_load(open('.github/workflows/tests.yml'))"

Expected: no output (valid YAML).

git add .github/workflows/sonar.yml .github/workflows/tests.yml
git commit -m "ci: replace sonar.yml with combined tests.yml (test+lint+version-check)"

Task 24: Add publish.yml

Files: Create .github/workflows/publish.yml

name: Publish to PyPI

on:
  push:
    branches: [main]
    paths:
      - "pyproject.toml"
      - "agtag/**"
  pull_request:
    branches: [main]
    paths:
      - "pyproject.toml"
      - "agtag/**"

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
      - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
      - run: uv python install 3.12
      - run: uv sync
      - run: uv run pytest -n auto -v

  test-publish:
    if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
    needs: test
    runs-on: ubuntu-latest
    environment: testpypi
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
      - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
      - run: uv python install 3.12
      - run: uv sync

      - name: Set dev version
        run: |
          BASE=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
          DEV_VERSION="${BASE}.dev${{ github.run_number }}"
          sed -i "s/^version = .*/version = \"${DEV_VERSION}\"/" pyproject.toml
          echo "DEV_VERSION=${DEV_VERSION}" >> "$GITHUB_ENV"
          echo "Publishing ${DEV_VERSION} to TestPyPI"

      - name: Build and publish to TestPyPI
        run: |
          uv build
          uv publish --publish-url https://test.pypi.org/legacy/ --trusted-publishing always --check-url https://test.pypi.org/simple/

      - name: Print install commands
        if: always()
        run: |
          echo "::notice::Test with: uv tool install --index-url https://test.pypi.org/simple/ --index-strategy unsafe-best-match agtag==${DEV_VERSION}"

  publish:
    if: github.event_name == 'push'
    needs: test
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
      - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
      - run: uv python install 3.12
      - run: uv sync

      - name: Build and publish to PyPI
        run: |
          uv build
          uv publish --trusted-publishing always --check-url https://pypi.org/simple/
python3 -c "import yaml; yaml.safe_load(open('.github/workflows/publish.yml'))"

Expected: no output.

git add .github/workflows/publish.yml
git commit -m "ci: publish.yml — TestPyPI on PR, PyPI on push (OIDC trusted publishing)"

Phase 10 — Verification + PR

Task 25: Pre-merge gate — verify trusted-publisher / environment configuration

Files: none (manual / gh API verification)

This is a manual task and the highest-risk step in the plan. The first publish run fails if the OIDC claim is missing.

gh api repos/agentculture/agtag/environments | python3 -c "import sys,json; d=json.load(sys.stdin); print([e['name'] for e in d['environments']])"

Expected: ['testpypi', 'pypi']. If not, you (the user) must create them at https://github.com/agentculture/agtag/settings/environments.

Open https://test.pypi.org/manage/account/publishing/ (logged in as the project owner). Confirm a “Pending publisher” or active publisher exists for:

Same as Step 2, on https://pypi.org/manage/account/publishing/, with environment pypi.

If any of the three above are missing, stop here and surface to the user. Do not proceed to PR creation; the publish workflow will fail.

If all three are configured: proceed to Task 26.


Task 26: Full local verification

Files: none (read-only verification)

cd /home/spark/git/agtag
uv pip install -e ".[dev]"
uv run black --check agtag tests
uv run isort --check-only agtag tests
uv run flake8 agtag tests
uv run bandit -c pyproject.toml -r agtag
markdownlint-cli2 "**/*.md" "#node_modules" "#.afi" "#.venv"

Expected: all four PASS, no markdownlint errors.

uv run pytest -n auto --cov=agtag --cov-report=term -v

Expected: all PASS, coverage ≥80%.

agtag --version
agtag learn | head -5
agtag explain issue post | head -5
agtag explain --json learn | python3 -m json.tool > /dev/null

Expected: each command exits 0; outputs are non-empty and well-formed.

Create a throwaway issue, fetch it, reply to it, then close.

ISSUE_URL=$(agtag issue post --repo agentculture/agtag --title "scaffold smoke test" --body "ignore me")
echo "$ISSUE_URL"
ISSUE_NUM=$(echo "$ISSUE_URL" | grep -oP '/issues/\K\d+')
agtag issue fetch --repo agentculture/agtag --number "$ISSUE_NUM" --json | python3 -m json.tool > /dev/null
agtag issue reply --repo agentculture/agtag --number "$ISSUE_NUM" --body "closing — smoke test"
gh issue close "$ISSUE_NUM" --repo agentculture/agtag --comment "smoke test complete"

Expected: each step prints a URL or completes silently; gh issue view "$ISSUE_NUM" --repo agentculture/agtag confirms the issue is closed.

git log --oneline main..HEAD
git status

Expected: ~25 commits since main; working tree clean.


Task 27: Open the PR via cicd skill

Files: none (PR creation is an action)

git push -u origin scaffold/v0.1

Write /tmp/agtag-scaffold-pr-body.md:

## Summary

- Adds the `agtag` v0.1.0 CLI: afi-template universals (`learn`, `explain`,
  `--version`, `--json`, structured errors) plus the `issue` noun group
  (`post`, `fetch`, `reply`) wrapping `gh` with culture.yaml-driven nick
  signing.
- Vendors three sibling skills from `agentculture/steward`:
  `version-bump`, `run-tests`, `pypi-maintainer`.
- Removes `communicate/scripts/post-issue.sh`; the `communicate` skill now
  routes the post-issue path through `agtag issue post`. `cicd` is
  untouched (its `pr-reply.sh` migration belongs to a future `agex-cli`
  repo).
- Replaces standalone `sonar.yml` with combined `tests.yml`
  (test+lint+version-check); adds `publish.yml` for TestPyPI-on-PR /
  PyPI-on-push trusted publishing.
- Coverage gate at 80% in `[tool.coverage.report]`.

Closes #<TRACKING_ISSUE_FROM_TASK_1>.

## Test plan

- [x] `uv run pytest -n auto --cov=agtag` — all PASS, coverage ≥80%.
- [x] `uv run black --check`, `isort --check-only`, `flake8`, `bandit`.
- [x] `markdownlint-cli2 "**/*.md"`.
- [x] Live smoke test against a throwaway issue (post / fetch / reply / close).
- [ ] CI `tests` job green.
- [ ] CI `lint` job green.
- [ ] CI `version-check` job green (skipped on this PR — no prior `pyproject.toml` on main).
- [ ] CI `test-publish` job publishes `0.1.0.dev<run>` to TestPyPI; install hint validates.
- [ ] After merge: `publish` job ships `0.1.0` to PyPI.

## Spec

[`docs/superpowers/specs/2026-05-09-agtag-cli-scaffold-design.md`](docs/superpowers/specs/2026-05-09-agtag-cli-scaffold-design.md).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

- agtag (Claude)

Replace <TRACKING_ISSUE_FROM_TASK_1> with the issue number recorded in Task 1 Step 3.

bash .claude/skills/cicd/scripts/create-pr-and-wait.sh \
  --title "Scaffold agtag CLI v0.1 (afi template + issue verbs + release pipeline)" \
  --body-file /tmp/agtag-scaffold-pr-body.md

If the helper rejects --body-file (check its --help), fall back to:

gh pr create \
  --title "Scaffold agtag CLI v0.1 (afi template + issue verbs + release pipeline)" \
  --body-file /tmp/agtag-scaffold-pr-body.md

Expected: prints the PR URL.

bash .claude/skills/cicd/scripts/wait-and-check.sh

Expected: polls until Qodo + Copilot finish reviewing. If feedback comes back, hand off to the cicd skill’s pr-reply / pr-batch flow per its SKILL.md (this plan ends at “PR opened”; review-feedback handling is the cicd skill’s job).

After the PR merges to main:

gh run watch --repo agentculture/agtag $(gh run list --repo agentculture/agtag --workflow publish.yml --limit 1 --json databaseId -q '.[0].databaseId')

Expected: publish job succeeds, agtag 0.1.0 appears at https://pypi.org/project/agtag/.

uv tool install agtag
agtag --version

Expected: agtag 0.1.0.


Self-review (plan author)

Spec coverage:

Placeholder scan: No “TBD”, “TODO”, or “implement later”. One register shim in Task 11 Step 5 is intentionally empty (the issue group registers verbs centrally in Task 14); explained inline.

Type consistency: Cross-task signatures align —