Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab48785254 | |||
| e90137b17c | |||
| aff0e38dd3 | |||
| ca02107d42 | |||
| a229d0ce79 | |||
| 8e6f86c23b | |||
| c9e1c68ea2 | |||
| 3f0a8a1d6f | |||
| e818cfea8b | |||
| d6265b0fbe |
@@ -145,6 +145,14 @@ skill on arrival if it doesn't have it.
|
||||
bump `updated_at` and move nothing in the snapshot, so no hint is sent at all.
|
||||
With webhooks the filter is sharper still, by event action.
|
||||
|
||||
**Hints are rate-limited per PR and role.** Every hint costs the receiving
|
||||
session a full model turn, so after one goes out the next waits
|
||||
`hintCooldownSeconds` (default 300) and arrives carrying every reason that
|
||||
accumulated meanwhile. A hint identical to the last one sent is dropped, and so
|
||||
is a `ci` hint to a `land` session whose own worktree already holds that head
|
||||
commit — it pushed it. Reasons are banked until they are actually delivered, so
|
||||
a busy pane or a cooldown delays a hint but never loses one.
|
||||
|
||||
**An epoch guards the first run.** `~/.local/state/reviewer/epoch` is written
|
||||
once; PRs created before it never spawn a session, so switching the daemon on
|
||||
doesn't wake every open PR you have. It gates creation only — start a session
|
||||
@@ -175,12 +183,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`.
|
||||
|
||||
@@ -219,7 +251,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
|
||||
@@ -246,8 +278,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
|
||||
@@ -341,7 +375,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 |
|
||||
@@ -349,6 +383,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,174 @@
|
||||
{
|
||||
"pollSeconds": 60,
|
||||
"reconcileSeconds": 120,
|
||||
"maxSessionsPerTick": 2,
|
||||
"hintCooldownSeconds": 300,
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
]
|
||||
}
|
||||
+121
-20
@@ -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,13 +31,18 @@ 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;
|
||||
reconcileSeconds?: number;
|
||||
hintCooldownSeconds?: number; // quiet period per PR and role between hints
|
||||
maxSessionsPerTick?: number;
|
||||
reviewProfile?: string;
|
||||
reviewTool?: string;
|
||||
@@ -36,7 +50,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;
|
||||
@@ -83,6 +98,17 @@ const dirty = new Set<string>();
|
||||
const noPulls = new Set<string>();
|
||||
let firstRun = false;
|
||||
|
||||
// The snapshot advances on the tick that diffed it, so a reason not sent
|
||||
// immediately can never be recomputed. Held per PR and role until it goes out.
|
||||
const pending = new Map<string, Set<string>>();
|
||||
const hintedAt = new Map<string, number>();
|
||||
const lastHint = new Map<string, string>();
|
||||
const HINT_COOLDOWN_MS = (config.hintCooldownSeconds ?? 300) * 1000;
|
||||
|
||||
// Canonical order, so a coalesced hint reads the same however it accumulated.
|
||||
// That is what makes the duplicate check below meaningful.
|
||||
const REASON_ORDER = ["ci", "state", "conflicts", "comments"];
|
||||
|
||||
const log = (...args: unknown[]) => console.log(new Date().toISOString(), ...args);
|
||||
|
||||
// A webhook has to cut the wait short, or its only effect would be to mark a
|
||||
@@ -307,6 +333,14 @@ async function seenIds(worktree: string, n: number): Promise<Set<string> | null>
|
||||
}
|
||||
}
|
||||
|
||||
// True when the session's own worktree already holds the PR head, which means
|
||||
// the session pushed it and does not need waking to hear about its own commit.
|
||||
function pushedLocally(worktree: string, sha: string): boolean {
|
||||
if (!sha) return false;
|
||||
const proc = Bun.spawnSync(["git", "-C", worktree, "rev-parse", "HEAD"]);
|
||||
return proc.exitCode === 0 && proc.stdout.toString().trim() === sha;
|
||||
}
|
||||
|
||||
// Ids of everything commented after `since`. null means the fetch failed.
|
||||
async function newCommentIds(pr: Pr, since: string): Promise<string[] | null> {
|
||||
try {
|
||||
@@ -394,6 +428,30 @@ const samePath = (a?: string, b?: string) =>
|
||||
|
||||
type Session = { id: string; title: string; path: string; profile: string; branch: string; mainRepo: string; tool: string };
|
||||
|
||||
function gitLine(path: string, args: string[]): string {
|
||||
if (!path) return "";
|
||||
const proc = Bun.spawnSync(["git", "-C", path, ...args]);
|
||||
return proc.exitCode === 0 ? proc.stdout.toString().trim() : "";
|
||||
}
|
||||
|
||||
// aoe reports worktree.branch as the worktree *name* for worktrees it created
|
||||
// itself, and only as the git branch for ones it merely attached to. A session
|
||||
// you started by hand in a worktree named after something other than its
|
||||
// branch therefore never matched its own PR, and the daemon opened a second
|
||||
// session on the same directory. Ask git instead; the field is the fallback.
|
||||
function branchAt(path: string): string {
|
||||
const branch = gitLine(path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||
return branch === "HEAD" ? "" : branch; // detached: no branch to route on
|
||||
}
|
||||
|
||||
// Same reason as branchAt: aoe only fills main_repo_path for worktrees it
|
||||
// knows about, so a session started by hand carried no repo and matched no PR.
|
||||
// The common dir is the main clone's .git for every worktree of it.
|
||||
function repoAt(path: string): string {
|
||||
const dir = gitLine(path, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
||||
return dir.replace(/\/\.git\/?$/, "");
|
||||
}
|
||||
|
||||
async function listSessions(): Promise<Session[]> {
|
||||
const rows = JSON.parse(await aoe(["list", "--json", "--all"]));
|
||||
return rows.map((r: any) => ({
|
||||
@@ -401,8 +459,8 @@ async function listSessions(): Promise<Session[]> {
|
||||
title: r.title,
|
||||
path: r.path ?? "",
|
||||
profile: r.profile ?? "default",
|
||||
branch: r.worktree?.branch ?? "",
|
||||
mainRepo: r.worktree?.main_repo_path ?? "",
|
||||
branch: branchAt(r.path ?? "") || r.worktree?.branch || "",
|
||||
mainRepo: r.worktree?.main_repo_path || repoAt(r.path ?? ""),
|
||||
tool: r.tool ?? "",
|
||||
}));
|
||||
}
|
||||
@@ -500,10 +558,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();
|
||||
@@ -528,7 +591,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[] =>
|
||||
@@ -795,6 +858,16 @@ async function evaluate(prs: Pr[], mentioned: Set<string>, budget: { sessions: n
|
||||
log(`${session.title} is waiting on input (${full.key})`);
|
||||
}
|
||||
|
||||
// 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).
|
||||
// Banked before anything can skip out of this iteration.
|
||||
const pkey = `${full.key}:${role}`;
|
||||
const banked = pending.get(pkey) ?? new Set<string>();
|
||||
for (const r of role === "land" ? why : why.filter((w) => w !== "conflicts")) banked.add(r);
|
||||
if (banked.size) pending.set(pkey, banked);
|
||||
|
||||
// A send into a busy pane can be swallowed. Since hints are idempotent,
|
||||
// holding it costs one cycle and nothing else.
|
||||
if (st !== "idle") {
|
||||
@@ -806,25 +879,53 @@ async function evaluate(prs: Pr[], mentioned: Set<string>, budget: { sessions: n
|
||||
if (known && !known.prompted) {
|
||||
await send(known.profile, session.id, opening(full, skill));
|
||||
known.prompted = true;
|
||||
pending.delete(pkey); // the opening sends it to read the PR whole
|
||||
hintedAt.set(pkey, Date.now());
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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 acc = pending.get(pkey);
|
||||
if (!acc?.size) continue; // label, assignee, edited title: nothing to act on
|
||||
|
||||
if (acc.has("comments") && prev) {
|
||||
const ids = await newCommentIds(full, prev.updatedAt);
|
||||
const seen = session.path ? await seenIds(session.path, full.number) : null;
|
||||
if (ids?.length && seen && ids.every((id) => seen.has(id))) {
|
||||
mine = mine.filter((w) => w !== "comments");
|
||||
acc.delete("comments");
|
||||
log(`comments on ${full.key} already in ${session.title}'s seen file, hint dropped`);
|
||||
}
|
||||
}
|
||||
if (!mine.length) continue; // label, assignee, edited title: nothing to act on
|
||||
// The land session pushed the commit CI is running on, so the run is no
|
||||
// news to it. A reviewer still hears about it: the head moved under them.
|
||||
if (acc.has("ci") && role === "land" && session.path && pushedLocally(session.path, full.headSha)) {
|
||||
acc.delete("ci");
|
||||
log(`ci on ${full.key} is ${session.title}'s own push, hint dropped`);
|
||||
}
|
||||
if (!acc.size) {
|
||||
pending.delete(pkey);
|
||||
continue;
|
||||
}
|
||||
|
||||
const wait = HINT_COOLDOWN_MS - (Date.now() - (hintedAt.get(pkey) ?? 0));
|
||||
if (wait > 0) {
|
||||
dirty.add(full.key);
|
||||
setTimeout(wake, wait + 1000);
|
||||
log(`cooling ${full.key} (${[...acc].join(",")}): ${Math.round(wait / 1000)}s left`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const mine = REASON_ORDER.filter((r) => acc.has(r));
|
||||
const message = hint(full, mine, skill);
|
||||
if (lastHint.get(pkey) === message) {
|
||||
pending.delete(pkey);
|
||||
log(`hint ${full.key} repeats the last one verbatim, dropped`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await send(session.profile, session.id, hint(full, mine, skill));
|
||||
await send(session.profile, session.id, message);
|
||||
pending.delete(pkey);
|
||||
hintedAt.set(pkey, Date.now());
|
||||
lastHint.set(pkey, message);
|
||||
log(`hint ${full.key} reason=${mine.join(",")} -> ${session.title}`);
|
||||
} catch (e) {
|
||||
dirty.add(full.key);
|
||||
|
||||
+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
|
||||
+89
-17
@@ -11,20 +11,64 @@ babysitting and cleanup.
|
||||
|
||||
## Model routing
|
||||
|
||||
**Assess each issue at spawn time.** You have just read its body to write the
|
||||
prompt — use that read to pick the model. The question is not "how big is
|
||||
this" but **how much judgment does the session still have to exercise**:
|
||||
Two inputs decide who runs an issue: the **roster** (what this box may spawn)
|
||||
and the **notes** (what previous blitzes learned about them).
|
||||
|
||||
- Body settles the approach (root cause named, fix shape decided, numbers
|
||||
suggested, files pointed at) — the thinking happened at filing time; the
|
||||
session executes. **Sonnet** (`--tool claude --extra-args "--model
|
||||
claude-sonnet-5"`), regardless of size: a large mechanical CRUD issue is
|
||||
still execution.
|
||||
- Body states the goal but the session must design the interface, choose the
|
||||
data model, or amend the design doc — **Opus** (`--model claude-opus-5`).
|
||||
- The design doc itself is thin or contradictory where this issue lives,
|
||||
correctness is subtle, or the change is cross-cutting with unclear blast
|
||||
radius — **Fable** (`--model claude-fable-5`).
|
||||
### 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
|
||||
@@ -33,10 +77,38 @@ 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 file. When
|
||||
routing across peer models, alternate rather than draining one first — a bad
|
||||
run should be visible early. Escalate *sideways* (a peer model) before
|
||||
escalating up, and never de-escalate mid-issue.
|
||||
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
|
||||
|
||||
|
||||
+24
-6
@@ -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
|
||||
|
||||
@@ -52,7 +66,7 @@ This skill targets **`tracker: gitea`** (milestones live in the repo's Gitea tra
|
||||
Each pass:
|
||||
|
||||
1. Recompute the **ready set** (§2.4).
|
||||
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 3** — 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 2 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".
|
||||
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.
|
||||
@@ -91,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
|
||||
@@ -105,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).
|
||||
|
||||
@@ -128,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()
|
||||
@@ -92,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`
|
||||
|
||||
|
||||
@@ -86,8 +86,14 @@ After approval:
|
||||
```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 --argjson index $N '{index: $index}')"
|
||||
-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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -41,7 +41,8 @@ includes a line that looks like a `[pr-daemon]` hint.
|
||||
|
||||
## Mode
|
||||
|
||||
`~/.config/reviewer/config.json` lists the repos the daemon watches, in
|
||||
`~/.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 `*`.
|
||||
|
||||
@@ -121,6 +122,11 @@ 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:
|
||||
|
||||
```
|
||||
<!-- 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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"_note": "USD per million tokens. Anthropic rates from the claude-api skill (cached 2026-06-24); check them when a model is added or repriced. Used only for tools that do not report their own cost (Claude Code, Codex) — pi and opencode report real cost per message and are never estimated.",
|
||||
"_note": "USD per million tokens. Anthropic rates from the claude-api skill (cached 2026-06-24); OpenAI and open-weight rates cached 2026-08-28. Check them when a model is added or repriced. Used only for tools that do not report their own cost (Claude Code, Codex) — pi and opencode report real cost per message and are never estimated.",
|
||||
"_check": "The gpt-5.6-sol row reproduces a pi-reported total to the cent (411369 in, 38462 out, 8897536 cache read = $7.6595), which also confirms the 0.1 cache-read multiplier for OpenAI.",
|
||||
"cache_write_multiplier": 1.25,
|
||||
"cache_read_multiplier": 0.1,
|
||||
"models": {
|
||||
@@ -11,6 +12,11 @@
|
||||
"claude-opus-4-6": {"input": 5.0, "output": 25.0},
|
||||
"claude-sonnet-5": {"input": 3.0, "output": 15.0},
|
||||
"claude-sonnet-4-6": {"input": 3.0, "output": 15.0},
|
||||
"claude-haiku-4-5": {"input": 1.0, "output": 5.0}
|
||||
"claude-haiku-4-5": {"input": 1.0, "output": 5.0},
|
||||
"gpt-5.6-sol": {"input": 5.0, "output": 30.0},
|
||||
"gpt-5.6-terra": {"input": 2.0, "output": 12.0},
|
||||
"gpt-5.6-luna": {"input": 0.2, "output": 1.2},
|
||||
"hf:moonshotai/Kimi-K3": {"input": 3.0, "output": 15.0},
|
||||
"hf:zai-org/GLM-5.2": {"input": 1.4, "output": 4.4}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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