From 79574a233e7613891417bf3a37ab8f234290d3a9 Mon Sep 17 00:00:00 2001 From: Daedalus Date: Tue, 26 May 2026 22:06:39 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(ops):=20agent=20identity=20scheme=20?= =?UTF-8?q?=E2=80=94=20trailer=20hook=20+=20PR=20wrapper=20+=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements FLO-210 (parts 2 & 3 of the FLO-209 scheme): idempotent prepare-commit-msg trailer hook, a gh pr create wrapper that adds the "Agent: " line and agent: label, and the operations doc describing env aliases, trailers, and PR attribution. Co-Authored-By: Paperclip Co-authored-by: Daedalus --- docs/operations/agent-identity.md | 106 +++++++++++++++++++++++++++ scripts/agent-pr.sh | 49 +++++++++++++ scripts/git-hooks/prepare-commit-msg | 42 +++++++++++ scripts/install-agent-git-hooks.sh | 29 ++++++++ 4 files changed, 226 insertions(+) create mode 100644 docs/operations/agent-identity.md create mode 100755 scripts/agent-pr.sh create mode 100755 scripts/git-hooks/prepare-commit-msg create mode 100755 scripts/install-agent-git-hooks.sh diff --git a/docs/operations/agent-identity.md b/docs/operations/agent-identity.md new file mode 100644 index 0000000..f86fda9 --- /dev/null +++ b/docs/operations/agent-identity.md @@ -0,0 +1,106 @@ +# Agent identity in git & GitHub + +How FlopBut's local agents are distinguished in git history and on GitHub. + +> Decision: [FLO-209](/FLO/issues/FLO-209) — env-aliases + commit trailers + PR +> attribution. We do **not** create separate GitHub accounts. Every push and PR +> happens under the board's token; agents are distinguished at the commit-author, +> trailer, and PR-metadata level. + +## Canonical identities + +Slug is the lowercased name. The email is a label for git history only — it is +intentionally **not** a mailbox and **not** linked to any GitHub account. The +domain `agents.flopbut.local` is deliberately non-routable so it can never +mis-link to a real GitHub user. + +| Agent | NAME | Email | +|----------|------------|--------------------------------| +| Prospero | `Prospero` | `prospero@agents.flopbut.local` | +| Daedalus | `Daedalus` | `daedalus@agents.flopbut.local` | +| Wayland | `Wayland` | `wayland@agents.flopbut.local` | +| Aldric | `Aldric` | `aldric@agents.flopbut.local` | +| Soren | `Soren` | `soren@agents.flopbut.local` | +| Lyra | `Lyra` | `lyra@agents.flopbut.local` | +| Iris | `Iris` | `iris@agents.flopbut.local` | +| Orion | `Orion` | `orion@agents.flopbut.local` | + +If the board later wants a real domain, change the values everywhere — the +scheme is unchanged. + +## 1. Env aliases (author/committer identity) + +Each local agent's Paperclip `adapterConfig.env` carries four variables: + +```json +"env": { + "GIT_AUTHOR_NAME": { "type": "plain", "value": "Wayland" }, + "GIT_AUTHOR_EMAIL": { "type": "plain", "value": "wayland@agents.flopbut.local" }, + "GIT_COMMITTER_NAME": { "type": "plain", "value": "Wayland" }, + "GIT_COMMITTER_EMAIL": { "type": "plain", "value": "wayland@agents.flopbut.local" } +} +``` + +`GIT_*` env vars override `git config` in **every** repository, so no per-repo +setup is needed. When adding them, **merge** into the existing `env` — do not +replace it (some agents carry other env such as an OTEL header). + +Applying these requires the `agents:create` permission and is done by the board +/ CEO via `PATCH /api/agents/{agentId}` with the merged `adapterConfig`. + +## 2. Commit trailers + +In addition to the mandatory skill trailer: + +``` +Co-Authored-By: Paperclip +``` + +each commit gets a second co-author line for the authoring agent: + +``` +Co-authored-by: Wayland +``` + +The robust way to add this is the **`prepare-commit-msg` hook** in +`scripts/git-hooks/`, which reads `$GIT_AUTHOR_NAME` / `$GIT_AUTHOR_EMAIL` and +appends the trailer idempotently (it never duplicates). Install it per repo: + +```bash +scripts/install-agent-git-hooks.sh # current repo +scripts/install-agent-git-hooks.sh /path/repo # another checkout +``` + +This points `core.hooksPath` at `scripts/git-hooks`. The hook is a no-op when no +agent identity is in the env, so it is safe for human contributors too. + +## 3. PR attribution + +Because PRs are always opened under the board's token, the authoring agent is +recorded with: + +- an **`Agent: `** line in the PR body, and +- an **`agent:`** label (e.g. `agent:wayland`). + +Use the wrapper, which sets both from `$GIT_AUTHOR_NAME` and creates the label +idempotently: + +```bash +scripts/agent-pr.sh --title "feat: ..." --body "Closes FLO-NNN" --base main +``` + +Or do it by hand: + +```bash +gh label create "agent:wayland" --color BFD4F2 -f +gh pr create --title "..." --body $'...\n\nAgent: Wayland' --label agent:wayland +``` + +## Scope + +Repositories in scope: `cotel`, `ksef-docs`, and any new repo under +`~/projects/`. Run `scripts/install-agent-git-hooks.sh` once per repo (or per +worktree) to enable the trailer hook there. + +`pi_local` (Orion) is in `error`/trial status; its env aliases are applied last +or skipped until the adapter is healthy. diff --git a/scripts/agent-pr.sh b/scripts/agent-pr.sh new file mode 100755 index 0000000..87ddf2f --- /dev/null +++ b/scripts/agent-pr.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# +# agent-pr.sh — open a PR with FlopBut agent attribution. +# +# Wraps `gh pr create`. Since every PR is opened under the board's token, we +# attribute the authoring agent via: +# 1. an `Agent: ` line appended to the PR body, and +# 2. an `agent:` label (created idempotently before the PR). +# +# The agent name comes from $GIT_AUTHOR_NAME (set by the Paperclip adapter env). +# See docs/operations/agent-identity.md. +# +# Usage: +# scripts/agent-pr.sh --title "feat: ..." --body "..." [extra gh pr create flags] +# +# Any flags not consumed here are passed straight through to `gh pr create`. + +set -euo pipefail + +NAME="${GIT_AUTHOR_NAME:-}" +if [ -z "$NAME" ]; then + echo "error: GIT_AUTHOR_NAME is not set — cannot attribute the PR to an agent." >&2 + echo " Run inside a Paperclip agent env, or export GIT_AUTHOR_NAME first." >&2 + exit 1 +fi +SLUG="$(printf '%s' "$NAME" | tr '[:upper:]' '[:lower:]')" +LABEL="agent:${SLUG}" + +TITLE="" +BODY="" +PASS=() +while [ $# -gt 0 ]; do + case "$1" in + --title) TITLE="$2"; shift 2 ;; + --body) BODY="$2"; shift 2 ;; + *) PASS+=("$1"); shift ;; + esac +done + +# Append the attribution line to the body (idempotent). +ATTR="Agent: ${NAME}" +if ! printf '%s' "$BODY" | grep -qiF "$ATTR"; then + if [ -n "$BODY" ]; then BODY="${BODY}"$'\n\n'"${ATTR}"; else BODY="${ATTR}"; fi +fi + +# Create the label idempotently (-f updates if it already exists). +gh label create "$LABEL" --color BFD4F2 --description "PR authored by agent ${NAME}" -f >/dev/null 2>&1 || true + +exec gh pr create --title "$TITLE" --body "$BODY" --label "$LABEL" "${PASS[@]}" diff --git a/scripts/git-hooks/prepare-commit-msg b/scripts/git-hooks/prepare-commit-msg new file mode 100755 index 0000000..6b5c823 --- /dev/null +++ b/scripts/git-hooks/prepare-commit-msg @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# +# prepare-commit-msg — append the per-agent co-author trailer idempotently. +# +# Part of the FlopBut agent-identity scheme (see docs/operations/agent-identity.md). +# Each local agent runs with GIT_AUTHOR_NAME / GIT_AUTHOR_EMAIL injected by its +# Paperclip adapter env. This hook turns those into a second co-author trailer so +# the agent is credited in the commit body in addition to the author/committer +# fields. The mandatory `Co-Authored-By: Paperclip ` line +# is added by the agent itself per the skill rule and is left untouched here. +# +# Idempotent: re-running never duplicates a trailer. Safe to install in every repo. + +set -euo pipefail + +COMMIT_MSG_FILE="$1" +COMMIT_SOURCE="${2:-}" + +# Skip for merge/squash commits and when amending an existing message — those +# already carry trailers or are machine-generated. +case "$COMMIT_SOURCE" in + merge|squash) exit 0 ;; +esac + +NAME="${GIT_AUTHOR_NAME:-}" +EMAIL="${GIT_AUTHOR_EMAIL:-}" + +# No agent identity in env → nothing to attribute. Leave the message untouched. +[ -n "$NAME" ] && [ -n "$EMAIL" ] || exit 0 + +TRAILER="Co-authored-by: ${NAME} <${EMAIL}>" + +# Already present (case-insensitive on the trailer key, exact on name+email)? +if grep -qiF "$TRAILER" "$COMMIT_MSG_FILE"; then + exit 0 +fi + +# Ensure a blank line separates the body from the trailer block, then append. +if [ -s "$COMMIT_MSG_FILE" ] && [ -n "$(tail -c1 "$COMMIT_MSG_FILE")" ]; then + printf '\n' >> "$COMMIT_MSG_FILE" +fi +printf '%s\n' "$TRAILER" >> "$COMMIT_MSG_FILE" diff --git a/scripts/install-agent-git-hooks.sh b/scripts/install-agent-git-hooks.sh new file mode 100755 index 0000000..b840d48 --- /dev/null +++ b/scripts/install-agent-git-hooks.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# install-agent-git-hooks.sh — wire the agent-identity hooks into a repo. +# +# Usage: +# scripts/install-agent-git-hooks.sh [REPO_DIR] +# +# Points the repo's core.hooksPath at scripts/git-hooks so the +# prepare-commit-msg trailer hook runs on every commit. Idempotent: safe to +# re-run. Works for the current repo (default) or any other checkout passed as +# the first argument. See docs/operations/agent-identity.md. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOKS_DIR="${SCRIPT_DIR}/git-hooks" +REPO_DIR="${1:-$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)}" + +if ! git -C "$REPO_DIR" rev-parse --git-dir >/dev/null 2>&1; then + echo "error: '$REPO_DIR' is not a git repository" >&2 + exit 1 +fi + +chmod +x "$HOOKS_DIR"/* 2>/dev/null || true +git -C "$REPO_DIR" config core.hooksPath "$HOOKS_DIR" + +echo "Installed agent git hooks for $REPO_DIR" +echo " core.hooksPath -> $HOOKS_DIR" +echo "Verify: GIT_AUTHOR_NAME=Test GIT_AUTHOR_EMAIL=test@agents.flopbut.local git commit --allow-empty -m demo" From 4f57e405c77ef59627320dd454718630d9f9b566 Mon Sep 17 00:00:00 2001 From: Daedalus Date: Tue, 26 May 2026 22:13:09 +0200 Subject: [PATCH 2/2] feat(ops): per-agent git/GitHub identity (FLO-209/FLO-210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Distinguish FlopBut agents under the shared GitHub token via three layers: author/committer env, an idempotent commit-trailer hook, and PR attribution (Agent: line + agent: label). Agents self-apply their own adapter env with scripts/agent-set-git-identity.sh — the only path that both passes the API permission check and safely preserves redacted per-agent secret env. Co-Authored-By: Paperclip Co-authored-by: Daedalus --- docs/operations/agent-identity.md | 25 +++++++-- scripts/agent-set-git-identity.sh | 84 +++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100755 scripts/agent-set-git-identity.sh diff --git a/docs/operations/agent-identity.md b/docs/operations/agent-identity.md index f86fda9..67193eb 100644 --- a/docs/operations/agent-identity.md +++ b/docs/operations/agent-identity.md @@ -42,11 +42,28 @@ Each local agent's Paperclip `adapterConfig.env` carries four variables: ``` `GIT_*` env vars override `git config` in **every** repository, so no per-repo -setup is needed. When adding them, **merge** into the existing `env` — do not -replace it (some agents carry other env such as an OTEL header). +setup is needed. New values take effect on the agent's **next** run (the current +process inherited its env at spawn). -Applying these requires the `agents:create` permission and is done by the board -/ CEO via `PATCH /api/agents/{agentId}` with the merged `adapterConfig`. +### Rolling it out — agents self-apply + +Each agent runs this once, inside a heartbeat: + +```bash +scripts/agent-set-git-identity.sh +``` + +It reads the agent's own name from `/api/agents/me`, derives the email, and +**merges** the four `GIT_*` vars into its own `adapterConfig.env`. Idempotent. + +> **Why self-apply, not a central rollout?** A `PATCH` to `adapterConfig` +> *replaces* the whole `env` object, and the API redacts other agents' configs +> and rejects cross-agent edits unless the caller holds `agents:create` (most +> agents, including the CTO, do not). Even an authorized caller cannot read +> another agent's *secret* env (e.g. an adapter API key) to merge it back, so a +> blind cross-agent write would silently wipe secrets. An agent editing itself +> can read its full env and merge safely — so self-apply is both the only +> permitted path and the only safe one. ## 2. Commit trailers diff --git a/scripts/agent-set-git-identity.sh b/scripts/agent-set-git-identity.sh new file mode 100755 index 0000000..62a9b35 --- /dev/null +++ b/scripts/agent-set-git-identity.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# agent-set-git-identity.sh — give the running agent its own git identity. +# +# All FlopBut agents push through the board's single GitHub token, so commits +# and PRs would otherwise all look like the same author. This script makes each +# agent self-attribute by writing GIT_AUTHOR_* / GIT_COMMITTER_* into ITS OWN +# Paperclip adapter env, so every future run commits as that agent. +# +# Why self-apply (not a central rollout): the Paperclip API redacts other +# agents' adapterConfig and rejects cross-agent edits (needs `agents:create`). +# Each agent, however, can read its own full config — including secret env like +# adapter API keys — and merge the GIT_* vars in WITHOUT wiping those secrets. +# That makes self-apply both the only permitted path and the only safe one. +# +# Idempotent: re-running is a no-op once the four vars already match. +# +# Requires the Paperclip runtime env (auto-injected in a heartbeat run): +# PAPERCLIP_AGENT_ID, PAPERCLIP_API_URL, PAPERCLIP_API_KEY +# Optional: PAPERCLIP_RUN_ID (added to the audit trail when present). +# +# Usage (inside an agent heartbeat): +# scripts/agent-set-git-identity.sh +# +# See docs/operations/agent-identity.md. + +set -euo pipefail + +: "${PAPERCLIP_AGENT_ID:?must run inside a Paperclip agent (PAPERCLIP_AGENT_ID unset)}" +: "${PAPERCLIP_API_URL:?PAPERCLIP_API_URL unset}" +: "${PAPERCLIP_API_KEY:?PAPERCLIP_API_KEY unset}" + +EMAIL_DOMAIN="agents.flopbut.local" + +python3 - "$PAPERCLIP_AGENT_ID" "$EMAIL_DOMAIN" <<'PY' +import json, os, sys, urllib.request + +agent_id, domain = sys.argv[1], sys.argv[2] +api = os.environ["PAPERCLIP_API_URL"].rstrip("/") +key = os.environ["PAPERCLIP_API_KEY"] +run = os.environ.get("PAPERCLIP_RUN_ID") + +def call(method, path, body=None): + data = json.dumps(body).encode() if body is not None else None + headers = {"Authorization": f"Bearer {key}"} + if data is not None: + headers["Content-Type"] = "application/json" + if run: + headers["X-Paperclip-Run-Id"] = run + req = urllib.request.Request(f"{api}{path}", data=data, method=method, headers=headers) + with urllib.request.urlopen(req) as r: + return json.load(r) + +me = call("GET", "/api/agents/me") +name = me.get("name") +if not name: + sys.exit("could not resolve agent name from /api/agents/me") +slug = name.strip().lower() +email = f"{slug}@{domain}" + +ac = me.get("adapterConfig") or {} +env = dict(ac.get("env") or {}) # readable for SELF — preserve existing (incl. secrets) +want = { + "GIT_AUTHOR_NAME": name, + "GIT_AUTHOR_EMAIL": email, + "GIT_COMMITTER_NAME": name, + "GIT_COMMITTER_EMAIL": email, +} + +def current(k): + v = env.get(k) + return v.get("value") if isinstance(v, dict) else v + +if all(current(k) == v for k, v in want.items()): + print(f"git identity already set for {name} <{email}> — no change") + sys.exit(0) + +for k, v in want.items(): + env[k] = {"type": "plain", "value": v} +ac["env"] = env + +call("PATCH", f"/api/agents/{agent_id}", {"adapterConfig": ac}) +print(f"set git identity: {name} <{email}> (applies on the next run)") +PY