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.
- Step 1: Compose brief
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`.
- Step 2: File the issue
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>.
- Step 3: Record the issue number
Save <N> for use in Task 30 (PR body Closes #<N>). No commit.
Task 2: Create branch + cite afi reference + update .gitignore
Files:
-
Create:
.afi/(generated, gitignored) -
Modify:
.gitignore -
Step 1: Branch from main
cd /home/spark/git/agtag
git checkout -b scaffold/v0.1
- Step 2: Run afi cli cite
afi cli cite .
Expected: prints wrote 11 files under .afi/reference/python-cli/. Verify ls .afi/reference/python-cli/{{slug}}/cli/_errors.py exists.
- Step 3: Add
.afi/to .gitignore
Append to .gitignore:
# afi-cli reference tree (read-only template; not committed)
.afi/
- Step 4: Commit
git add .gitignore
git commit -m "chore: ignore .afi/ reference tree"
Phase 1 — Project foundation
Task 3: pyproject.toml + culture.yaml + CHANGELOG
Files:
-
Create:
pyproject.toml -
Create:
culture.yaml -
Create:
CHANGELOG.md -
Step 1: Write
pyproject.toml
[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"
- Step 2: Write
culture.yaml
agents:
- suffix: agtag
backend: claude
- Step 3: Write
CHANGELOG.md
# 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`.
- Step 4: Verify uv sync works
uv sync
Expected: creates .venv/, installs hatchling/pyyaml + dev deps, prints Resolved N packages. No errors.
- Step 5: Commit
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:
-
Create:
agtag/__init__.py -
Create:
agtag/__main__.py -
Create:
tests/__init__.py(empty) -
Create:
tests/test_version.py -
Step 1: Write the failing test
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"
- Step 2: Verify it fails
uv run pytest tests/test_version.py -v
Expected: FAIL with ModuleNotFoundError: No module named 'agtag'.
- Step 3: Write
agtag/__init__.py
"""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__"]
- Step 4: Write
agtag/__main__.py
"""Entry point for ``python -m agtag``."""
from __future__ import annotations
import sys
from agtag.cli import main
if __name__ == "__main__":
sys.exit(main())
- Step 5: Write
tests/__init__.py(empty file)
Create empty tests/__init__.py.
- Step 6: Re-install editable so
agtagpackage metadata resolves
uv pip install -e .
uv run pytest tests/test_version.py -v
Expected: PASS. __version__ == "0.1.0".
- Step 7: Commit
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:
-
Create:
agtag/cli/__init__.py(placeholder, replaced in Task 8) -
Create:
agtag/cli/_errors.py -
Create:
agtag/cli/_output.py -
Create:
tests/test_errors_output.py -
Step 1: Write the failing test
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"
- Step 2: Verify it fails
uv run pytest tests/test_errors_output.py -v
Expected: FAIL with ModuleNotFoundError: No module named 'agtag.cli'.
- Step 3: Write
agtag/cli/__init__.py(placeholder)
"""agtag CLI package (parser lands in Task 8)."""
- Step 4: Write
agtag/cli/_errors.py(verbatim from afi template, renamedAfiError→AgtagError, project name substituted)
"""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,
}
- Step 5: Write
agtag/cli/_output.py(verbatim from afi template,AfiError→AgtagError, module{{module}}→agtag)
"""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")
- Step 6: Verify tests pass
uv run pytest tests/test_errors_output.py -v
Expected: 7 PASSED.
- Step 7: Commit
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:
-
Create:
agtag/nick.py -
Create:
tests/test_nick.py -
Step 1: Write the failing test
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"
- Step 2: Verify it fails
uv run pytest tests/test_nick.py -v
Expected: FAIL with ModuleNotFoundError: No module named 'agtag.nick'.
- Step 3: Write
agtag/nick.py
"""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
- Step 4: Verify tests pass
uv run pytest tests/test_nick.py -v
Expected: 5 PASSED.
- Step 5: Commit
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:
-
Create:
agtag/explain/__init__.py -
Create:
agtag/explain/catalog.py -
Create:
tests/test_explain_catalog.py -
Step 1: Write the failing test
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)
- Step 2: Verify it fails
uv run pytest tests/test_explain_catalog.py -v
Expected: FAIL with ModuleNotFoundError: No module named 'agtag.explain'.
- Step 3: Write
agtag/explain/__init__.py(verbatim from afi template, names substituted)
"""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())
- Step 4: Write
agtag/explain/catalog.pywith all v0.1 entries
"""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,
}
- Step 5: Verify tests pass
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.
- Step 6: Commit
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:
-
Create:
agtag/cli/_commands/__init__.py(empty) -
Create:
agtag/cli/_commands/learn.py -
Modify:
tests/test_cli.py(new file) -
Step 1: Write the failing test
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
- Step 2: Verify it fails
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).
- Step 3: Write
agtag/cli/_commands/__init__.py(empty file)
Create empty file.
- Step 4: Write
agtag/cli/_commands/learn.py
"""``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)
- Step 5: Replace
agtag/cli/__init__.pywith the parser
Replace placeholder with the full parser (verbatim from afi template, {{module}} → agtag, {{project_name}} → agtag, AfiError → AgtagError):
"""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())
- Step 6: Verify tests pass
uv run pytest tests/test_cli.py -v
Expected: 4 PASSED.
- Step 7: Commit
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:
-
Create:
agtag/cli/_commands/explain.py -
Modify:
agtag/cli/__init__.py(register explain) -
Modify:
tests/test_cli.py(add explain assertions) -
Step 1: Append failing tests to
tests/test_cli.py
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("#")
- Step 2: Verify they fail
uv run pytest tests/test_cli.py -v
Expected: 4 of 8 FAIL (the 4 new ones; argparse will reject explain as unknown subcommand).
- Step 3: Write
agtag/cli/_commands/explain.py(verbatim from afi, substituted)
"""``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)
- Step 4: Register
explaininagtag/cli/__init__.py
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)
- Step 5: Verify all tests pass
uv run pytest tests/test_cli.py -v
Expected: 8 PASSED.
- Step 6: Commit
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:
-
Create:
agtag/issue/__init__.py -
Create:
agtag/issue/_gh.py -
Create:
tests/test_gh.py -
Step 1: Write the failing test
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
- Step 2: Verify it fails
uv run pytest tests/test_gh.py -v
Expected: FAIL with ModuleNotFoundError: No module named 'agtag.issue'.
- Step 3: Write
agtag/issue/__init__.py(empty file)
Create empty file.
- Step 4: Write
agtag/issue/_gh.py
"""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
- Step 5: Verify tests pass
uv run pytest tests/test_gh.py -v
Expected: 3 PASSED.
- Step 6: Commit
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:
-
Create:
agtag/issue/_sign.py -
Create:
agtag/issue/post.py -
Create:
agtag/cli/_commands/issue_post.py -
Modify:
agtag/cli/__init__.py(register) -
Create:
tests/test_issue_post.py -
Step 1: Write the failing test
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
- Step 2: Verify it fails
uv run pytest tests/test_issue_post.py -v
Expected: FAIL with ModuleNotFoundError: No module named 'agtag.issue.post'.
- Step 3: Write
agtag/issue/_sign.py
"""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}"
- Step 4: Write
agtag/issue/post.py
"""``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}
- Step 5: Write
agtag/cli/_commands/issue_post.py
"""``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.)
- Step 6: Verify post-module tests pass
uv run pytest tests/test_issue_post.py -v
Expected: 4 PASSED.
- Step 7: Commit
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:
-
Create:
agtag/issue/fetch.py -
Create:
agtag/cli/_commands/issue_fetch.py -
Create:
tests/test_issue_fetch.py -
Step 1: Write the failing test
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
- Step 2: Verify it fails
uv run pytest tests/test_issue_fetch.py -v
Expected: FAIL with ModuleNotFoundError: No module named 'agtag.issue.fetch'.
- Step 3: Write
agtag/issue/fetch.py
"""``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,
}
- Step 4: Write
agtag/cli/_commands/issue_fetch.py
"""``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
- Step 5: Verify tests pass
uv run pytest tests/test_issue_fetch.py -v
Expected: 3 PASSED.
- Step 6: Commit
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:
-
Create:
agtag/issue/reply.py -
Create:
agtag/cli/_commands/issue_reply.py -
Create:
tests/test_issue_reply.py -
Step 1: Write the failing test
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
- Step 2: Verify it fails
uv run pytest tests/test_issue_reply.py -v
Expected: FAIL — agtag.issue.reply not found.
- Step 3: Write
agtag/issue/reply.py
"""``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}
- Step 4: Write
agtag/cli/_commands/issue_reply.py
"""``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
- Step 5: Verify tests pass
uv run pytest tests/test_issue_reply.py -v
Expected: 4 PASSED.
- Step 6: Commit
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:
-
Create:
agtag/cli/_commands/issue.py(group registrar) -
Modify:
agtag/cli/__init__.py(register issue group) -
Modify:
tests/test_cli.py(assert dispatch reaches verbs via mock) -
Step 1: Append failing tests to
tests/test_cli.py
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"
- Step 2: Verify they fail
uv run pytest tests/test_cli.py -v
Expected: 3 new FAIL — argparse rejects issue as unknown subcommand.
- Step 3: Write
agtag/cli/_commands/issue.py(the noun-group registrar)
"""``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)
- Step 4: Register
issuegroup inagtag/cli/__init__.py
Add to imports:
from agtag.cli._commands import issue as _issue_grp
In _build_parser, after _explain_cmd.register(sub):
_issue_grp.register(sub)
- Step 5: Verify all tests pass
uv run pytest -v
Expected: all tests PASS (test_cli has 11; full suite ~30+).
- Step 6: Commit
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)
- Step 1: Run full suite with coverage
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%.
- Step 2: If coverage < 80% in any file:
For each gap, decide:
- Reachable but untested branch → write a focused test in the relevant
tests/test_*.py. Re-runpytest --cov=agtagto confirm. - Genuinely unreachable (e.g.,
if __name__ == "__main__":shim, defensivePackageNotFoundError) → already excluded by[tool.coverage.report]exclude_lines; verify the line shows# pragma: no coverif needed.
Common likely gaps and their fix tests:
-
agtag/cli/__init__.py_dispatchlast-resortexcept Exceptionbranch → add a test that registers a fake command which raises a non-AgtagError, asserts wrapped error path. -
agtag/cli/_commands/issue_post.py--bodyand--body-filemutual-exclusion branches → add CLI-level tests for both error paths. -
Step 3: Re-run and confirm ≥80%
uv run pytest -n auto --cov=agtag --cov-report=term -v
Expected: Required test coverage of 80% reached. Total coverage: ≥80.0%.
- Step 4: Commit any added tests
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:
-
Create:
.claude/skills/version-bump/SKILL.md -
Create:
.claude/skills/version-bump/scripts/bump.py+ any siblings -
Step 1: Copy the skill tree
cp -R /home/spark/git/steward/.claude/skills/version-bump /home/spark/git/agtag/.claude/skills/
- Step 2: Verify scripts are executable
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).
- Step 3: Verify SKILL.md
steward/__init__.pyreference
grep -n 'steward/__init__.py\|steward-cli' /home/spark/git/agtag/.claude/skills/version-bump/SKILL.md
Edit any matches: steward/__init__.py → agtag/__init__.py. The bump.py script itself is generic and reads pyproject.toml directly — no script changes needed.
- Step 4: Smoke-test the script
cd /home/spark/git/agtag
python3 .claude/skills/version-bump/scripts/bump.py show
Expected: 0.1.0.
- Step 5: Commit
git add .claude/skills/version-bump/
git commit -m "vendor: version-bump skill from agentculture/steward"
Task 17: Vendor run-tests skill
Files:
-
Create:
.claude/skills/run-tests/SKILL.md -
Create:
.claude/skills/run-tests/scripts/test.sh -
Step 1: Copy and chmod
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
- Step 2: Smoke-test
cd /home/spark/git/agtag
bash .claude/skills/run-tests/scripts/test.sh -p -q
Expected: pytest runs, all PASS.
- Step 3: Commit
git add .claude/skills/run-tests/
git commit -m "vendor: run-tests skill from agentculture/steward"
Task 18: Vendor pypi-maintainer skill
Files:
-
Create:
.claude/skills/pypi-maintainer/SKILL.md -
Create:
.claude/skills/pypi-maintainer/scripts/switch-source.sh -
Step 1: Copy and chmod
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
- Step 2: Syntax-check the bash
bash -n /home/spark/git/agtag/.claude/skills/pypi-maintainer/scripts/switch-source.sh
Expected: no output (no syntax errors).
- Step 3: Commit
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:
-
Delete:
.claude/skills/communicate/scripts/post-issue.sh -
Modify:
.claude/skills/communicate/SKILL.md -
Step 1: Delete the bash script
git rm .claude/skills/communicate/scripts/post-issue.sh
- Step 2: Edit
.claude/skills/communicate/SKILL.md
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.”
- Step 3: Verify the SKILL.md no longer references the script
grep -n 'post-issue.sh' .claude/skills/communicate/SKILL.md
Expected: no output.
- Step 4: Commit
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
- Step 1: Update the table
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:
| Skill | Upstream | Local divergence |
|---|---|---|
version-bump | agentculture/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-tests | agentculture/steward (.claude/skills/run-tests/) | none — scripts/test.sh reads [tool.coverage.run] from pyproject.toml, fully package-agnostic. |
pypi-maintainer | agentculture/steward (.claude/skills/pypi-maintainer/) | none — scripts/switch-source.sh takes the package name as its first argument; copied verbatim. |
- Step 2: Commit
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
- Step 1: Replace contents
# 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:
-
Create:
docs/purpose.md -
Create:
docs/features.md -
Create:
docs/commands.md -
Create:
docs/culture.md -
Step 1: Write
docs/purpose.md
# 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`).
- Step 2: Write
docs/features.md
# 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.
- Step 3: Write
docs/commands.md
# 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:
0success1bad repo / missing args / mutually-exclusive--bodyand--body-fileboth passed2ghmissing or unauthenticated
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
| Repo | Role |
|---|---|
culture | IRC-based agent mesh. |
daria | Autonomous awareness agent. |
steward | Resident-agent alignment + skill source-of-truth. |
cultureagent, agentirc | Other mesh-side components. |
(planned) agex-cli | Agent / 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.
- Step 6: Commit
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:
-
Delete:
.github/workflows/sonar.yml -
Create:
.github/workflows/tests.yml -
Step 1: Delete the existing sonar workflow
git rm .github/workflows/sonar.yml
- Step 2: Write
.github/workflows/tests.yml(port of steward’stests.yml, project name swapped)
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
- Step 3: Lint the YAML
python3 -c "import yaml; yaml.safe_load(open('.github/workflows/tests.yml'))"
Expected: no output (valid YAML).
- Step 4: Commit
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
- Step 1: Write the workflow (port of steward’s, project name swapped)
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/
- Step 2: Lint the YAML
python3 -c "import yaml; yaml.safe_load(open('.github/workflows/publish.yml'))"
Expected: no output.
- Step 3: Commit
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.
- Step 1: Verify GitHub deployment environments exist
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.
- Step 2: Verify TestPyPI trusted-publisher record
Open https://test.pypi.org/manage/account/publishing/ (logged in as the project owner). Confirm a “Pending publisher” or active publisher exists for:
-
PyPI Project Name:
agtag -
Owner:
agentculture -
Repository name:
agtag -
Workflow name:
publish.yml -
Environment name:
testpypi -
Step 3: Verify PyPI trusted-publisher record
Same as Step 2, on https://pypi.org/manage/account/publishing/, with environment pypi.
- Step 4: Decision gate
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)
- Step 1: Re-install editable
cd /home/spark/git/agtag
uv pip install -e ".[dev]"
- Step 2: Run the full lint suite
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.
- Step 3: Run the test suite with coverage
uv run pytest -n auto --cov=agtag --cov-report=term -v
Expected: all PASS, coverage ≥80%.
- Step 4: Smoke-test installed CLI
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.
- Step 5: Live smoke test (real
ghcall)
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.
- Step 6: Verify all commits on the branch are clean
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)
- Step 1: Push the branch
git push -u origin scaffold/v0.1
- Step 2: Compose the PR body
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.
- Step 3: Create the PR via the cicd skill’s create-pr-and-wait helper
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.
- Step 4: Run the cicd review-wait helper
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).
- Step 5: Final verification on PR merge
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:
- §1 Purpose → reflected in Tasks 21–22 (README + docs/purpose.md, features.md).
- §2 Module layout → Tasks 4–14 land every file in the layout. ✓
- §3 CLI surface → Tasks 8 (learn), 9 (explain), 11 (post), 12 (fetch), 13 (reply), 14 (parser wiring). ✓
- §4 Nick + culture.yaml → Tasks 3 (yaml) + 6 (resolver). ✓
- §5 CI / release pipeline → Tasks 23 (tests.yml) + 24 (publish.yml) + 25 (trusted-publisher gate). ✓
- §6 Skills + adaptations → Tasks 16–18 (vendor) + 19 (communicate migration) + 20 (ledger). ✓
- §7 PR plan → Task 1 (tracking issue), 2 (branch + cite), 27 (PR open). ✓
- §8 Testing strategy → Tests created alongside each module in Tasks 4–14; coverage gate verified in Task 15. ✓
- §9 Out of scope → explicit no-touch on
cicd, noprgroup, noagtag message. ✓ - §10 Risks → Task 25 makes the trusted-publisher unknown an explicit decision gate.
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 —
AgtagError(code, message, remediation)consistent across Tasks 5, 7, 9, 10, 11, 13._run_gh(args, *, stdin=None) -> strdefined in Task 10, called identically in Tasks 11 & 13.sign(body, nick) -> strdefined in Task 11, used in Task 13.resolve_nick(cwd=None) -> strdefined in Task 6, used in Tasks 11 & 13.post(*, repo, title, body, nick=None) -> dictandreply(*, repo, number, body, nick=None) -> dictalign in their CLI wrappers (Tasks 11 & 13).fetch(*, repo, number) -> dictandparse_issue_url(url) -> tuple[str,int]align with the CLI dispatch (Task 12).