Compare commits

...

10 Commits

Author SHA1 Message Date
Miguel Palhas ab48785254 feat(week-review): price the gpt-5.6, Kimi and GLM models
ci / nix (pull_request) Successful in 9s
ci / lint (pull_request) Failing after 14s
Unpriced models counted as zero, so every codex row read as free and the
tool comparison was wrong. Rates are the providers' published ones; the
sol row reproduces a pi-reported total to the cent, which also confirms
the 0.1 cache-read multiplier applies to OpenAI.

GLM-4.7-Flash has no published per-token rate and stays out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 17:18:50 +01:00
Miguel Palhas e90137b17c fix(pr-daemon): rate-limit and coalesce hints
Every hint costs the receiving session a full model turn. The daemon sent
one per forge event with no quiet period, so a busy PR produced 20+ in a
day and sometimes repeated a payload verbatim.

Reasons are now banked per PR and role until a hint actually goes out, so
a busy pane or a cooldown delays one but never loses it. After a hint,
the next waits hintCooldownSeconds (default 300) and carries everything
that accumulated. A payload identical to the last is dropped, as is a ci
hint to a land session whose worktree already holds that head commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 17:18:50 +01:00
Miguel Palhas aff0e38dd3 fix(pr-daemon): route sessions by git branch, not aoe's field
ci / nix (push) Successful in 11s
ci / lint (push) Failing after 50s
aoe fills worktree.branch with the worktree *name* for worktrees it
created, so a session started by hand in a worktree named after anything
else never matched its own PR and the daemon opened a second session on
the same directory -- PR #412 got one next to the session already sitting
on 387-rehearse-ta-wallet-registration in worktrees/spanish.

Read the branch from the worktree instead, and fall back to the field.
main_repo_path gets the same treatment: it is empty for worktrees aoe
does not manage, which left those sessions matching no repo at all.

Takes effect after: systemctl --user restart pr-daemon (runs from
~/tea/agent-skills, so this must reach main first).
2026-08-27 15:34:27 +01:00
Miguel Palhas ca02107d42 fix(pr): pin agent-meta marker, add harness field
ci / nix (push) Successful in 10s
ci / lint (push) Failing after 12s
2026-08-26 10:34:52 +01:00
Miguel Palhas a229d0ce79 fix(plan-milestone): send owner and repo in dependency payload
ci / nix (push) Successful in 9s
ci / lint (push) Failing after 13s
2026-08-26 07:01:58 +01:00
Miguel Palhas 8e6f86c23b fix(blitz): label Claude budget results
ci / nix (push) Successful in 9s
ci / lint (push) Failing after 11s
2026-08-25 14:08:51 +01:00
Miguel Palhas c9e1c68ea2 feat(blitz): account for provider usage pressure
ci / nix (push) Successful in 8s
ci / lint (push) Failing after 11s
2026-08-25 14:08:07 +01:00
Miguel Palhas 3f0a8a1d6f chore(blitz): use conservative default budget
ci / nix (push) Successful in 8s
ci / lint (push) Successful in 11s
2026-08-25 13:35:24 +01:00
Miguel Palhas e818cfea8b feat(config): one agent roster for daemon and blitz
ci / lint (push) Successful in 1m12s
ci / nix (push) Successful in 18s
The reviewer pool was the only place naming harness+model combos, and
blitz kept its own table inline. `reviewers` becomes `agents`, gains
`roles` and `tiers`, and blitz routes from it via scripts/roster.sh.

Config path moves to ~/.config/agent-skills/; the reviewer path stays
readable so a box migrates with a mv.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 12:37:04 +01:00
Miguel Palhas d6265b0fbe docs(rev): arm the watcher by command name
ci / nix (push) Successful in 13s
ci / lint (push) Successful in 29s
rev is a nix package now and puts rev-watch on PATH. The old path pointed into
a working checkout, which is dev-only from here on.
2026-08-25 10:01:40 +01:00
16 changed files with 732 additions and 125 deletions
+54 -7
View File
@@ -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.
+174
View File
@@ -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"
}
}
-53
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+63
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+149
View File
@@ -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()
+6 -1
View File
@@ -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`
+7 -1
View File
@@ -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.
+13 -6
View File
@@ -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.
+7 -1
View File
@@ -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
+8 -2
View File
@@ -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}
}
}
+6 -3
View File
@@ -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