Compare commits
13 Commits
5871dbf24a
...
ae1531835d
| Author | SHA1 | Date | |
|---|---|---|---|
| ae1531835d | |||
| 5c5f576df0 | |||
| d329e511fd | |||
| 1308e61c7e | |||
| 2141a85884 | |||
| ccbed97776 | |||
| 1df78ee77f | |||
| c840182d12 | |||
| 7b4c1cd091 | |||
| 47c3ba63f8 | |||
| dcd90cd45e | |||
| 0708e99e4e | |||
| f9e3b6adda |
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 +0,0 @@
|
||||
Research notes live in `research/`. Product requirement docs live in `prds/`.
|
||||
@@ -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.
|
||||
@@ -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,6 +1,6 @@
|
||||
# PRD 0003: Bottle Backend abstraction
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-10
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PRD 0004: Split out provisioners
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-11
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PRD 0006: pipelock native TLS interception
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-12
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PRD 0008: Git gate
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-12
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PRD 0009: Remove ssh-gate and bottle.ssh
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-13
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PRD 0011: Per-file Markdown manifest
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-24
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
# PRD 0013: Supervise plane foundation
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-25
|
||||
- **Parent:** PRD 0012
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
# PRD 0018: One Compose project per bottle instance
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-25
|
||||
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
# PRD 0023: smolmachines bottle backend
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-26
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PRD 0024: Consolidate per-bottle sidecars into a single bundle
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-26
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
# PRD 0026: Agent Provider Templates
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** codex
|
||||
- **Created:** 2026-05-28
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user