Compare commits

...

13 Commits

Author SHA1 Message Date
didericis ae1531835d docs: drop "forge" jargon for concrete Gitea wording
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 53s
test / unit (push) Successful in 36s
test / integration (push) Successful in 57s
We use Gitea, not an abstract forge. Reword the docs added in this
branch: "forge thread" -> "Gitea thread", and the research note's
generic "forge" -> "Gitea" / "hosting provider" as context demands,
keeping its portability argument coherent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:02 -04:00
didericis 5c5f576df0 docs(research): add README describing research notes
Document what research notes are (opinionated investigations of a
question/design space), their unnumbered kebab-case naming, and their
loose verdict-first shape — explicitly freeform, not a template. Point
the AGENTS.md research line at it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:02 -04:00
didericis d329e511fd docs: drop docs/INDEX.md, add PRD README with format
Remove the one-line docs/INDEX.md (its directory pointers are covered
by docs/README.md's "when to write which document" table). Add
docs/prds/README.md documenting the PRD naming, Status lifecycle, and
section format. Repoint the AGENTS.md repository-layout list at the
new READMEs and add the decisions/ dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:02 -04:00
didericis 1308e61c7e docs: hoist "when to write which document" to docs/README.md
Move the document-type comparison out of docs/decisions/README.md
(where it only surfaced if you were already in the decisions dir) up
to a new docs/README.md, renamed "When to write which document".
Leave a pointer from the decisions README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:02 -04:00
didericis 2141a85884 docs(decisions): drop hand-maintained index from README
Per review on PR #97: an index that lists every ADR is a sync
burden. The files in docs/decisions/ are the index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:02 -04:00
didericis ccbed97776 docs(prd): inline #88 rationale into PRD 0025
Add an "Alternatives considered" section enumerating the design
options from issue #88 (duplicate bottles / agent-side bottle_config
/ bottle-side extends) and why extends won, so the PRD stands without
the forge thread. Repoint the two phrases that depended on the #88
comment thread at the new section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:02 -04:00
didericis 1df78ee77f docs(decisions): add ADR-lite decision log
Add docs/decisions/ with a convention README and back-fill two
decisions that previously had no in-repo home: merging PRs with
rebase (ADR 0001) and the agent-identity claimed-not-vouched trust
posture from PRD 0027 (ADR 0002). Point docs/INDEX.md at it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:02 -04:00
didericis c840182d12 docs(research): issue tracking vs in-repo decision history
Analyze tracking feature requests in Gitea against the project's
in-repo PRDs/research notes, given the goal of keeping decision
history portable and not provider-locked. Recommends demoting issues
to an ephemeral inbox and reifying durable rationale into the repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:02 -04:00
didericis 7b4c1cd091 docs: drop "forge" jargon for concrete wording
test / unit (pull_request) Successful in 26s
test / integration (pull_request) Successful in 43s
test / unit (push) Successful in 28s
test / integration (push) Successful in 42s
We use Gitea, not an abstract forge. Reword the pre-existing research
and PRD docs: the generic "Forge-API gate"/"forge tokens" become
"Git-host-API gate"/"Git-host tokens" (the gate still spans Gitea /
GitHub / GitLab), "Git/forge history" -> "Git/Gitea history", and the
KNOWN_FORGE_HOSTS / forge: manifest-field examples -> KNOWN_GIT_HOSTS
/ git_host:. Meaning preserved; only the word "forge" is dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 22:57:20 -04:00
didericis 47c3ba63f8 docs(prd): mark merged PRDs as Active
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 58s
test / unit (push) Successful in 32s
test / integration (push) Successful in 54s
Flip Status: Draft -> Active for the 23 PRDs whose work has shipped to
main (including 0027, now that PR #95 has merged). Leaves the
terminal-status PRDs unchanged: 0007 and 0010 (Superseded) and 0014
(Retargeted) were replaced, not shipped as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 22:12:03 -04:00
didericis dcd90cd45e docs(manifest): document + demo agent-level git.user
test / unit (pull_request) Successful in 35s
test / integration (pull_request) Successful in 57s
test / unit (push) Successful in 32s
test / integration (push) Successful in 44s
README manifest section documents the agent git.user overlay, the
bottle-only git.remotes boundary, and the claimed-not-vouched trust
note. Collapses the example: implementer carries its own identity
against the shared dev bottle instead of an identity-only bottle.

Refs #94

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 21:10:47 -04:00
didericis 0708e99e4e feat(manifest): lift git.user to the agent layer
Agents may declare git.user (name/email); it overlays the referenced
bottle's git.user per-field at Manifest.bottle_for (agent wins on
non-empty), mirroring the extends: merge. git.remotes is rejected on
agents — it carries credentials and host trust and stays bottle-only.

The overlay lives at bottle_for, the single chokepoint both backends
use, so the docker/smolmachines git provisioners are unchanged. Adds
Manifest.git_identity_summary with per-field (agent)/(bottle)
provenance, printed in both preflights and `info`.

Refs #94

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 21:10:47 -04:00
didericis f9e3b6adda docs(prd): add PRD 0027 agent-level git user identity
test / unit (pull_request) Successful in 27s
test / integration (pull_request) Successful in 43s
Lift git.user (name/email) to the agent layer with a per-field
overlay onto the referenced bottle, mirroring the extends: merge.
git.remotes stays bottle-only. Includes identity provenance in
preflight/info and an example collapse.

