feat(pm-agent): an in-cluster AI Program Manager that turns Gitea issues into pull requests #57

Merged
jakman85 merged 7 commits from feat/pm-agent-issue-to-pr into main 2026-10-06 04:12:31 +00:00
7 Commits
Author SHA1 Message Date
jakman85andClaude Opus 5 867a82d8ad docs(pm-agent): correct the handoff — a STALE approval still satisfies the gate
gate / gate (pull_request) Successful in 1m52s
gate / conformance (pull_request) Has been skipped
I recorded that merging main in would dismiss the operator's approval and that
approval-policy would therefore go red until they re-approved. Measured: wrong.

dismiss_stale_approvals is true, so the review did go stale — but
ci/approval-policy.sh selects on .state == "APPROVED" and a non-author login and reads
nothing about staleness, and required_approvals is 0 so Gitea never required one either.
CI run #257 on the merged head: "approved by 'jakman85' (author 'gitea-admin') —
sensitive change is human-reviewed", approval-policy pass, gitea-settings pass, required
check green, PR mergeable.

The 'push again and you must override again' rule applies to the /approve-override
directive, which IS staleness-checked against the head commit — not to a review.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 23:03:28 -05:00
jakman85andClaude Opus 5 e4a9b02b00 docs(pm-agent): CLAUDE.md §0 is already satisfied in a worker — tell it not to make a worktree
gate / gate (pull_request) Successful in 1m52s
gate / conformance (pull_request) Has been skipped
PR #56 added §0 (work in your own worktree, never the shared checkout) while this branch
was open. A worker agent reads CLAUDE.md first, and §0's instruction is to create a git
worktree outside the shared checkout — which does not exist in a worker's container. It
holds a fresh clone of its own, made for one assignment and thrown away with the pod,
which is what §0 is actually asking for.

A worker that obeyed §0 literally would 'git worktree add', edit somewhere the wrapper
that started it does not look, and have its work committed by nobody while the issue got
a 'changed nothing' report. worker-rules.md now says §0 is satisfied and not to do that.

Also records in the handoff entry: the merge and its one conflict, and that this push
dismisses the operator's approving review — measured, dismiss_stale_approvals=true — so
the approval-policy stage needs them to re-approve. That is theirs to do, not an agent's.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 23:00:50 -05:00
jakman85andClaude Opus 5 0569d47d49 Merge branch 'main' into feat/pm-agent-issue-to-pr
One conflict, in clusters/base/kustomization.yaml: PR #55 (vpn-ingress) and this branch
both appended a resource line to the same tail. Resolved by keeping BOTH, with main's
vpn-ingress first — it is a platform-config-tier leaf, while pm-agent dependsOn
apps-wave-0, which is later.

Nothing else conflicted. runbooks/kustomization.yaml auto-merged and carries both the
vpn-remote-access and pm-agent entries; the handoff entries are separate files and had
nothing to merge, which is the whole point of decisions/ being a directory.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 22:57:40 -05:00
jakman85andClaude Opus 5 005e80d38a docs(pm-agent): record the PR's own CI result, read from the log not the status tick
gate / gate (pull_request) Successful in 1m50s
gate / conformance (pull_request) Has been skipped
The PR gate ran and BLOCKED on approval-policy alone — the sensitive-path rule, with no
non-author review — while every technical stage passed, gitea-settings included (the one
stage a workstation cannot run). That is the gate working, and it is the one thing an
agent must not clear for itself.

Also recorded: the commit-status API reported 'pending — Waiting to run' for a run that
had already completed, so the log is the source of truth here. §2b again.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 22:38:55 -05:00
jakman85andClaude Opus 5 8b5e950baa test(pm-agent): a committed negative-path probe for the worker's input guards
gate / gate (pull_request) Failing after 1m52s
gate / conformance (pull_request) Has been skipped
pm-worker's command is pinned by admission with no args, so everything a caller could
influence arrives in the environment — and the guards at the top of that script are what
stand between an injected variable and a clone from the wrong host or a push to the
wrong ref. Nothing exercised them, which makes them a claim rather than a control.

probe-validation.sh asserts 17 refusals, each on BOTH a non-zero exit and the specific
message, so a guard that stopped firing cannot pass by failing somewhere else. It covers
a redirected git host, a real and reachable WRONG one (the egress policy permits tcp/443,
so that case is not hypothetical), the right host with a smuggled path, 'main' and five
other bad branch shapes, a malformed owner/repo, a bad mode, a bad issue number, and each
missing credential. build-image.sh runs it inside the built image too.

Writing it found three things:
 - the PM_BRANCH != main guard is unreachable while the pattern check above it holds, so
   the comment claiming it as the defence was wrong about which line does the work. Kept
   as an assertion independent of the regex; the comment now says so.
 - the EXIT trap ended in a failing test, which under set -e can replace the script's
   real exit code with that failure.
 - a provenance LOG LINE read a file through a redirect that set -o pipefail would have
   turned into a dead worker if the file were absent.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 22:34:46 -05:00
jakman85andClaude Opus 5 54418363aa fix(pm-agent): take the newest comment by list order, not by comparing timestamp strings
gate / gate (pull_request) Failing after 1m51s
gate / conformance (pull_request) Has been skipped
last_activity() is the whole trigger: the PM acts iff the newest thing in a thread was
not written by it. It found that newest comment by comparing Gitea's RFC 3339
created_at STRINGS, which is only correct while every offset is identical — true today,
silently wrong after a server timezone change, and the failure would be the PM answering
its own last comment forever.

The comments endpoint returns them in creation order, so the last element IS the newest.
Using that removes the dependency entirely. Found by self-review, before anything ran.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 22:30:44 -05:00
jakman85andClaude Opus 5 c5b5b8672a feat(pm-agent): an in-cluster AI Program Manager that turns Gitea issues into pull requests
gate / gate (pull_request) Failing after 1m51s
gate / conformance (pull_request) Has been skipped
A service that reads this repository's Gitea issues, decides what to do about each
thread as the Program Manager persona, and runs one worker agent per assignment that
authors a change, runs the merge gate and opens a pull request. It reacts to issue
creation and to comments; the assignment and all correspondence travel through the
issue thread and the PR. It authenticates with an Anthropic subscription token. It
never pushes to main and never merges.

Three choices carry the design, with alternatives recorded in docs/plans/:
 - it POLLS rather than receiving webhooks (Gitea's webhook SSRF guard, lost
   deliveries, and no inbound reach needed at all);
 - it keeps NO state: the trigger is "the newest thing in this thread was not
   written by me", so the issue thread is the state — no PVC, no backup obligation;
 - the model holds NO tools: its reply is one JSON object this code validates, so
   nothing in the path executes text.

The boundary is platform/kyverno/admission/pm-agent-job-shape.yaml (Enforce,
failurePolicy: Fail), not the RBAC: Kubernetes places no `use` check on
serviceAccountName, so "may create Jobs" is "may run arbitrary code as the
namespace's most privileged identity". Six rules admit only the committed shape, and
the worker identity is bound to nothing with no token mounted, so there is nothing to
escalate to. 28/28 offline Kyverno assertions.

Nothing has run: the image has never been built or pushed, and both the image digest
and the two credentials are deliberate placeholders. See the handoff entry's
"What I did NOT verify".

Refs CLAUDE.md §1/§3/§6, R-SEC-02, R-GITOPS-02.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 22:28:10 -05:00