Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
123 changes: 123 additions & 0 deletions docs/operations/agent-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# 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. New values take effect on the agent's **next** run (the current
process inherited its env at spawn).

### 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

In addition to the mandatory skill trailer:

```
Co-Authored-By: Paperclip <noreply@paperclip.ing>
```

each commit gets a second co-author line for the authoring agent:

```
Co-authored-by: Wayland <wayland@agents.flopbut.local>
```
Comment on lines +55 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Inconsistent trailer capitalization and missing language specifiers.

Lines 56 and 62 show different capitalizations: "Co-Authored-By" vs "Co-authored-by". While Git treats trailer keys case-insensitively, the documentation should be consistent. The hook at line 31 in scripts/git-hooks/prepare-commit-msg outputs "Co-authored-by" (capital C, lowercase a/b), so this example should match.

Additionally, both code blocks are missing language specifiers, which reduces syntax highlighting and accessibility.

📝 Proposed fix
-```
+```text
 Co-Authored-By: Paperclip <noreply@paperclip.ing>

each commit gets a second co-author line for the authoring agent:

- -Co-authored-by: Wayland <wayland@agents.flopbut.local> +text
+Co-Authored-By: Wayland wayland@agents.flopbut.local

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 55-55: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 61-61: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/operations/agent-identity.md` around lines 55 - 63, Update the two
example commit-trailer code blocks to use a consistent "Co-authored-by: Name
<email>" capitalization (capital C, lowercase "oauthored-by") to match the
prepare-commit-msg hook output, and add a language specifier by changing the
fences to ```text for both blocks so they render with proper syntax highlighting
and accessibility; specifically replace the "Co-Authored-By" / "Co-authored-by"
lines in the docs/operations/agent-identity.md examples with "Co-authored-by:
Paperclip <noreply@paperclip.ing>" and "Co-authored-by: Wayland
<wayland@agents.flopbut.local>" inside ```text fences.


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: <Name>`** line in the PR body, and
- an **`agent:<slug>`** 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.
49 changes: 49 additions & 0 deletions scripts/agent-pr.sh
Original file line number Diff line number Diff line change
@@ -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: <Name>` line appended to the PR body, and
# 2. an `agent:<slug>` 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
Comment on lines +29 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Argument parsing doesn't handle --flag=value format or validate required flags.

The parser only handles space-separated flags (--title value), not equals-separated (--title=value). If a user passes --title="feat: xyz", it won't be extracted into $TITLE and will pass through to $PASS, causing --title to appear twice in the final gh pr create command (line 49). While the last value typically wins, this is confusing.

Additionally, --title is required by gh pr create, but the script doesn't validate that it was provided. If missing, line 49 would call gh pr create --title "", which may fail or prompt interactively.

🛡️ Proposed fix with validation and better parsing
 TITLE=""
 BODY=""
 PASS=()
 while [ $# -gt 0 ]; do
   case "$1" in
-    --title) TITLE="$2"; shift 2 ;;
-    --body)  BODY="$2";  shift 2 ;;
+    --title)
+      if [ $# -lt 2 ]; then
+        echo "error: --title requires a value" >&2
+        exit 1
+      fi
+      TITLE="$2"; shift 2 ;;
+    --title=*) TITLE="${1#--title=}"; shift ;;
+    --body)
+      if [ $# -lt 2 ]; then
+        echo "error: --body requires a value" >&2
+        exit 1
+      fi
+      BODY="$2"; shift 2 ;;
+    --body=*) BODY="${1#--body=}"; shift ;;
     *)       PASS+=("$1"); shift ;;
   esac
 done
+
+if [ -z "$TITLE" ]; then
+  echo "error: --title is required" >&2
+  exit 1
+fi
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
TITLE=""
BODY=""
PASS=()
while [ $# -gt 0 ]; do
case "$1" in
--title)
if [ $# -lt 2 ]; then
echo "error: --title requires a value" >&2
exit 1
fi
TITLE="$2"; shift 2 ;;
--title=*) TITLE="${1#--title=}"; shift ;;
--body)
if [ $# -lt 2 ]; then
echo "error: --body requires a value" >&2
exit 1
fi
BODY="$2"; shift 2 ;;
--body=*) BODY="${1#--body=}"; shift ;;
*) PASS+=("$1"); shift ;;
esac
done
if [ -z "$TITLE" ]; then
echo "error: --title is required" >&2
exit 1
fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/agent-pr.sh` around lines 29 - 38, Update the argument parser in the
while/case loop to accept both space-separated and equals-separated forms (e.g.,
--title value and --title=value) by splitting on '=' when present and assigning
to TITLE and BODY accordingly (referencing variables TITLE, BODY and array PASS
and the while/case block), ensure any unrecognized args still go into PASS, and
add a validation step before invoking gh pr create that checks TITLE is
non-empty and exits with a clear error if missing (referencing the final gh pr
create call so the script never runs with an empty --title).


# 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[@]}"
84 changes: 84 additions & 0 deletions scripts/agent-set-git-identity.sh
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions scripts/git-hooks/prepare-commit-msg
Original file line number Diff line number Diff line change
@@ -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 <noreply@paperclip.ing>` 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"
Comment on lines +38 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Blank line logic doesn't ensure trailer separation.

The comment at line 38 states the intent to "ensure a blank line separates the body from the trailer block," but the implementation only ensures the file ends with a single newline, not two. Git trailers should be preceded by a blank line.

Current behavior:

  • If file = "message\n": appends "Trailer\n" → result: "message\nTrailer\n" (no blank line)
  • If file = "message": adds "\n", appends "Trailer\n" → result: "message\nTrailer\n" (still no blank line)

Expected:

  • Result should be "message\n\nTrailer\n" (blank line before trailer)
🐛 Proposed fix
-# 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"
+# Ensure a blank line separates the body from the trailer block, then append.
+if [ -s "$COMMIT_MSG_FILE" ]; then
+  # Add one newline if file doesn't end with one, then always add blank line
+  if [ -n "$(tail -c1 "$COMMIT_MSG_FILE")" ]; then
+    printf '\n' >> "$COMMIT_MSG_FILE"
+  fi
+  printf '\n' >> "$COMMIT_MSG_FILE"
+fi
+printf '%s\n' "$TRAILER" >> "$COMMIT_MSG_FILE"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/git-hooks/prepare-commit-msg` around lines 38 - 42, The code that
appends TRAILER to COMMIT_MSG_FILE doesn't guarantee a blank line before the
trailer; change the logic so if the file is non-empty and its last line is not
empty (e.g., test via tail -n1 "$COMMIT_MSG_FILE" | grep -qv '^$' or check last
char(s)), append an extra newline before writing TRAILER; specifically update
the block handling COMMIT_MSG_FILE/TAILER so it ensures a blank separator line
(resulting in "\n\nTrailer\n" when necessary) rather than only a single trailing
newline.

29 changes: 29 additions & 0 deletions scripts/install-agent-git-hooks.sh
Original file line number Diff line number Diff line change
@@ -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"