Skip to content

Agent development workflow

Status: Authoritative workflow for AI agents and local automation.

This document owns the durable workflow detail that should not live inline in AGENTS.md. The agent entrypoint stays short; this file carries the operational steps that keep parallel work safe and shippable.

All local agent work happens in a dedicated worktree created from fresh origin/main. The shared repo root is read-only inspection space. Never push directly to main. All changes go through PRs.

Terminal window
SLUG=<short-task-slug>
BRANCH=<branch-name>
WT="$HOME/.local/share/gondolier/worktrees/$SLUG"
git fetch origin main --quiet
git worktree add -b "$BRANCH" "$WT" origin/main
cd "$WT"

Fetch before:

  1. creating a worktree;
  2. rebasing or re-queuing after CI failure;
  3. deciding whether previous work exists on main;
  4. arming or re-arming auto-merge after a branch has aged.

Do not edit, install, build, test, commit, or push from the shared repo root.

  1. Create the worktree from fresh origin/main.

  2. Make precise changes in the worktree only.

  3. Update durable docs when behavior, data contracts, public URLs, operations, security model, feature flags, or agent conventions change.

  4. Run validation appropriate to the diff.

  5. For code changes, run the rubber-duck diff review before pushing. Doc-only and pure config-only changes are exempt unless they change workflow logic, but the PR body should say why.

  6. For user-impacting risk paths, request independent human review from the relevant CODEOWNERS. Rubber-duck/self-review, automated gates, and repository approval enforcement are separate layers; see user-impacting-review-policy.md.

  7. Commit using the shared cloudkoopa git identity (do not add a Copilot authorship trailer):

  8. Push from the worktree branch to the remote.

  9. Open a Forgejo PR targeting main.

  10. Enable Forgejo auto-merge when the PR is otherwise ready. In this repository, auto-merge is the shunt enqueue path: shunt keeps the PR blocked with a required status until its mq/** queue run passes.

  11. Babysit through the shunt mq/** gate and final main merge. A pushed branch or open PR is not done.

  12. After merge or abandonment, remove the worktree and local branch:

    Terminal window
    git worktree remove "$WT"
    git branch -D "$BRANCH"

    If Forgejo/shunt did not delete the remote branch, also delete it explicitly.

Default for code changes:

Terminal window
go vet ./... && go test ./... && go build ./cmd/gondolier

Additional requirements:

  • Security/credential changes: audit every log. and fmt.Printf call in the diff for token/credential leakage. Run gosec ./... if available.
  • Database migration changes: verify the migration SQL in a local PostgreSQL instance before pushing. Include rollback instructions in the PR body.
  • API changes: test with curl or a lightweight HTTP client against a local instance before pushing.
  • Documentation-only changes do not need build/test unless a docs-specific check exists.

Commit authorship and pusher identity are separate. Local agent commits should use the shared cloudkoopa git identity. Do not run git config user.* from a worktree expecting it to be scoped; it writes shared repo config.

The canonical remote is Forgejo:

Terminal window
git remote set-url origin ssh://git@git.rbtr.dev/laputacloudco/gondolier.git
GIT_SSH_COMMAND='ssh -i ~/.ssh/cloudkoopa -o IdentitiesOnly=yes' git push -u origin HEAD

Do not switch this repo back to the old GitHub remote. The gh CLI is not authoritative for gondolier repository work.

For local CLI sessions, use the existing Forgejo token file when present:

Terminal window
FORGEJO_TOKEN_FILE="$HOME/.config/forgejo/token"
API="https://git.rbtr.dev/api/v1"
TOKEN="$(tr -d '\n' < "$FORGEJO_TOKEN_FILE")"
curl -fsS -H "Authorization: token $TOKEN" "$API/user" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["login"])'

Rules for Forgejo API tokens:

  • never print, commit, or paste token contents;
  • do not put tokens in shell history, PR bodies, commit messages, or docs;
  • if the local token file is absent or invalid, stop and report that PR/API auth is unavailable;
  • do not mint tokens with Forgejo admin CLI, Kubernetes, database access, or other infrastructure/admin paths from a gondolier repo task;
  • do not delete a durable local user token after use.

Check before the first push from a new worktree:

Terminal window
git remote -v
ssh -i ~/.ssh/cloudkoopa -o IdentitiesOnly=yes -T git@git.rbtr.dev

If a diff touches .forgejo/workflows/**, verify the workflow parses in Forgejo Actions and watch the first PR run before considering the work handed off.

After pushing the feature branch, create the PR through Forgejo’s REST API. Keep payloads in temporary files so the token and JSON body do not leak through shell history.

Terminal window
set -euo pipefail
TOKEN="$(tr -d '\n' < "$HOME/.config/forgejo/token")"
API="https://git.rbtr.dev/api/v1"
BODY_FILE="$(mktemp)"
OUT_FILE="$(mktemp)"
trap 'rm -f "$BODY_FILE" "$OUT_FILE"' EXIT
python3 - "$BODY_FILE" <<'PY'
import json, sys
payload = {
"base": "main",
"head": "<branch-name>",
"title": "<PR title>",
"body": """## Summary
- ...
## Validation
- ...
## Docs
- ...
""",
}
with open(sys.argv[1], "w", encoding="utf-8") as f:
json.dump(payload, f)
PY
curl -fsS \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-X POST --data-binary "@$BODY_FILE" \
"$API/repos/laputacloudco/gondolier/pulls" \
-o "$OUT_FILE"
python3 - "$OUT_FILE" <<'PY'
import json, sys
with open(sys.argv[1], "r", encoding="utf-8") as f:
pr = json.load(f)
print(f"PR #{pr['number']} {pr['html_url']}")
PY

Before creating a PR, check for an existing open PR on the same branch:

Terminal window
TOKEN="$(tr -d '\n' < "$HOME/.config/forgejo/token")"
API="https://git.rbtr.dev/api/v1"
OUT_FILE="$(mktemp)"
trap 'rm -f "$OUT_FILE"' EXIT
curl -fsS -H "Authorization: token $TOKEN" \
"$API/repos/laputacloudco/gondolier/pulls?state=open" \
-o "$OUT_FILE"
python3 - "$OUT_FILE" <<'PY'
import json, sys
branch = "<branch-name>"
with open(sys.argv[1], "r", encoding="utf-8") as f:
pulls = json.load(f)
for pr in pulls:
if (pr.get("head") or {}).get("ref") == branch:
print(f"PR #{pr['number']} {pr['html_url']}")
PY

Forgejo auto-merge is the allowed enqueue action. It is not the same thing as direct merge: it queues the PR so mq-bot/shunt can test and merge the queue. Use the merge endpoint only with all of these safeguards:

  • merge_when_checks_succeed: true;
  • force_merge: false;
  • head_commit_id set to the current PR head SHA returned by Forgejo;
  • no manual merge-queue status writes;
  • no queueing when the change needs owner input, product judgment, or discretionary review; leave those PRs open for review;
  • stop if the API reports a branch-protection, approval, shunt, or conflict blocker.
Terminal window
set -euo pipefail
TOKEN="$(tr -d '\n' < "$HOME/.config/forgejo/token")"
API="https://git.rbtr.dev/api/v1"
PR="<pr-number>"
PR_FILE="$(mktemp)"
BODY_FILE="$(mktemp)"
OUT_FILE="$(mktemp)"
trap 'rm -f "$PR_FILE" "$BODY_FILE" "$OUT_FILE"' EXIT
curl -fsS -H "Authorization: token $TOKEN" \
"$API/repos/laputacloudco/gondolier/pulls/$PR" \
-o "$PR_FILE"
python3 - "$PR_FILE" "$BODY_FILE" <<'PY'
import json, sys
with open(sys.argv[1], "r", encoding="utf-8") as f:
pr = json.load(f)
payload = {
"Do": "merge",
"head_commit_id": pr["head"]["sha"],
"merge_when_checks_succeed": True,
"delete_branch_after_merge": True,
"force_merge": False,
}
with open(sys.argv[2], "w", encoding="utf-8") as f:
json.dump(payload, f)
print(f"arming auto-merge for PR #{pr['number']} at {pr['head']['sha'][:8]}")
PY
curl -fsS \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-X POST --data-binary "@$BODY_FILE" \
"$API/repos/laputacloudco/gondolier/pulls/$PR/merge" \
-o "$OUT_FILE"

Verify after arming auto-merge:

Terminal window
TOKEN="$(tr -d '\n' < "$HOME/.config/forgejo/token")"
API="https://git.rbtr.dev/api/v1"
PR="<pr-number>"
OUT_FILE="$(mktemp)"
trap 'rm -f "$OUT_FILE"' EXIT
curl -fsS -H "Authorization: token $TOKEN" \
"$API/repos/laputacloudco/gondolier/pulls/$PR" \
-o "$OUT_FILE"
python3 - "$OUT_FILE" <<'PY'
import json, sys
with open(sys.argv[1], "r", encoding="utf-8") as f:
pr = json.load(f)
print(f"state={pr['state']} merged={pr.get('merged')} mergeable={pr.get('mergeable')}")
PY

Continue watching the PR until shunt stages it on mq/**, required checks pass, the PR merges.

The merge-queue required status is a shunt-owned control. Agents and humans working on normal feature/docs/config PRs must not:

  • POST or PATCH a commit status with context=merge-queue;
  • call the Forgejo PR merge API for an immediate/direct merge;
  • pass force_merge or use any merge API field to work around branch protection;
  • mutate Forgejo branch-protection rows or shunt state in the database;
  • mint elevated Forgejo tokens or use Kubernetes/Forgejo admin access to satisfy a required status, close a merge blocker, or merge faster.

Allowed merge action: enable Forgejo auto-merge on the PR after review and PR checks are ready, either through the web UI or the API recipe above. This queues the PR for mq-bot/shunt; it must not merge the branch directly. If the change needs owner input or discretionary review, do not queue it. If shunt does not enqueue, the mq/** gate fails, or branch protection reports a blocker, stop and report the blocker instead of satisfying the status yourself. Emergency production rollback still uses the rollback contract, not ad hoc queue bypass.

gondolier repository tasks do not include infrastructure administration. Do not use or mutate infrastructure, admin access, RBAC, secrets-manager policy, runner configuration, service databases, or other control-plane state from a gondolier task. If infrastructure access appears available, treat it as out of scope and unusable. If infrastructure blocks the work, stop and report the blocker; do not self-revoke, self-escalate, exec into services, patch access, mint admin tokens, or edit infrastructure state.

Use the PR template in .github/pull_request_template.md. Keep entries concise, but do not omit material risk:

  • summary of the user-visible or operational change;
  • validation run and result;
  • docs impact;
  • credential/encryption audit notes for changes touching secrets or log lines;
  • rubber-duck result when required;
  • independent human review for user-impacting risk paths;
  • hard dependencies in main with verification commands and output when present;
  • rollback plan.

For non-trivial multi-step work, default to self-hosted general-purpose (Qwen) subagents for exploration, implementation, validation, and git preparation. The parent agent remains responsible for scope, synthesis, security decisions, final review, and user communication.

Subagents are tools, not delegates: they cannot bypass any repository safety rule. The same constraints that apply to the parent agent apply to subagents:

  • Dedicated worktree only. Never edit, commit, or push from the shared repo root.
  • No shunt bypass. Subagents must not post or edit the merge-queue status, direct-merge through the Forgejo API, or use admin access to make a blocked merge look ready.
  • Infrastructure boundary. Subagents must not mutate infrastructure, admin access, RBAC, secrets-manager policy, runner configuration, service databases, or other control-plane state.
  • Private information. Subagents must not commit or quote secrets, tokens, private env values, local filesystem paths, private identities, account IDs, or confidential operational details.
  • Credential encryption. Tenant credentials must never appear in logs, error messages, API responses, or crash dumps.

If a subagent encounters an infrastructure blocker or needs owner input, it should stop and report the blocker to the parent agent, which then handles user communication.

Subagent lessons from production debugging

Section titled “Subagent lessons from production debugging”
  • Verify agent diffs match PR titles before shipping. Timed-out or interrupted subagents can leave contaminated branches with stale or partial changes. Always inspect the actual diff before merging — do not rely on the PR title as a proxy for what was actually changed.
  • Prefer narrow single-purpose agent tasks with exact paths. Tell subagents the specific files to touch and what to write. Avoid “go explore and fix” scopes; they produce noisy diffs that are hard to audit. Use exact paths and explicit instructions — “no discovery.”
  • Deploy the Pages site from a worktree at origin/main. Never deploy from the local checkout if it may be stale. Use git fetch origin main --quiet and build/deploy from that tree.
  • The automation browser cannot validate CORS. Browsers will not send credentials with cross-origin requests from agent-run pages. Use curl with an Origin header to verify CORS responses instead.
  • Commit identity is cloudkoopa with no Copilot trailer. Subagent commits must use the repo’s cloudkoopa identity and must NOT include Co-authored-by: Copilot trailers. The Copilot trailer is a PR-level convention, not a commit-level one.

Worktrees isolate files, but not every machine resource. For gondolier:

Surface Default Safer override
Local PostgreSQL 5432 Use a different port or Docker container
Local Go server 8080 PORT=<unique> go run ./cmd/gondolier

Use unique values whenever a server supports it. Do not let tools silently choose a port that tests are not targeting.

For background processes:

  • prefer non-detached processes for task-local work;
  • use detached mode only for servers/daemons that must survive the session;
  • capture the PID immediately;
  • stop with a specific kill <PID>, never pkill or killall.

Local .env (or similar) files are git-ignored by convention and may contain per-developer secrets, local-only config, or credentials not tracked in source. The following rules prevent accidental overwrite or loss.

Never overwrite or delete an existing ignored local environment file. If a file already exists, never write it back from scratch. Always treat the disk version as the source of truth.

Rules when modifying a local environment file:

  1. Read before write. Inventory the existing keys (names only, no values) and verify the file is git-ignored (git check-ignore or git ls-files --ignored).
  2. Append only the narrow set of requested keys. Do not re-write the full file. Insert or update only the keys explicitly requested by the task.
  3. After write, inventory again. Confirm key names before and after are consistent — only the requested keys changed.
  4. Retain existing file mode. Copy stat permissions from the original file and preserve them on the appended/updated content.
  5. Require explicit confirmation before replace or delete. If the task involves replacing or deleting the file, stop and ask the user. Do not proceed without an affirmative, unambiguous response.
  6. If original values are lost, stop and request a secure backup. Never invent values, never reconstruct from logs, history, or diff. Ask the user for a secure backup. If the user cannot provide one, leave the file in its pre-change state.

This applies to any git-ignored environment or config file (.env, .env.local, .env.development, etc.), not just .env.

When work concerns CI workflows, verify both the workflow run and the output. A green workflow can still produce incorrect results if the test fixtures are wrong.

Common workflows:

  • ci.yaml — lint (go vet), tests, build. Runs on PR, main, and mq/**.
  • The mq/** push trigger is the merge queue gate — it has no paths-ignore.

Forgejo Actions API responses can be sparse in this installation; when the web UI/API is not enough, inspect the runner/job logs in Forgejo rather than trusting only the checkmark.

Shunt stages queued PRs on mq/<base>/staging refs. Forgejo auto-merge is the enqueue action; shunt blocks the PR with a required status until the staged queue run passes, then allows Forgejo to merge. The .forgejo/workflows/ci.yaml push trigger for mq/** is the required merge-queue gate.

Only shunt may create or clear the required merge-queue status. A green PR-head CI run is not a substitute for the mq/** staging run, and repository admin capability does not authorize an agent to synthesize queue evidence.

Cleanup is part of the work:

  • remove task worktrees after merge or abandon;
  • delete local branches after merge or abandon;
  • stop background processes you started;
  • leave unrelated dirty shared-root files alone;
  • do not revert user or peer-agent changes unless explicitly requested.