Refs #94

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 20:58:00 -04:00
41 changed files with 1104 additions and 44 deletions
+4 -3
View File
@@ -28,9 +28,10 @@ the container lifecycle and the copying of skills and env vars into it.
- `bot-bottle.json` — legacy manifest of named agents (env / skills / prompt
per agent), consumed by `cli.py`. See "Manifest" under
"Intended design".
- `docs/INDEX.md`pointer to the research notes.
- `docs/prds/` — product requirement docs.
- `docs/research/` — research notes (empty for now, kept tracked via `.gitkeep`).
- `docs/README.md`docs overview; when to write which document.
- `docs/prds/` — product requirement docs (see `docs/prds/README.md` for format).
- `docs/research/` — research notes (see `docs/research/README.md`).
- `docs/decisions/` — decision records (ADR-lite).
## Conventions
+17
View File
@@ -362,6 +362,10 @@ Dockerfile while keeping the bot-bottle sidecars in place.
bottle: gitea-dev
skills:
- init-prd
git:
user:
name: gitea-helper
email: eric+gitea-helper@dideric.is
---
You help maintain Gitea-hosted projects.
@@ -375,6 +379,19 @@ frontmatter — bot-bottle ignores them at launch but doesn't
reject them, so the same file can drop into `~/.claude/agents/` as a
Claude Code subagent.
An agent may also declare `git.user` (`name` / `email`). It overlays
the referenced bottle's `git.user` per-field — the agent's non-empty
fields win, the rest fall through to the bottle — so two agents can
share one bottle and still commit under distinct identities without
an identity-only bottle (PRD 0027). Only `git.user` is allowed at the
agent level; `git.remotes` stays bottle-only because it carries
credentials and host trust. The launch preflight and `cli.py info`
print the effective identity annotated `(agent)` / `(bottle)` so you
can see where each field came from. Git authorship is not a
credential — push auth is the bottle's remote key/token — so a
repo-shipped agent setting its own identity grants no access; treat
an agent identity as *claimed, not vouched*.
Unknown top-level frontmatter keys die at load with a "did you mean"
pointer; typos don't silently ghost into an empty config.
+4
View File
@@ -85,6 +85,10 @@ class DockerBottlePlan(BottlePlan):
print_multi("skills ", list(agent.skills))
info(f"bottle : {agent.bottle}")
identity = manifest.git_identity_summary(spec.agent_name)
if identity:
info(f" git identity : {identity}")
git_lines = [
f"{u.upstream_host}:{u.upstream_port}"
for u in self.git_gate_plan.upstreams
@@ -125,6 +125,9 @@ class SmolmachinesBottlePlan(BottlePlan):
print_multi("env ", env_names)
print_multi("skills ", list(agent.skills))
info(f"bottle : {agent.bottle}")
identity = manifest.git_identity_summary(spec.agent_name)
if identity:
info(f" git identity : {identity}")
if upstreams:
print_multi(" git gate ", upstreams)
if routes:
+3
View File
@@ -31,6 +31,9 @@ def cmd_info(argv: list[str]) -> int:
f"first line: {prompt_first_line or '(empty)'}"
)
info(f"bottle : {agent.bottle}")
identity = manifest.git_identity_summary(args.name)
if identity:
info(f" git identity : {identity}")
if bottle.git:
for e in bottle.git:
info(
+72 -8
View File
@@ -47,7 +47,7 @@ import ipaddress
import json
import os
import re
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Mapping, cast
@@ -692,6 +692,11 @@ class Agent:
bottle: str
skills: tuple[str, ...] = ()
prompt: str = ""
# Per-agent git identity (issue #94). Overlays the referenced
# bottle's git.user per-field at `Manifest.bottle_for`. Only the
# `user` block is allowed at the agent level; `git.remotes` stays
# bottle-only because it carries credentials and host trust.
git_user: GitUser = GitUser()
@classmethod
def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "Agent":
@@ -731,7 +736,25 @@ class Agent:
else:
die(f"agent '{name}' prompt must be a string (was {type(prompt_raw).__name__})")
return cls(bottle=bottle, skills=skills, prompt=prompt)
# git: agents may declare only `git.user` (name/email). Any
# other git key — notably `remotes` — is rejected: remotes
# carry credentials and host trust and stay bottle-only.
git_user = GitUser()
git_raw = d.get("git")
if git_raw is not None:
gd = _as_json_object(git_raw, f"agent '{name}' git")
for k in gd.keys():
if k != "user":
die(
f"agent '{name}' git.{k} is not allowed at the "
f"agent level; only git.user (name/email) may be "
f"set on an agent. git.remotes is bottle-only "
f"(it carries credentials and host trust)."
)
if "user" in gd:
git_user = GitUser.from_dict(name, gd["user"])
return cls(bottle=bottle, skills=skills, prompt=prompt, git_user=git_user)
@dataclass(frozen=True)
@@ -874,11 +897,50 @@ class Manifest:
)
die(f"bottle '{name}' not defined in bot-bottle.json (no bottles defined).")
def _effective_git_user(self, agent_name: str) -> GitUser:
"""Merge the agent's git.user over the referenced bottle's,
per-field, agent-wins-on-non-empty (issue #94). Same overlay
the `extends:` resolver applies between bottles
(`_merge_bottles`)."""
agent = self.agents[agent_name]
base = self.bottles[agent.bottle].git_user
over = agent.git_user
if over.is_empty():
return base
return GitUser(
name=over.name or base.name,
email=over.email or base.email,
)
def bottle_for(self, agent_name: str) -> Bottle:
"""Resolve the Bottle the named agent references. The validator
guarantees both lookups succeed for a manifest built via
from_json_obj."""
return self.bottles[self.agents[agent_name].bottle]
"""Resolve the Bottle the named agent references, with the
agent's git.user overlaid on top. The validator guarantees both
lookups succeed for a manifest built via from_json_obj.
The overlay lives here, the single point both backends call to
resolve an agent's bottle, so the docker / smolmachines git
provisioners pick up the merged identity unchanged."""
bottle = self.bottles[self.agents[agent_name].bottle]
merged = self._effective_git_user(agent_name)
if merged == bottle.git_user:
return bottle
return replace(bottle, git_user=merged)
def git_identity_summary(self, agent_name: str) -> str | None:
"""One-line effective git identity with per-field provenance
for launch summaries, e.g.
`name=claude (agent), email=eric@dideric.is (bottle)`.
Returns None when neither agent nor bottle sets an identity."""
over = self.agents[agent_name].git_user
merged = self._effective_git_user(agent_name)
if merged.is_empty():
return None
parts: list[str] = []
if merged.name:
parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})")
if merged.email:
parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})")
return ", ".join(parts)
def _as_json_object(value: object, label: str) -> dict[str, object]:
@@ -1053,7 +1115,7 @@ _BOTTLE_KEYS = frozenset(
{"env", "extends", "agent_provider", "git", "egress", "supervise"}
)
_AGENT_KEYS_REQUIRED = frozenset({"bottle"})
_AGENT_KEYS_OPTIONAL = frozenset({"skills"})
_AGENT_KEYS_OPTIONAL = frozenset({"skills", "git"})
# Claude Code subagent fields bot-bottle ignores at launch but
# doesn't reject — lets the same file double as `~/.claude/agents/*.md`.
_AGENT_KEYS_CC_PASSTHROUGH = frozenset({
@@ -1301,11 +1363,13 @@ def _load_agents_from_dir(
)
# Build the dict Agent.from_dict expects. The body becomes
# prompt; CC passthrough fields stay in fm and get ignored
# by from_dict (which only reads bottle/skills/prompt).
# by from_dict (which reads bottle/skills/git/prompt).
agent_dict: dict[str, object] = {
"bottle": fm.get("bottle"),
"skills": fm.get("skills", []),
"prompt": body.strip(),
}
if "git" in fm:
agent_dict["git"] = fm["git"]
out[name] = Agent.from_dict(name, agent_dict, bottle_names)
return out
-1
View File
@@ -1 +0,0 @@
Research notes live in `research/`. Product requirement docs live in `prds/`.
+18
View File
@@ -0,0 +1,18 @@
# Docs
How this project records what it builds and why — and a guide to
picking the right document for what you're capturing.
## When to write which document
| Artifact | For |
|---|---|
| **PRD** (`docs/prds/`) | A feature: what to build, scope, success criteria. |
| **Research note** (`docs/research/`) | A landscape/tradeoff investigation. |
| **Decision record** (`docs/decisions/`) | A decision that isn't itself a feature — a policy, a convention, a "we will / won't do this," or a load-bearing choice made inside a larger PRD that deserves to be discoverable on its own. |
A decision that's fully specified by a PRD doesn't need duplicating in
a decision record. Write one when the *decision* would otherwise be
buried in prose, lost in an issue thread, or have no in-repo home at
all (small requests that don't merit a PRD; non-feature choices like
merge strategy or a trust posture).
@@ -0,0 +1,47 @@
# ADR 0001: Merge PRs with rebase, not merge commits
- **Status:** Accepted
- **Date:** 2026-05-28
- **Deciders:** didericis
## Context
PRs need a merge strategy. Gitea offers merge-commit, squash, rebase,
and rebase-merge. The project uses [Conventional
Commits](https://www.conventionalcommits.org/) enforced by a
`commit-msg` hook, and PRDs typically land as a multi-commit PR where
each commit is meaningful on its own (e.g. PR #95: a `docs(prd)` commit,
a `feat(manifest)` implementation commit, and a `docs(manifest)`
commit). The history should stay readable and the individual
conventional commits should survive onto `main`.
## Decision
Merge PRs with **rebase** (Gitea's `rebase` style; `Do: "rebase"` via
the API). The branch's commits are replayed onto `main` with no merge
commit, producing a linear history that preserves each commit verbatim.
## Consequences
- **Linear history**, no merge bubbles; `git log --oneline` reads as a
straight sequence of conventional commits.
- **Each commit is preserved** (unlike squash, which would collapse the
PRD/impl/docs commits into one and lose the staged structure).
- **Commit SHAs are rewritten at merge.** The replayed commits on `main`
get new SHAs, and the source branch is deleted, so a link to a file
by *branch name* (`/src/branch/<feature>/…`) dies at merge. This is
why links to not-yet-merged files are pinned to a **commit SHA**
(`/src/commit/<sha>/…`), which stays reachable via the retained
`refs/pull/<n>/head` ref. See
`docs/research/issue-tracking-vs-in-repo-decision-history.md`.
- **Trade-off accepted:** without a merge commit, the "these commits
landed together as PR #N" grouping is not recorded in git itself — it
lives in forge state (the PR). That is a mild concession against the
keep-history-in-the-repo posture; the conventional-commit scopes and
PRD references in the messages keep changes traceable without it.
## Links
- `docs/research/issue-tracking-vs-in-repo-decision-history.md` — the
commit-pinning consequence above.
- Observed practice: PRs #92, #93 merged with rebase; #95 to follow.
@@ -0,0 +1,48 @@
# ADR 0002: Agent-set git identity is claimed, not vouched
- **Status:** Accepted
- **Date:** 2026-05-28
- **Deciders:** didericis
## Context
PRD 0027 lifts `git.user` (name/email) to the agent layer, so an agent
file may declare its own commit identity. Agent files can live in
`$CWD/.bot-bottle/agents/` — i.e. they can be supplied by a cloned,
less-trusted repository. That raises the question of whether a
repo-supplied agent setting its own git identity is a security concern,
and whether agent identity should be gated differently for `$CWD`
agents than for `$HOME` agents.
This record exists because the decision is a **trust posture** worth
finding on its own, separate from the feature PRD that introduced it.
The full analysis lives in PRD 0027; the decision is summarized here.
## Decision
Allow agents to set `git.user`, and treat an agent-declared identity as
**claimed, not vouched**. No `$CWD`-vs-`$HOME` gating on the identity
field. `git.remotes` stays bottle-only (home-only).
## Consequences
- A cloned repo's agent file can present any commit author name/email,
including one that reads like a real person's. This is accepted: git
authorship is **not a credential** (push auth is the bottle's remote
key/token), is **already forgeable** from inside the bottle at runtime
(`git config user.email …`), and was never a trust anchor.
- If attribution integrity ever matters, the answer is commit
**signing** (SSH/GPG), not the author field — so this decision closes
no door that was open.
- `git.remotes` is deliberately *not* lifted to the agent layer: it
carries credentials and host trust (IdentityFile, KnownHostKey) and
remains a bottle-only, home-only concern.
- Revisit if a future change ever makes commit identity load-bearing
(e.g. enforced signing keyed on author), at which point gating
`$CWD`-supplied identities would matter.
## Links
- PRD 0027 (`docs/prds/0027-agent-git-user-identity.md`) — full trust
analysis and schema.
- Issue #94, PR #95 — the feature this decision was made for.
+42
View File
@@ -0,0 +1,42 @@
# Decision records
Short, durable records of decisions — one file per decision. This is a
lightweight [Architecture Decision Record](https://adr.github.io/)
practice: capture *what was decided and why* in a versioned file so the
reasoning lives in the clone, not in a Gitea issue thread or a chat log
that disappears when the host does.
See `docs/research/issue-tracking-vs-in-repo-decision-history.md` for
the rationale behind keeping decision history in-repo, and
[`docs/README.md`](../README.md) for when to write a decision record
vs. a PRD or research note.
## Format
One Markdown file per decision, numbered sequentially and zero-padded
(`0001-…`, `0002-…`), matching the PRD numbering style. Keep it short —
the discipline is writing it down, not the ceremony.
```markdown
# ADR 0000: <short imperative title>
- **Status:** Proposed | Accepted | Superseded by ADR NNNN
- **Date:** YYYY-MM-DD
- **Deciders:** <who>
## Context
What forced the decision; the constraints in play.
## Decision
What we decided, stated plainly.
## Consequences
What follows — the good, and the costs/trade-offs accepted.
## Links
PRDs, research notes, issues/PRs. Gitea links are convenience
pointers; the reasoning above must stand without them.
```
The records are the index: `ls docs/decisions/` or skim the titles.
No hand-maintained list to keep in sync.
@@ -1,6 +1,6 @@
# PRD 0001: Per-agent egress proxy via pipelock
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-08
@@ -1,6 +1,6 @@
# PRD 0002: Test pipeline on Gitea Actions
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-08
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0003: Bottle Backend abstraction
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-10
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0004: Split out provisioners
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-11
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0006: pipelock native TLS interception
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-12
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0008: Git gate
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-12
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0009: Remove ssh-gate and bottle.ssh
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-13
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0011: Per-file Markdown manifest
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-24
+2 -2
View File
@@ -1,6 +1,6 @@
# PRD 0012: Stuck-agent recovery flow
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-24
@@ -22,7 +22,7 @@ A real stuck agent recovers end-to-end in each of the three categories: a **cred
- Live attach or in-place mutation of running containers. The whole design exists to avoid this.
- Agent-to-agent communication. Re-stated from the project's existing non-goals; the recovery flow is human→agent only.
- Auditing or forensic replay of agent runs. Git/forge history is the audit log; this PRD does not add a separate run log.
- Auditing or forensic replay of agent runs. Git/Gitea history is the audit log; this PRD does not add a separate run log.
- Reducing time-to-unstuck below some target. Faster than kill-and-restart is implicit, but no specific SLO is in scope.
## Stuck categories
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0013: Supervise plane foundation
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-25
- **Parent:** PRD 0012
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0015: pipelock block remediation
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-25
- **Parent:** PRD 0012
@@ -1,6 +1,6 @@
# PRD 0016: capability block remediation
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-25
- **Parent:** PRD 0012
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0017: Egress-proxy — universal MITM with path filtering + auth injection
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-25
- **Supersedes:** the cred-proxy sidecar (PRD 0010) — hard cutover.
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0018: One Compose project per bottle instance
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-25
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0019: Active agents in the dashboard, agent-scoped edit verbs
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-26
@@ -1,6 +1,6 @@
# PRD 0020: Start and attach to agents from inside the dashboard
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-26
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0021: Dashboard as left tmux pane, selected agent as right pane
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-26
@@ -1,6 +1,6 @@
# PRD 0022: End-to-end sandbox-escape integration test
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-26
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0023: smolmachines bottle backend
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-26
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0024: Consolidate per-bottle sidecars into a single bundle
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-26
+40 -5
View File
@@ -1,6 +1,6 @@
# PRD 0025: Bottle composition via `extends:`
- **Status:** Draft
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-27
- **Issue:** #88
@@ -39,6 +39,41 @@ trust boundary*: only `$HOME` bottles can declare it, only `$HOME`
bottles can be its target. Cloned repos still cannot author
bottle-equivalent config.
## Alternatives considered
The question raised in issue #88 was *where composition should live*.
Three points in that design space, recorded here so the decision
stands on its own without the issue thread:
1. **Duplicate bottles (status quo).** Copy `dev.md` to `staging.md`
and edit. Zero new mechanism, but every shared field drifts: a
route added to `dev` is silently missing from `staging`. This is
the pain that prompted #88.
2. **Agent-side `bottle_config:` override (the original #88
proposal).** Let an agent file carry an inline block that merges
over its referenced bottle. Ergonomically attractive — one file,
no second bottle — but it **breaks the trust boundary**: agent
files can come from `$CWD/.bot-bottle/agents/` in a cloned repo, so
a clone could redeclare egress routes, env mappings, and git
remotes — i.e. grant itself bottle-equivalent authority over
credentials and network egress. The home-only-bottle invariant
exists precisely to stop this.
3. **Bottle-side `extends:` (chosen).** Move composition to the
bottle layer, where it inherits the home-only property for free:
only `$HOME` bottles can declare `extends:`, and only `$HOME`
bottles can be its target. Identical duplication relief to option
2, none of its trust erosion. The cost is that an override requires
a (home-owned) child bottle rather than an inline agent block —
which is the *point*: the override authority stays in `$HOME`.
`extends:` wins because it solves the duplication pain entirely on the
trusted side of the agent-vs-bottle boundary. (PRD 0027 later lifts a
deliberately narrow, non-credential field — `git.user` — to the agent
layer, on the separate reasoning that commit identity is not a
capability; egress, credentials, and remotes stay bottle-only.)
## Goals / Success Criteria
- Add `extends: <bottle-name>` to the bottle frontmatter schema.
@@ -58,9 +93,9 @@ bottle-equivalent config.
## Non-goals
- **No agent-side `bottle_config:`.** That's the design issue #88
considered and weighed against; this PRD is the alternative
picked in the issue's design discussion. Don't reintroduce it.
- **No agent-side `bottle_config:`.** Option 2 under "Alternatives
considered" — weighed and rejected on trust grounds. Don't
reintroduce it.
- **No additive list merges** (e.g., `routes: append` keyword).
The `extends:` design uses full-replace for list-valued fields
(see "Merge rules"); if a use case shows up that genuinely
@@ -167,7 +202,7 @@ Bottles continue to be loaded from `$HOME/.bot-bottle/bottles/`
only (`Manifest.from_md_dirs` is unchanged). The `extends:` field
references another file in that same directory. No cwd-readable
file gains the ability to declare or modify bottle config — the
attack surface from issue #88's comment thread stays closed.
attack surface from option 2 ("Alternatives considered") stays closed.
If a future change ever introduces cwd-loaded bottles, the
`extends:` resolver should be gated to forbid a `$CWD` bottle
+1 -1
View File
@@ -1,6 +1,6 @@
# PRD 0026: Agent Provider Templates
- **Status:** Draft
- **Status:** Active
- **Author:** codex
- **Created:** 2026-05-28
+226
View File
@@ -0,0 +1,226 @@
# PRD 0027: Agent-level git user identity
- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-28
- **Issue:** #94
## Summary
Let an **agent** file declare `git.user` (name / email). At launch
the agent's `git.user` overlays the referenced bottle's `git.user`
per-field (agent wins on non-empty fields), mirroring the
`extends:` overlay from PRD 0025. `git.remotes` stays bottle-only.
Solves the "I need a whole separate bottle just to change the commit
name" coupling: today commit attribution — a purpose/presentation
concern — can only be varied by authoring a new security boundary
(a bottle). After this change, two agents (e.g. `implementer` and
`reviewer`) can share one bottle and still commit under distinct
identities.
## Problem
`git.user.name` / `git.user.email` is a bottle-only field. Agent
frontmatter is validated against a strict allowlist (`_AGENT_KEYS`
in `manifest.py`); a `git:` block in an agent file dies at parse
with "unknown frontmatter key(s)". So the only way to give an agent
a distinct commit identity is to give it a distinct *bottle*.
That couples identity to the trust boundary. Bottles are home-only
and define security (egress, credentials, provider). Forcing a new
bottle per commit-name means either (a) duplicating a bottle just to
flip one string, or (b) leaning on PRD 0025 `extends:` to make a
near-clone whose only delta is `git.user`. Both are heavier than the
concern deserves.
## Why this is safe (trust analysis)
Allowing agent files — which can live in `$CWD/.bot-bottle/agents/`
and therefore be supplied by a cloned repo — to set `git.user` does
**not** weaken the security model, because git author identity is
not a credential or a capability:
- **Push auth is separate.** Authentication to a remote is the
bottle's `git.remotes` IdentityFile / token. `user.name` /
`user.email` grant zero access.
- **It's already forgeable.** The agent inside the bottle can run
`git config user.email <anything>` or `git commit --author=...`
at runtime regardless of the manifest. The manifest field is only
a *default*; allowing agents to set it adds no capability that
isn't already reachable one layer down.
- **Authorship was never a trust anchor.** If attribution integrity
matters, that is a commit-*signing* concern (SSH/GPG), which a
name/email field cannot provide either way.
The one residual is cosmetic impersonation (a cloned repo's agent
file could set an identity that reads like a real person's). Commits
still push under the bottle's credentials, and the author string was
never vouched-for, so this is presentation, not escalation. We
document that an agent identity is *claimed, not vouched*.
`git.remotes` is explicitly **not** lifted to the agent layer — that
block carries credentials and host trust (IdentityFile, KnownHostKey)
and stays a bottle-only, home-only concern.
## Goals / Success Criteria
- Add an optional `git.user` block to the agent frontmatter schema
(name and/or email), reusing the existing `GitUser` validator.
- An agent's declared `git.user` overlays the referenced bottle's
`git.user` per-field; each non-empty agent field wins, empties
fall through to the bottle. Identical overlay semantics to the
bottle `extends:` merge (PRD 0025).
- `git.remotes` in an agent file is rejected at parse with a clear
"bottle-only" pointer.
- The overlay is applied at `Manifest.bottle_for()` — the single
point both backends call to resolve an agent's bottle — so the
docker and smolmachines provisioners need no changes.
- Existing agent files continue to parse identically — `git.user`
is opt-in.
- **Identity provenance is surfaced.** The y/N preflight (both
backends) and `cli.py info <agent>` print the effective git
identity with a per-field `(agent)` / `(bottle)` annotation so the
operator can see which level each field came from.
- **The example collapses to demonstrate the pattern.** The bundled
example replaces the identity-only-bottle shape with one shared
bottle + per-agent `git.user`, showing the intended usage.
## Non-goals
- **No agent-level `git.remotes`.** Credentials and host trust stay
bottle-only and home-only.
- **No other agent-level bottle fields.** This PRD lifts `git.user`
and nothing else; agents do not gain `egress`, `env`,
`agent_provider`, etc. (that is the issue #88 design PRD 0025
deliberately rejected).
- **No commit signing.** Attribution integrity via SSH/GPG is a
separate concern, out of scope here.
- **No CWD-vs-HOME identity gating.** Because the field is
non-enforcing (see trust analysis), CWD and HOME agents are
treated the same. If a future change makes identity load-bearing,
revisit.
## Design
### Schema
A new optional `git:` block on agent files, carrying only `user`:
```yaml
---
bottle: claude-dev
git:
user:
name: claude-implementer
email: eric+claude-implementer@dideric.is
---
You are a feature-implementation agent ...
```
- `git.user` accepts `name` and/or `email` (string-or-die; at least
one non-empty), validated by the **existing** `GitUser.from_dict`.
- `git.remotes` (or any `git` key other than `user`) in an agent
file dies at parse: "git.remotes is bottle-only".
- `git` is added to `_AGENT_KEYS`; the agent md-loader threads the
raw `git` block into the dict `Agent.from_dict` consumes.
### Merge rule
When `Manifest.bottle_for(agent)` resolves the agent's bottle, it
returns the bottle with `git_user` overlaid by the agent's
`git_user`:
| Field | Merge |
|-------------------|------------------------------------------------|
| `git_user.name` | agent's if non-empty, else bottle's |
| `git_user.email` | agent's if non-empty, else bottle's |
This is the same per-field overlay `_merge_bottles` already applies
for `extends:` (`manifest.py:1212`). Empty string = "not set", the
same predicate the provisioner's `is_empty()` uses. All other bottle
fields are returned unchanged.
### Where the overlay lives
```
def bottle_for(self, agent_name) -> Bottle:
agent = self.agents[agent_name]
bottle = self.bottles[agent.bottle]
if agent.git_user.is_empty():
return bottle
merged = GitUser(
name=agent.git_user.name or bottle.git_user.name,
email=agent.git_user.email or bottle.git_user.email,
)
return dataclasses.replace(bottle, git_user=merged)
```
Both `provision/git.py` paths (docker + smolmachines) already call
`bottle_for(agent_name)` and read `bottle.git_user`, so they pick up
the merged identity with no edits. The dashboard / `info` / preflight
surfaces that print bottle config go through the same resolution.
### Provenance display
A sibling `Manifest.git_identity_summary(agent_name) -> str | None`
returns the effective identity annotated per field, e.g.:
```
identity : name=claude-implementer (agent), email=eric+claude-implementer@dideric.is (bottle)
```
`(agent)` when the agent's `git.user` supplied that field, `(bottle)`
when it fell through to the referenced bottle. Returns `None` when no
identity is set at either level (callers omit the line). Both
`bottle_plan.py` preflights and `cli/info.py` print it; `info` today
prints `git remotes` but not the identity, so this adds the line.
### `Agent` dataclass
`Agent` gains `git_user: GitUser = GitUser()`. `Agent.from_dict`
parses the optional `git.user` and rejects non-`user` git keys.
## Implementation chunks
1. **PRD (this commit).** Sets the design.
2. **Schema + overlay + tests.**
- Add `"git"` to `_AGENT_KEYS`.
- Add `git_user: GitUser = GitUser()` to `Agent`; parse it in
`Agent.from_dict`, reusing `GitUser.from_dict`; reject any
`git` key other than `user` with a bottle-only message.
- Thread `fm.get("git")` through `_load_agents_from_dir`'s
`agent_dict`.
- Apply the per-field overlay in `Manifest.bottle_for()`.
- Add `Manifest.git_identity_summary()` and print it in both
`bottle_plan.py` preflights and `cli/info.py`.
- Unit tests: agent name+email overlay; agent name-only (email
falls through to bottle); agent email-only; agent identity with
a bottle that declares none; agent `git.remotes` dies; agent
with no `git` block unchanged; bottle-only behavior preserved;
provenance summary returns the right `(agent)`/`(bottle)` tags
and `None` when unset.
3. **Docs + example.** README manifest section: note `git.user` is
allowed on agents and overlays the bottle; `git.remotes` stays
bottle-only. Collapse the example — `examples/agents/implementer.md`
carries its own `git.user` against the shared `dev` bottle,
demonstrating per-agent identity without an identity-only bottle.
## Testing strategy
- **Unit (must):** the overlay matrix above, the parse-time reject
for agent `git.remotes`, and confirmation that `bottle_for` leaves
every non-`git_user` bottle field untouched.
- **No integration changes needed:** provisioners consume the
already-merged `Bottle` via `bottle_for`; existing docker /
smolmachines git-provisioning tests cover the consumption path.
## Open questions
- **Should a `git.user`-declaring agent be loadable as a Claude Code
subagent file too?** The CC-passthrough keys (`name`, `model`, …)
let one file double as `~/.claude/agents/*.md`; `git` is not a CC
key, so a file carrying it won't round-trip as a CC subagent. Not a
blocker (bot-bottle ignores unknown CC keys; CC ignores `git`), but
worth noting. Out of scope.
+63
View File
@@ -0,0 +1,63 @@
# Product requirement docs
One PRD per feature: what to build, why, and how it's scoped. The PRD
is the durable spec — it should stand on its own without a Gitea issue
thread (see [`../README.md`](../README.md) for when a PRD is the right
document vs. a research note or a decision record).
## Naming and numbering
`NNNN-kebab-title.md`, zero-padded and sequential (`0024-…`, `0025-…`).
Numbers are never reused; gaps are fine (there is no 0005). The number
is assigned at creation and stays fixed for the life of the doc.
## Status
The `Status:` line near the top tracks the PRD's lifecycle:
- **Draft** — proposed, not yet shipped.
- **Active** — the design has shipped to `main` and is in effect.
- **Superseded by [PRD NNNN](…)** — replaced by a later PRD; kept for history.
- **Retargeted by [PRD NNNN](…)** — folded into a later PRD's scope.
## Format
```markdown
# PRD NNNN: <short title>
- **Status:** Draft
- **Author:** <who>
- **Created:** YYYY-MM-DD
- **Issue:** #<n> # optional — convenience pointer only
## Summary
One paragraph: what this builds and the pain it solves.
## Problem
The current state and why it's inadequate.
## Goals / Success Criteria
Bullets a reviewer can check the finished work against.
## Non-goals
What this explicitly does not do — and won't, to head off scope creep.
## Scope
In scope / out of scope, when the boundary needs spelling out.
## Design
How it works: schema, data flow, diagrams, algorithms as needed.
## Implementation chunks
Ordered, mergeable steps (optional; for multi-PR features).
## Open questions
Unresolved decisions — resolve or fold into Design before shipping.
```
Sections are a guide, not a straitjacket: drop the ones a given PRD
doesn't need (a small change rarely needs Scope or Implementation
chunks) and add others where they help (e.g. Testing strategy,
Alternatives considered, References). Keep the rationale self-contained
— inline the reasoning rather than linking out to an issue thread, so
the PRD survives a move off Gitea.
+42
View File
@@ -0,0 +1,42 @@
# Research notes
Investigations into a question or a design space — landscape surveys,
tradeoff analyses, "should we do X or Y," assessments of an approach
before (or instead of) committing it to a PRD. A research note is where
the *thinking* lives; a PRD is where a decided feature lives, and a
decision record is where a settled choice lives (see
[`../README.md`](../README.md) for picking between them).
Notes are opinionated. They reach a conclusion rather than dumping a
neutral survey — the point is to move a decision forward and leave a
durable record of why it went the way it did.
## Naming
`kebab-case-topic.md`, named by subject and **not** numbered (unlike
PRDs and decision records). Pick a name that says what was
investigated: `bash-vs-python-vs-go.md`, `pipelock-assessment.md`,
`issue-tracking-vs-in-repo-decision-history.md`.
## Shape (freeform)
There's no fixed template — use whatever structure fits the question.
In practice most notes share a loose shape:
- **Open with the question** — a sentence or two on what's being
investigated and why it came up.
- **Lead with the verdict** — a `## Summary` near the top stating the
conclusion, so a reader gets the answer without reading the whole
thing.
- **Then the analysis** — whatever the argument needs: comparison
tables, per-option sections, failure-mode walkthroughs, the axes that
actually matter.
- **End with a recommendation** when the note exists to drive a
decision.
Keep the reasoning self-contained and grounded: cite sources, link
files and PRDs, and prefer concrete evidence from this repo over
generic claims — a note should stand on its own without a chat log or a
Gitea thread. When a note's recommendation gets acted on, capture the
resulting decision in a PRD or a decision record; the note stays as the
"why we looked into it," not the system of record for the choice.
@@ -314,9 +314,9 @@ In priority order:
npm even if it captures something. Also disable Sentry error
reporting via `DISABLE_ERROR_REPORTING=1`.
3. **Generalize the same proxy to forge tokens.** Add a manifest
3. **Generalize the same proxy to Git-host tokens.** Add a manifest
field along the lines of
`forge: { kind: "gitea", url, tokenRef }` so a per-bottle token
`git_host: { kind: "gitea", url, tokenRef }` so a per-bottle token
reference resolves at launch, the proxy starts as root before
`node` is exec'd, and `tea` plus git HTTPS remotes are
pre-configured to point at the proxy. Use
@@ -0,0 +1,196 @@
# Tracking feature requests in Gitea vs. in-repo decision history
Research into whether bot-bottle should track feature requests (and the
decision-making around them) as Gitea issues, given that the project
already records specs in-repo as PRDs (`docs/prds/`) and rationale as
research notes (`docs/research/`). The stated constraint is that the
*history of why we decided things* should be durable and portable —
not locked into a single hosting provider (Gitea today, conceivably
GitHub or something else tomorrow).
## Summary
Keep using issues, but demote them. The repository — not Gitea — is
the system of record for any decision you would be unhappy to lose.
Issues are an excellent **inbox and coordination surface** (cheap
capture, triage, async discussion, notifications, auto-linking) and a
**poor archive** (provider-locked storage, brittle numeric references,
rationale stranded in comment threads). The failure mode to avoid is the
one already present in the repo: a PRD whose reasoning is only complete
if you also read a Gitea issue thread.
The fix is a discipline, not a tool: **every load-bearing decision gets
reified into a versioned file in the repo before the issue that prompted
it is closed.** PRDs already do this for features; the gap is (a) small
requests that never merit a PRD and (b) decisions that aren't features
at all (e.g. "we merge with rebase," "author identity is claimed-not-
vouched"). Close that gap with a lightweight in-repo decision log. Then
issues can be as disposable as Gitea makes them, and migrating off
Gitea costs you triage state, not history.
## Why this even comes up here
The project already leans on the repo for durable artifacts:
- **PRDs** (`docs/prds/0001…0027`) — the spec and its rationale.
- **Research notes** (`docs/research/`) — the "why," with tradeoffs.
- **Conventional-commit history** — a machine-greppable change log.
But the issue layer has quietly become load-bearing in places:
- PRD 0025 says it picked "option 3" *"from the #88 design
discussion"* and that the rejected alternative lives "in issue #88's
comment thread." The PRD's rationale is therefore **incomplete without
the issue**. If Gitea is gone, the strongest argument for the chosen
design is gone with it.
- PR #89's description links `…/didericis/claude-bottle/issues/88`
the **pre-rename** repo path (the project was Codex-bottle/claude-
bottle before the bot-bottle rebrand). That link is already
half-dead: a concrete demonstration that Gitea URLs rot under the
most routine event imaginable, a rename.
- Issue/PR numbers (`#88`, `#90`, `#94`, `#95`) are **Gitea-assigned
from a shared sequence**. They cannot be reconstructed from a clone,
and they collide/renumber on import into a different tracker.
So the question isn't academic. The current practice is already
producing references that don't survive a rename, let alone a migration.
## What each medium is actually good at
| Concern | Gitea issue | In-repo file (PRD / note / log) |
|---|---|---|
| Capture friction | Near-zero — file a one-line idea | High — a PRD is a heavy artifact; a note less so |
| Triage (labels, milestones, open/closed, assignee) | Native, good | Absent / hand-rolled |
| Async discussion + notifications | Native (threads, @mentions, watch) | None — needs a PR review or out-of-band chat |
| Auto-linking (`Closes #N`, PR↔issue, commit↔issue) | Native | Manual cross-reference |
| Version control of the content | None — lives in Gitea's DB | Full — diff, blame, branch, revert |
| Travels with `git clone` | No | Yes |
| Survives a move off Gitea | Degrades (export/import; threads, authors, timestamps, refs lossy) | Unaffected |
| Survives a Gitea outage | Inaccessible | Local clone has it |
| Greppable offline / by tooling | Only via API | `grep docs/` |
| Reproducible identifiers | Gitea-assigned numbers | Filenames you control (`0027-…`) |
The split is clean: **issues win on the live, social, coordination axes;
the repo wins on every durability and portability axis.** Nothing about
that table says "pick one." It says "use each for what it's good at, and
don't let the durable thing depend on the ephemeral one."
## Lock-in failure modes (the cons, concretely)
1. **Stranded rationale.** The single most valuable output of a feature
discussion — *why we rejected the obvious alternative* — usually
emerges in a thread and dies there unless someone copies it into the
spec. PRD 0025 is already in this state.
2. **Reference rot.** `Closes #88` / "see issue #90" are meaningful only
against one Gitea instance at one point in time. A rename already
broke one such link; a migration would break all of them and
silently renumber the survivors.
3. **Two sources of truth.** A PRD carries `Status: Draft`; the issue
carries open/closed. They drift. Which is authoritative?
4. **Availability coupling.** Self-hosted Gitea down (or the Tailscale
path to it down) means the backlog and its history are unreachable,
even though the code and PRDs are right there in the clone.
5. **Export is lossy.** Gitea→GitHub (or the reverse) moves issue *text*
tolerably but mangles cross-references, comment authorship for
non-mapped users, timestamps, and reactions. The graph of "#88 → PR
#89 → commit abc" does not survive intact.
None of these are arguments against *having* issues. They're arguments
against issues being the **only** place a decision is recorded.
## Pros of keeping issues anyway
Worth stating plainly, because "just use the repo for everything"
overcorrects:
- A PR per half-formed idea is absurd; issues are the right weight for
"someone should look at X someday."
- Triage state (priority, milestone, assignee, open/closed) is genuine
project-management value the repo does not natively provide.
- Notifications and threaded discussion are how a decision *gets made*
before it's ready to be written down. Killing issues doesn't move that
conversation into the repo — it moves it into chat/DMs, which is
*worse* for durability, not better.
- `Closes #N` automation and PR↔issue linkage are real ergonomics.
The goal is not to abandon the tracker. It's to make sure that when the
tracker eventually goes away, you lose the *backlog*, not the *history*.
## What belongs where
- **Gitea issue** — intake, triage, status, and the live discussion.
Treat it as a **cache**: useful now, expendable later.
- **PRD (`docs/prds/`)** — the durable spec for anything that warrants
one. Rule: a PRD must be **self-contained**. Synthesize the issue
discussion into the Problem / Design / Open-questions sections;
reference the issue as a convenience pointer, never as the only home
of a load-bearing argument. (Retrofit PRD 0025: inline the #88
"option 3 vs `bottle_config:`" reasoning so the PRD stands alone.)
- **Research note (`docs/research/`)** — the durable "why," exactly like
this file. Comparative analysis, landscape surveys, tradeoffs.
- **Commit message** — the durable "what changed and why, at this point
in the diff."
- **Decision log (proposed, see below)** — durable record of decisions
that aren't features and don't merit a PRD.
## Closing the gap: a portable decision record
Two classes of decision currently have no in-repo home:
- **Sub-PRD feature requests** — too small for a PRD, but you still want
a tracked "we will / won't do this, because." Today these live only as
issues.
- **Non-feature decisions** — "merge with rebase, not merge-commit,"
"agent identity is claimed-not-vouched," "bottles are home-only."
Some land inside a PRD that happens to touch them; many are folded
into chat and lost.
Options, cheapest first:
1. **An ADR-lite log under `docs/decisions/`.** One short Markdown file
per decision: context, decision, consequences, date, links. This is
the industry-standard Architecture Decision Record pattern, and it's
a near-exact fit for "track decision history, portably." Numbered
like PRDs (`0001-merge-with-rebase.md`). ~10 lines each; the
discipline is writing them, not the format.
2. **Reuse the journal.** The repo ships an `init-entry` skill that
writes timestamped prose to `docs/JOURNAL.md` (not yet created here).
A stream-of-thought journal is a fine home for decision *narrative*
and is already part of the toolchain — lower ceremony than ADRs, less
structured for later retrieval. The `tag-entries` skill could tag
decision entries for grep-ability.
3. **Periodic issue export.** Belt-and-suspenders: a scheduled job hits
the Gitea API and dumps open/closed issues + comments to JSON under
`docs/issues-archive/`, committed. Preserves the raw thread against
losing Gitea without changing daily workflow. Mechanical, not a
substitute for reifying rationale (a JSON dump of a thread is
evidence, not a decision).
These compose: ADRs/journal for the *decision*, optional export for the
*raw evidence*, issues for *live coordination*.
## Recommendation
1. **Keep Gitea issues for intake, triage, and discussion.** Don't fight
Gitea on the things it's good at.
2. **Make the repo the system of record.** Adopt the rule: no decision
is "done" until its rationale exists in a versioned file (PRD,
research note, or decision log). The issue is a pointer, never the
sole source.
3. **Add `docs/decisions/` (ADR-lite).** Smallest change that closes the
real gap — sub-PRD requests and non-feature decisions. Start by
back-filling the few decisions already made only in threads or chat
(rebase-merge policy; the agent-identity trust call from PRD 0027).
4. **Retrofit PRD 0025** to inline its #88 rationale, removing the one
existing hard dependency on a Gitea thread.
5. **Treat issue numbers as disposable.** When a PRD/commit cites an
issue, ensure the cited content is mirrored in-repo so the citation
degrades to a dead-but-harmless link, not lost information. (The
already-broken `claude-bottle/issues/88` link is the warning.)
6. **Optional:** automate a Gitea issue export into the repo if you want
the raw threads preserved without manual transcription.
Net: issues stay, because the alternative to issues is chat, which is
worse. But the project's durable memory must live where the project
already lives — in the clone — so that moving off Gitea, or losing it,
costs you a backlog you can rebuild, never a history you can't.
@@ -148,7 +148,7 @@ telemetry to `statsig.anthropic.com` — are documented in
[`agent-credential-proxy-landscape.md`](agent-credential-proxy-landscape.md)
§Anthropic / Claude Code.
**Forge-API gate (Gitea / GitHub / GitLab).** Holds the PAT;
**Git-host-API gate (Gitea / GitHub / GitLab).** Holds the PAT;
exposes a narrow REST surface. Token auth on all three is
stateless `Authorization`-header injection — no CSRF, no request
signing, no per-request nonce — so one proxy generalizes by
@@ -221,7 +221,7 @@ Add a `secret: true` flag (or a `secrets:` sibling of `env:`) that:
AWS_SECRET_ACCESS_KEY").
- Refuses to launch if `egress.allowlist` contains any host that
is not source-controlled by the user (heuristic: not on a
built-in `KNOWN_FORGE_HOSTS` list).
built-in `KNOWN_GIT_HOSTS` list).
- Forces an explicit acknowledgement that a credential is being
placed into the bottle rather than behind a gate.
@@ -280,7 +280,7 @@ In priority order:
([`agent-credential-proxy-landscape.md`](agent-credential-proxy-landscape.md)
§Recommended). Removes the highest-value secret and closes the
passthrough hole as a side effect.
2. **Forge-API gate** (same doc, same section — one proxy
2. **Git-host-API gate** (same doc, same section — one proxy
generalizes across Gitea / GitHub / GitLab by config).
3. **Egress data budget** in pipelock — small lift, large damage
bound.
+4
View File
@@ -5,6 +5,10 @@ model: opus
bottle: dev
skills:
- init-prd
git:
user:
name: implementer-bot
email: eric+implementer@dideric.is
---
You are a feature-implementation agent running inside an ephemeral
+248
View File
@@ -0,0 +1,248 @@
"""Unit: agent-level git.user overlay + provenance (PRD 0027, issue #94).
An agent file may declare `git.user` (name/email). At
`Manifest.bottle_for()` it overlays the referenced bottle's
`git.user` per-field, agent-wins-on-non-empty. `git.remotes` is
rejected on agents. `Manifest.git_identity_summary()` reports the
effective identity with per-field `(agent)`/`(bottle)` provenance.
The `from_json_obj` path drives `Agent.from_dict` + `bottle_for`;
a temp-dir case locks the md loader (the `_AGENT_KEYS` allow + the
`git` threading into `agent_dict`)."""
from __future__ import annotations
import contextlib
import io
import os
import shutil
import tempfile
import textwrap
import unittest
from pathlib import Path
from bot_bottle.log import Die
from bot_bottle.manifest import Manifest
def _die_message(callable_, *args, **kwargs) -> str:
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
try:
callable_(*args, **kwargs)
except Die:
return buf.getvalue()
raise AssertionError("expected Die was not raised")
def _manifest(*, bottle_user=None, agent_git=None) -> Manifest:
bottle: dict = {}
if bottle_user is not None:
bottle = {"git": {"user": bottle_user}}
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"}
if agent_git is not None:
agent["git"] = agent_git
return Manifest.from_json_obj({
"bottles": {"dev": bottle},
"agents": {"impl": agent},
})
class TestAgentGitUserOverlay(unittest.TestCase):
def test_agent_supplies_both_fields(self):
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
u = m.bottle_for("impl").git_user
self.assertEqual("a", u.name)
self.assertEqual("a@b", u.email)
def test_agent_name_only_email_falls_through_to_bottle(self):
m = _manifest(
bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"name": "a"}},
)
u = m.bottle_for("impl").git_user
self.assertEqual("a", u.name) # agent wins
self.assertEqual("b@c", u.email) # bottle falls through
def test_agent_email_only_name_falls_through_to_bottle(self):
m = _manifest(
bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"email": "a@b"}},
)
u = m.bottle_for("impl").git_user
self.assertEqual("B", u.name)
self.assertEqual("a@b", u.email)
def test_agent_identity_with_bottle_declaring_none(self):
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
# The underlying bottle declares no identity; the merged one does.
self.assertTrue(m.bottles["dev"].git_user.is_empty())
self.assertFalse(m.bottle_for("impl").git_user.is_empty())
def test_bottle_only_identity_preserved_when_agent_silent(self):
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
u = m.bottle_for("impl").git_user
self.assertEqual("B", u.name)
self.assertEqual("b@c", u.email)
def test_bottle_for_returns_same_instance_when_no_overlay(self):
# No agent git.user → no replace(); the cached Bottle is
# returned as-is (identity check guards against churn).
m = _manifest(bottle_user={"name": "B"})
self.assertIs(m.bottles["dev"], m.bottle_for("impl"))
def test_bottle_for_returns_same_instance_when_overlay_is_noop(self):
# Agent restates exactly what the bottle already has → merged
# == bottle.git_user → same instance, no replace().
m = _manifest(
bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"name": "B", "email": "b@c"}},
)
self.assertIs(m.bottles["dev"], m.bottle_for("impl"))
def test_other_bottle_fields_untouched_by_overlay(self):
m = Manifest.from_json_obj({
"bottles": {"dev": {
"env": {"FOO": "bar"},
"supervise": True,
"git": {"user": {"name": "B"}},
}},
"agents": {"impl": {
"bottle": "dev", "skills": [], "prompt": "",
"git": {"user": {"name": "a"}},
}},
})
b = m.bottle_for("impl")
self.assertEqual("a", b.git_user.name)
self.assertEqual({"FOO": "bar"}, dict(b.env))
self.assertTrue(b.supervise)
class TestAgentGitUserRejections(unittest.TestCase):
def test_agent_remotes_dies_bottle_only(self):
msg = _die_message(_manifest, agent_git={
"remotes": {"h": {"Name": "r", "Upstream": "ssh://x/y.git"}},
})
self.assertIn("git.remotes", msg)
self.assertIn("bottle-only", msg)
def test_agent_unknown_git_subkey_dies(self):
msg = _die_message(_manifest, agent_git={"nope": {}})
self.assertIn("not allowed at the agent level", msg)
def test_agent_git_user_both_empty_dies(self):
# Reuses GitUser.from_dict validation.
msg = _die_message(_manifest, agent_git={"user": {"name": "", "email": ""}})
self.assertIn("neither name nor email", msg)
class TestGitIdentitySummary(unittest.TestCase):
def test_both_from_agent(self):
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
self.assertEqual(
"name=a (agent), email=a@b (agent)",
m.git_identity_summary("impl"),
)
def test_mixed_provenance(self):
m = _manifest(
bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"name": "a"}},
)
self.assertEqual(
"name=a (agent), email=b@c (bottle)",
m.git_identity_summary("impl"),
)
def test_bottle_only(self):
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
self.assertEqual(
"name=B (bottle), email=b@c (bottle)",
m.git_identity_summary("impl"),
)
def test_none_when_unset_anywhere(self):
m = _manifest()
self.assertIsNone(m.git_identity_summary("impl"))
_BOTTLE_DEV = """
---
git:
user:
name: bottle-name
email: bottle@example.com
---
dev bottle.
"""
_AGENT_WITH_GIT = """
---
bottle: dev
git:
user:
name: agent-name
---
impl agent.
"""
_AGENT_WITH_REMOTES = """
---
bottle: dev
git:
remotes:
h:
Name: r
Upstream: ssh://x/y.git
---
bad agent.
"""
class TestAgentGitUserMdLoader(unittest.TestCase):
"""Locks the md path: `git` is an accepted agent key and threads
into the parsed Agent (not rejected as an unknown frontmatter
key), and agent `git.remotes` dies through the same loader."""
def setUp(self) -> None:
self.home = Path(tempfile.mkdtemp(prefix="cb-home-"))
self._orig_home = os.environ.get("HOME")
os.environ["HOME"] = str(self.home)
def tearDown(self) -> None:
if self._orig_home is None:
os.environ.pop("HOME", None)
else:
os.environ["HOME"] = self._orig_home
shutil.rmtree(self.home, ignore_errors=True)
def _write(self, rel: str, text: str) -> None:
p = self.home / ".bot-bottle" / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(textwrap.dedent(text).lstrip("\n"))
def test_md_agent_git_user_overlays_bottle(self):
self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_GIT)
m = Manifest.resolve(str(self.home))
u = m.bottle_for("impl").git_user
self.assertEqual("agent-name", u.name) # agent wins
self.assertEqual("bottle@example.com", u.email) # bottle falls through
self.assertEqual(
"name=agent-name (agent), email=bottle@example.com (bottle)",
m.git_identity_summary("impl"),
)
def test_md_agent_remotes_dies(self):
self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_REMOTES)
msg = _die_message(Manifest.resolve, str(self.home))
self.assertIn("git.remotes", msg)
self.assertIn("bottle-only", msg)
if __name__ == "__main__":
unittest.main()