steward-cli 0.1.2__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. steward_cli-0.2.0/.claude/skills/doc-test-alignment/SKILL.md +55 -0
  2. steward_cli-0.2.0/.claude/skills/doc-test-alignment/scripts/check.sh +24 -0
  3. {steward_cli-0.1.2 → steward_cli-0.2.0}/.markdownlint-cli2.yaml +2 -2
  4. {steward_cli-0.1.2 → steward_cli-0.2.0}/CHANGELOG.md +38 -0
  5. {steward_cli-0.1.2 → steward_cli-0.2.0}/CLAUDE.md +24 -0
  6. {steward_cli-0.1.2 → steward_cli-0.2.0}/PKG-INFO +1 -1
  7. steward_cli-0.2.0/docs/sibling-pattern.md +80 -0
  8. steward_cli-0.2.0/docs/skill-sources.md +43 -0
  9. {steward_cli-0.1.2 → steward_cli-0.2.0}/pyproject.toml +1 -1
  10. {steward_cli-0.1.2 → steward_cli-0.2.0}/steward/cli/__init__.py +2 -0
  11. steward_cli-0.2.0/steward/cli/_commands/verify.py +228 -0
  12. {steward_cli-0.1.2 → steward_cli-0.2.0}/tests/test_cli.py +3 -1
  13. steward_cli-0.2.0/tests/test_cli_verify.py +78 -0
  14. steward_cli-0.2.0/tests/test_skills_convention.py +106 -0
  15. {steward_cli-0.1.2 → steward_cli-0.2.0}/uv.lock +1 -1
  16. {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/agent-config/SKILL.md +0 -0
  17. {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/agent-config/scripts/show.sh +0 -0
  18. {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/pr-review/SKILL.md +0 -0
  19. {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/pr-review/scripts/portability-lint.sh +0 -0
  20. {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/pr-review/scripts/pr-batch.sh +0 -0
  21. {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/pr-review/scripts/pr-comments.sh +0 -0
  22. {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/pr-review/scripts/pr-reply.sh +0 -0
  23. {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/pr-review/scripts/pr-status.sh +0 -0
  24. {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/pr-review/scripts/workflow.sh +0 -0
  25. {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/version-bump/SKILL.md +0 -0
  26. {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/version-bump/scripts/bump.py +0 -0
  27. {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills.local.yaml.example +0 -0
  28. {steward_cli-0.1.2 → steward_cli-0.2.0}/.flake8 +0 -0
  29. {steward_cli-0.1.2 → steward_cli-0.2.0}/.github/workflows/publish.yml +0 -0
  30. {steward_cli-0.1.2 → steward_cli-0.2.0}/.github/workflows/tests.yml +0 -0
  31. {steward_cli-0.1.2 → steward_cli-0.2.0}/.gitignore +0 -0
  32. {steward_cli-0.1.2 → steward_cli-0.2.0}/LICENSE +0 -0
  33. {steward_cli-0.1.2 → steward_cli-0.2.0}/README.md +0 -0
  34. {steward_cli-0.1.2 → steward_cli-0.2.0}/steward/__init__.py +0 -0
  35. {steward_cli-0.1.2 → steward_cli-0.2.0}/steward/__main__.py +0 -0
  36. {steward_cli-0.1.2 → steward_cli-0.2.0}/steward/cli/_commands/__init__.py +0 -0
  37. {steward_cli-0.1.2 → steward_cli-0.2.0}/steward/cli/_commands/show.py +0 -0
  38. {steward_cli-0.1.2 → steward_cli-0.2.0}/steward/cli/_errors.py +0 -0
  39. {steward_cli-0.1.2 → steward_cli-0.2.0}/steward/cli/_output.py +0 -0
  40. {steward_cli-0.1.2 → steward_cli-0.2.0}/tests/__init__.py +0 -0
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: doc-test-alignment
3
+ description: >
4
+ Verify that committed docs (README.md, CLAUDE.md, SKILL.md descriptions) still
5
+ describe what the code and tests actually do. Use at the end of a plan, before
6
+ PR creation, or when the user says "check doc-test alignment", "verify docs",
7
+ or "do the docs still match the code". STUB — `scripts/check.sh` exits with a
8
+ not-yet-implemented error today; the contract for what it will do lives in
9
+ this file.
10
+ ---
11
+
12
+ # doc-test-alignment (stub)
13
+
14
+ This skill is a stub. The real workflow is intentionally not yet implemented —
15
+ the file exists so that `steward verify` can find it and so contributors who
16
+ land here know it is on the roadmap, not forgotten.
17
+
18
+ ## How to run
19
+
20
+ `scripts/check.sh` is the entry point. Today it prints a not-yet-implemented
21
+ notice and exits non-zero. When the workflow lands, the script will gate
22
+ PR-readiness on the alignment contract below; until then, treat any green
23
+ exit code from this script as a bug.
24
+
25
+ ## What it will check
26
+
27
+ The skill is the contract for four narrow alignments. README.md command
28
+ examples must still execute against the current checkout and produce output
29
+ that matches the surrounding prose. The "build/test/publish" command lines in
30
+ CLAUDE.md must do the same. For each `.claude/skills/<name>/`, the SKILL.md
31
+ `description` frontmatter must agree with what the scripts under
32
+ `scripts/` actually do — surfacing disagreements (e.g. SKILL.md claims the
33
+ skill bumps versions but `scripts/` has no bump script). And for each test,
34
+ the test name should still describe the assertions the test makes — flagging
35
+ drift where the name advertises a feature the assertions no longer touch.
36
+
37
+ ## Why it ships as a stub
38
+
39
+ Each of those four checks is independently non-trivial. Shipping a partial
40
+ implementation would either silently pass when it shouldn't, or false-positive
41
+ on intentional doc-vs-code differences. The right path is to land the checks
42
+ one at a time, with their own tests, behind a
43
+ `steward verify --check doc-test-alignment` flag. The parent verbs (`verify`,
44
+ `doctor`) are named in the "Roadmap" section of `CLAUDE.md`; the broader
45
+ sibling-pattern contract lives in `docs/sibling-pattern.md`.
46
+
47
+ ## What this stub guarantees today
48
+
49
+ - The skill directory exists, so `steward verify`'s skills-convention check
50
+ finds the standard layout (SKILL.md + `scripts/` with an entry-point).
51
+ - `scripts/check.sh` is the entry-point script, satisfying the steward skills
52
+ convention requirement that every skill ships an executable script.
53
+ - This `SKILL.md` is the contract for what the skill will do — when the
54
+ implementation lands, it must satisfy this description or the description
55
+ must move first.
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # doc-test-alignment skill — entry point.
3
+ #
4
+ # STUB: the real workflow is not implemented yet. This script exists so the
5
+ # steward skills convention is satisfied (every skill ships an executable
6
+ # entry-point script); when the real implementation lands here, it must
7
+ # satisfy the contract documented in ../SKILL.md.
8
+ #
9
+ # Exits 2 (EXIT_USER_ERROR-ish for "you asked for something that isn't
10
+ # wired up yet") so callers can tell the difference between "checks passed"
11
+ # (would be 0) and "stub".
12
+
13
+ set -euo pipefail
14
+
15
+ cat >&2 <<'EOF'
16
+ doc-test-alignment: not yet implemented.
17
+
18
+ This skill is a stub; the contract for what `check.sh` will assert lives in
19
+ .claude/skills/doc-test-alignment/SKILL.md. Until the implementation lands,
20
+ treat any green exit code from this script as a bug.
21
+
22
+ Roadmap: see CLAUDE.md ("Roadmap (CLI surface)") and docs/sibling-pattern.md.
23
+ EOF
24
+ exit 2
@@ -1,6 +1,6 @@
1
1
  # markdownlint-cli2 config for steward.
2
- # markdownlint-cli2 stops walking at the git root, so the user's global
3
- # ~/.markdownlint-cli2.yaml isn't picked up from inside the repo.
2
+ # markdownlint-cli2 stops walking at the git root, so a per-user global
3
+ # config in the home directory isn't picked up from inside the repo.
4
4
  # Mirrors the afi-cli / cfafi preset for workspace consistency.
5
5
 
6
6
  config:
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
5
5
  Format follows [Keep a Changelog](https://keepachangelog.com/). This project
6
6
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.0] - 2026-04-26
9
+
10
+ ### Added
11
+
12
+ - `steward verify <path>` — read-only diagnosis of a sibling repo against the
13
+ AgentCulture sibling pattern. Two checks today: `portability` (runs steward's
14
+ own vendored `portability-lint.sh --all` with `cwd=<target>`, so the target
15
+ doesn't need to vendor it and `verify` only ever executes a known-trusted
16
+ script) and `skills-convention` (every `SKILL.md` has a sibling `scripts/`
17
+ directory and a matching frontmatter `name`). Aggregates findings across all
18
+ selected checks; human-readable findings go to stderr, `--json` puts the
19
+ structured findings list on stdout. `--check <name>` repeatable. Exits 1 if
20
+ any finding was reported.
21
+ - `docs/sibling-pattern.md` — single source of truth for the AgentCulture
22
+ sibling pattern (12 required artifacts, 5 machine-checkable invariants,
23
+ 5 deterministic repairs). Consumed by `steward verify`; will be consumed
24
+ by the future `steward doctor`.
25
+ - `docs/skill-sources.md` — per-skill upstream declarations and vendoring
26
+ policy so `doctor` can vendor deterministically.
27
+ - `.claude/skills/doc-test-alignment/` — stub skill describing the intended
28
+ doc/test alignment workflow. Implementation TBD.
29
+ - `tests/test_skills_convention.py` — repo-level invariants for steward's own
30
+ skills (every skill has SKILL.md + scripts/, frontmatter name matches dir,
31
+ no per-user/home-dir paths in skill scripts).
32
+ - `tests/test_cli_verify.py` — end-to-end tests for the new verb, including a
33
+ dogfood test that runs `steward verify` against steward itself.
34
+
35
+ ### Changed
36
+
37
+ - `CLAUDE.md` gains a "Roadmap (CLI surface)" section naming `verify` and
38
+ `doctor` as the next two verbs.
39
+ - `.markdownlint-cli2.yaml` header comment reworded to avoid tripping the
40
+ portability lint with its own self-reference (caught by the new
41
+ `tests/test_cli_verify.py` dogfood test on first run).
42
+ - `tests/test_cli.py` help-output assertion loosened to match individual
43
+ verb names instead of the literal `{show}` group, so adding verbs doesn't
44
+ break it.
45
+
8
46
  ## [0.1.2] - 2026-04-26
9
47
 
10
48
  ### Added
@@ -61,6 +61,30 @@ Per-machine paths (Culture server manifest location, sibling-project paths, etc.
61
61
 
62
62
  Steward is a "skills supplier" for the Culture mesh. When a skill stabilizes here, the next step is propagating it to sibling projects (`culture`, `daria`, etc.) — the all-backends rule applied to skills.
63
63
 
64
+ ## Roadmap (CLI surface)
65
+
66
+ The current CLI ships one verb (`steward show`). The next two verbs are
67
+ `verify` (read-only diagnosis against the AgentCulture sibling pattern) and
68
+ `doctor` (diagnose-and-fix; default dry-run, `--apply` to commit). Together
69
+ they encode steward's mission as code instead of prose:
70
+
71
+ - `steward verify <path>` — score a target repo against `docs/sibling-pattern.md`.
72
+ Aggregates findings across all selected checks, then exits non-zero if any
73
+ finding was reported. Human-readable findings go to stderr; `--json` emits
74
+ the structured findings list to stdout. The first cut runs steward's own
75
+ vendored `.claude/skills/pr-review/scripts/portability-lint.sh` against the
76
+ target (so the target doesn't need to vendor it), plus a skills-convention
77
+ check (every `SKILL.md` has a sibling `scripts/` entry-point).
78
+ - `steward doctor <path>` — repair what `verify` flagged, where the repair is
79
+ unambiguous (missing `scripts/` directory, missing `.markdownlint-cli2.yaml`,
80
+ missing `.claude/skills.local.yaml.example`, etc.). Larger emissions (CLI
81
+ scaffold) land later as additional repair handlers, eventually consuming
82
+ `../afi-cli/afi/cite/_engine.py` rather than re-implementing it.
83
+
84
+ Per-skill upstreams (which repo owns the canonical copy of `version-bump`,
85
+ `pr-review`, etc.) are recorded in `docs/skill-sources.md` so `doctor` can
86
+ vendor deterministically.
87
+
64
88
  ## Working with Culture from here
65
89
 
66
90
  Steward will need to read or write Culture artifacts (agent definitions, server configs, mesh links). Useful entry points:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steward-cli
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Steward — aligns and maintains resident agents across Culture projects.
5
5
  Project-URL: Homepage, https://github.com/agentculture/steward
6
6
  Project-URL: Issues, https://github.com/agentculture/steward/issues
@@ -0,0 +1,80 @@
1
+ # AgentCulture sibling pattern
2
+
3
+ The shape every AgentCulture sibling repo (`steward`, `cfafi`, `ghafi`, `daria`,
4
+ …) is expected to wear. This document is the single source of truth that
5
+ `steward verify` and `steward doctor` consume.
6
+
7
+ The companion file `sibling-pattern.json` (TBD; emit from this doc) is the
8
+ machine-readable form. Until it lands, the checks `verify` runs are hard-coded
9
+ in `steward/cli/_commands/verify.py`; this document remains the human-readable
10
+ contract that those hard-coded checks are expected to honor.
11
+
12
+ ## Required artifacts
13
+
14
+ | # | Artifact | Path | Why |
15
+ |---|----------|------|-----|
16
+ | 1 | Toolchain | `pyproject.toml` (hatchling, Python ≥3.12, zero runtime deps where possible) | Uniform install/build/publish across the mesh. |
17
+ | 2 | Top-level package | `<pkg>/__init__.py`, `<pkg>/__main__.py` | `__version__` via `importlib.metadata`; `python -m <pkg>` works. |
18
+ | 3 | CLI scaffolding | `<pkg>/cli/__init__.py`, `cli/_errors.py`, `cli/_output.py`, `cli/_commands/` | The afi-cli pattern: structured errors, stdout/stderr split, `--json`. |
19
+ | 4 | Agent-first verbs | `cli/_commands/{learn,explain,whoami}.py` | `learn`/`explain` are the agent-affordance verbs; `whoami` is the smallest auth probe. |
20
+ | 5 | Mutation safety | Any write verb defaults to dry-run; `--apply` to commit | Agents call CLIs in loops; safe-by-default is mandatory. |
21
+ | 6 | Tests | `tests/test_cli_*.py`, pytest-xdist, coverage | CI gate; no untested verb ships. |
22
+ | 7 | CI | `.github/workflows/tests.yml`, `.github/workflows/publish.yml` | Tests + lint + version-check; PyPI/TestPyPI via Trusted Publishing. |
23
+ | 8 | Changelog | `CHANGELOG.md` (Keep-a-Changelog) | Bumped on every PR by the `version-bump` skill. |
24
+ | 9 | Skills | `.claude/skills/<name>/SKILL.md` + `scripts/` entry-point per skill | Convention: no external path deps, no per-user dotfile refs. |
25
+ | 10 | Per-machine config | `.claude/skills.local.yaml.example` (committed) + `.claude/skills.local.yaml` (git-ignored) | Skills read the local file, fall back to the example. |
26
+ | 11 | Lint configs | `.flake8`, `.markdownlint-cli2.yaml` (repo-local) | No reliance on per-user home-directory configs. |
27
+ | 12 | `CLAUDE.md` | Project shape, build/test/publish commands, conventions | What future Claude instances need that isn't discoverable from a 30-second `ls`. |
28
+
29
+ ## Invariants (machine-checkable)
30
+
31
+ The full set of invariants the AgentCulture sibling pattern asserts. The
32
+ **Status** column reflects what is wired into `steward verify` *today*; items
33
+ marked `(planned)` are described here as the contract `verify` is expected to
34
+ grow into.
35
+
36
+ - **portability** *(implemented as `--check portability`)* — no
37
+ `/home/<user>/...` paths in tracked files; no `~/.<dotfile>` config refs in
38
+ committed `.md`/`.yaml`/`.toml`/`.json`/`.jsonc` outside the carve-outs
39
+ (`~/.claude/skills/.../scripts/`, `~/.culture/`).
40
+ *Source:* `.claude/skills/pr-review/scripts/portability-lint.sh`.
41
+ - **skills-convention** *(implemented as `--check skills-convention`)* —
42
+ every `.claude/skills/<name>/SKILL.md` has a sibling
43
+ `.claude/skills/<name>/scripts/` directory, **and** the SKILL.md frontmatter
44
+ `name` equals the directory name. (The "every skill has at least one
45
+ entry-point script" invariant is satisfied by the directory existing today
46
+ to keep the check noise-free; tightening to "directory has ≥1 file" is
47
+ *(planned)*.)
48
+ - **changelog-format** *(planned)* — `CHANGELOG.md` has at least one
49
+ `## [x.y.z] - YYYY-MM-DD` heading.
50
+ - **lint-config-local** *(planned)* — `.markdownlint-cli2.yaml` exists at the
51
+ repo root (no reliance on per-user home configs).
52
+
53
+ ## Repairs (machine-fixable, run by `steward doctor`)
54
+
55
+ `steward doctor` is **not yet implemented** (see `CLAUDE.md`'s Roadmap
56
+ section); the table below is the contract it will honor when it lands. A
57
+ repair is included only if it is **deterministic and idempotent**. Where the
58
+ right answer depends on judgement, `doctor` will report the gap and stop.
59
+
60
+ | Invariant violated | Planned repair |
61
+ |--------------------|----------------|
62
+ | `.claude/skills/<name>/scripts/` missing | Create the empty directory + a stub entry-point script. |
63
+ | `.markdownlint-cli2.yaml` missing | Vendor steward's copy verbatim. |
64
+ | `.claude/skills.local.yaml.example` missing | Vendor a minimal template documenting the `culture_server_yaml` and `sibling_projects` keys. |
65
+ | `CHANGELOG.md` missing | Create a Keep-a-Changelog skeleton with one `## [Unreleased]` heading. |
66
+ | `SKILL.md` frontmatter `name` ≠ dir name | Reported only — too many false-positive renames to auto-correct. |
67
+ | Hard-coded `/home/...` path in tracked file | Reported only — fix requires understanding intent. |
68
+
69
+ ## Skill upstream policy
70
+
71
+ Per-skill upstream declarations live in `docs/skill-sources.md`. `doctor`
72
+ consults that file when vendoring a skill into a target sibling: each skill
73
+ has exactly one canonical source repo, and `doctor` copies from there.
74
+
75
+ ## Out of scope (for the pattern, not for steward)
76
+
77
+ - Pre-commit hooks (suggested but not required; siblings vary on this).
78
+ - Specific CI runners or Python versions beyond ≥3.12.
79
+ - Anything Culture-mesh-specific (server manifest, agent definitions) — that
80
+ belongs in `docs/` of the relevant Culture-side project, not in this pattern.
@@ -0,0 +1,43 @@
1
+ # Skill upstream sources
2
+
3
+ Each skill has exactly one canonical source repo. `steward doctor` consults
4
+ this file when vendoring a skill into a target sibling so the choice is
5
+ deterministic.
6
+
7
+ When a skill exists in multiple repos, the **upstream** column wins. Other
8
+ repos are downstream copies that may lag and should periodically re-sync from
9
+ upstream.
10
+
11
+ | Skill | Upstream | Downstream copies (known) | Notes |
12
+ |-------|----------|---------------------------|-------|
13
+ | `version-bump` | `steward` (`.claude/skills/version-bump/`) | `cfafi`, `afi-cli` | Pure Python, prepends Keep-a-Changelog entry; no per-repo customization needed. |
14
+ | `pr-review` | `steward` (`.claude/skills/pr-review/`) | `cfafi` (variant) | Steward owns the canonical workflow; downstream copies may add reviewer-specific wiring (Qodo/Copilot, etc.). |
15
+ | `agent-config` | `steward` (`.claude/skills/agent-config/`) | — | Steward-specific (resolves Culture agent suffixes); not portable as-is. |
16
+ | `doc-test-alignment` | `steward` (`.claude/skills/doc-test-alignment/`) | — | Stub; real implementation TBD. |
17
+ | `cfafi`, `cfafi-write` | `cfafi` (`.claude/skills/cfafi*/`) | — | CloudFlare-specific; not vendored elsewhere. |
18
+ | `poll` | `cfafi` (`.claude/skills/poll/`) | — | Background-reviewer subagent; candidate for promotion to `steward` if it stabilizes. |
19
+
20
+ ## Vendoring policy
21
+
22
+ - **Cite, don't import.** Skills are copied into the consuming repo, not
23
+ symlinked or installed as a dependency. Each consumer owns and may modify
24
+ their copy.
25
+ - **Re-sync explicitly.** When upstream changes, downstream copies do not
26
+ auto-update. `steward doctor --skill <name>` is the intended re-sync path
27
+ (TBD).
28
+ - **Diverge intentionally.** A downstream copy may diverge for repo-specific
29
+ reasons (e.g. `cfafi`'s `pr-review` adds CloudFlare-API reviewers). Record
30
+ the divergence in the downstream `SKILL.md`'s frontmatter `description`.
31
+
32
+ ## When a skill should be promoted upstream
33
+
34
+ A skill currently owned downstream (e.g. `poll` in `cfafi`) should be promoted
35
+ to `steward` when:
36
+
37
+ 1. At least one other sibling has copy-pasted it, OR
38
+ 2. Its scripts have no repo-specific assumptions (no hard-coded API
39
+ credentials, no per-product paths), AND
40
+ 3. Its `SKILL.md` describes a pattern (not a single product's workflow).
41
+
42
+ Promotion is a manual decision — `steward doctor` will not move skills
43
+ between repos.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "steward-cli"
3
- version = "0.1.2"
3
+ version = "0.2.0"
4
4
  description = "Steward — aligns and maintains resident agents across Culture projects."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -33,6 +33,7 @@ def _build_parser() -> argparse.ArgumentParser:
33
33
  # Deferred import to avoid coupling the parser module to the command modules
34
34
  # at import time (matches afi-cli's pattern; cheap insurance).
35
35
  from steward.cli._commands import show as _show_cmd
36
+ from steward.cli._commands import verify as _verify_cmd
36
37
 
37
38
  parser = _StewardArgumentParser(
38
39
  prog="steward",
@@ -46,6 +47,7 @@ def _build_parser() -> argparse.ArgumentParser:
46
47
  sub = parser.add_subparsers(dest="command", parser_class=_StewardArgumentParser)
47
48
 
48
49
  _show_cmd.register(sub)
50
+ _verify_cmd.register(sub)
49
51
 
50
52
  return parser
51
53
 
@@ -0,0 +1,228 @@
1
+ """``steward verify`` — read-only diagnosis of a sibling repo against the
2
+ AgentCulture sibling pattern (`docs/sibling-pattern.md`).
3
+
4
+ First cut: two checks — `portability` (delegates to steward's own vendored
5
+ `portability-lint.sh --all` run with the target as cwd) and `skills-convention`
6
+ (every `SKILL.md` has a sibling `scripts/` directory). All findings are
7
+ aggregated; the command exits non-zero if any check produced findings.
8
+ `--json` emits structured findings to stdout.
9
+
10
+ Future checks land here behind `--check <name>` flags. The full set of
11
+ invariants is enumerated in `docs/sibling-pattern.md` ("Invariants").
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json as json_mod
18
+ import re
19
+ import subprocess
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ from steward.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, StewardError
24
+ from steward.cli._output import emit_diagnostic, emit_result
25
+
26
+ FRONTMATTER_NAME_RE = re.compile(r"^name:\s*(\S+)\s*$", re.MULTILINE)
27
+ PORTABILITY_LINT_RELPATH = Path(".claude/skills/pr-review/scripts/portability-lint.sh")
28
+
29
+
30
+ @dataclass
31
+ class Finding:
32
+ check: str
33
+ path: str
34
+ message: str
35
+
36
+ def to_dict(self) -> dict[str, str]:
37
+ return {"check": self.check, "path": self.path, "message": self.message}
38
+
39
+
40
+ def _resolve_target(raw: str) -> Path:
41
+ target = Path(raw).expanduser().resolve()
42
+ if not target.is_dir():
43
+ raise StewardError(
44
+ code=EXIT_USER_ERROR,
45
+ message=f"target is not a directory: {raw}",
46
+ remediation="pass a path to a sibling repo checkout",
47
+ )
48
+ return target
49
+
50
+
51
+ def _find_git_root(start: Path) -> Path | None:
52
+ for directory in (start, *start.parents):
53
+ if (directory / ".git").exists():
54
+ return directory
55
+ return None
56
+
57
+
58
+ def _resolve_steward_portability_lint() -> Path:
59
+ """Locate steward's own vendored ``portability-lint.sh``.
60
+
61
+ Walks up from cwd, but **stops at the git repository boundary** (mirrors
62
+ the resolver in :mod:`steward.cli._commands.show`). Running steward's
63
+ own copy — instead of executing whatever script the *target* repo ships —
64
+ keeps ``verify`` to a fixed, known-trusted code surface.
65
+ """
66
+ start = Path.cwd().resolve()
67
+ repo_root = _find_git_root(start)
68
+
69
+ current = start
70
+ while True:
71
+ candidate = current / PORTABILITY_LINT_RELPATH
72
+ if candidate.is_file():
73
+ return candidate
74
+ if current == repo_root or current.parent == current:
75
+ break
76
+ if repo_root is None:
77
+ break
78
+ current = current.parent
79
+
80
+ hint = f"run from inside a Steward git checkout that contains {PORTABILITY_LINT_RELPATH}"
81
+ raise StewardError(
82
+ code=EXIT_ENV_ERROR,
83
+ message="steward's portability-lint.sh not found",
84
+ remediation=hint,
85
+ )
86
+
87
+
88
+ def _check_skills_convention(target: Path) -> list[Finding]:
89
+ """Every `.claude/skills/<name>/SKILL.md` has a sibling `scripts/` dir,
90
+ and the SKILL.md frontmatter `name` matches the directory name."""
91
+ findings: list[Finding] = []
92
+ skills_dir = target / ".claude" / "skills"
93
+ if not skills_dir.is_dir():
94
+ return findings # No skills is fine; not every sibling has them yet.
95
+ for skill_dir in sorted(p for p in skills_dir.iterdir() if p.is_dir()):
96
+ skill_md = skill_dir / "SKILL.md"
97
+ if not skill_md.is_file():
98
+ findings.append(
99
+ Finding(
100
+ check="skills-convention",
101
+ path=str(skill_dir.relative_to(target)),
102
+ message="missing SKILL.md",
103
+ )
104
+ )
105
+ continue
106
+ if not (skill_dir / "scripts").is_dir():
107
+ findings.append(
108
+ Finding(
109
+ check="skills-convention",
110
+ path=str(skill_dir.relative_to(target)),
111
+ message="missing scripts/ directory",
112
+ )
113
+ )
114
+ match = FRONTMATTER_NAME_RE.search(skill_md.read_text())
115
+ if not match:
116
+ findings.append(
117
+ Finding(
118
+ check="skills-convention",
119
+ path=str(skill_md.relative_to(target)),
120
+ message="no `name:` field in frontmatter",
121
+ )
122
+ )
123
+ elif match.group(1) != skill_dir.name:
124
+ findings.append(
125
+ Finding(
126
+ check="skills-convention",
127
+ path=str(skill_md.relative_to(target)),
128
+ message=f"frontmatter name {match.group(1)!r} != dir {skill_dir.name!r}",
129
+ )
130
+ )
131
+ return findings
132
+
133
+
134
+ def _check_portability(target: Path) -> list[Finding]:
135
+ """Run steward's own vendored ``portability-lint.sh --all`` against the
136
+ target's working tree.
137
+
138
+ The script is resolved from the steward checkout (not the target), then
139
+ invoked with ``cwd=target`` so its ``git ls-files`` lists target files.
140
+ This means ``verify`` works whether or not the target has vendored its
141
+ own copy of the lint, and limits subprocess execution to a known-trusted
142
+ script.
143
+ """
144
+ script = _resolve_steward_portability_lint()
145
+ # bandit S603: argv is a fixed two-element list (resolved script path +
146
+ # literal "--all"); no shell, no expansion. Script path comes from
147
+ # _resolve_steward_portability_lint() which is constrained to the
148
+ # current git checkout, so an attacker can't substitute a different
149
+ # portability-lint.sh from an ancestor directory.
150
+ try:
151
+ completed = subprocess.run( # noqa: S603
152
+ [str(script), "--all"],
153
+ cwd=target,
154
+ check=False,
155
+ capture_output=True,
156
+ text=True,
157
+ )
158
+ except OSError as exc:
159
+ raise StewardError(
160
+ code=EXIT_ENV_ERROR,
161
+ message=f"could not execute {script}: {exc}",
162
+ remediation="ensure the script is executable (chmod +x)",
163
+ ) from exc
164
+ if completed.returncode == 0:
165
+ return []
166
+ return [
167
+ Finding(
168
+ check="portability",
169
+ path=".",
170
+ message=(completed.stdout + completed.stderr).strip()
171
+ or f"portability-lint exited {completed.returncode}",
172
+ )
173
+ ]
174
+
175
+
176
+ CHECKS = {
177
+ "skills-convention": _check_skills_convention,
178
+ "portability": _check_portability,
179
+ }
180
+
181
+
182
+ def register(sub: argparse._SubParsersAction) -> None:
183
+ parser = sub.add_parser(
184
+ "verify",
185
+ help="Diagnose a sibling repo against the AgentCulture sibling pattern.",
186
+ description=(
187
+ "Read-only diagnosis. Aggregates findings across selected checks, "
188
+ "then exits 0 if there are none and 1 if there are any. See "
189
+ "docs/sibling-pattern.md for the invariants."
190
+ ),
191
+ )
192
+ parser.add_argument(
193
+ "target",
194
+ help="Path to a sibling repo directory.",
195
+ )
196
+ parser.add_argument(
197
+ "--json",
198
+ action="store_true",
199
+ help="Emit findings as JSON to stdout instead of human-readable lines on stderr.",
200
+ )
201
+ parser.add_argument(
202
+ "--check",
203
+ action="append",
204
+ choices=sorted(CHECKS.keys()),
205
+ help="Run only the named check (repeatable). Default: run all checks.",
206
+ )
207
+ parser.set_defaults(func=_handle)
208
+
209
+
210
+ def _handle(args: argparse.Namespace) -> int:
211
+ target = _resolve_target(args.target)
212
+ selected = args.check or sorted(CHECKS.keys())
213
+ findings: list[Finding] = []
214
+ for name in selected:
215
+ findings.extend(CHECKS[name](target))
216
+
217
+ if args.json:
218
+ # Structured output is the command's *result* — goes to stdout per
219
+ # the steward stdout/stderr split.
220
+ emit_result(json_mod.dumps([f.to_dict() for f in findings], indent=2))
221
+ elif findings:
222
+ # Human-readable findings are diagnostics — stderr by default.
223
+ for f in findings:
224
+ emit_diagnostic(f"{f.check}: {f.path}: {f.message}")
225
+ else:
226
+ emit_result(f"verify clean ({len(selected)} checks against {target})")
227
+
228
+ return 0 if not findings else 1
@@ -30,7 +30,9 @@ def test_no_args_prints_help_and_exits_zero(capsys: pytest.CaptureFixture[str])
30
30
  assert rc == 0
31
31
  captured = capsys.readouterr()
32
32
  assert "usage: steward" in captured.out
33
- assert "{show}" in captured.out
33
+ # Subcommand list — match loosely so adding more verbs doesn't break this.
34
+ assert "show" in captured.out
35
+ assert "verify" in captured.out
34
36
 
35
37
 
36
38
  def test_unknown_command_exits_with_user_error_code(
@@ -0,0 +1,78 @@
1
+ """End-to-end tests for `steward verify`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from steward.cli import main
11
+
12
+ REPO_ROOT = Path(__file__).resolve().parent.parent
13
+
14
+
15
+ def test_verify_against_steward_repo_passes(capsys: pytest.CaptureFixture[str]) -> None:
16
+ """Steward should pass `steward verify` on itself.
17
+
18
+ This is the dog-food test: if steward can't verify steward, the pattern
19
+ isn't internally consistent.
20
+ """
21
+ rc = main(["verify", str(REPO_ROOT)])
22
+ captured = capsys.readouterr()
23
+ assert rc == 0, f"verify failed:\n{captured.out}\n{captured.err}"
24
+ assert "verify clean" in captured.out
25
+
26
+
27
+ def test_verify_unknown_target_fails_user_error(
28
+ capsys: pytest.CaptureFixture[str],
29
+ ) -> None:
30
+ """A non-directory target exits 1 with a structured error on stderr."""
31
+ rc = main(["verify", "/nonexistent/path/that/should/not/exist"])
32
+ captured = capsys.readouterr()
33
+ assert rc == 1
34
+ assert "error: target is not a directory" in captured.err
35
+
36
+
37
+ def test_verify_json_output_is_parseable(
38
+ capsys: pytest.CaptureFixture[str],
39
+ ) -> None:
40
+ """`--json` emits a JSON list (empty when clean)."""
41
+ rc = main(["verify", "--json", str(REPO_ROOT)])
42
+ captured = capsys.readouterr()
43
+ assert rc == 0
44
+ parsed = json.loads(captured.out)
45
+ assert isinstance(parsed, list)
46
+ assert parsed == []
47
+
48
+
49
+ def test_verify_skills_convention_catches_missing_scripts(
50
+ tmp_path: Path,
51
+ capsys: pytest.CaptureFixture[str],
52
+ ) -> None:
53
+ """Skill with SKILL.md but no scripts/ dir is reported on stderr."""
54
+ skill = tmp_path / ".claude" / "skills" / "broken"
55
+ skill.mkdir(parents=True)
56
+ (skill / "SKILL.md").write_text("---\nname: broken\ndescription: x\n---\n")
57
+ rc = main(["verify", "--check", "skills-convention", str(tmp_path)])
58
+ captured = capsys.readouterr()
59
+ assert rc == 1
60
+ # Findings are diagnostics → stderr, per the stdout/stderr split in
61
+ # steward.cli._output.
62
+ assert "missing scripts/ directory" in captured.err
63
+ assert captured.out == ""
64
+
65
+
66
+ def test_verify_skills_convention_catches_name_mismatch(
67
+ tmp_path: Path,
68
+ capsys: pytest.CaptureFixture[str],
69
+ ) -> None:
70
+ """SKILL.md whose frontmatter name differs from the dir name is reported."""
71
+ skill = tmp_path / ".claude" / "skills" / "real-name"
72
+ (skill / "scripts").mkdir(parents=True)
73
+ (skill / "SKILL.md").write_text("---\nname: wrong-name\ndescription: x\n---\n")
74
+ rc = main(["verify", "--check", "skills-convention", str(tmp_path)])
75
+ captured = capsys.readouterr()
76
+ assert rc == 1
77
+ assert "frontmatter name 'wrong-name' != dir 'real-name'" in captured.err
78
+ assert captured.out == ""
@@ -0,0 +1,106 @@
1
+ """Repo-level invariants for steward's own skills.
2
+
3
+ These are the same checks `steward verify` will run against any sibling repo,
4
+ applied here to steward itself so we eat our own dog food and CI catches
5
+ regressions before PR review.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ REPO_ROOT = Path(__file__).resolve().parent.parent
16
+ SKILLS_DIR = REPO_ROOT / ".claude" / "skills"
17
+ FRONTMATTER_NAME_RE = re.compile(r"^name:\s*(\S+)\s*$", re.MULTILINE)
18
+
19
+
20
+ def _skill_dirs() -> list[Path]:
21
+ return sorted(p for p in SKILLS_DIR.iterdir() if p.is_dir())
22
+
23
+
24
+ @pytest.mark.parametrize("skill_dir", _skill_dirs(), ids=lambda p: p.name)
25
+ def test_skill_has_skill_md(skill_dir: Path) -> None:
26
+ """Every skill directory ships a SKILL.md."""
27
+ assert (skill_dir / "SKILL.md").is_file(), f"missing SKILL.md in {skill_dir}"
28
+
29
+
30
+ @pytest.mark.parametrize("skill_dir", _skill_dirs(), ids=lambda p: p.name)
31
+ def test_skill_has_scripts_directory(skill_dir: Path) -> None:
32
+ """Every skill directory ships a `scripts/` directory.
33
+
34
+ Per the skills convention in CLAUDE.md: "Following the skill should be
35
+ 'run this script,' not 'do these ten manual steps.'" An empty `scripts/`
36
+ (with `.gitkeep`) is acceptable for stub skills that document the
37
+ contract before the implementation lands.
38
+ """
39
+ scripts = skill_dir / "scripts"
40
+ assert scripts.is_dir(), f"missing scripts/ in {skill_dir}"
41
+
42
+
43
+ @pytest.mark.parametrize("skill_dir", _skill_dirs(), ids=lambda p: p.name)
44
+ def test_skill_frontmatter_name_matches_dir(skill_dir: Path) -> None:
45
+ """SKILL.md frontmatter `name` equals the directory name."""
46
+ text = (skill_dir / "SKILL.md").read_text()
47
+ match = FRONTMATTER_NAME_RE.search(text)
48
+ assert match, f"no `name:` field in {skill_dir / 'SKILL.md'}"
49
+ assert (
50
+ match.group(1) == skill_dir.name
51
+ ), f"SKILL.md name {match.group(1)!r} != dir {skill_dir.name!r}"
52
+
53
+
54
+ _HOME_RE = re.compile(r"/home/[a-z][a-z0-9_-]+/")
55
+ # Match every per-user dotfile ref; carve_outs below allow specific shapes.
56
+ _DOTFILE_RE = re.compile(r"~/\.[A-Za-z][A-Za-z0-9_-]*")
57
+ _DOTFILE_CARVE_OUTS = (
58
+ re.compile(r"~/\.claude/skills/[^\s\"]+/scripts/"),
59
+ re.compile(r"~/\.culture/"),
60
+ )
61
+
62
+
63
+ def _scan_line_for_offenses(path: Path, lineno: int, line: str) -> list[str]:
64
+ offenses: list[str] = []
65
+ if _HOME_RE.search(line):
66
+ offenses.append(f"{path}:{lineno}: hard-coded /home/ path: {line.strip()}")
67
+ for hit in _DOTFILE_RE.finditer(line):
68
+ if any(c.match(line, hit.start()) for c in _DOTFILE_CARVE_OUTS):
69
+ continue
70
+ offenses.append(f"{path}:{lineno}: per-user dotfile ref: {line.strip()}")
71
+ return offenses
72
+
73
+
74
+ def _read_text_or_none(path: Path) -> str | None:
75
+ try:
76
+ return path.read_text()
77
+ except UnicodeDecodeError:
78
+ return None
79
+
80
+
81
+ def _iter_script_files() -> list[Path]:
82
+ files: list[Path] = []
83
+ for skill_dir in _skill_dirs():
84
+ for path in (skill_dir / "scripts").rglob("*"):
85
+ if path.is_file():
86
+ files.append(path)
87
+ return files
88
+
89
+
90
+ def test_no_per_user_paths_in_skill_scripts() -> None:
91
+ """No `/home/<user>/...` or per-user `~/.dotfile` refs in skill scripts.
92
+
93
+ This is the same rule `portability-lint.sh` enforces on PR diffs, applied
94
+ here at the unit-test level so a single-file change can never reintroduce
95
+ a leak that's missed by the diff lint (e.g. a brand-new file added in a
96
+ commit but the lint only ran on a different range).
97
+ """
98
+ offenders: list[str] = []
99
+ for path in _iter_script_files():
100
+ text = _read_text_or_none(path)
101
+ if text is None:
102
+ continue
103
+ for lineno, line in enumerate(text.splitlines(), start=1):
104
+ offenders.extend(_scan_line_for_offenses(path, lineno, line))
105
+
106
+ assert not offenders, "skills/scripts portability violations:\n " + "\n ".join(offenders)
@@ -439,7 +439,7 @@ wheels = [
439
439
 
440
440
  [[package]]
441
441
  name = "steward-cli"
442
- version = "0.1.2"
442
+ version = "0.2.0"
443
443
  source = { editable = "." }
444
444
 
445
445
  [package.dev-dependencies]
File without changes
File without changes
File without changes
File without changes