Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 66 additions & 8 deletions README.md

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Operating guide for agents

This is the short version of how a coding agent should behave in a repository governed by QuorumGit. The [README](../README.md) is the reference; this page is the checklist.

## Identity

- Always act as your own registered agent: set `QUORUMGIT_AGENT` (or pass `--agent`) once per session and never change it to another agent's name. Identity is asserted, not authenticated — QuorumGit trusts you to be who you say you are.
- Never vote on an approval you requested, benefit from, or intend to carry out. The repository's policy refuses most of these; don't go looking for the ones it doesn't.

## The work loop

1. **Find work.** `quorumgit task list --repo <repo>`; pick an `open` task.
2. **Claim it with honest scopes.** `quorumgit claim <task> --branch <branch> --scope '<glob>'…`. Declare every path you expect to touch. In a hub deployment the hook compares every push with these scopes, so a scope that is too narrow turns routine pushes into approval requests, and one that is too broad blocks other agents (`OVERLAPPING`). Never use `--override-overlap` to get around another agent's live claim without being told to.
3. **Work only in your checkout.** Local model: the worktree the claim printed. Hub model: your own clone, with the claim made `--no-worktree`.
4. **Checkpoint** at meaningful, verified points: `quorumgit checkpoint <claim> --note '…'` (hub model: `--commit <oid>` of a pushed commit).
5. **Renew before the lease runs out** (`quorumgit renew <claim>`). An expired lease cannot be renewed; the task becomes claimable by anyone.
6. **Finish with a trail.** Hand off with what was done, what remains, blockers, and how it was validated (`quorumgit handoff create …`), or `release` if nobody needs to continue. Never simply stop.

## Pushing (hub model)

- **Plan before you push.** `quorumgit approve prepare --repo <repo> --ref <branch>` shows the paths your push changes and every approval it needs, with the exact hashes the hook will demand. If it says `approvals required: none`, push.
- If approvals are needed, first ask whether the push should be different: changes outside your scopes usually mean the work belongs to another task, or your claim should have declared more. Only when the change is right, run `prepare … --request` and wait for an eligible reviewer or operator to vote.
- Fetch before planning. A plan is bound to the hub's current ref value; if the hub moves, re-plan.
- Never force-push, delete refs, or push to protected refs as a workaround. Each needs its own approval, and each approval authorizes one exact update once.
- A rejected push is information, not an obstacle: read the reason and act on it. Don't retry the same push hoping for a different answer.

## Secrets

QuorumGit's records are deliberately permanent. The audit table is append-only (enforced by a database trigger), and handoff notes, checkpoint notes, approval operations, and task text are kept for good. Anything you write there cannot be taken back.

- Never put credentials, tokens, private keys, connection strings, or personal data in task titles/objectives, checkpoint notes, handoff fields (`--completed`, `--remaining`, `--blockers`, `--validation`), or approval payloads. Describe *where* a secret lives ("the deploy key in the CI secret store"), never its value.
- Never commit secrets. A committed secret reaches every clone and every later reader of the hub; removing it later needs history rewriting, which is itself a governed, approval-gated force push. If you do commit one, stop, tell a human, and treat the secret as compromised — rotating it matters more than rewriting history.
- Ask your operator to protect secret-adjacent paths (`quorumgit repo protect-path <repo> '<glob>'`), for example deployment configuration or CI workflow files, so that changes there always get a second pair of eyes.
- Don't paste command output containing environment variables or configuration into handoffs without reading it first.
142 changes: 140 additions & 2 deletions src/quorumgit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,18 @@
import json
import sys

from . import __version__, audit, config, gate, handoff, registry, store, trees, work
from . import (
__version__,
audit,
config,
gate,
git_objects,
handoff,
registry,
store,
trees,
work,
)


def _agent(args, cfg) -> str:
Expand Down Expand Up @@ -104,7 +115,11 @@ def cmd_doctor(args, cfg) -> int:
def cmd_repo_add(args, cfg) -> int:
with store.session(cfg) as conn:
repo_id = registry.add_repository(
conn, args.name, args.path, protected_refs=args.protected_ref
conn,
args.name,
args.path,
protected_refs=args.protected_ref,
protected_paths=args.protected_path,
)
conn.commit()
print(f"repository {args.name} registered (id {repo_id}).")
Expand Down Expand Up @@ -143,6 +158,39 @@ def cmd_repo_policy(args, cfg) -> int:
print(f"approval threshold: {policy['threshold']}")
print(f"approving roles: {', '.join(policy['roles'])}")
print(f"requester may vote: {'yes' if policy['requester_may_vote'] else 'no'}")
with store.session(cfg) as conn:
repo = registry.get_repository(conn, args.name)
print(f"protected refs: {', '.join(repo['protected_refs']) or '-'}")
print(f"protected paths: {', '.join(repo['protected_paths']) or '-'}")
print(f"allowed ref namespaces: {', '.join(repo['allowed_ref_namespaces']) or '-'}")
return 0


def cmd_repo_protect_path(args, cfg) -> int:
with store.session(cfg) as conn:
globs = registry.set_protected_path(
conn,
args.name,
args.glob,
actor=getattr(args, "agent", None) or cfg.agent,
remove=args.remove,
)
conn.commit()
print(f"protected paths for {args.name}: {', '.join(globs) or '-'}")
return 0


