Skip to content
Merged
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md), [DESIGN.md](DESIGN.md), and
changing this repository. Skill contracts live next to the client:
`agent skills path` (spine, review-loop, pr-review, error-fix).

Static scripts own assignment acceptance, its issue confirmation before the
implementation lane starts, every lane/subagent start, test execution, and all
GitHub communication. Implementers and reviewers never run tests, spawn agents,
or access GitHub themselves. See DESIGN.md §§19.1 and 19.7. Distinguish required
behavior from implemented and verified behavior; never invent evidence.

Models never start monitors, poll status, or wait for CI or other events. Return
results or blockers to the script when there is no more work. The script owns
monitoring and informs a model when an observed event provides useful work.

Installation defaults for GitHub accounts, AI accounts, roles, and selections
are unconfigured (`NULL`). Add them explicitly through configuration, with no
fixed count. See DESIGN.md §19.8 and docs/github-accounts.md for the implemented
GitHub configuration, remaining ambient-`gh` gaps (such as `agent a38`
visibility lookup), and the AI/role configuration gap.

Draft publication is immediate after the first signed task commit; see the
lifecycle. A draft plus local tests is not done. Ready for review is signed
commits on a branch in this repository, grok quality and logic then Codex
Expand Down
16 changes: 15 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,25 @@
- Public repository: English for commits and comments. The visible pull-request summary is an `EN:` block, optionally followed by a labeled `DE:` block.
- Do not name private repositories, internal hostnames, or internal infrastructure.
- Add or update tests in the same change.
- Run `pytest` on the exact clean signed final head before Ready for review. Tests need PostgreSQL (`AGENT_TEST_PG` or a local `initdb`). Full pytest is not a gate for the first draft publication.
- The static script runs `pytest` on the exact clean signed final head before Ready for review. Model lanes never execute tests themselves. Tests need PostgreSQL (`AGENT_TEST_PG` or a local `initdb`). Full pytest is not a gate for the first draft publication.
- Pytest (or any green local suite) is a **check**, not Ready for review and not completion.

## Ready for review

