Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1b06995
docs: add scaffold design spec for issue #1
OriNachum May 22, 2026
2e7af72
docs: use bare lepenseur package name (not lepenseur-cli)
OriNachum May 22, 2026
3e9ecc2
docs: refine spec (afi cli doctor gate, cli noun, doctor stub) + add …
OriNachum May 22, 2026
6d55b0e
feat: scaffold lepenseur package from lecodeur twin (renamed)
OriNachum May 22, 2026
2c9f926
feat: rewrite identity for the thinker (catalog, learn, runtime files)
OriNachum May 22, 2026
a6181e0
fix: correct CLI description to thinking agent
OriNachum May 22, 2026
3886034
feat: add read-only overview verb
OriNachum May 22, 2026
89444f2
feat: add cli noun with cli overview (rubric cli_noun check)
OriNachum May 22, 2026
d825ef4
feat: add doctor stub verb (rubric bundle 7; semantics deferred)
OriNachum May 22, 2026
3a0eed7
refactor: address code-quality review (format, public report(), cli -…
OriNachum May 22, 2026
745e070
ci: add afi cli doctor --strict rubric gate to lint job
OriNachum May 22, 2026
284bafb
docs: reframe vendored skills for lepenseur (provenance: steward)
OriNachum May 22, 2026
b80b1f3
docs: add CHANGELOG and align CLAUDE.md to bare lepenseur package
OriNachum May 22, 2026
d45a38d
chore: fix stale lecodeur comment in markdownlint config
OriNachum May 22, 2026
788b192
chore: commit uv.lock (tracked, per sibling convention)
OriNachum May 22, 2026
1387534
fix: add sonar config, structured cli errors, mirror culture.yaml to …
OriNachum May 22, 2026
69344cd
style: merge implicitly concatenated string in doctor stub (SonarCloud)
OriNachum May 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .claude/skills.local.yaml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Per-machine paths and settings for lepenseur's skills.
#
# Copy this file to `.claude/skills.local.yaml` (git-ignored) and fill in the
# values for your machine. Skills read the local file and fall back to this
# example when the local copy hasn't been created yet.

# Where sibling Culture projects are checked out on this machine. Used by the
# `communicate` and `cicd` skills when they reference siblings. Defaults assume
# the shared-workspace layout (siblings alongside this repo).
workspace_root: ".."

# The Culture nick this repo's agent posts as. Resolved from culture.yaml's
# `suffix:` when unset; the repo basename is the final fallback.
nick: "lepenseur"
169 changes: 169 additions & 0 deletions .claude/skills/cicd/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
---
name: cicd
description: >
Steward's CI/CD lane, layered on `agex pr`. Delegates lint / open /
read / reply / delta to agex; adds two steward extensions — `status`
(SonarCloud quality gate + hotspots + unresolved-thread tally) and
`await` (read --wait + status with non-zero exit on Sonar ERROR or
unresolved threads). Use when: creating PRs in steward, handling
review feedback, polling CI status, or the user says "create PR",
"review comments", "address feedback", "resolve threads". Renamed
from `pr-review` in steward 0.7.0; rebased on agex in 0.12.0.
---

# CI/CD — Steward edition

> Vendored from steward (canonical upstream: `docs/skill-sources.md`); this copy is owned by lepenseur and may diverge.

`agex pr` (in `agentculture/agex-cli`) is the upstream for the
five core PR-lifecycle verbs — `lint`, `open`, `read`, `reply`,
`delta`. Steward used to vendor parallel scripts for each; in 0.12.0
those vendored copies were dropped in favor of delegating to `agex`.
What's left in this skill is **the steward-specific gating layer**:

- `status` — SonarCloud quality gate, OPEN issues, hotspots, deploy
preview URL, unresolved-inline-thread tally.
- `await` — composes `agex pr read --wait` with `status` and gates on
Sonar `ERROR` / unresolved threads. The single command to run after
pushing a fix when you want "wake me when this PR is triage-able."

Those two are the steward unique surface today. They're filed as a
feature ask upstream
([agex-cli#41](https://github.com/agentculture/agex-cli/issues/41));
once they land they migrate out of this skill.

The workflow is encapsulated in `scripts/workflow.sh` — follow that
(or call `agex pr` directly).

## Prerequisites

Hard requirements: `agex` (>=0.1), `gh` (GitHub CLI), `jq`, `bash`,
`python3` (stdlib only), `curl` (used by `pr-status.sh`).

Install agex once:

```bash
uv tool install agex-cli # or: pip install --user agex-cli
```

Soft requirement: `PyYAML` is needed **only for suffix mode** of the
sibling `agent-config` skill, where it parses Culture's server
manifest. Every `cicd` script works without it; suffix mode prints a
clear install hint when invoked without it.

Per-machine paths (sibling-project layout) live in
`.claude/skills.local.yaml`; see the committed `.example` for the
schema. `agex pr delta` reads the same file.

## How to run

`scripts/workflow.sh` is the entry point. Subcommands:

| Command | What it does |
|---------|--------------|
| `workflow.sh lint` | `agex pr lint --exit-on-violation` — portability + alignment-trigger check. |
| `workflow.sh open [gh-flags]` | `agex pr open --delayed-read`. Creates the PR, then polls 180s for an initial briefing. `--title TITLE` required; body via `--body-file PATH` or stdin. |
| `workflow.sh read [PR] [--wait N]` | `agex pr read`. One-shot briefing (CI checks, SonarCloud gate + new issues, all comments, next-step footer). Pass `--wait N` to poll up to N seconds for required reviewers. |
| `workflow.sh reply <PR>` | `agex pr reply <PR>` — batch JSONL replies (stdin) + thread resolve. agex auto-signs from `culture.yaml`. |
| `workflow.sh delta` | `agex pr delta` — sibling alignment dump. |
| `workflow.sh status <PR>` | **Steward extension.** `pr-status.sh` — Sonar gate, OPEN issues, hotspots, unresolved-thread breakdown, deploy preview URL. Authoritative gate for `await`. |
| `workflow.sh await <PR>` | **Steward extension.** `agex pr read --wait` then `status`. Exits non-zero on Sonar ERROR or unresolved threads. Tunables: `STEWARD_PR_AWAIT_WAIT` (default 1800s passed to `--wait`), `STEWARD_PR_AWAIT_SECONDS` (legacy fixed pre-sleep, deprecated). |
| `workflow.sh help` | Print the list. |

You can also call `agex pr <verb>` directly — `workflow.sh` is a
typing-saver around the same verbs. The steward `status` and `await`
extensions only have shell entry points.

The vendored single-comment helper `pr-reply.sh` (plus its
`_resolve-nick.sh` dependency) is still shipped — pinned by
`tests/test_pr_reply_signature.py` and `tests/test_resolve_nick.py`,
and useful when a one-off reply doesn't merit batch JSONL. It is not
called by `workflow.sh` anymore. The vendored `portability-lint.sh`
is also still shipped — `steward doctor`'s portability check runs it
directly against target repos. Both are scheduled for follow-up
migration to agex.

## Long waits (background polling)

`agex pr read --wait N` polls in-session for up to N seconds. The
Anthropic prompt cache has a 5-minute TTL; sleeping past it burns
context every cache miss. Two ways to drive the wait:

- **Synchronous** — `workflow.sh await <PR>` after `gh pr create` /
`workflow.sh open`. Fine when readiness is expected within ~5
minutes.
- **Asynchronous** — for longer waits, run `agex pr read --wait NNN`
inside a background subagent (Agent tool, `run_in_background: true`)
so the main session only pays the cache cost when readiness fires.
The subagent's only job is to invoke `agex pr read --wait` and echo
its headline back. The parent triages with `workflow.sh await`
when the notification arrives. The user can interrupt with
TaskStop.

This pattern was originally borrowed from sibling repo
[`agentculture/cfafi`](https://github.com/agentculture/cfafi)'s `poll`
skill. The async guidance is also filed upstream
([agex-cli#41](https://github.com/agentculture/agex-cli/issues/41)).

## Conventions

`agex pr` emits a **"Next step:"** footer at the end of every command
that names the right next verb (the same chain `agex learn cicd`
documents) — follow that rather than memorizing an order. `workflow.sh
help` mirrors the verb table when you need the steward-flavored
extensions (`status`, `await`) on top.

Branch naming: `fix/<desc>`, `feat/<desc>`, `docs/<desc>`,
`skill/<name>`. PR / comment signature: `- <nick> (Claude)`, where
`<nick>` is resolved by `agex` from the agent's own `culture.yaml`
(first agent's `suffix`), falling back to the git-repo basename. agex
auto-appends the signature on `pr open` and `pr reply` only when the
body isn't already signed.

## Triage rules

For every comment, decide **FIX** or **PUSHBACK** with reasoning.

Default to **FIX** for: portability complaints (always valid for
Steward — recurring bug class), test or doc requests, style nits
aligned with workspace conventions.

Default to **PUSHBACK** for: architecture opinions that conflict with
workspace `CLAUDE.md` or the all-backends rule; greenfield
false-positives (e.g. "add tests" before there's any source — defer
to a later PR, don't refuse).

### Alignment-delta rule

If the PR touches `CLAUDE.md`, `culture.yaml`, or anything under
`.claude/skills/`, run `workflow.sh delta` **before** declaring FIX or
PUSHBACK on each comment. Note any sibling that needs a follow-up PR
and mention it in your reply.

## Greenfield-aware steps

The lint and the workflow script are always-on. Stack-specific steps
are conditional and currently no-op (greenfield repo):

```bash
[ -d tests ] && [ -f pyproject.toml ] && uv run pytest tests/ -x -q
[ -f pyproject.toml ] && bump_version_per_project_convention # see project README
[ -f .markdownlint-cli2.yaml ] && markdownlint-cli2 "$(git diff --name-only --cached '*.md')"
```

Revisit each line as the corresponding stack element actually lands.
A `pr lint --extra=tests,version,markdown` ask is filed upstream
([agex-cli#41](https://github.com/agentculture/agex-cli/issues/41)).

## Reply etiquette

Every comment must get a reply — no silent fixes. `agex pr reply`
includes thread-resolve by default. Reference the review-comment IDs
in the fix-up commit message.

The `status` extension queries SonarCloud directly (it predates the
upstream Sonar integration in `agex pr read`). Both surfaces are
trustworthy — `agex pr read` for display in the briefing, `status` for
the gate. Steward isn't yet a registered mesh agent, so the
post-merge IRC ping that Culture's `pr-review` includes is still
skipped — that returns when Steward joins the mesh.
43 changes: 43 additions & 0 deletions .claude/skills/cicd/scripts/_resolve-nick.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail

# Resolve the agent's nick for GitHub message signing.
# Order: first agent's `suffix` in <repo-root>/culture.yaml,
# then basename of the git repo root.
# Prints the nick to stdout. Always exits 0 — pr-reply.sh needs *some*
# nick to sign with — but if a culture.yaml exists and we couldn't
# extract a suffix from it, emits a stderr warning so a misconfigured
# manifest doesn't silently mask itself behind the basename fallback.

repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "$repo_root" ]]; then
repo_root="$PWD"
fi

manifest="$repo_root/culture.yaml"

if [[ -f "$manifest" ]]; then
if ! command -v python3 >/dev/null 2>&1; then
echo "_resolve-nick: python3 not found; cannot parse $manifest, falling back to repo basename" >&2
else
nick="$(python3 - "$manifest" <<'PY' 2>/dev/null || true
import re, sys
path = sys.argv[1]
with open(path, encoding="utf-8") as f:
for raw in f:
line = raw.rstrip("\n")
m = re.match(r"^[\s-]*\s*suffix:\s*(\S+)", line)
if m:
print(m.group(1).strip("'\""))
break
PY
)"
if [[ -n "$nick" ]]; then
printf '%s\n' "$nick"
exit 0
fi
echo "_resolve-nick: $manifest exists but no suffix could be parsed; falling back to repo basename" >&2
fi
fi

basename "$repo_root"
57 changes: 57 additions & 0 deletions .claude/skills/cicd/scripts/portability-lint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Portability lint: catch path leaks and per-user config dependencies in
# committed docs/configs before they ship in a PR. Steward's recurring bug
# class.
#
# Usage: portability-lint.sh [--all]
# default: lint files modified vs HEAD (staged + unstaged)
# --all: lint all tracked files
#
# Exits 0 if clean, 1 if any leak is found.

set -euo pipefail

mode="${1:-diff}"
case "$mode" in
--all) files=$(git ls-files -- ':(exclude)*.lock') ;;
diff|--diff) files=$(git diff --diff-filter=AMR --name-only HEAD -- ':(exclude)*.lock') ;;
*) echo "Usage: $(basename "$0") [--all]" >&2; exit 2 ;;
esac

[ -z "$files" ] && { echo "(no files to check)"; exit 0; }

# ----- Check 1: hard-coded /home/<user>/... paths -----
hits1=$(echo "$files" | xargs -r grep -nE '/home/[a-z][a-z0-9_-]+/' 2>/dev/null || true)

# ----- Check 2: per-user dotfile *config* refs in committed docs/configs -----
# Carve-outs (allowed, NOT flagged):
# - ~/.claude/skills/<x>/scripts/ vendored tool calls
# - ~/.culture/ Culture mesh data this skill is supposed to read
md_yaml=$(echo "$files" | grep -E '\.(md|ya?ml|toml|json|jsonc)$' || true)
if [ -n "$md_yaml" ]; then
hits2=$(echo "$md_yaml" | xargs -r grep -nE '~/\.[A-Za-z]' 2>/dev/null \
| grep -vE '~/\.claude/skills/[^[:space:]"]+/scripts/' \
| grep -vE '~/\.culture/' \
|| true)
else
hits2=""
fi

fail=0
if [ -n "$hits1" ]; then
echo "❌ Hard-coded /home/<user>/ paths:"
echo "$hits1" | sed 's/^/ /'
echo " Fix: use ../sibling, repo URL, or \$WORKSPACE/sibling instead."
fail=1
fi
if [ -n "$hits2" ]; then
[ "$fail" -eq 1 ] && echo
echo "❌ Per-user ~/.<dotfile> config refs in committed doc/config:"
echo "$hits2" | sed 's/^/ /'
echo " Allowed carve-outs: ~/.claude/skills/.../scripts/ (tool calls), ~/.culture/ (mesh data)."
echo " Otherwise: commit a repo-local config or document a portable lookup."
fail=1
fi

[ "$fail" -eq 0 ] && echo "✓ portability lint clean ($(echo "$files" | wc -l | tr -d ' ') files checked)"
exit $fail
77 changes: 77 additions & 0 deletions .claude/skills/cicd/scripts/pr-reply.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail

# Reply to a PR review comment, optionally resolve its thread.
# Usage: pr-reply.sh [--repo OWNER/REPO] [--resolve] PR_NUMBER COMMENT_ID "body"

REPO=""
RESOLVE=false
PRINT_BODY=false

while [[ $# -gt 0 ]]; do
case "$1" in
--repo) REPO="$2"; shift 2 ;;
--resolve) RESOLVE=true; shift ;;
--print-body) PRINT_BODY=true; shift ;;
*) break ;;
esac
done

PR_NUMBER="${1:?Usage: pr-reply.sh [--repo OWNER/REPO] [--resolve] [--print-body] PR_NUMBER COMMENT_ID \"body\"}"
COMMENT_ID="${2:?Missing COMMENT_ID}"
BODY="${3:?Missing reply body}"

if [[ "$PRINT_BODY" != true && -z "$REPO" ]]; then
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
fi

# Sign with the agent's nick. Resolved per invocation so siblings that
# vendor this skill pick up their own culture.yaml suffix automatically.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NICK="$("$SCRIPT_DIR/_resolve-nick.sh")"
SIG="- ${NICK} (Claude)"
if ! printf '%s' "$BODY" | grep -qFx -- "$SIG"; then
BODY="${BODY}

${SIG}"
fi

if [[ "$PRINT_BODY" == true ]]; then
printf '%s\n' "$BODY"
exit 0
fi

# Post reply
REPLY_URL=$(gh api "repos/$REPO/pulls/$PR_NUMBER/comments/$COMMENT_ID/replies" \
-f body="$BODY" \
--jq '.html_url')
echo "Replied: $REPLY_URL"

# Resolve thread if requested
if [[ "$RESOLVE" == true ]]; then
# Find the thread ID for this comment
THREAD_ID=$(gh api graphql -f query="
{
repository(owner: \"${REPO%%/*}\", name: \"${REPO##*/}\") {
pullRequest(number: $PR_NUMBER) {
reviewThreads(first: 100) {
nodes {
id
comments(first: 100) {
nodes { databaseId }
}
}
}
}
}
}" --jq ".data.repository.pullRequest.reviewThreads.nodes[] | select(any(.comments.nodes[]; .databaseId == $COMMENT_ID)) | .id")

if [[ -n "$THREAD_ID" ]]; then
RESOLVED=$(gh api graphql -f query="
mutation { resolveReviewThread(input: {threadId: \"$THREAD_ID\"}) { thread { isResolved } } }
" --jq '.data.resolveReviewThread.thread.isResolved')
echo "Resolved: $RESOLVED (thread $THREAD_ID)"
else
echo "Warning: could not find thread for comment $COMMENT_ID"
fi
fi
Loading
Loading