def cmd_repo_allow_ref(args, cfg) -> int:
with store.session(cfg) as conn:
prefixes = registry.set_allowed_ref_namespace(
conn,
args.name,
args.prefix,
actor=getattr(args, "agent", None) or cfg.agent,
remove=args.remove,
)
conn.commit()
print(f"allowed ref namespaces for {args.name}: {', '.join(prefixes) or '-'}")
return 0


Expand Down Expand Up @@ -451,6 +499,66 @@ def cmd_approve_vote(args, cfg) -> int:
return 0


def cmd_approve_prepare(args, cfg) -> int:
pusher = getattr(args, "agent", None) or cfg.agent
if args.request and not pusher:
raise SystemExit(
"Agent identity required to --request: pass --agent or set "
"QUORUMGIT_AGENT."
)
with store.session(cfg) as conn:
plan = gate.prepare_push(
conn,
args.repo,
args.ref,
args.clone,
None if args.delete else args.new,
pusher=pusher,
)
if args.request and not plan["refusals"]:
for entry in plan["operations"]:
approval = gate.request_approval(
conn, entry["operation"], requested_by=pusher
)
entry["approval"] = {
key: approval[key] for key in ("id", "status", "threshold")
}
conn.commit()
if args.json:
print(json.dumps(plan, indent=2, sort_keys=True))
return 1 if plan["refusals"] else 0

print(f"repository: {plan['repository']}")
print(f"refname: {plan['refname']}")
print(f"oldrev: {plan['oldrev']}")
print(f"newrev: {plan['newrev']}")
claim = plan["claim"]
if claim is not None:
print(f"claim: {claim['id']} by {claim['agent']} "
f"(scopes: {', '.join(claim['scopes'])})")
print(f"changed paths ({len(plan['paths'])}):")
for path in plan["paths"]:
print(f" {path}")
for refusal in plan["refusals"]:
print(f"REFUSED: {refusal}")
if not plan["operations"]:
print("approvals required: none")
else:
print(f"approvals required ({len(plan['operations'])}):")
for entry in plan["operations"]:
operation = entry["operation"]
print(f" {operation['type']} {entry['hash']}")
for path in operation.get("paths", []):
print(f" {path}")
if "approval" in entry:
approval = entry["approval"]
print(f" approval {approval['id']} status={approval['status']} "
f"threshold={approval['threshold']}")
if plan["operations"] and not args.request and not plan["refusals"]:
print("re-run with --request to open these approvals.")
return 1 if plan["refusals"] else 0


def cmd_approve_hash(args, cfg) -> int:
print(gate.operation_hash(_operation_from_args(args)))
return 0
Expand Down Expand Up @@ -512,6 +620,8 @@ def add(name, fn, configure=None, parent=None):
sp.add_argument("name"),
sp.add_argument("path"),
sp.add_argument("--protected-ref", action="append", default=[]),
sp.add_argument("--protected-path", action="append", default=[],
help="path glob whose changes always need an approval"),
), parent=repo)
add("list", cmd_repo_list, parent=repo)
add("policy", cmd_repo_policy, lambda sp: (
Expand All @@ -526,6 +636,18 @@ def add(name, fn, configure=None, parent=None):
sp.add_argument("--requester-may-vote", dest="requester_may_vote",
action=argparse.BooleanOptionalAction, default=None),
), parent=repo)
add("protect-path", cmd_repo_protect_path, lambda sp: (
sp.add_argument("name"),
sp.add_argument("glob"),
sp.add_argument("--remove", action="store_true"),
sp.add_argument("--agent", help="operator making the change"),
), parent=repo)
add("allow-ref", cmd_repo_allow_ref, lambda sp: (
sp.add_argument("name"),
sp.add_argument("prefix", help="ref namespace such as refs/tags/"),
sp.add_argument("--remove", action="store_true"),
sp.add_argument("--agent", help="operator making the change"),
), parent=repo)

agent = sub.add_parser("agent").add_subparsers(dest="sub", required=True)
add("add", cmd_agent_add, lambda sp: (
Expand Down Expand Up @@ -632,6 +754,21 @@ def add(name, fn, configure=None, parent=None):
sp.add_argument("--agent"),
sp.add_argument("--deny", action="store_true"),
), parent=ap)
add("prepare", cmd_approve_prepare, lambda sp: (
sp.add_argument("--repo", required=True),
sp.add_argument("--ref", required=True,
help="ref to push (a bare name means refs/heads/<name>)"),
sp.add_argument("--new", default="HEAD",
help="revision in your clone to push (default HEAD)"),
sp.add_argument("--delete", action="store_true",
help="plan deleting the ref instead"),
sp.add_argument("-C", dest="clone", default=".",
help="your clone (default: current directory)"),
sp.add_argument("--request", action="store_true",
help="open an approval for every required operation"),
sp.add_argument("--json", action="store_true"),
sp.add_argument("--agent"),
), parent=ap)
add("hash", cmd_approve_hash,
lambda sp: sp.add_argument("operation"), parent=ap)

Expand Down Expand Up @@ -662,6 +799,7 @@ def main(argv: list[str] | None = None) -> int:
gate.GateError,
handoff.HandoffError,
config.ConfigError,
git_objects.GitObjectError,
) as exc:
print(f"[quorumgit] ERROR: {exc}", file=sys.stderr)
return 1
Expand Down
Loading
Loading