Compare commits

...

5 Commits

Author SHA1 Message Date
Miguel Palhas 7a55c42408 chore(reviewer): drop synthetic-hosted models from reviewer pool
Removes the GLM 5.2 entries that stood in for kimi in the previous commit.
The pool is now claude and gpt-5.6 only, across claude, pi and opencode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 11:25:18 +01:00
Miguel Palhas 4dd0c9d241 chore(reviewer): drop kimi-k3 from reviewer pool
Kimi K3 subscription is exhausted. Replaces the kimi entries in the
example config and README with GLM 5.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 11:19:26 +01:00
Miguel Palhas 5d1a81ec48 fix(review-pr): anchor findings to diff lines
The gitea posting snippet used the issue-comment endpoint, which has no
path or line, so findings named `path:line` in prose instead of landing
on the code. Both forges now post one COMMENT review carrying anchored
findings in comments[] and loose ones in the body.

land's gitea baseline missed review-comment ids, which would replay
every code comment on the first hint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 10:24:00 +01:00
Miguel Palhas 465b20a7c6 Merge branch 'persistent-reviewer' 2026-08-19 22:19:53 +01:00
Miguel Palhas 7c9b5cf4e0 feat(nix): ship the pr-daemon, hourlog and week-review units
The units ran from nixos-config while the scripts they execute live
here, so a fix like KillMode had to be made in the repo that does not
contain the daemon. They are defined here now and each machine opts in
with programs.agentSkills.<name>.enable, which keeps the property that
nothing starts a session unless a host asks for it.