The static script starts all implementation and review lanes, including each
implementation pass that addresses findings. Model lanes never launch subagents
or other lanes and never interact with GitHub. All GitHub reads and writes are
script operations. See [DESIGN.md §§19.1–19.7](DESIGN.md#191-responsibility-split)
for the responsibility split and the required assignment workflow.

CI waiting and all other monitoring belong to the script. A model must not
start a monitor or poll for progress; it returns its result or blocker when its
work is exhausted. The script detects events and informs a lane when useful.

New integrations must preserve the unconfigured (`NULL`) installation default
for accounts, roles, and selections. Configuration is explicit; there is no
fallback identity or fixed account/role count. See DESIGN.md §19.8.

A draft plus local tests is not done. Do not claim the pull request is finished, done, or completed at that point — including after leave-draft. Draft timing and CI ownership while the draft is open are defined in [docs/pull-request-lifecycle.md](docs/pull-request-lifecycle.md).

Ready for review requires all of:
Expand Down
118 changes: 114 additions & 4 deletions DESIGN.md

Large diffs are not rendered by default.

25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,29 @@ This device is the write owner of its own rows. The local store is PostgreSQL on

The [A38 standard](docs/a38.md) defines repository-owned local test requirements and author reports using the existing local-CI format. `agent a38` measures and validates reports; the [dfx pr guard](docs/a38-guard.md) explains repository rules and checks author comments without executing pull-request code.

The required GitHub issue-to-PR workflow is defined in
[DESIGN.md §19.7](DESIGN.md#197-issue-assignment-to-human-merge), together with the
current implementation boundaries. A static script accepts the assignment and
confirms it in the issue before starting the implementation lane. Scripts own
all lane starts, tests, and GitHub communication; model lanes do not start
subagents, run tests, or access GitHub. The user works in GitHub and merges the
reviewed PR there. This is a workflow requirement, not a claim of a complete
deployed integration.

Model lanes perform work and return results or blockers. They never start
monitors, poll statuses, or wait for CI. Scripts own monitoring and inform a
model when an observed event provides useful work; see DESIGN.md §19.1.

Accounts and roles must start unconfigured (`NULL`); see
[DESIGN.md §19.8](DESIGN.md#198-configuration-starts-empty). Configure GitHub
execution accounts and session bindings explicitly in
[`github-accounts.json`](docs/github-accounts.md) for the executors that load
it. There is no default GitHub account or fallback to the host login on those
paths. Some legacy CLI paths still use ambient host `gh` (for example
`agent a38` visibility lookup when `--private` is omitted); that gap is named
in the design and the accounts document. AI-account and role configuration
remain separate implementation work, as recorded in the design.

## Install

```bash
Expand Down Expand Up @@ -115,7 +138,7 @@ The error-fix executor find-or-creates the implement task and isolated worktree;
{ "assigned_repos": ["Owner/repo"], "session_id": "assigned" }
```

Missing or empty `assigned_repos` is an error. `session_id` is optional, defaults to `assigned`, and may contain only `A-Za-z0-9_-`. A session already present under that id must be `kind=runner`. The auto-created runner session attaches `spine`, `review-loop`, and `pr-review` (those skills stay opt-in for every other session). The working directory is `$AGENT_HOME/sessions/<session_id>` unless `AGENT_SESSION_ROOT` is set. The first successful scan records the `assigned_watch_since` watermark and the assigned session id, and creates no activities. Changing `session_id` after that pin is an error. The scan uses the paired GitHub login; a missing pair or a `gh api user` mismatch is an error. Later scans enqueue `issue.assigned` on **that one** runner session, push to the hub, write `MANDATE.md` / `QUEUE.md`, and start Grok only if that session is not already attached. The insert does not notify the knock daemon. There is one terminal; further assignments wait in the knock queue until the supervise script records `issue.assigned.ack` with `payload.assigned_id` set to that activity id. The follow CLI does not ack from pane text. `MANDATE.md` lists session and activity ids. `QUEUE.md` lists ids and urls. Neither file contains issue bodies. Use `--follow` for a 30s loop, or cron for one-shot runs.
Missing or empty `assigned_repos` is an error. `session_id` is optional, defaults to `assigned`, and may contain only `A-Za-z0-9_-`. A session already present under that id must be `kind=runner`. The auto-created runner session attaches `spine`, `review-loop`, and `pr-review` (those skills stay opt-in for every other session). The working directory is `$AGENT_HOME/sessions/<session_id>` unless `AGENT_SESSION_ROOT` is set. The first successful scan records the `assigned_watch_since` watermark and the assigned session id, and creates no activities. Changing `session_id` after that pin is an error. The scan uses the session account from `github-accounts.json`; a missing binding or a `gh api user` mismatch is an error. Hub pairing is separate and still used for sync. Later scans enqueue `issue.assigned` on **that one** runner session, push to the hub, write `MANDATE.md` / `QUEUE.md`, and start Grok only if that session is not already attached. The insert does not notify the knock daemon. There is one terminal; further assignments wait in the knock queue until the supervise script records `issue.assigned.ack` with `payload.assigned_id` set to that activity id. The follow CLI does not ack from pane text. `MANDATE.md` lists session and activity ids. `QUEUE.md` lists ids and urls. Neither file contains issue bodies. Use `--follow` for a 30s loop, or cron for one-shot runs.

### Session terminal control

Expand Down
119 changes: 119 additions & 0 deletions docs/github-accounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# GitHub execution accounts

Static scripts that load this manifest select GitHub accounts from
`$AGENT_HOME/github-accounts.json`. Installation creates no accounts or
bindings. A missing file, `{}`, or null/empty `accounts` and `sessions` leaves
those covered GitHub executors unconfigured. There is no implicit account from
hub pairing, the host login, or environment tokens. CLI paths that never load
this file are outside this enforcement; see the remaining gaps below.

The following is an operator-supplied example, not an installed default:

```json
{
"accounts": {
"worker-a": {
"login": "example-worker-a",
"gh_config_dir": "/absolute/path/to/worker-a/gh",
"git": {
"name": "Example Worker A",
"email": "worker-a@example.com",
"signing_format": "ssh",
"signing_key": "/absolute/path/to/worker-a/signing-key"
}
},
"worker-b": {
"login": "example-worker-b",
"gh_config_dir": "/absolute/path/to/worker-b/gh"
}
},
"sessions": {
"implementation-session": "worker-a",
"review-session": "worker-b"
}
}
```

Add as many named accounts and session bindings as needed; no fixed account list
or count is built in. Each account references its own GitHub CLI configuration
directory. Tokens belong there, not in this manifest, activity payloads, or git.
The optional `git` object supplies the identity and signing configuration for
scripted Git operations; it is required when that account performs a Git push.
Signing formats are Git's `ssh`, `openpgp`, or `x509` values. GitHub operations
without Git writes need only `login` and `gh_config_dir`.

For an account whose credentials and signing key live in a container, an optional
`command_prefix` supplies the static executor argv, for example
`["docker", "exec", "-i", "worker-container"]`. An optional `worktree_paths`
object maps absolute host worktree roots to absolute paths in that executor,
for example `{"/srv/worker/data": "/data"}`. The longest matching root is used
for Git's `-C` argument. Account configuration and signing-key paths refer to
the executor's filesystem. These are trusted operator settings, not commands
from a model or an issue. No prefix or path mapping is installed by default.

The executor runs `gh api user` with that configuration before execution and
requires the returned login to match (case-insensitive). It clears inherited
GitHub token variables for the child process, does not change the process-wide
environment, and does not switch the active account in another configuration
directory. Authentication failure or a mismatch blocks the action; there is no
fallback. Git operations use the selected account's credential helper and
signing configuration. Before supported `fetch` / `push` / `pull` / `clone`
forms, the runner asks Git for the effective remote URL via
`git remote get-url [--push] --all` (which already applies a distinct `pushurl`
and `insteadOf` / `pushInsteadOf` rewrite effects) and rejects
credential-bearing, non-HTTPS, or non-`github.com` network remotes. Explicit
URL arguments are resolved the same way through a temporary command-scoped
remote for fetch, push, and pull. Clone uses the metadata-only
`git ls-remote --get-url` resolution, which applies clone URL rewrites without
requiring an existing local repository or contacting the remote. Before each
supported transfer, all configured `http.*extraHeader` keys, including
repository-specific URL matches, are reset for that invocation so ambient
Authorization headers cannot override the selected account.
Only transfer forms with one explicit repository argument are accepted
(for example `git fetch -- origin`, `git push -- origin HEAD:refs/heads/feature`,
and `git push --set-upstream origin feature`); implicit default-remote forms,
`fetch --all` / `--multiple`, and `--repo` combined with a different positional
repository are refused. Rejection messages never echo URL values. Local Git
metadata commands are unchanged and are not treated as network transfers. Use
HTTPS GitHub remotes; SSH transport and interactive credential prompts are
disabled for this account runner.

`agent github pending` binds each GitHub activity to its session's configured
account. `execution_account` records the account name and expected login. A
retry with a different binding is refused. Reusing an existing PR also requires
its author to match; a PR authored by another account is not silently adopted.

The same session selection applies to assignment scans, PR-merge scans,
supervised issue reads, and the `pushed`/`mergeable` steps of `agent run`.
The `mergeable` step passes an explicit `--repo` together with a PR number or
branch selector: when the task already records the pull-request target
(`repo` + numeric `ref`), that pair is used (fork targets may differ from
origin) and Git signing identity is not required; otherwise the branch comes
from mapped `git -C` and the repo from the validated origin remote. Either path
keeps container-backed accounts independent of the executor's default working
directory. Account selection is script configuration, never an instruction
taken from an issue body or a model's activity payload. Configure existing
sessions explicitly before enabling these operations after upgrading.

Hub pairing and event ownership are separate: using a second GitHub execution
account does not re-pair the device or change ownership of its rows.

**Remaining gaps.** This manifest does not cover legacy ambient `gh` paths that
never load it. One reachable example is `agent a38` visibility lookup
(`gh repo view` when `--private` is omitted), which still uses the host `gh`
login. Configurable AI accounts and roles are part of the
[empty-default requirement](../DESIGN.md#198-configuration-starts-empty) and are
not implemented by this manifest.

Transfer options are deliberately limited to the explicit allowlists in
`github_accounts.py`. Unknown options (including custom receive/upload programs),
implicit or multiple repositories, and per-command global configuration/context
overrides are rejected rather than guessed. A transfer may use one mapped `-C`
working directory; validation and execution use that same directory. Automatic
submodule transfers are disabled so a validated parent remote does not authorize
another remote. Other Git commands are not a sandboxed command interface; only
trusted static scripts may supply executor argv.

Every effective URL returned for a named remote must identify the same GitHub
owner/repository, case-insensitively, including every additional push URL.
Fetch and push URL lists must also agree before a transfer is allowed.
56 changes: 48 additions & 8 deletions src/agent_cli/git_act.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
PROTECTED = frozenset({"develop", "main", "master"})

_SHA_RE = re.compile(r"^[0-9a-fA-F]{7,40}$")
_REPO_NAME = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")


class GitActError(Exception):
Expand Down Expand Up @@ -105,13 +106,52 @@ def push_branch(*, cwd: str, runner: Runner) -> str:


def measure_mergeable(
*, cwd: str, runner: Runner, expected_head: str | None = None
*,
cwd: str,
runner: Runner,
expected_head: str | None = None,
repo: str | None = None,
number: int | None = None,
) -> str:
"""Return a short evidence string when the current branch PR is MERGEABLE
and every GitHub check is SUCCESS (or there are no checks). Else raise GitActError."""
_ = cwd # gh argv has no -C; cwd is inherited from _exec_argv
and every GitHub check is SUCCESS (or there are no checks). Else raise GitActError.

Prefer an explicit task ``repo`` + PR ``number`` (fork target may differ from
origin). Otherwise derive the branch via mapped ``git -C`` and a validated
origin remote. Every ``gh`` call gets both ``--repo`` and a PR selector so
container-backed accounts do not depend on executor cwd.
"""
from .github_accounts import GitHubHttpsRemoteError, validate_repo_remote

selector: str
target_repo: str
if repo is not None or number is not None:
if repo is None or number is None:
raise GitActError("pull request repo and number must be provided together")
if not isinstance(repo, str) or _REPO_NAME.fullmatch(repo) is None:
raise GitActError("invalid pull request repository")
if isinstance(number, bool) or not isinstance(number, int) or number <= 0:
raise GitActError("invalid pull request number")
target_repo = repo
selector = str(number)
else:
try:
target_repo = validate_repo_remote(runner, cwd, "origin")
except GitHubHttpsRemoteError as exc:
raise GitActError(str(exc)) from exc
completed = runner(_git(cwd, "rev-parse", "--abbrev-ref", "HEAD"))
if completed.returncode != 0:
raise GitActError(_fail_detail(completed, "git failed"))
branch = completed.stdout.strip()
if not branch or branch == "HEAD":
raise GitActError("empty branch name")
selector = branch

completed = runner(
["gh", "pr", "view", "--json", "mergeable,state,url,number,headRefOid"]
[
"gh", "pr", "view", selector, "--repo", target_repo,
"--json", "mergeable,state,url,number,headRefOid",
]
)
if completed.returncode != 0:
raise GitActError(_fail_detail(completed, "gh failed"))
Expand All @@ -129,8 +169,8 @@ def measure_mergeable(
state_ok = isinstance(state, str) and state.upper() == "OPEN"
if mergeable != "MERGEABLE" or not state_ok:
raise GitActError(f"mergeable={mergeable!r} state={state!r}")
number = view.get("number")
if isinstance(number, bool) or not isinstance(number, int) or number <= 0:
view_number = view.get("number")
if isinstance(view_number, bool) or not isinstance(view_number, int) or view_number <= 0:
raise GitActError("pr view missing number")
oid = view.get("headRefOid")
if not isinstance(oid, str) or oid == "":
Expand All @@ -142,7 +182,7 @@ def measure_mergeable(
raise GitActError(f"pr head {oid} does not match {want}")

completed = runner(
["gh", "pr", "checks", str(number), "--json", "name,state"]
["gh", "pr", "checks", str(view_number), "--repo", target_repo, "--json", "name,state"]
)
if completed.returncode != 0:
raise GitActError(_fail_detail(completed, "gh failed"))
Expand All @@ -165,4 +205,4 @@ def measure_mergeable(
if str(check_state or "").upper() != "SUCCESS":
raise GitActError(f"check {name} is {check_state}")

return f"mergeable number={number} checks=ok"
return f"mergeable number={view_number} checks=ok"
Loading
Loading