Compare commits
25 Commits
burgundians
...
reviews-2
| Author | SHA1 | Date | |
|---|---|---|---|
| ca02107d42 | |||
| a229d0ce79 | |||
| 8e6f86c23b | |||
| c9e1c68ea2 | |||
| 3f0a8a1d6f | |||
| e818cfea8b | |||
| d6265b0fbe | |||
| 0900e5fea1 | |||
| 37cdfd2c1f | |||
| 8313842de8 | |||
| c7becda233 | |||
| d49d4e140e | |||
| ca790b19c5 | |||
| b735c16b30 | |||
| febf397885 | |||
| 59e47308bd | |||
| d38e207b50 | |||
| ca0021eca9 | |||
| 851f7f7fbe | |||
| 777ee3e72d | |||
| 2be68476f1 | |||
| f61371c74a | |||
| dba8416192 | |||
| f29d170702 | |||
| e627f53934 |
@@ -96,6 +96,9 @@ Needs `loginctl enable-linger` so the timer runs while logged out. Logs are in `
|
||||
`bin/hourlog-session.sh`, which opens an Agent of Empires session on a scratch
|
||||
dir, sends it `/hourlog --week this`, and pushes an ntfy notification.
|
||||
|
||||
It runs on sonnet — reading session logs into a table is not opus work —
|
||||
overridable with `HOURLOG_MODEL`, or empty for the harness default.
|
||||
|
||||
Same shape as the weekly review and interactive for the same reason: the skill
|
||||
proposes hours and stops for approval before writing anything to the timesheet.
|
||||
An unattended run would be deciding a company record on your behalf. It skips
|
||||
@@ -153,8 +156,17 @@ run and sets a later epoch, which filters more, never less.
|
||||
| PR | skill | session |
|
||||
|----|-------|---------|
|
||||
| yours | `land` | default profile, `--yolo --trust-hooks` |
|
||||
| yours, with `selfReview` | both | plus a reviewer on a different agent |
|
||||
| someone else's | `review-pr` | `review` profile, no yolo, no trusted hooks, sandboxed |
|
||||
| github, review requested from you | `review-pr` | `review` profile, no yolo, no trusted hooks, sandboxed |
|
||||
| yours on github, review requested, with `selfReview` | both | plus a reviewer on a different agent |
|
||||
|
||||
**A reviewer needs an explicit request.** Two conditions, both required: the
|
||||
forge is github, and one of your logins sits in the PR's `requested_reviewers`.
|
||||
Gitea never spawns one, and a merely non-draft PR doesn't either. An audit of 47
|
||||
closed PRs is where that came from — roughly a third of the findings paid for
|
||||
themselves and nearly all of those were daemon and core changes, while small
|
||||
PRs reviewed clean often enough that the reviewing cost bought nothing. Github
|
||||
won't let you request a review from a PR's own author, so `selfReview` now only
|
||||
fires when another of your logins opened the PR.
|
||||
|
||||
Both roles can run on one PR because the role is carried by the worktree
|
||||
branch: the author side works on the head branch, the reviewer on a local
|
||||
@@ -163,12 +175,36 @@ orphan either of them. The reviewer only hears about new commits and the PR
|
||||
closing — replying to threads is the author's job, so comments aren't routed to
|
||||
it.
|
||||
|
||||
### The agent roster
|
||||
|
||||
`agents` is one roster of harness+model combos for everything in this repo that
|
||||
spawns a session — the reviewer rotation and blitz's worker sessions — so a
|
||||
model added once is available to both. Each entry names a harness and whatever
|
||||
flags pin its model and effort; consumers pass `args` through `--extra-args`
|
||||
and know nothing about what they mean. Two fields say who may pick an entry:
|
||||
|
||||
- `roles` — `review` for the reviewer rotation, `blitz` for milestone workers.
|
||||
Absent means both, which is the useful default for a general-purpose combo.
|
||||
- `tiers` — blitz's difficulty routing (`execution`, `design`, `subtle`), and
|
||||
meaningless to the daemon. An entry with no `tiers` is never auto-routed by
|
||||
blitz, though the operator can still name it in an invocation.
|
||||
|
||||
Markdown skills query it through `scripts/roster.sh` (linked to
|
||||
`~/.claude/scripts/roster.sh`) rather than parsing the config themselves:
|
||||
|
||||
```sh
|
||||
~/.claude/scripts/roster.sh --role blitz --tier execution --format aoe
|
||||
# --tool claude --extra-args "--model sonnet"
|
||||
```
|
||||
|
||||
The key was `reviewers` when only the daemon read it; that name is still
|
||||
accepted. A machine-wide `blitz` block (`maxSessions`, `notifyService`) lives
|
||||
here too, overridden per repo by `.claude/tracker.json`.
|
||||
|
||||
### Reviewer rotation
|
||||
|
||||
`selfReview` exists so a PR is never reviewed by the agent that wrote it. The
|
||||
`reviewers` list is the rotation pool, each entry naming a harness and whatever
|
||||
flags pin its model and effort; the daemon passes `args` through `--extra-args`
|
||||
and knows nothing about what they mean. Effort is per-harness — `--effort` on
|
||||
rotation pool is every `agents` entry whose `roles` include `review`. Effort is per-harness — `--effort` on
|
||||
claude, a `:high` suffix on pi's model pattern, and nothing usable on opencode,
|
||||
whose `--variant` exists only under `opencode run`.
|
||||
|
||||
@@ -207,7 +243,7 @@ gated — no prompt, no approval, and a boundary the session cannot argue with:
|
||||
| no prompts | `defaultMode: dontAsk` — a denial goes to the agent, not to you | `--ask-for-approval never` |
|
||||
| writes | sandbox `allowWrite`: the worktree and `<main>/.git/worktrees` | `--sandbox workspace-write --add-dir <main>/.git/worktrees` |
|
||||
| network | sandbox allowlist: the configured forge API hosts only | full egress (codex has no per-domain list) |
|
||||
| reads | everything except `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.env`, `~/.env.claude`, `~/.config/reviewer` and the two agent credential files | same list, as sandbox `denyRead` |
|
||||
| reads | everything except `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.env`, `~/.env.claude`, `~/.config/agent-skills`, `~/.config/reviewer` and the two agent credential files | same list, as sandbox `denyRead` |
|
||||
| project config | no `--trust-hooks` | `trust_level = "untrusted"`, which also answers codex's trust prompt without granting it |
|
||||
|
||||
The grants are generated per repo in `sandboxArgs` — a settings file under
|
||||
@@ -234,8 +270,10 @@ default list — but it carries no settings of its own.
|
||||
|
||||
### Setup
|
||||
|
||||
Config from `bin/reviewer-config.example.json` to `~/.config/reviewer/config.json`.
|
||||
Secrets in `~/.config/reviewer/env`, never here:
|
||||
Config from `bin/agents-config.example.json` to `~/.config/agent-skills/config.json`
|
||||
(`~/.config/reviewer/config.json` still works — the daemon reads whichever
|
||||
exists, so an old box migrates with a `mv`). Secrets in `env` next to it, never
|
||||
here:
|
||||
|
||||
```sh
|
||||
REVIEWER_GITEA_TOKEN=... # read-only
|
||||
@@ -329,7 +367,7 @@ Vendored skills are excluded from all of it.
|
||||
| `land` | drive a PR **you authored** to green + ready-to-merge; user clicks merge |
|
||||
| `review-pr` | review a PR **someone else authored**; findings only, never pushes, never runs the branch's code |
|
||||
| `pr-common` | shared PR-loop mechanics: hint format, seen file, state file, forge resolution (dependency of land/review-pr) |
|
||||
| `blitz` | drive a whole milestone to done |
|
||||
| `blitz` | drive a whole milestone to done; keeps model feedback in `~/.local/state/agent-skills/models.md` |
|
||||
| `nightshift` | hours-long unattended build; architect delegating to subagents, backs off before the 5h limit |
|
||||
| `tracker-common` | shared GitHub/Gitea/Linear tracker config, worktree conventions, and local verification budget (dependency of work/yolo/blitz/nightshift) |
|
||||
| `week-review` | review the past week's sessions for recurring friction; reads open issues here as carry-over |
|
||||
@@ -337,6 +375,19 @@ Vendored skills are excluded from all of it.
|
||||
| `intercomms` | find and talk to other agent sessions on this machine via `aoe`; discovery is a query, nothing is tracked |
|
||||
| `improve-codebase-architecture` | misc |
|
||||
|
||||
### Model notes
|
||||
|
||||
Blitz reads `~/.local/state/agent-skills/models.md` before routing issues to
|
||||
worker sessions and rewrites it before the run ends. It is a compiled summary —
|
||||
difficulty tiers, task fit, cost effectiveness, caveats — capped at ~60 lines
|
||||
and edited in place, never appended to, so the next run reads a current belief
|
||||
instead of a log. `skills/blitz/AOE-WORKERS.md` holds the rules.
|
||||
|
||||
Machine-local on purpose: the skill dirs are read-only nix store paths here, and
|
||||
the notes describe runs on this box. The roster it draws models from is the PR
|
||||
shared `agents` roster in `~/.config/agent-skills/config.json`; changes to the
|
||||
roster go through `week-review`, not blitz.
|
||||
|
||||
## Vendored skills
|
||||
|
||||
`humanizer` and `impeccable` are third-party and copied in, not written here.
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"pollSeconds": 60,
|
||||
"reconcileSeconds": 120,
|
||||
"maxSessionsPerTick": 2,
|
||||
"reviewProfile": "review",
|
||||
"group": "pr",
|
||||
"webhookPort": 7474,
|
||||
"notifyWaiting": true,
|
||||
"ledger": "/home/you/.local/state/reviewer/reviewers.jsonl",
|
||||
"pathRoots": [
|
||||
"/home/you/code",
|
||||
"/home/you/work"
|
||||
],
|
||||
"forges": {
|
||||
"gitea": {
|
||||
"api": "https://git.example.com/api/v1",
|
||||
"tokenEnv": "REVIEWER_GITEA_TOKEN",
|
||||
"reviewTokenEnv": "REVIEWER_GITEA_REVIEW_TOKEN",
|
||||
"webhookSecretEnv": "REVIEWER_GITEA_SECRET",
|
||||
"self": [
|
||||
"you",
|
||||
"you-bot"
|
||||
]
|
||||
},
|
||||
"github": {
|
||||
"api": "https://api.github.com",
|
||||
"tokenEnv": "REVIEWER_GITHUB_TOKEN",
|
||||
"webhookSecretEnv": "REVIEWER_GITHUB_SECRET",
|
||||
"self": "you"
|
||||
}
|
||||
},
|
||||
"repos": [
|
||||
{
|
||||
"forge": "gitea",
|
||||
"repo": "*",
|
||||
"mode": "drive",
|
||||
"tool": "claude",
|
||||
"selfReview": true
|
||||
},
|
||||
{
|
||||
"forge": "github",
|
||||
"repo": "acme/webapp",
|
||||
"mode": "review"
|
||||
}
|
||||
],
|
||||
"agents": [
|
||||
{
|
||||
"id": "claude/sonnet@med",
|
||||
"tool": "claude",
|
||||
"args": [
|
||||
"--model",
|
||||
"sonnet"
|
||||
],
|
||||
"roles": [
|
||||
"blitz"
|
||||
],
|
||||
"tiers": [
|
||||
"execution"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "claude/opus@med",
|
||||
"tool": "claude",
|
||||
"args": [
|
||||
"--model",
|
||||
"opus",
|
||||
"--effort",
|
||||
"medium"
|
||||
],
|
||||
"tiers": [
|
||||
"design"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "claude/opus@high",
|
||||
"tool": "claude",
|
||||
"args": [
|
||||
"--model",
|
||||
"opus",
|
||||
"--effort",
|
||||
"high"
|
||||
],
|
||||
"tiers": [
|
||||
"design",
|
||||
"subtle"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pi/gpt5.6@high",
|
||||
"tool": "pi",
|
||||
"args": [
|
||||
"--model",
|
||||
"openai-codex/gpt-5.6-sol:high"
|
||||
],
|
||||
"tiers": [
|
||||
"design",
|
||||
"subtle"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pi/gpt5.6@med",
|
||||
"tool": "pi",
|
||||
"args": [
|
||||
"--model",
|
||||
"openai-codex/gpt-5.6-sol:medium"
|
||||
],
|
||||
"tiers": [
|
||||
"execution",
|
||||
"design"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "claude/fable@high",
|
||||
"tool": "claude",
|
||||
"args": [
|
||||
"--model",
|
||||
"fable",
|
||||
"--effort",
|
||||
"high"
|
||||
],
|
||||
"tiers": [
|
||||
"subtle"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "oc/gpt5.6",
|
||||
"tool": "opencode",
|
||||
"args": [
|
||||
"--model",
|
||||
"openai/gpt-5.6-sol"
|
||||
],
|
||||
"roles": [
|
||||
"review"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "codex/gpt5.6@med",
|
||||
"tool": "codex",
|
||||
"args": [
|
||||
"-c",
|
||||
"model_reasoning_effort=medium"
|
||||
],
|
||||
"roles": [
|
||||
"review",
|
||||
"blitz"
|
||||
],
|
||||
"tiers": [
|
||||
"execution",
|
||||
"design"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "codex/gpt5.6@high",
|
||||
"tool": "codex",
|
||||
"args": [
|
||||
"-c",
|
||||
"model_reasoning_effort=high"
|
||||
],
|
||||
"roles": [
|
||||
"review",
|
||||
"blitz"
|
||||
],
|
||||
"tiers": [
|
||||
"design",
|
||||
"subtle"
|
||||
]
|
||||
}
|
||||
],
|
||||
"blitz": {
|
||||
"maxSessions": 2,
|
||||
"notifyService": "mobile_app_pixel_7_naps"
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -6,9 +6,17 @@ set -euo pipefail
|
||||
|
||||
PROMPT="${HOURLOG_PROMPT:-/hourlog --week this}"
|
||||
TOPIC="${HOURLOG_NTFY_TOPIC:-homelab}"
|
||||
AOE="${HOURLOG_AOE:-$HOME/.local/bin/aoe}"
|
||||
AOE="${HOURLOG_AOE:-$(command -v aoe || echo "$HOME/.nix-profile/bin/aoe")}"
|
||||
LOG="$HOME/.local/state/hourlog/run.log"
|
||||
|
||||
# Which model reads the week. Sonnet by default: the work is reading session
|
||||
# logs and filling a table, and it held up on the first run. The value goes
|
||||
# straight to the agent binary, so it has to be a name that binary knows
|
||||
# (`sonnet`, `opus` for claude); set it empty to take the harness default.
|
||||
MODEL="${HOURLOG_MODEL-sonnet}"
|
||||
extra=()
|
||||
[ -n "$MODEL" ] && extra=(--extra-args "--model $MODEL")
|
||||
|
||||
WEEK="$(date +%G-W%V)"
|
||||
TITLE="hourlog-$WEEK"
|
||||
|
||||
@@ -46,7 +54,7 @@ fi
|
||||
|
||||
# --scratch keeps the session's cwd under the agent-of-empires app dir, which
|
||||
# the hourlog config excludes — otherwise it lands in next week's scan.
|
||||
"$AOE" add --scratch --title "$TITLE" --cmd claude --yolo --trust-hooks
|
||||
"$AOE" add --scratch --title "$TITLE" --cmd claude --yolo --trust-hooks "${extra[@]}"
|
||||
"$AOE" session start "$TITLE"
|
||||
|
||||
# The agent needs its TUI up before it can take a prompt; `send` into a
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"pollSeconds": 60,
|
||||
"reconcileSeconds": 120,
|
||||
"maxSessionsPerTick": 2,
|
||||
"reviewProfile": "review",
|
||||
"group": "pr",
|
||||
"webhookPort": 7474,
|
||||
"notifyWaiting": true,
|
||||
"ledger": "/home/you/.local/state/reviewer/reviewers.jsonl",
|
||||
|
||||
"pathRoots": ["/home/you/code", "/home/you/work"],
|
||||
|
||||
"forges": {
|
||||
"gitea": {
|
||||
"api": "https://git.example.com/api/v1",
|
||||
"tokenEnv": "REVIEWER_GITEA_TOKEN",
|
||||
"reviewTokenEnv": "REVIEWER_GITEA_REVIEW_TOKEN",
|
||||
"webhookSecretEnv": "REVIEWER_GITEA_SECRET",
|
||||
"self": ["you", "you-bot"]
|
||||
},
|
||||
"github": {
|
||||
"api": "https://api.github.com",
|
||||
"tokenEnv": "REVIEWER_GITHUB_TOKEN",
|
||||
"webhookSecretEnv": "REVIEWER_GITHUB_SECRET",
|
||||
"self": "you"
|
||||
}
|
||||
},
|
||||
|
||||
"repos": [
|
||||
{
|
||||
"forge": "gitea",
|
||||
"repo": "*",
|
||||
"mode": "drive",
|
||||
"tool": "claude",
|
||||
"selfReview": true
|
||||
},
|
||||
{
|
||||
"forge": "github",
|
||||
"repo": "acme/webapp",
|
||||
"mode": "review"
|
||||
}
|
||||
],
|
||||
|
||||
"reviewers": [
|
||||
{ "id": "claude/opus@med", "tool": "claude", "args": ["--model", "opus", "--effort", "medium"] },
|
||||
{ "id": "claude/opus@high", "tool": "claude", "args": ["--model", "opus", "--effort", "high"] },
|
||||
{ "id": "pi/gpt5.6@high", "tool": "pi", "args": ["--model", "openai-codex/gpt-5.6-sol:high"] },
|
||||
{ "id": "pi/gpt5.6@med", "tool": "pi", "args": ["--model", "openai-codex/gpt-5.6-sol:medium"] },
|
||||
{ "id": "claude/fable@high", "tool": "claude", "args": ["--model", "fable", "--effort", "high"] },
|
||||
{ "id": "oc/gpt5.6", "tool": "opencode", "args": ["--model", "openai/gpt-5.6-sol"] },
|
||||
{ "id": "codex/gpt5.6@high", "tool": "codex", "enabled": false, "args": ["-c", "model_reasoning_effort=high"] }
|
||||
]
|
||||
}
|
||||
+58
-15
@@ -8,7 +8,16 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, write
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const CONFIG_PATH = process.env.REVIEWER_CONFIG ?? join(homedir(), ".config/reviewer/config.json");
|
||||
// One config feeds every agent-spawning thing in this repo -- this daemon and
|
||||
// the blitz skill -- so it is no longer reviewer-specific. The reviewer path
|
||||
// stays readable, which makes the move a `mv` and not a migration.
|
||||
const CONFIG_CANDIDATES = [
|
||||
process.env.AGENTS_CONFIG,
|
||||
process.env.REVIEWER_CONFIG,
|
||||
join(homedir(), ".config/agent-skills/config.json"),
|
||||
join(homedir(), ".config/reviewer/config.json"),
|
||||
].filter(Boolean) as string[];
|
||||
const CONFIG_PATH = CONFIG_CANDIDATES.find((p) => existsSync(p)) ?? CONFIG_CANDIDATES.at(-1)!;
|
||||
const EPOCH_PATH = process.env.REVIEWER_EPOCH ?? join(homedir(), ".local/state/reviewer/epoch");
|
||||
|
||||
type Mode = "drive" | "review";
|
||||
@@ -22,9 +31,13 @@ type RepoConfig = {
|
||||
selfReview?: boolean; // also spawn an outside reviewer on your own PRs
|
||||
};
|
||||
|
||||
// One reviewer combination: harness plus whatever flags pin its model and
|
||||
// effort. The daemon passes args through verbatim and knows nothing about them.
|
||||
type Reviewer = { id: string; tool: string; args?: string[]; enabled?: boolean };
|
||||
// One agent combination: harness plus whatever flags pin its model and effort.
|
||||
// The daemon passes args through verbatim and knows nothing about them.
|
||||
// `roles` is who may pick the entry -- "review" is this rotation, "blitz" is
|
||||
// milestone worker sessions -- and absent means both. `tiers` is blitz's
|
||||
// difficulty routing and carries no meaning here.
|
||||
type Agent = { id: string; tool: string; args?: string[]; enabled?: boolean; roles?: string[]; tiers?: string[] };
|
||||
const ROLES_DEFAULT = ["review", "blitz"];
|
||||
|
||||
type Config = {
|
||||
pollSeconds?: number;
|
||||
@@ -36,7 +49,8 @@ type Config = {
|
||||
webhookPort?: number;
|
||||
notifyWaiting?: boolean;
|
||||
pathRoots?: string[]; // scanned one level deep to find clones by origin URL
|
||||
reviewers?: Reviewer[]; // rotation pool for review sessions
|
||||
agents?: Agent[]; // shared roster: review rotation + blitz worker routing
|
||||
reviewers?: Agent[]; // legacy name for `agents`, still read
|
||||
ledger?: string; // append-only record of which reviewer got which PR
|
||||
forges: Record<string, {
|
||||
api: string;
|
||||
@@ -72,6 +86,7 @@ type Pr = Snapshot & {
|
||||
author: string;
|
||||
createdAt: string;
|
||||
url: string;
|
||||
requestedReviewers: string[];
|
||||
cfg: RepoConfig;
|
||||
};
|
||||
|
||||
@@ -213,6 +228,13 @@ async function repos(): Promise<RepoConfig[]> {
|
||||
return out.filter((r) => r.path && existsSync(r.path));
|
||||
}
|
||||
|
||||
// Who the PR is currently asking for a review. GitHub clears the entry once
|
||||
// that reviewer submits, which is fine: by then the session exists and routes
|
||||
// by branch.
|
||||
function reviewerLogins(p: any): string[] {
|
||||
return (p.requested_reviewers ?? []).map((r: any) => r?.login).filter(Boolean);
|
||||
}
|
||||
|
||||
// The list endpoints carry everything except mergeable and the comment counts,
|
||||
// so the detail call happens only for PRs that already look changed.
|
||||
async function listPrs(cfg: RepoConfig): Promise<Pr[]> {
|
||||
@@ -234,6 +256,7 @@ async function listPrs(cfg: RepoConfig): Promise<Pr[]> {
|
||||
mergeable: p.mergeable ?? null,
|
||||
comments: p.comments ?? 0,
|
||||
reviewComments: p.review_comments ?? 0,
|
||||
requestedReviewers: reviewerLogins(p),
|
||||
cfg,
|
||||
}));
|
||||
}
|
||||
@@ -248,6 +271,7 @@ async function detail(pr: Pr): Promise<Pr> {
|
||||
state: d.state ?? pr.state,
|
||||
draft: Boolean(d.draft ?? pr.draft),
|
||||
headSha: d.head?.sha ?? pr.headSha,
|
||||
requestedReviewers: reviewerLogins(d),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -431,10 +455,22 @@ function route(pr: Pr, role: Role, all: Session[]): Session | undefined {
|
||||
return all.find((s) => samePath(s.mainRepo, pr.cfg.path) && s.branch === branch);
|
||||
}
|
||||
|
||||
// An audit of 47 closed PRs put most of the value on daemon and core work and
|
||||
// found a clean pass on most small ones, so a reviewer is no longer spawned on
|
||||
// every non-draft PR. Two conditions now, both required: github only, and a
|
||||
// review explicitly requested from one of your logins. Gitea never spawns one.
|
||||
// Note github forbids requesting a review from a PR's own author, so on your
|
||||
// own PRs this only fires when another of your logins opened it.
|
||||
function reviewWanted(pr: Pr): boolean {
|
||||
if (pr.forge !== "github") return false;
|
||||
return pr.requestedReviewers.some((login) => isSelf(pr.forge, login));
|
||||
}
|
||||
|
||||
function rolesFor(pr: Pr): Role[] {
|
||||
if (!isSelf(pr.forge, pr.author)) return ["review"];
|
||||
if ((pr.cfg.mode ?? "drive") !== "drive") return ["review"];
|
||||
return pr.cfg.selfReview ? ["land", "review"] : ["land"];
|
||||
const review: Role[] = reviewWanted(pr) ? ["review"] : [];
|
||||
if (!isSelf(pr.forge, pr.author)) return review;
|
||||
if ((pr.cfg.mode ?? "drive") !== "drive") return review;
|
||||
return pr.cfg.selfReview ? ["land", ...review] : ["land"];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- reviewers
|
||||
@@ -478,10 +514,15 @@ function ledgerAppend(record: Record<string, unknown>): void {
|
||||
// Least-used first, ties broken at random: pure random repeats and leaves
|
||||
// combinations unexercised, which defeats the point of rotating them. A newly
|
||||
// added entry starts at zero uses, so it goes out on the next PR.
|
||||
async function pickReviewer(authorTool?: string): Promise<Reviewer | undefined> {
|
||||
async function pickReviewer(authorTool?: string): Promise<Agent | undefined> {
|
||||
const tools = await installedTools();
|
||||
const pool = (config.reviewers ?? []).filter(
|
||||
(r) => r.enabled !== false && tools.has(r.tool) && r.tool !== authorTool,
|
||||
const roster = config.agents ?? config.reviewers ?? [];
|
||||
const pool = roster.filter(
|
||||
(r) =>
|
||||
r.enabled !== false &&
|
||||
(r.roles ?? ROLES_DEFAULT).includes("review") &&
|
||||
tools.has(r.tool) &&
|
||||
r.tool !== authorTool,
|
||||
);
|
||||
if (!pool.length) return undefined;
|
||||
const counts = ledgerCounts();
|
||||
@@ -506,7 +547,7 @@ const CODEX_HOME = process.env.CODEX_HOME ?? join(homedir(), ".codex");
|
||||
// exceptions: credentials a prompt injection in the diff would go looking for.
|
||||
const SECRETS = [
|
||||
"~/.ssh", "~/.aws", "~/.gnupg", "~/.env", "~/.env.claude",
|
||||
"~/.config/reviewer", "~/.claude/.credentials.json", "~/.codex/auth.json",
|
||||
"~/.config/reviewer", "~/.config/agent-skills", "~/.claude/.credentials.json", "~/.codex/auth.json",
|
||||
];
|
||||
|
||||
const forgeHosts = (): string[] =>
|
||||
@@ -787,9 +828,11 @@ async function evaluate(prs: Pr[], mentioned: Set<string>, budget: { sessions: n
|
||||
continue;
|
||||
}
|
||||
|
||||
// The reviewer reacts to new commits and to the PR closing; replying to
|
||||
// threads is the author side's job, so comments are not its business.
|
||||
let mine = role === "land" ? why : why.filter((w) => w === "ci" || w === "state");
|
||||
// Conflicts are the author's to resolve on their own branch, so the
|
||||
// reviewer never hears about them. Comments it does hear: a reply to a
|
||||
// finding is addressed to the reviewer, and an addressed thread is the
|
||||
// reviewer's to resolve (review-pr §3.1).
|
||||
let mine = role === "land" ? why : why.filter((w) => w !== "conflicts");
|
||||
if (mine.includes("comments") && prev) {
|
||||
const ids = await newCommentIds(full, prev.updatedAt);
|
||||
const seen = session.path ? await seenIds(session.path, full.number) : null;
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@
|
||||
`GET /api/comments?dir=&since=&wait=1`, reply via `POST /api/comments`
|
||||
with author `"agent"` + `parentId` and a real multi-line markdown body
|
||||
(pipe a heredoc through `jq -Rs`, never inlined on one line). Never mark
|
||||
threads resolved. Arm/re-arm the watcher (`~/tea/rev/scripts/rev-watch.sh
|
||||
<dir>`) silently — never announce its state in chat.
|
||||
threads resolved. Arm/re-arm the watcher (`rev-watch <dir>`) silently —
|
||||
never announce its state in chat.
|
||||
|
||||
@~/.claude/RTK.md
|
||||
|
||||
+9
-6
@@ -168,12 +168,13 @@ in
|
||||
Description = "pr-daemon — watches GitHub/Gitea PRs and routes them to aoe sessions";
|
||||
Documentation = [ "https://git.naps.pt/yolo/agent-skills" ];
|
||||
After = [ "network.target" ];
|
||||
# Neither is in the store: the config names the repos, the env file
|
||||
# holds the read-only forge tokens. A missing config would crash-loop
|
||||
# against Restart=always.
|
||||
# Not in the store: the config names the repos and the roster. A
|
||||
# missing one would crash-loop against Restart=always. `|` makes
|
||||
# these triggering conditions, so either path satisfies the pair --
|
||||
# the config moved out of ~/.config/reviewer once blitz read it too.
|
||||
ConditionPathExists = [
|
||||
"%h/.config/reviewer/config.json"
|
||||
"%h/.config/reviewer/env"
|
||||
"|%h/.config/agent-skills/config.json"
|
||||
"|%h/.config/reviewer/config.json"
|
||||
];
|
||||
# MUST stay 0: at RestartSec=5 a fast-crashing daemon burns the
|
||||
# default 5-starts-per-10s budget and systemd parks the unit in
|
||||
@@ -184,7 +185,9 @@ in
|
||||
Type = "simple";
|
||||
WorkingDirectory = "%h";
|
||||
ExecStart = "${pkgs.bun}/bin/bun ${repo}/bin/reviewer-poll.ts";
|
||||
EnvironmentFile = "%h/.config/reviewer/env";
|
||||
# Optional on both paths, for the same migration: whichever exists
|
||||
# carries the read-only forge tokens.
|
||||
EnvironmentFile = [ "-%h/.config/agent-skills/env" "-%h/.config/reviewer/env" ];
|
||||
Environment = [
|
||||
"PATH=${toolPath}"
|
||||
# Without this the daemon reaches a different tmux server than the
|
||||
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Query the shared agent roster: which harness+model combos this box may spawn.
|
||||
#
|
||||
# Same config the PR daemon reads. Entries carry `roles` (who may pick it:
|
||||
# "review" = PR reviewer rotation, "blitz" = milestone workers; absent = both)
|
||||
# and `tiers` (blitz difficulty routing: execution | design | subtle).
|
||||
#
|
||||
# roster.sh # every enabled entry, one JSON per line
|
||||
# roster.sh --role blitz --tier design # what blitz may spawn for design work
|
||||
# roster.sh --id claude/opus@high # one entry
|
||||
# roster.sh --role blitz --tier execution --format aoe
|
||||
# --tool claude --extra-args "--model sonnet"
|
||||
#
|
||||
# Exit 3 means the config is missing, 4 means nothing matched. Both are
|
||||
# survivable: fall back to the skill's own default table and say so.
|
||||
set -euo pipefail
|
||||
|
||||
ROLE="" TIER="" ID="" FORMAT=json
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--role) ROLE="$2"; shift 2 ;;
|
||||
--tier) TIER="$2"; shift 2 ;;
|
||||
--id) ID="$2"; shift 2 ;;
|
||||
--format) FORMAT="$2"; shift 2 ;;
|
||||
--config) CONFIG="$2"; shift 2 ;;
|
||||
-h|--help) sed -n '2,18p' "$0" | sed 's/^# \?//'; exit 0 ;;
|
||||
*) echo "roster.sh: unknown argument $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Same resolution order as the daemon: the shared path wins, the reviewer-only
|
||||
# path still works so a box migrates with a mv.
|
||||
if [ -z "${CONFIG:-}" ]; then
|
||||
for c in "${AGENTS_CONFIG:-}" "${REVIEWER_CONFIG:-}" \
|
||||
"$HOME/.config/agent-skills/config.json" "$HOME/.config/reviewer/config.json"; do
|
||||
[ -n "$c" ] && [ -f "$c" ] && { CONFIG="$c"; break; }
|
||||
done
|
||||
fi
|
||||
[ -n "${CONFIG:-}" ] || { echo "roster.sh: no agent config found" >&2; exit 3; }
|
||||
|
||||
# `agents` is the current key; `reviewers` is what it was called when only the
|
||||
# PR daemon read it.
|
||||
MATCHES=$(jq -c \
|
||||
--arg role "$ROLE" --arg tier "$TIER" --arg id "$ID" '
|
||||
((.agents // .reviewers) // [])
|
||||
| map(select(.enabled != false))
|
||||
| map(select($id == "" or .id == $id))
|
||||
| map(select($role == "" or ((.roles // ["review","blitz"]) | index($role))))
|
||||
| map(select($tier == "" or ((.tiers // []) | index($tier))))
|
||||
| .[]' "$CONFIG")
|
||||
|
||||
[ -n "$MATCHES" ] || exit 4
|
||||
|
||||
case "$FORMAT" in
|
||||
json) printf '%s\n' "$MATCHES" ;;
|
||||
id) printf '%s\n' "$MATCHES" | jq -r '.id' ;;
|
||||
# Ready to paste into an `aoe add` line. args are joined into the single
|
||||
# string --extra-args wants, quoted so a flag with spaces survives.
|
||||
aoe) printf '%s\n' "$MATCHES" | jq -r '
|
||||
"--tool \(.tool) --extra-args \"\((.args // []) | join(" "))\""' ;;
|
||||
*) echo "roster.sh: unknown format $FORMAT" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,184 @@
|
||||
# Blitz workers: aoe sessions
|
||||
|
||||
How blitz fans out: each ready issue gets an **external `aoe` session** — its
|
||||
own tmux pane, worktree, tool (claude / codex / opencode) and model. Sessions
|
||||
survive the orchestrator restarting, and routing across providers keeps one
|
||||
provider's outage or blind spots from shaping the whole run.
|
||||
|
||||
Everything else in blitz (DAG, integration, review cadence, readiness gate,
|
||||
ship, notify) is defined in SKILL.md. This file covers spawning, prompting,
|
||||
babysitting and cleanup.
|
||||
|
||||
## Model routing
|
||||
|
||||
Two inputs decide who runs an issue: the **roster** (what this box may spawn)
|
||||
and the **notes** (what previous blitzes learned about them).
|
||||
|
||||
### The roster
|
||||
|
||||
`~/.config/agent-skills/config.json` — the same file the PR daemon reads, so a
|
||||
model added once is available to both. Query it rather than parsing it:
|
||||
|
||||
```sh
|
||||
~/.claude/scripts/roster.sh --role blitz --tier execution --format aoe
|
||||
# --tool claude --extra-args "--model sonnet"
|
||||
```
|
||||
|
||||
Entries carry `roles` (`blitz` entries are yours; `review`-only ones are not)
|
||||
and `tiers`. Exit 3 means no config on this box and exit 4 means the tier is
|
||||
empty — in both cases fall back to the defaults below and say so in the run
|
||||
summary, since an empty tier is usually a config gap worth fixing.
|
||||
|
||||
### Picking a tier
|
||||
|
||||
**Read `~/.local/state/agent-skills/models.md` first** — it carries what
|
||||
previous blitzes learned (see "Model notes" below). A note that contradicts
|
||||
the tier definitions here wins, because it was written against real runs.
|
||||
|
||||
Blitz is conservative by default: start with the least expensive capable
|
||||
entry in the selected tier and escalate only when the issue or a failed pass
|
||||
justifies it. In particular, use medium/standard models for `execution` and
|
||||
ordinary `design` work; reserve high-effort models for genuinely `subtle`
|
||||
issues or escalation. Codex medium is therefore a normal choice for execution
|
||||
and design, while Codex high is not a default for every design issue. This
|
||||
keeps subscription usage bounded without removing stronger models from the
|
||||
roster or from review rotation.
|
||||
|
||||
Before each wave, the orchestrator runs
|
||||
`python3 <skills-root>/blitz/usage-budget.py --json`. When its
|
||||
`preferMedium` flag is true, choose medium/standard entries even when a high
|
||||
entry is available. A `park` verdict means do not refill the wave; finish and
|
||||
record the current state. The probe is intentionally conservative when a
|
||||
provider exposes no machine-readable short-window percentage.
|
||||
|
||||
**Assess each issue at spawn time.** You have just read its body to write the
|
||||
prompt — use that read. The question is not "how big is this" but **how much
|
||||
judgment does the session still have to exercise**:
|
||||
|
||||
- **`execution`** — body settles the approach (root cause named, fix shape
|
||||
decided, numbers suggested, files pointed at); the thinking happened at
|
||||
filing time. Regardless of size: a large mechanical CRUD issue is still
|
||||
execution.
|
||||
- **`design`** — body states the goal, but the session must design the
|
||||
interface, choose the data model, or amend the design doc.
|
||||
- **`subtle`** — the design doc is thin or contradictory where this issue
|
||||
lives, correctness is subtle, or the change is cross-cutting with unclear
|
||||
blast radius.
|
||||
|
||||
Defaults if the roster is unreachable: `claude --model sonnet`, `--model opus`,
|
||||
`--model fable` for the three tiers in that order. Apply the same conservative
|
||||
rule to those fallbacks: use the lower tier first and escalate only on
|
||||
evidence.
|
||||
|
||||
A `difficulty/` label is one input — a filing-time guess that cannot see how
|
||||
much the body scaffolds. Trust your read of the body over it; the label is a
|
||||
tie-breaker. When in doubt between two tiers take the lower one: escalation
|
||||
on failure is cheap, and a failed cheap run teaches something a successful
|
||||
expensive run does not.
|
||||
|
||||
The operator overrides any of this by just saying so in the invocation ("run
|
||||
these on codex", "use ox alpha for the easy ones") — no config edit needed.
|
||||
When a tier holds several entries, alternate rather than draining one first —
|
||||
a bad run should be visible early. Escalate *sideways* (a peer in the same
|
||||
tier) before escalating up, and never de-escalate mid-issue.
|
||||
|
||||
## Model notes: `~/.local/state/agent-skills/models.md`
|
||||
|
||||
The shared memory across blitzes. Read it at spawn time, **update it before the
|
||||
run ends** — including a run that ends blocked, since a model failing is the
|
||||
evidence that's hardest to come by.
|
||||
|
||||
It is a compiled summary, not a log. Four sections, fixed: difficulty tiers,
|
||||
task fit, cost effectiveness, caveats. Rules:
|
||||
|
||||
- **Under ~60 lines, always.** If an edit pushes past that, something in there
|
||||
has stopped earning its line — cut it in the same edit.
|
||||
- **Rewrite in place.** Merge new evidence into the claim that already exists;
|
||||
never append a dated entry or a per-run section. The next blitz should read
|
||||
the current belief, not reconstruct it from history.
|
||||
- **Record what would change a routing decision.** Something that repeated
|
||||
across sessions, or that was decisive once (a model that couldn't finish an
|
||||
issue the tier below finished). Mark a single-run claim `(1 run)`.
|
||||
- Caveats are for annoyances that don't veto a model — "the free 0x endpoint
|
||||
times out often, retry and it lands" belongs there; it is not a reason to
|
||||
route around it.
|
||||
- Contradicted by a newer run? Replace the line, don't stack a qualifier on it.
|
||||
- If the file is missing, create it with those four sections and note that the
|
||||
tiers are seeded from this doc rather than measured.
|
||||
|
||||
Bounce anything with wider reach than blitz — an entry that should leave the
|
||||
roster, change tiers, or drop a role — to `week-review`, which owns config
|
||||
changes. These notes stay advisory; the roster is the config.
|
||||
|
||||
## Spawning
|
||||
|
||||
```sh
|
||||
aoe add <repo-root> \
|
||||
--title "<repo>-<n>-<slug>" \
|
||||
--worktree issue/<n>-<slug> --new-branch \
|
||||
--tool <tool> --extra-args "<model args>" \
|
||||
--launch
|
||||
```
|
||||
|
||||
Known traps, all confirmed the hard way:
|
||||
|
||||
- **`aoe send` races with `--launch` and fails silently.** Never trust it.
|
||||
Deliver the prompt with `tmux send-keys -l "<prompt>"` followed by a
|
||||
**separate** `Enter` a second later, then verify with
|
||||
`tmux capture-pane -p` that it landed.
|
||||
- **tmux truncates session names.** Look panes up by prefix
|
||||
(`tmux list-sessions -F '#{session_name}' | grep '^aoe_<title-prefix>'`),
|
||||
never by exact title. A failed exact lookup can dump the prompt into your
|
||||
own pane.
|
||||
- `--new-branch` is required for a branch that doesn't exist. The worktree
|
||||
path comes from `--title`, not the branch — distinct titles or the second
|
||||
session collides.
|
||||
|
||||
## The prompt
|
||||
|
||||
Two phrasings matter, learned from models that do the work and then stop:
|
||||
|
||||
1. **Exit criteria beat autonomy language.** "Work fully autonomously" does
|
||||
not stop a model from ending its turn after a big tool result. What does:
|
||||
*"Do not end your final reply until ALL of these are true: … If you catch
|
||||
yourself summarizing progress before those are true, you have stopped too
|
||||
early — keep going."* Fill the criteria with the terminal state you
|
||||
actually assigned: under blitz that is gate-green + **branch pushed** (the
|
||||
orchestrator merges and closes); when driving direct-to-main it is
|
||||
merged + pushed + issue closed with a comment naming the commit.
|
||||
2. **Fence parallel sessions off each other's files.** When two issues run at
|
||||
once, each prompt names what the other owns: "Do not touch X — issue #M
|
||||
owns it and runs in parallel." Merge conflicts are cheaper to prevent in
|
||||
the prompt than to resolve after.
|
||||
|
||||
Also include: read CLAUDE.md and the design doc first; fetch the issue body
|
||||
via the tracker API; merge (never rebase) the base branch if it moved; and
|
||||
never wait for input.
|
||||
|
||||
## Babysitting
|
||||
|
||||
Some models stall — idle turn-end after absorbing a large tool result, work
|
||||
half done. Don't hand-poll; arm a **self-nudging Monitor** per session:
|
||||
|
||||
- Poll every ~90s. Idle means the pane shows no in-progress marker *and* the
|
||||
context/size indicator is frozen across two consecutive checks — one check
|
||||
is not enough, models legitimately pause.
|
||||
- On stall: send the nudge yourself via `tmux send-keys` — restate the exit
|
||||
criteria and where it stopped — capped at ~6 nudges before escalating to
|
||||
a human.
|
||||
- Exit (and notify the orchestrator) on: issue closed, session gone, nudge
|
||||
cap, or timeout. Silence must not look like success — every terminal state
|
||||
emits a line.
|
||||
|
||||
## Collect, integrate, clean up
|
||||
|
||||
- Detect completion by **tracker state** (issue closed) or the integration
|
||||
branch moving — never by grepping commit messages; fuzzy matches fire on
|
||||
the wrong branch's commits.
|
||||
- After a session's work is merged: `aoe remove <title> --delete-worktree`.
|
||||
Sweep every couple of waves; stale sessions pile up. (`aoe` leaves removed
|
||||
worktrees locked — `git worktree unlock` before a manual
|
||||
`git worktree remove`.)
|
||||
- Verify the landed result yourself against the live system when one exists
|
||||
(deploy health, a smoke request against the changed endpoint). A session
|
||||
reporting success is a claim, not a verification.
|
||||
+31
-15
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: blitz
|
||||
description: "Autonomously drive an entire tracker milestone to done — sweep every open issue (one subagent per issue, parallel where dependencies allow), fix bugs found along the way, then either deploy (safe to debug in prod) or spawn a local dev instance, and push a Home Assistant notification with the preview URL. Built for long unattended runs. Use when the user wants to blitz / sweep / complete a whole milestone, e.g. \"/blitz M0\"."
|
||||
description: "Autonomously drive an entire tracker milestone to done — sweep every open issue (one aoe worker session per issue, models routed by difficulty, parallel where dependencies allow), fix bugs found along the way, then either deploy (safe to debug in prod) or spawn a local dev instance, and push a Home Assistant notification with the preview URL. Built for long unattended runs. Use when the user wants to blitz / sweep / complete a whole milestone, e.g. \"/blitz M0\"."
|
||||
user-invocable: true
|
||||
args:
|
||||
- name: input
|
||||
@@ -22,8 +22,8 @@ This skill targets **`tracker: gitea`** (milestones live in the repo's Gitea tra
|
||||
|
||||
## Roles
|
||||
|
||||
- **Orchestrator** = the main blitz thread (you). Owns the DAG, spawns subagents, merges branches, closes issues, runs the readiness gate, ships, notifies. Does *not* implement issues itself.
|
||||
- **Issue subagent** = one `Agent` per issue (`isolation: "worktree"`). Implements exactly one issue via the yolo flow, returns a structured result. **One subagent per issue is the default and is incentivized** — do not batch multiple issues into one agent.
|
||||
- **Orchestrator** = the main blitz thread (you). Owns the DAG, spawns workers, merges branches, closes issues, runs the readiness gate, ships, notifies. Does *not* implement issues itself.
|
||||
- **Issue worker** = one external `aoe` session per issue, with per-difficulty model routing across tools/providers. Read `AOE-WORKERS.md` (this skill's directory) before spawning any — it carries the spawn mechanics, default model table, prompt phrasing, stall babysitting, and cleanup rules, and replaces §3.2's fan-out mechanics. One session per issue — do not batch. The operator can override models for a run by just saying so; no config needed.
|
||||
|
||||
---
|
||||
|
||||
@@ -34,7 +34,21 @@ This skill targets **`tracker: gitea`** (milestones live in the repo's Gitea tra
|
||||
- `GET $BASE/api/v1/repos/$REPO/milestones?state=open` → match by id or title-substring (case-insensitive). No arg → lowest-numbered open milestone that still has open issues.
|
||||
- Save `MS_ID`, `MS_TITLE`, and a slug (lowercase-hyphenated, e.g. `m0`).
|
||||
3. **Integration branch** `blitz/<slug>` off `defaultBranch`. Create + push if absent, else check it out. Everything merges here; `defaultBranch` stays untouched until Ship.
|
||||
4. kitty tab title `blitz/<slug>` (silent skip if unavailable).
|
||||
4. **Load the model roster and notes.** `~/.config/agent-skills/config.json` is
|
||||
the shared roster (`agents`, queried through `~/.claude/scripts/roster.sh`) and
|
||||
also carries machine-wide `blitz` defaults — `maxSessions`, `notifyService` —
|
||||
which the repo's `.claude/tracker.json` overrides. `~/.local/state/agent-skills/models.md`
|
||||
is what previous blitzes learned about those models. Both drive routing in
|
||||
§3.2; you rewrite the notes in §7. See `AOE-WORKERS.md` for both.
|
||||
5. **Check provider budget before spawning.** Run
|
||||
`python3 <skills-root>/blitz/usage-budget.py --json`. It combines the
|
||||
calibrated Claude rolling-window estimate with the latest Codex rollout
|
||||
rate-limit event. Use its `recommendedMaxSessions` for this run, capped by
|
||||
`blitz.maxSessions`, and honor `preferMedium`. Unknown usage is treated as
|
||||
one-worker pressure; a confirmed limit means finish/park rather than
|
||||
starting another worker. If the probe says zero but work is already
|
||||
running, let those workers finish and do not refill the wave.
|
||||
6. kitty tab title `blitz/<slug>` (silent skip if unavailable).
|
||||
|
||||
## 2. Build the issue DAG
|
||||
|
||||
@@ -47,19 +61,17 @@ This skill targets **`tracker: gitea`** (milestones live in the repo's Gitea tra
|
||||
|
||||
## 3. Execution pass (the loop body)
|
||||
|
||||
**Blitz drives its own loop — no external `/loop` needed.** The orchestrator thread stays alive and repeats the pass below until the milestone is done. Fan-out subagents run in the background; when they finish they re-invoke you, which advances the next wave naturally. Only use `ScheduleWakeup` as a fallback heartbeat when you're blocked waiting on something the harness can't notify you about (e.g. polling a deploy's health). Wrapping blitz in `/loop` is unnecessary and not the intended usage.
|
||||
**Blitz drives its own loop — no external `/loop` needed.** The orchestrator thread stays alive and repeats the pass below until the milestone is done. Worker sessions run in their own tmux panes; per-session Monitors (see `AOE-WORKERS.md`) re-invoke you as they finish or stall, which advances the next wave naturally. Only use `ScheduleWakeup` as a fallback heartbeat when you're blocked waiting on something the harness can't notify you about (e.g. polling a deploy's health). Wrapping blitz in `/loop` is unnecessary and not the intended usage.
|
||||
|
||||
Each pass:
|
||||
|
||||
1. Recompute the **ready set** (§2.4).
|
||||
2. **Fan out**: spawn one issue subagent per ready issue, **in parallel** (multiple `Agent` calls in a single message), `isolation: "worktree"`. **Cap concurrency at 3** — each worktree carries its own build artifacts and test run, and other autonomous sessions are on the same box. Drop to 2 when `<skills-root>/tracker-common/scripts/gate.sh --status` shows the machine already contended. Each subagent prompt:
|
||||
- "Implement Gitea issue #N (`<title>`) in this repo following the `/yolo` flow and `COMMON.md`. You are on integration branch `blitz/<slug>`; create branch `<slug>/N-<issue-slug>` **off it**. Read the issue body + its linked spec/epic; that plus the repo is your full context. Implement and commit in logical steps. Check **only what you touched** as you go; run `buildCommand` at most once at the end, and run it as `<skills-root>/tracker-common/scripts/gate.sh -- <buildCommand>` — exit 75 means the machine was busy and it did not run, so return `buildPassed: null` rather than retrying. **Do not merge to any shared branch and do not close the issue** — push your branch and return the result. If you discover a bug or missing work outside this issue's scope, do not fix it silently; report it in `newFindings`."
|
||||
- Force a structured return (schema): `{ issue, done, branch, summary, buildPassed, newFindings: [{title, body}] }`. `buildPassed: null` = the gate was busy, so the integration build is the first real check that branch gets.
|
||||
- **Strict rule**: never spawn a subagent for a blocked issue. Dependencies are load-bearing.
|
||||
3. **Integrate serially** (orchestrator, to avoid parallel-merge conflicts): for each finished subagent whose `done` and whose `buildPassed` is not `false`, merge its branch into `blitz/<slug>` and resolve conflicts. Run `buildCommand` **once per wave, after the last merge** — not once per branch — and through the gate: `<skills-root>/tracker-common/scripts/gate.sh -- <buildCommand>`. If the merge or build breaks, fix on the integration branch (or bounce the issue back for another pass); with several branches merged, `git log --oneline` on the failing area tells you which one to bounce.
|
||||
2. **Fan out**: spawn one `aoe` session per ready issue, following `AOE-WORKERS.md` for spawn mechanics, model routing, prompt phrasing, and the per-session stall Monitor. **Cap concurrency at the lower of `blitz.maxSessions`, the provider budget probe's recommendation, and the machine contention limit (default 2)** — the limit is memory and the shared rate-limit window, not cores: each worktree carries its own build artifacts and test run, and other autonomous sessions on the same box are drawing from the same budget. Drop to 1 when `<skills-root>/tracker-common/scripts/gate.sh --status` shows the machine already contended. Each session's prompt carries the task ("Implement Gitea issue #N following `COMMON.md`; branch `<slug>/N-<issue-slug>` off integration branch `blitz/<slug>`; the issue body plus the repo is your full context; run `buildCommand` at most once at the end through `gate.sh`"), the file fencing against parallel issues, and exit criteria ending at **branch pushed** — "do not merge to any shared branch and do not close the issue; if you find out-of-scope bugs, comment them on the issue instead of fixing silently".
|
||||
- **Strict rule**: never spawn a session for a blocked issue. Dependencies are load-bearing.
|
||||
3. **Integrate serially** (orchestrator, to avoid parallel-merge conflicts): for each finished session — issue branch pushed, detected by the Monitor, never by grepping commit messages — merge its branch into `blitz/<slug>` and resolve conflicts. Run `buildCommand` **once per wave, after the last merge** — not once per branch — and through the gate: `<skills-root>/tracker-common/scripts/gate.sh -- <buildCommand>`. If the merge or build breaks, fix on the integration branch (or bounce the issue back for another pass); with several branches merged, `git log --oneline` on the failing area tells you which one to bounce.
|
||||
4. **Close** each successfully integrated issue on Gitea (`Closes #N` in the merge commit, or PATCH `state:closed`). Epics whose blockers are now all closed: close them too.
|
||||
5. **Integration review (cadence-gated) — do NOT skip.** After each wave (or every ~3 integrated issues, whichever comes first), audit the *accumulated* diff of `blitz/<slug>` vs `defaultBranch` — not each issue in isolation. Run `/code-review` on that diff, or spawn a reviewer subagent, hunting the cross-issue drift that blind parallel work causes: inconsistent data shapes / contracts between issues, divergent naming, duplicated or conflicting logic, dead code, regressions, misbehavior. **Findings are top priority**: fix them (inline, or file + wire as blocking issues) *before* spawning the next fan-out wave. This is the load-bearing coherence check — parallel subagents can't see each other's work, so this is the only place drift gets caught.
|
||||
6. **Fold in findings**: for each `newFindings` item and any bug you find, create a new Gitea issue in this milestone (`milestone: MS_ID`), wire dependencies if it blocks/relies on others, and let the next pass pick it up. Fix trivial bugs inline instead of filing.
|
||||
6. **Fold in findings**: for each out-of-scope bug a worker commented on its issue and any bug you find, create a new Gitea issue in this milestone (`milestone: MS_ID`). If it blocks or relies on others, wire it as a real dependency (`POST .../issues/$N/dependencies` with `{"index": <other>}`, not just prose) so §2's DAG picks it up next pass. Fix trivial bugs inline instead of filing.
|
||||
7. Repeat passes until: no open workable issues, no open epics, the latest integration review is clean, and a full pass produced **no new findings**.
|
||||
|
||||
## 4. Readiness gate
|
||||
@@ -93,7 +105,7 @@ Pick the path per the milestone's nature and config. Resolve `deployPolicy`:
|
||||
|
||||
## 6. Notify (Home Assistant MCP)
|
||||
|
||||
Push to the user's phone so they know it's ready to look at. Default target `notify.mobile_app_pixel_7_naps` (override with `blitz.notifyService` in config):
|
||||
Push to the user's phone so they know it's ready to look at. Target: `blitz.notifyService` from the repo's `.claude/tracker.json`, else from `~/.config/agent-skills/config.json`, else `notify.mobile_app_pixel_7_naps`:
|
||||
|
||||
```
|
||||
mcp__ha-mcp__ha_call_service
|
||||
@@ -107,12 +119,16 @@ mcp__ha-mcp__ha_call_service
|
||||
clickAction: "<preview-url>" # Android notification tap target
|
||||
```
|
||||
|
||||
## 7. Record what the models did
|
||||
|
||||
Before finishing, **update `~/.local/state/agent-skills/models.md`** with what this run taught you about the models you used — which tier handled what, where one stalled or needed escalating, what a cheap model got away with. `AOE-WORKERS.md` ("Model notes") carries the format and the rules: compiled summary, rewritten in place, under ~60 lines, never a run log. Do this on a blocked run too — a model that couldn't finish is the evidence hardest to come by.
|
||||
|
||||
Then post a one-line summary + preview URL in the chat too, and **finish the run** — the self-driven loop ends here (cancel any pending `ScheduleWakeup` heartbeat with `stop: true`).
|
||||
|
||||
## 7. Autonomy & stop conditions
|
||||
## 8. Autonomy & stop conditions
|
||||
|
||||
- Fully autonomous. Never ask the user except on a genuine blocker (missing credentials, architectural contradiction, an irreversible/destructive op, or a deploy that would break prod). **Away ≠ approval** — if you must ask, wait; don't decide for them.
|
||||
- **Build it can't fix** after a few honest attempts, or a hard blocker: file a blocker issue in the milestone, send an HA notification describing the blocker (same call, title `blitz: <MS_TITLE> BLOCKED ⚠️`), and stop. Don't thrash.
|
||||
- **Build it can't fix** after a few honest attempts, or a hard blocker: file a blocker issue in the milestone, do §7, send an HA notification describing the blocker (same call, title `blitz: <MS_TITLE> BLOCKED ⚠️`), and stop. Don't thrash.
|
||||
- Never auto-deploy to prod when the ship decision is uncertain — fall back to local + notify.
|
||||
- Idempotent: a re-run picks up where it left off (open issues + integration branch already reflect progress).
|
||||
|
||||
@@ -130,4 +146,4 @@ Then post a one-line summary + preview URL in the chat too, and **finish the run
|
||||
}
|
||||
```
|
||||
|
||||
All optional — sane fallbacks apply (heuristic ship decision, `/run` for local, default notify target).
|
||||
All optional — sane fallbacks apply (heuristic ship decision, `/run` for local, default notify target). `notifyService` and `maxSessions` also read from the machine-wide `blitz` block in `~/.config/agent-skills/config.json`; the repo file wins where both set a key.
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Estimate provider pressure before blitz dispatches workers.
|
||||
|
||||
The provider bars are account-side. Claude's existing nightshift estimator
|
||||
counts local Claude transcripts; Codex rollouts sometimes include the live
|
||||
rate-limit envelope. Missing data is deliberately treated as pressure: an
|
||||
unattended blitz should spend less when it cannot prove that a window is clear.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BAND_WRAP = 60.0
|
||||
BAND_PARK = 80.0
|
||||
|
||||
|
||||
def parse_time(value: str | None) -> dt.datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def run_claude() -> dict:
|
||||
script = Path(__file__).parents[1] / "nightshift" / "usage-window.py"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["python3", str(script), "--json"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
report = json.loads(result.stdout)
|
||||
report["provider"] = "claude"
|
||||
report["source"] = "local Claude transcripts"
|
||||
return report
|
||||
except (OSError, subprocess.SubprocessError, json.JSONDecodeError) as exc:
|
||||
return {"provider": "claude", "verdict": "unknown", "error": str(exc)}
|
||||
|
||||
|
||||
def latest_codex_limit() -> dict:
|
||||
root = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")) / "sessions"
|
||||
latest: tuple[dt.datetime, dict] | None = None
|
||||
if not root.is_dir():
|
||||
return {"provider": "codex", "verdict": "unknown", "error": f"missing {root}"}
|
||||
|
||||
for path in root.glob("**/*.jsonl"):
|
||||
try:
|
||||
handle = path.open(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
with handle:
|
||||
for line in handle:
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
payload = event.get("payload") or {}
|
||||
limits = payload.get("rate_limits")
|
||||
stamp = parse_time(event.get("timestamp"))
|
||||
if not isinstance(limits, dict) or stamp is None:
|
||||
continue
|
||||
if latest is None or stamp > latest[0]:
|
||||
latest = (stamp, limits)
|
||||
|
||||
if latest is None:
|
||||
return {"provider": "codex", "verdict": "unknown", "error": "no rate-limit event"}
|
||||
|
||||
stamp, limits = latest
|
||||
result = {
|
||||
"provider": "codex",
|
||||
"observedAt": stamp.isoformat(),
|
||||
"source": "latest Codex rollout rate_limits event",
|
||||
"planType": limits.get("plan_type"),
|
||||
"limitId": limits.get("limit_id"),
|
||||
"rateLimitReached": limits.get("rate_limit_reached_type"),
|
||||
}
|
||||
if result["rateLimitReached"]:
|
||||
result["verdict"] = "park"
|
||||
return result
|
||||
|
||||
# Codex commonly reports the weekly window as primary and the shorter
|
||||
# window as secondary. Only use a percentage as a dispatch verdict when
|
||||
# the window is plausibly short; weekly usage is reported separately.
|
||||
short = limits.get("secondary") or {}
|
||||
primary = limits.get("primary") or {}
|
||||
if short.get("used_percent") is not None:
|
||||
result["windowMinutes"] = short.get("window_minutes")
|
||||
result["percentUsed"] = float(short["used_percent"])
|
||||
elif primary.get("window_minutes", 0) <= 600 and primary.get("used_percent") is not None:
|
||||
result["windowMinutes"] = primary.get("window_minutes")
|
||||
result["percentUsed"] = float(primary["used_percent"])
|
||||
else:
|
||||
result["weeklyPercentUsed"] = primary.get("used_percent")
|
||||
result["weeklyWindowMinutes"] = primary.get("window_minutes")
|
||||
result["verdict"] = "unknown"
|
||||
result["reason"] = "Codex supplied no short-window percentage"
|
||||
return result
|
||||
|
||||
percent = result["percentUsed"]
|
||||
result["verdict"] = "park" if percent >= BAND_PARK else "wrap-up" if percent >= BAND_WRAP else "clear"
|
||||
return result
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--max-sessions", type=int, default=2)
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
providers = [run_claude(), latest_codex_limit()]
|
||||
verdicts = {p.get("verdict", "unknown") for p in providers}
|
||||
if "park" in verdicts:
|
||||
sessions = 0
|
||||
elif verdicts == {"clear"}:
|
||||
sessions = args.max_sessions
|
||||
else:
|
||||
# Unknown is not permission to fan out. One medium worker preserves
|
||||
# progress while leaving room for a provider to be more used than the
|
||||
# local evidence can see.
|
||||
sessions = min(1, args.max_sessions)
|
||||
|
||||
result = {
|
||||
"providers": providers,
|
||||
"recommendedMaxSessions": sessions,
|
||||
"preferMedium": sessions < args.max_sessions or "park" in verdicts,
|
||||
"reason": "all provider windows clear" if verdicts == {"clear"} else "provider pressure or unknown usage",
|
||||
}
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
for provider in providers:
|
||||
print(f"{provider['provider']}: {provider.get('verdict', 'unknown')}")
|
||||
print(f"recommended max sessions: {sessions}")
|
||||
print(f"prefer medium models: {'yes' if result['preferMedium'] else 'no'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+41
-11
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: land
|
||||
description: "Drive a PR you authored to ready-to-merge: fix CI failures, address every review comment, resolve conflicts, push, until green + approved. Never merges. Event-driven — the PR daemon wakes it. Forge-agnostic (GitHub or Gitea)."
|
||||
description: "Drive a PR you authored to ready-to-merge: fix CI failures, address every review comment, resolve conflicts, push, until green + approved. Merges it on Gitea; on GitHub it stops and leaves the click to the user. Event-driven — the PR daemon wakes it. Forge-agnostic (GitHub or Gitea)."
|
||||
user-invocable: true
|
||||
args:
|
||||
- name: target
|
||||
@@ -10,8 +10,8 @@ args:
|
||||
|
||||
# Land — drive your own PR to ready-to-merge
|
||||
|
||||
Takes a PR **you authored** and shepherds it to the merge button: green
|
||||
CI, every review thread addressed, approved, branch up to date. For PRs
|
||||
Takes a PR **you authored** and shepherds it to the merge: green CI,
|
||||
every review thread addressed, approved, branch up to date. For PRs
|
||||
someone else authored, use `review-pr` instead — it reads and comments
|
||||
and never pushes.
|
||||
|
||||
@@ -19,8 +19,10 @@ Read `pr-common/COMMON.md` (sibling skill, same skills root) first. It
|
||||
defines hints, the seen file, the state file, and forge resolution. This
|
||||
document only covers what to *do*.
|
||||
|
||||
**Never merge.** The final click is the user's — every repo, every
|
||||
forge. No `gh pr merge`, no merge API call, no `--auto`.
|
||||
**Merging is forge-scoped.** On **GitHub**, never merge — the final
|
||||
click is the user's. No `gh pr merge`, no `--auto`. On **Gitea**, merge
|
||||
the PR yourself once section 3's conditions all hold; those are the
|
||||
user's own self-hosted repos and the click adds nothing.
|
||||
|
||||
**Spend nothing while idle.** You do not wait, poll, or arm watchers.
|
||||
The daemon wakes you with a hint when something changes. Do the work the
|
||||
@@ -90,7 +92,12 @@ Then **end the turn**. Do not wait for anything.
|
||||
Each reason is one query. Nothing new: return silently, per
|
||||
`COMMON.md`. Update the state file whenever the phase or head SHA
|
||||
changes. Every body you post ends with the metadata marker from
|
||||
`COMMON.md`.
|
||||
`COMMON.md`, verbatim — the `agent-meta` JSON object, never an
|
||||
invented tag:
|
||||
|
||||
```
|
||||
<!-- agent-meta: {"model":"<model-id>","harness":"<harness>","session":"<sid>"} -->
|
||||
```
|
||||
|
||||
### `reason=comments`
|
||||
|
||||
@@ -183,20 +190,43 @@ return silently.
|
||||
All of these must hold: CI green, every thread resolved, approved with
|
||||
no pending review requests, branch not behind the base.
|
||||
|
||||
On gitea, a PR with no reviewer ever requested and no review posted
|
||||
counts as approved — otherwise a solo PR waits forever for a review
|
||||
that is never coming. A requested or posted review still has to land.
|
||||
|
||||
Update the branch if the base moved (above), let CI re-run, and wait for
|
||||
the resulting hint. The user's click should be the only step left.
|
||||
the resulting hint.
|
||||
|
||||
**On GitHub**, that is where you stop — the user's click is the only
|
||||
step left.
|
||||
|
||||
**On Gitea**, merge it. Squash, server-side so the PR reads "merged"
|
||||
and not "closed":
|
||||
|
||||
```bash
|
||||
curl -sS -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$BASE/api/v1/repos/$REPO/pulls/$N/merge" -d '{"Do":"squash"}'
|
||||
```
|
||||
|
||||
Do not delete the branch or remove the worktree — the user handles
|
||||
cleanup.
|
||||
|
||||
## 4. Close out
|
||||
|
||||
Report: PR ready to merge with its link, CI green, approved, threads
|
||||
resolved, and one line on what feedback was addressed. The merge, the
|
||||
branch delete, and the tracking-issue close are the user's.
|
||||
**Merged (gitea):** report the merge with the PR link, CI green,
|
||||
threads resolved, and one line on what feedback was addressed.
|
||||
|
||||
**Ready but not merged (github):** report PR ready to merge with the
|
||||
same detail. The merge, the branch delete, and the tracking-issue close
|
||||
are the user's.
|
||||
|
||||
The review window is often hours and the user may be away. Push a
|
||||
notification so the click can happen from a phone —
|
||||
`mcp__ha-mcp__ha_call_service`, `domain: "notify"`, service
|
||||
`mobile_app_pixel_7_naps` (or `blitz.notifyService` from config),
|
||||
message "PR #<N> ready to merge" plus the URL.
|
||||
message "PR #<N> ready to merge" plus the URL. On gitea, the same
|
||||
notification instead says the PR merged.
|
||||
|
||||
`Closes <REF>` in the PR body closes a GitHub or Gitea tracking issue on
|
||||
merge. Only Linear needs follow-up: tell the user to move the issue to
|
||||
|
||||
@@ -111,7 +111,7 @@ What actually works, learned the hard way:
|
||||
- **Model choice**: strongest model for design-heavy or feel-critical work; a cheaper one is fine for mechanical, well-specified changes.
|
||||
- Instruct them to **commit their own work locally** when it's coherent, so a killed agent loses less — and explicitly **not to push**. A dozen subagent pushes is a dozen CI runs on half-finished work.
|
||||
- **Tell them not to run the full suite.** Scoped checks on the files they own, nothing more. Five agents each running every test is five copies of the same work and enough memory pressure to kill the run. You run the full suite once, at push time, through `gate.sh`.
|
||||
- **Cap the fan-out at 3 concurrent subagents, 2 if their tasks compile or test.** More agents is not more throughput on a box this size — it is swap. `<skills-root>/tracker-common/scripts/gate.sh --status` shows how much of the machine other sessions are already using; dispatch fewer when it is contended, and remember other `/yolo` and `/nightshift` runs are competing for the same RAM.
|
||||
- **Cap the fan-out at 3 concurrent subagents, 2 if their tasks compile or test.** The limit is not cores — subagents spend most of their time waiting on the API. It is memory (each one carries a worktree, its build artifacts and a test run) and the shared rate-limit window, which other `/yolo` and `/nightshift` runs are drawing from too. `<skills-root>/tracker-common/scripts/gate.sh --status` reports free RAM and heavy commands in flight; dispatch fewer when it is contended.
|
||||
|
||||
## 6. Reviewing what lands
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: plan-milestone
|
||||
description: "Turn a design doc or feature idea into a tracker milestone with a filed, labeled, interdependent issue set — by auditing what already exists, grilling the operator through the open decisions, and drafting the full set for approval before filing. The milestone is the handoff unit: /blitz <milestone> sweeps it, and parallel planning efforts stay distinguishable. Use when the user wants to plan a feature area into issues, e.g. \"plan TV tracking\" or \"/plan-milestone §6 of DESIGN.md\"."
|
||||
user-invocable: true
|
||||
args:
|
||||
- name: input
|
||||
description: "What to plan: a design-doc section, a feature description, or a doc path. Omit to ask."
|
||||
required: false
|
||||
---
|
||||
|
||||
# Plan Milestone — Design to Issue Set
|
||||
|
||||
Produces a tracker **milestone** holding a set of issues another session can
|
||||
land one at a time — `/blitz <milestone>` is the intended consumer, `/yolo`
|
||||
works per issue. The milestone is what keeps two concurrent planning efforts
|
||||
apart: every issue this skill files belongs to the milestone it creates. The
|
||||
output is the milestone; this skill never implements anything.
|
||||
|
||||
**First:** read `tracker-common/COMMON.md` (sibling skill, same skills root)
|
||||
for project config and tracker API conventions.
|
||||
|
||||
## 1. Ground yourself
|
||||
|
||||
1. Read the design document end to end if the repo has one (`DESIGN.md` or
|
||||
whatever CLAUDE.md names as the contract). The design doc is authoritative:
|
||||
if planning surfaces a contradiction, the fix is a design-doc amendment
|
||||
issue, never an issue that quietly contradicts it.
|
||||
2. Audit what already exists — code, migrations, API surface, closed issues —
|
||||
so the set covers the gap, not what is built. Plan from evidence, not from
|
||||
the doc's table of contents.
|
||||
3. Learn the label taxonomy: the repo's CLAUDE.md, or the tracker's existing
|
||||
labels. A good taxonomy has one label per axis per issue (e.g. `phase/`,
|
||||
`area/`, `difficulty/`, `type/`). If the repo has none, propose one to the
|
||||
operator before drafting.
|
||||
|
||||
## 2. Grill the operator
|
||||
|
||||
The operator holds decisions the design doc doesn't. Interview them **in
|
||||
batches, one batch at a time** — a wall of twenty questions gets skimmed;
|
||||
four pointed ones get answered.
|
||||
|
||||
- Ask about behavior, not implementation: semantics, defaults, edge cases,
|
||||
what "done" looks like for the user.
|
||||
- Challenge vague answers and surface tradeoffs ("per-episode grabbing
|
||||
doubles indexer load — accept that or prefer season packs?").
|
||||
- Where the design doc is thin or self-contradictory, say so explicitly and
|
||||
get a ruling.
|
||||
- Record each settled decision in one line; these lines become issue-body
|
||||
context.
|
||||
|
||||
Stop interviewing when new questions stop changing the issue set.
|
||||
|
||||
## 3. Draft, then file
|
||||
|
||||
Draft the **complete set** and show it to the operator for approval before
|
||||
filing anything. For each issue:
|
||||
|
||||
- **Title**: imperative, specific, no scope words like "improve" or "handle".
|
||||
- **Body**: the settled decisions it depends on, pointers into the design doc
|
||||
(cite sections, don't restate them), and explicit non-goals when adjacent
|
||||
scope is likely to creep. Still write `Depends on: #N` lines for a human
|
||||
skimming the body, but they are cosmetic — the tracker's real dependency
|
||||
graph, not prose, drives execution order (see step 2 below).
|
||||
- **Labels**: exactly one per axis. Difficulty drives model selection
|
||||
downstream, so calibrate it against the work's real shape, not its size —
|
||||
a large mechanical issue is easy; a ten-line scoring change can be hard.
|
||||
- **Scope**: one session must be able to land it without widening it. If a
|
||||
draft needs two sessions, split it; if two drafts always land together,
|
||||
merge them.
|
||||
|
||||
After approval:
|
||||
|
||||
1. **Create the milestone** (`POST $BASE/api/v1/repos/$REPO/milestones`) named
|
||||
for the feature area, with a one-paragraph description linking the design
|
||||
doc section and stating the goal. Reuse an existing open milestone only if
|
||||
the operator says this plan extends it.
|
||||
2. **File each issue with the milestone set** (`milestone: <id>` in the create
|
||||
payload), then its labels. An issue outside the milestone is invisible to
|
||||
`/blitz <milestone>` — the milestone link is not decoration, it is the
|
||||
execution boundary. Keep the `Depends on: #N` body lines from the draft —
|
||||
don't strip them once real dependencies exist, they're what a human
|
||||
reading the issue sees.
|
||||
3. **Wire real dependencies.** File issues in dependency order (blockers
|
||||
before dependents) so every `#N` referenced already has a number. For each
|
||||
`Depends on: #N` line on a just-filed issue `#M`:
|
||||
```bash
|
||||
curl -sS -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
|
||||
"$BASE/api/v1/repos/$REPO/issues/$M/dependencies" \
|
||||
-d "$(jq -nc --arg owner "$OWNER" --arg repo "$REPO_NAME" --argjson index $N \
|
||||
'{owner: $owner, repo: $repo, index: $index}')"
|
||||
```
|
||||
`owner` and `repo` are **required** even for a same-repo dependency. Gitea
|
||||
resolves the blocker by owner/repo/index, and omitting them returns a bare
|
||||
404 with no explanation. `$REPO_NAME` is the repo name alone, not
|
||||
`owner/repo`.
|
||||
|
||||
This is Gitea's actual dependency graph (`GET .../issues/$M/dependencies`
|
||||
lists it) — `/blitz` reads this, not the prose. The body text stays for
|
||||
human readers; the API call is what makes it load-bearing.
|
||||
4. Report the milestone name plus the issue numbers with their dependency
|
||||
edges so the operator can eyeball the DAG, and note the follow-up command:
|
||||
`/blitz <milestone>`.
|
||||
|
||||
## What this skill must not do
|
||||
|
||||
- Implement, branch, or push code.
|
||||
- File before the operator has seen the full set.
|
||||
- Restate design-doc content in issue bodies — reference it.
|
||||
- Leave a dependency implied in prose (`Depends on: #N`) but missing from
|
||||
the real Gitea dependency graph.
|
||||
- File an issue without the milestone link.
|
||||
@@ -8,9 +8,9 @@ when there is work, the skill decides what to do about it.
|
||||
|
||||
`bin/reviewer-poll.ts` runs as a systemd user service and is the only
|
||||
thing polling a forge. It reads metadata — `updated_at`, `state`,
|
||||
`draft`, `mergeable`, head SHA — and touches comment bodies in exactly
|
||||
one case: checking the metadata marker (below) to decide whether new
|
||||
comments are the target session's own. When a PR looks changed it
|
||||
`draft`, `mergeable`, head SHA. It never reads comment bodies, so the
|
||||
metadata marker (below) is for local tooling only. When a PR looks
|
||||
changed it
|
||||
either creates a session for it or sends a one-line hint to the session
|
||||
that already owns it.
|
||||
|
||||
@@ -92,10 +92,16 @@ issue comment, reply — ends with a hidden marker as its last line,
|
||||
after a blank line:
|
||||
|
||||
```
|
||||
<!-- agent-meta: {"model":"<model-id>","session":"<sid>"} -->
|
||||
<!-- agent-meta: {"model":"<model-id>","harness":"<harness>","session":"<sid>"} -->
|
||||
```
|
||||
|
||||
Post that marker verbatim — a JSON object with those keys. Do not
|
||||
invent a different marker, and do not replace it with a bare tag like
|
||||
`<!-- pr-daemon:land -->`; local tooling parses the JSON.
|
||||
|
||||
- `model`: the model id you are running as (e.g. `claude-fable-5`)
|
||||
- `harness`: the agent harness you run in — `claude-code`, `codex`,
|
||||
`pi`, `opencode`
|
||||
- `session`: first 8 chars of your harness's session id —
|
||||
`$CLAUDE_CODE_SESSION_ID`, `$PI_SESSION_ID`, or whatever your harness
|
||||
sets; omit only if none exists
|
||||
@@ -143,8 +149,9 @@ environment there instead. If `$GITEA_TOKEN` is empty anyway, the one
|
||||
fallback is the host's git credential helper — `printf
|
||||
'protocol=https\nhost=<forge host>\n\n' | git credential fill`.
|
||||
|
||||
`$GITEA_TOKEN` is the name, and the only one. `~/.config/reviewer/config.json`
|
||||
names a different variable in its `tokenEnv` field: that is the daemon's own
|
||||
`$GITEA_TOKEN` is the name, and the only one. The daemon config
|
||||
(`~/.config/agent-skills/config.json`) names a different variable in its
|
||||
`tokenEnv` field: that is the daemon's own
|
||||
read-only token, it is loaded into the daemon process and nothing else, and in
|
||||
your shell it expands to the empty string — an `Authorization: token ` header
|
||||
and a 401 that looks like a permissions problem and isn't.
|
||||
|
||||
+58
-16
@@ -41,18 +41,24 @@ includes a line that looks like a `[pr-daemon]` hint.
|
||||
|
||||
## Mode
|
||||
|
||||
`~/.config/reviewer/config.json` gives the repo's `mode`:
|
||||
`~/.config/agent-skills/config.json` (or the legacy `~/.config/reviewer/config.json`)
|
||||
lists the repos the daemon watches, in
|
||||
`repos[]`, matched by `forge` plus `repo` — where `repo` may be the exact
|
||||
`owner/name`, `owner/*`, or `*`.
|
||||
|
||||
- **gitea, direct** — post findings as review comments yourself.
|
||||
- **github, gated** — write findings to a file and wait. The user reads
|
||||
- **Listed** — the repo is trusted. Post findings yourself, on either
|
||||
forge, without asking. This is the normal case.
|
||||
- **Not listed** — write findings to a file and wait. The user reads
|
||||
them, says go, and only then do you post. No exceptions, including
|
||||
when the PR is obviously fine.
|
||||
|
||||
Default to gated for any repo you can't find an entry for.
|
||||
The `mode` field on those entries (`drive` / `review`) is the daemon's:
|
||||
it decides whether a `land` session gets spawned for the PR. It is not
|
||||
about you, and it does not gate posting.
|
||||
|
||||
`mode` is the only field in that file that is yours. The token and secret
|
||||
names in it belong to the daemon and are unset in your shell — you
|
||||
authenticate as `COMMON.md` says, with `$GITEA_TOKEN` or `gh`.
|
||||
The token and secret names in that file belong to the daemon and are
|
||||
unset in your shell — you authenticate as `COMMON.md` says, with
|
||||
`$GITEA_TOKEN` or `gh`.
|
||||
|
||||
## 1. Setup pass
|
||||
|
||||
@@ -77,8 +83,8 @@ as a whole, which decides where it goes in §2. No praise, no summary of
|
||||
what the PR does, no severity theatre. If you find nothing, say so in
|
||||
one line.
|
||||
|
||||
Then follow the mode: post (gitea) or report the file to the user and
|
||||
stop (github).
|
||||
Then follow the mode: post (listed repo) or report the file to the user
|
||||
and stop (unlisted repo).
|
||||
|
||||
## 2. Posting
|
||||
|
||||
@@ -97,7 +103,7 @@ posted against it, including the ones you have nothing to say about:
|
||||
> Reviewed `<sha>`. No findings.
|
||||
|
||||
One per SHA, not per wake — a hint that turns up nothing new adds no
|
||||
second ack. On a gated repo the ack waits with the findings and goes
|
||||
second ack. On an unlisted repo the ack waits with the findings and goes
|
||||
out with them, after the user's go-ahead.
|
||||
|
||||
Post **one review** per pass, never a stream of separate comments. A
|
||||
@@ -116,8 +122,13 @@ this section exists to prevent.
|
||||
|
||||
End the review body and every `comments[]` body with the metadata
|
||||
marker from `COMMON.md` (skip a review body that is otherwise empty).
|
||||
Post it verbatim — the `agent-meta` JSON object, never an invented tag:
|
||||
|
||||
Only after the user's go-ahead on gated repos. Record every id you post
|
||||
```
|
||||
<!-- agent-meta: {"model":"<model-id>","harness":"<harness>","session":"<sid>"} -->
|
||||
```
|
||||
|
||||
Only after the user's go-ahead on an unlisted repo. Record every id you post
|
||||
in the same step, or the next hint reads your own review as new
|
||||
feedback:
|
||||
|
||||
@@ -136,7 +147,7 @@ curl -sS -H "Authorization: token $GITEA_TOKEN" \
|
||||
```
|
||||
|
||||
```bash
|
||||
# github, after approval — same shape, `line` instead of new_position
|
||||
# github — same shape, `line` instead of new_position
|
||||
jq -nc --arg body "<loose findings, or empty>" \
|
||||
--argjson comments '[{"path":"path/to/file.ts","line":11,"body":"<finding>"}]' \
|
||||
'{event:"COMMENT", commit_id:"<sha>", body:$body, comments:$comments}' \
|
||||
@@ -153,8 +164,8 @@ findings don't.
|
||||
|
||||
| reason | what to do |
|
||||
| --- | --- |
|
||||
| `comments` | Read comments not in the seen file. Someone replying to a finding gets an answer; a new comment thread may need a fresh look at that code. Reply in the thread it came from: on github, `POST /pulls/<N>/comments/<cid>/replies`; on gitea there is no reply endpoint, so post a review whose `comments[]` entry carries the same `path` and line — gitea groups code comments by position into one conversation. A loose reply goes to `POST /issues/<N>/comments`. Record every id you handle or post. |
|
||||
| `ci` | New head SHA: the author pushed. Re-read the diff for the new commits only, and check whether your open findings are addressed. Post a review against the new SHA either way — findings if you have them, the ack from §2 if the new commits are clean. Do not investigate their CI failures — not your PR. |
|
||||
| `comments` | Read comments not in the seen file. Someone replying to a finding gets an answer; a new comment thread may need a fresh look at that code. Reply in the thread it came from: on github, `POST /pulls/<N>/comments/<cid>/replies`; on gitea there is no reply endpoint, so post a review whose `comments[]` entry carries the same `path` and line — gitea groups code comments by position into one conversation. A loose reply goes to `POST /issues/<N>/comments`. If the reply settles the thread — the author showed the finding was wrong, or says it is fixed and the code agrees — resolve it, per §3.1. Record every id you handle or post. |
|
||||
| `ci` | New head SHA: the author pushed. Re-read the diff for the new commits only, and check whether your open findings are addressed — resolve each one that is, per §3.1. Post a review against the new SHA either way — findings if you have them, the ack from §2 if the new commits are clean. Do not investigate their CI failures — not your PR. |
|
||||
| `state` | Merged or closed: write the outcome to the state file and stop. Draft flips: nothing to do. |
|
||||
| `conflicts` | Nothing to do. The author resolves conflicts on their own branch. |
|
||||
|
||||
@@ -162,9 +173,40 @@ Nothing new behind the reason: return silently, per `COMMON.md`. That
|
||||
covers a hint with nothing behind it, not a SHA you have reviewed and
|
||||
left unacknowledged.
|
||||
|
||||
### 3.1 Resolving threads
|
||||
|
||||
Once a finding is addressed — the fix is in the new commits, or a reply
|
||||
settled it — resolve the thread. Any thread, whoever opened it: yours,
|
||||
another reviewer's, a bot's (Copilot, CodeRabbit, crit), the user's.
|
||||
Left open, findings accumulate for the life of the PR, and the author's
|
||||
`land` session, which will not declare a PR ready over an unaddressed
|
||||
thread, is blocked on them.
|
||||
|
||||
```bash
|
||||
# github — needs the thread id, not the comment id
|
||||
gh api graphql -f query='{ repository(owner:"<OWNER>", name:"<REPO>") { pullRequest(number:<N>) {
|
||||
reviewThreads(first:100) { nodes { id isResolved comments(first:1) { nodes { databaseId } } } } } } }'
|
||||
gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "<tid>"}) { thread { isResolved } } }'
|
||||
```
|
||||
|
||||
```bash
|
||||
# gitea (1.26+; on 404 leave the thread and let the reply stand as the signal)
|
||||
curl -sS -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||
"$BASE/api/v1/repos/$REPO/pulls/comments/<cid>/resolve"
|
||||
```
|
||||
|
||||
Resolve only what is actually addressed, and only after reading the
|
||||
code that addresses it. A finding the author merely disagreed with, and
|
||||
a question still waiting on an answer, both stay open — that is the
|
||||
user's call, not a backlog for you to clear.
|
||||
|
||||
Resolving is the whole of your authority here. It is not approval: the
|
||||
review decision stays `COMMENT`, per §2.
|
||||
|
||||
## 4. Close out
|
||||
|
||||
When your findings are posted (or handed over, on gated repos) and no
|
||||
thread is waiting on you, say so in one line and stop. Do not track the
|
||||
When your findings are posted (or handed over, on an unlisted repo), no
|
||||
thread is waiting on you, and every addressed thread is resolved, say so
|
||||
in one line and stop. Do not track the
|
||||
PR to merge — that's the author's job, and on someone else's PR it isn't
|
||||
yours to drive.
|
||||
|
||||
@@ -271,7 +271,7 @@ It bounds concurrency machine-wide (default `nproc/4` slots), caps the command's
|
||||
### Subagents
|
||||
|
||||
- Subagents **never run the full suite**, ever. They run scoped checks on what they touched. The session that dispatched them runs the full suite once, at the end.
|
||||
- Cap concurrent subagents at **3** per session, **2** if their tasks build or test. `gate.sh --status` showing no free slots is a signal to dispatch fewer, not to wait.
|
||||
- Cap concurrent subagents at **3** per session, **2** if their tasks build or test. The binding constraint is memory per worktree and the shared rate-limit window, not cores — subagents are mostly idle waiting on the API. `gate.sh --status` showing no free slots is a signal to dispatch fewer, not to wait.
|
||||
|
||||
### Pushing
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ JOBS="${AGENT_GATE_JOBS:-}"
|
||||
|
||||
cores=$(nproc 2>/dev/null || echo 4)
|
||||
[ -n "$SLOTS" ] || SLOTS=$(( cores / 4 )); [ "$SLOTS" -lt 1 ] && SLOTS=1
|
||||
[ -n "$JOBS" ] || JOBS=$(( cores / SLOTS / 2 )); [ "$JOBS" -lt 1 ] && JOBS=1
|
||||
# Leave 2 cores for the agent sessions themselves; the rest is split across slots.
|
||||
[ -n "$JOBS" ] || JOBS=$(( (cores - 2) / SLOTS )); [ "$JOBS" -lt 1 ] && JOBS=1
|
||||
[ -n "$CPU_QUOTA" ] || CPU_QUOTA="$(( JOBS * 100 ))%"
|
||||
|
||||
avail_mb() { awk '/MemAvailable/ {print int($2/1024); exit}' /proc/meminfo 2>/dev/null || echo 99999; }
|
||||
|
||||
@@ -72,7 +72,7 @@ Advisory, not a hard gate: for a trivial diff (typo, one-liner, config bump) ski
|
||||
|
||||
### 5. Hand off to `/land`
|
||||
|
||||
The PR is open — now drive it to ready-to-merge. **Invoke `/land <N>`** (the `land` skill). It owns the whole review/CI iteration loop: waits for CI + reviews without idling, fixes failures, resolves every comment (including bot reviewers), pushes, re-arms, and once green + approved it updates the branch and hands the merge click to the user — it never merges.
|
||||
The PR is open — now drive it to ready-to-merge. **Invoke `/land <N>`** (the `land` skill). It owns the whole review/CI iteration loop: waits for CI + reviews without idling, fixes failures, resolves every comment (including bot reviewers), pushes, re-arms, and once green + approved it updates the branch, then merges on gitea or hands the merge click to the user on GitHub.
|
||||
|
||||
Do not re-implement that loop here — `/land` is the single source of truth for it, and it reads the same `remoteHost` / tracker config. `/land` derives the tracking issue from the PR body's `Closes <REF>`, so no extra hand-off state is needed.
|
||||
|
||||
|
||||
@@ -57,4 +57,4 @@ Follow the implementation guidelines from COMMON.md. Move fast — this is yolo
|
||||
|
||||
This stays true to yolo: fire-and-forget, model idle, surfaces only a broken build.
|
||||
|
||||
**If a PR does exist and you want it driven to green + ready-to-merge** (CI waited on, review comments resolved, iterated until done; the merge click stays with the user) — don't hand-roll it here. Hand off to **`/land <N>`** (the `land` skill), the same loop `/work` uses. That's the escape hatch when a "yolo" task turns out to need real review follow-through.
|
||||
**If a PR does exist and you want it driven to green + ready-to-merge** (CI waited on, review comments resolved, iterated until done; it merges on gitea, and on GitHub the merge click stays with the user) — don't hand-roll it here. Hand off to **`/land <N>`** (the `land` skill), the same loop `/work` uses. That's the escape hatch when a "yolo" task turns out to need real review follow-through.
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
Description=PR daemon — watches GitHub/Gitea PRs and routes them to aoe sessions
|
||||
Documentation=https://git.naps.pt/yolo/agent-skills
|
||||
After=network.target
|
||||
ConditionPathExists=%h/.config/reviewer/config.json
|
||||
ConditionPathExists=%h/.config/reviewer/env
|
||||
# `|` makes these triggering conditions, so either path satisfies the pair:
|
||||
# the config moved out of ~/.config/reviewer once blitz started reading it too.
|
||||
ConditionPathExists=|%h/.config/agent-skills/config.json
|
||||
ConditionPathExists=|%h/.config/reviewer/config.json
|
||||
# MUST stay 0: at RestartSec=5 a fast-crashing daemon burns the default
|
||||
# 5-starts-per-10s budget and systemd parks the unit in `failed` until a
|
||||
# manual `systemctl --user reset-failed`.
|
||||
@@ -16,7 +18,8 @@ Environment=PATH=%h/.local/bin:%h/.nix-profile/bin:/etc/profiles/per-user/naps62
|
||||
# Without this the daemon reaches a different tmux server than the shell and
|
||||
# TUI do, so sessions it starts are invisible where you look for them.
|
||||
Environment=TMUX_TMPDIR=%t
|
||||
EnvironmentFile=%h/.config/reviewer/env
|
||||
EnvironmentFile=-%h/.config/agent-skills/env
|
||||
EnvironmentFile=-%h/.config/reviewer/env
|
||||
ExecStart=bun %h/tea/agent-skills/bin/reviewer-poll.ts
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
Reference in New Issue
Block a user