ExecStart still points at the checkout, not the store: an edit should
take effect on restart rather than needing a flake bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:19:53 +01:00
5 changed files with 221 additions and 31 deletions
+15 -2
View File
@@ -45,6 +45,19 @@ imports = [ inputs.agent-skills.homeModules.default ];
`recursive = true` links files individually, so machine-local skills can coexist in the same dir. `nixos-rebuild switch` to apply/update.
The module also carries the user units — `pr-daemon`, `hourlog`, `week-review` — so each lives next to the script it runs. All three are off by default, because every one of them starts an agent session and a second machine enabling them would run the same job twice:
```nix
programs.agentSkills = {
machine = "yolo";
prDaemon.enable = true;
hourlog.enable = true;
weekReview.enable = true;
};
```
`repoPath` (default `%h/tea/agent-skills`) is what the units execute from. Deliberately a checkout rather than a store path: the daemon and the scripts change far more often than the flake input is bumped, so a restart is enough to pick up an edit. The `systemd/` unit files stay for non-Nix machines, where `link.sh` installs them.
## Shared machine, many sessions
Several autonomous runs share one box. `skills/linear-common/scripts/gate.sh` is a machine-wide semaphore for heavy commands (full test suites, whole-project builds): bounded slots, memory + CPU cap via a systemd user scope, pinned build/test parallelism. Skills run scoped checks in the inner loop and put only the once-per-push full suite through the gate; exit 75 means it never ran and CI takes over. Policy lives in `linear-common/COMMON.md` under "Local verification budget".
@@ -164,11 +177,11 @@ Every pick is appended to `ledger` (default
`~/.local/state/reviewer/reviewers.jsonl`):
```json
{"at":"…","pr":"gitea:yolo/rev#75","title":"rev-75-fix-race","reviewer":"pi/kimi-k3@high","author":"claude"}
{"at":"…","pr":"gitea:yolo/rev#75","title":"rev-75-fix-race","reviewer":"pi/gpt5.6@high","author":"claude"}
```
That's the raw material for rating later — group by harness, by model, or by
effort, and `pi/kimi-k3@med` against `@high` is the cleanest comparison in
effort, and `pi/gpt5.6@med` against `@high` is the cleanest comparison in
there. It's append-only analytics, not routing state, so nothing the daemon
does depends on it surviving.
+3 -2
View File
@@ -44,8 +44,9 @@
{ "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/kimi-k3@med", "tool": "pi", "args": ["--model", "synthetic/hf:moonshotai/Kimi-K3:medium"] },
{ "id": "oc/glm5.2", "tool": "opencode", "args": ["--model", "synthetic/hf:zai-org/GLM-5.2"] },
{ "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"] }
]
}
+152 -9
View File
@@ -24,17 +24,51 @@ let
concatMd =
name: files:
pkgs.writeText name (lib.concatMapStringsSep "\n" builtins.readFile files);
# A user unit gets almost no PATH by default; the units below shell out to
# aoe, git and tmux, which live in the profile dirs.
toolPath = lib.concatStringsSep ":" [
"%h/.local/bin"
"%h/.nix-profile/bin"
"/etc/profiles/per-user/${config.home.username}/bin"
"/run/current-system/sw/bin"
];
# These run from the working checkout, not the store: the scripts and the
# daemon are edited far more often than the flake input is bumped, and a
# restart is meant to be enough to pick a change up.
repo = cfg.repoPath;
mkEnable = what: lib.mkEnableOption "the ${what} user unit";
in
{
options.programs.agentSkills.machine = lib.mkOption {
type = lib.types.str;
default = "default";
example = "yolo";
description = ''
Which claude-md/machines/<name>.md to link as ~/.claude/machine.md.
The shared entry file @imports it, so it always has to resolve; the
"default" profile is the conservative one (no passwordless root).
'';
options.programs.agentSkills = {
machine = lib.mkOption {
type = lib.types.str;
default = "default";
example = "yolo";
description = ''
Which claude-md/machines/<name>.md to link as ~/.claude/machine.md.
The shared entry file @imports it, so it always has to resolve; the
"default" profile is the conservative one (no passwordless root).
'';
};
repoPath = lib.mkOption {
type = lib.types.str;
default = "%h/tea/agent-skills";
description = ''
Checkout the units run from, as a systemd unit specifier path. Not a
store path: the units are pointed at working copies so an edit takes
effect on restart instead of requiring a flake bump and a rebuild.
'';
};
# Off by default, and that matters: every one of these starts an agent
# session, so enabling them on a second machine would run the same job twice.
prDaemon.enable = mkEnable "PR daemon";
hourlog.enable = mkEnable "Friday hour log timer";
weekReview.enable = mkEnable "weekly review timer";
};
config.home.file = {
@@ -103,6 +137,115 @@ in
recursive = true;
};
};
# Unit definitions live here, next to the scripts they run; a machine opts in
# with `programs.agentSkills.<name>.enable`. Nothing is enabled by default --
# each of these starts an agent session, and two machines running the same
# timer means the same job twice.
config.systemd.user.services = lib.mkMerge [
(lib.mkIf cfg.prDaemon.enable {
pr-daemon = {
Unit = {
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.
ConditionPathExists = [
"%h/.config/reviewer/config.json"
"%h/.config/reviewer/env"
];
# 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`.
StartLimitIntervalSec = 0;
};
Service = {
Type = "simple";
WorkingDirectory = "%h";
ExecStart = "${pkgs.bun}/bin/bun ${repo}/bin/reviewer-poll.ts";
EnvironmentFile = "%h/.config/reviewer/env";
Environment = [
"PATH=${toolPath}"
# 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.
"TMUX_TMPDIR=%t"
];
Restart = "always";
RestartSec = 5;
# The agent tmux sessions this daemon starts land in its cgroup, so
# the default control-group kill takes every running agent down with
# a daemon restart.
KillMode = "process";
};
Install.WantedBy = [ "default.target" ];
};
})
(lib.mkIf cfg.hourlog.enable {
hourlog = {
Unit = {
Description = "Start the Friday hour log in a tmux session";
Documentation = [ "https://git.naps.pt/yolo/agent-skills" ];
ConditionPathIsDirectory = repo;
};
Service = {
Type = "oneshot";
ExecStart = "${repo}/bin/hourlog-session.sh";
Environment = [ "PATH=${toolPath}" ];
# This unit may be what starts the tmux server; the default cgroup
# kill would take it back down as soon as ExecStart returns.
KillMode = "process";
};
};
})
(lib.mkIf cfg.weekReview.enable {
week-review = {
Unit = {
Description = "Start the weekly agent-skills review in a tmux session";
Documentation = [ "https://git.naps.pt/yolo/agent-skills" ];
ConditionPathIsDirectory = repo;
};
Service = {
Type = "oneshot";
ExecStart = "${repo}/bin/week-review-session.sh";
Environment = [ "PATH=${toolPath}" ];
KillMode = "process";
};
};
})
];
config.systemd.user.timers = lib.mkMerge [
(lib.mkIf cfg.hourlog.enable {
hourlog = {
Unit.Description = "Friday hour log, 18:00 Europe/Lisbon";
Timer = {
# Zone suffix pinned because the machine clock is UTC; keeps it at
# 18:00 wall time across DST.
OnCalendar = "Fri 18:00 Europe/Lisbon";
Persistent = true;
AccuracySec = "1min";
};
Install.WantedBy = [ "timers.target" ];
};
})
(lib.mkIf cfg.weekReview.enable {
week-review = {
Unit.Description = "Weekly agent-skills review, Fridays 17:00 Europe/Lisbon";
Timer = {
OnCalendar = "Fri 17:00 Europe/Lisbon";
Persistent = true;
AccuracySec = "1min";
};
Install.WantedBy = [ "timers.target" ];
};
})
];
}
# Hook wiring lives in ~/.claude/settings.json, which this module does not own.
# See hooks/README.md for the snippet.
+11 -3
View File
@@ -61,9 +61,11 @@ fi
Gitea equivalent — issue comments plus reviews:
```bash
{ curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/issues/$N/comments"; \
curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/pulls/$N/reviews"; } 2>/dev/null \
| jq -r '.[]?.id' > "$seen" || : > "$seen"
{ curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/issues/$N/comments" | jq -r '.[]?.id'
for r in $(curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/pulls/$N/reviews" | jq -r '.[]?.id'); do
echo "$r"
curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/pulls/$N/reviews/$r/comments" | jq -r '.[]?.id'
done; } 2>/dev/null > "$seen" || : > "$seen"
```
**Request the Copilot review** (github only, once). Its comments then
@@ -111,6 +113,12 @@ and act on what's left:
echo "$rid" >> "$seen"
```
Gitea has no reply endpoint, so that lands as a loose PR comment. To
answer a code comment inside its own thread, post a review instead
whose `comments[]` entry repeats the same `path` and `new_position` —
gitea groups code comments by position into one conversation. Record
the review id and its comment ids.
- **Resolve the thread** (github only — gitea has no per-thread
resolve, so a short confirming reply plus the pushed fix is the
signal):
+40 -15
View File
@@ -62,43 +62,68 @@ than reviewing hunks in isolation.
**Write the findings** to `<git-dir>/pr-<N>-findings.md` — in the git
dir, not the working tree, so nothing lands in the branch under review.
One finding per entry: `path:line`, what's wrong, what to do. No praise,
no summary of what the PR does, no severity theatre. If you find
nothing, say so in one line.
One finding per entry: `path:line`, what's wrong, what to do — and mark
whether it anchors to a diff line or is a loose remark about the change
as a whole, which decides where it goes in §2. No praise, no summary of
what the PR does, no severity theatre. If you find nothing, say so in
one line.
Then follow the mode: post (gitea) or report the file to the user and
stop (github).
## 2. Posting
Post **one review** per pass, never a stream of separate comments. A
review carries two kinds of finding at once:
- **Anchored** — the finding is about a specific line in the diff. It
belongs in `comments[]` with a `path` and a line, so it renders on
the code.
- **Loose** — the finding is about the change as a whole, or about code
the diff doesn't touch, or it has no single line to sit on. It goes
in the review `body`.
Anchor whatever can be anchored. Writing `path:line` into prose when
the API would have put the comment on that line is the failure mode
this section exists to prevent.
Only after the user's go-ahead on gated repos. Record every id you post
in the same step, or the next hint reads your own review as new
feedback:
```bash
# gitea
rid=$(curl -sS -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
"$BASE/api/v1/repos/$REPO/issues/$N/comments" -d "$(jq -nc --arg body "<finding>" '{body:$body}')" | jq -r .id)
# gitea — body is the loose findings, comments[] the anchored ones
# new_position = line in the new file; use old_position for a removed line
rid=$(jq -nc \
--arg body "<loose findings, or empty>" \
--argjson comments '[{"path":"path/to/file.ts","new_position":11,"body":"<finding>"}]' \
'{event:"COMMENT", body:$body, comments:$comments}' \
| curl -sS -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
"$BASE/api/v1/repos/$REPO/pulls/$N/reviews" -d @- | jq -r .id)
echo "$rid" >> "$seen"
curl -sS -H "Authorization: token $GITEA_TOKEN" \
"$BASE/api/v1/repos/$REPO/pulls/$N/reviews/$rid/comments" | jq -r '.[].id' >> "$seen"
```
```bash
# github, after approval
rid=$(gh api repos/<OWNER>/<REPO>/pulls/<N>/comments -f body="<finding>" \
-f commit_id=<sha> -f path=<path> -F line=<line> --jq .id)
echo "$rid" >> "$seen"
# github, after approval — same shape, `line` instead of new_position
jq -nc --arg body "<loose findings, or empty>" \
--argjson comments '[{"path":"path/to/file.ts","line":11,"body":"<finding>"}]' \
'{event:"COMMENT", commit_id:"<sha>", body:$body, comments:$comments}' \
| gh api repos/<OWNER>/<REPO>/pulls/<N>/reviews --input - --jq .id >> "$seen"
gh api repos/<OWNER>/<REPO>/pulls/<N>/comments --jq '.[].id' >> "$seen"
```
Prefer one review with several comments over a stream of separate
comments. **Never approve and never request changes as a review
decision** — that's the user's call on someone else's PR, and it carries
weight your findings don't.
`event: "COMMENT"` is the only event either forge should see from you.
**Never approve and never request changes as a review decision**
that's the user's call on someone else's PR, and it carries weight your
findings don't.
## 3. Handling a hint
| reason | what to do |
| --- | --- |
| `comments` | Read comments not in the seen file. Someone replying to a finding gets an answer; a new comment thread may need a fresh look at that code. Record every id you handle or post. |
| `comments` | Read comments not in the seen file. Someone replying to a finding gets an answer; a new comment thread may need a fresh look at that code. Reply in the thread it came from: on github, `POST /pulls/<N>/comments/<cid>/replies`; on gitea there is no reply endpoint, so post a review whose `comments[]` entry carries the same `path` and line — gitea groups code comments by position into one conversation. A loose reply goes to `POST /issues/<N>/comments`. Record every id you handle or post. |
| `ci` | New head SHA: the author pushed. Re-read the diff for the new commits only, and check whether your open findings are addressed. Do not investigate their CI failures — not your PR. |
| `state` | Merged or closed: write the outcome to the state file and stop. Draft flips: nothing to do. |
| `conflicts` | Nothing to do. The author resolves conflicts on their own branch. |