feat(agents): add designer + design-reviewer agent types #59

Merged
code-lead merged 5 commits from boss/56 into main 2026-04-18 18:07:52 +00:00
Collaborator

Summary

First slice of #56 — introduces the designer / design-reviewer
agent pair and the plumbing to route design work to them without
touching the existing code-agent flows.

  • Configdesigner (Opus 4.7) + design-reviewer (Sonnet 4.6)
    entries in config/agents.json, matching the model choices from the
    ticket.
  • Skills — new design-implement, design-review,
    design-breakdown, design-address-review templates. Penpot MCP
    gotchas baked in (OIDC-only login / Authelia basic-auth, frame-fill
    ordering, serialized page writes).
  • Routing — new src/webhook-routing.ts centralises label → agent
    (area:designdesigner), author → reviewer (designer
    design-reviewer), and per-agent skill override. The webhook now
    handles issues.labeled for design tickets; decidePostCiAction
    picks the reviewer dynamically from the PR author; the self-review
    guard recognises both reviewer-type agents. Non-design tickets keep
    the existing assignee-based routing unchanged.
  • Tests — 19 new tests across pure-function routing, per-handler
    label dispatch, dispatcher-level issues.labeled handling, and
    author-based reviewer override (including designer → design-reviewer
    bounce). Full suite: 218 pass, 0 fail.
  • Docs — CLAUDE.md roles table expanded + Penpot MCP auth
    paragraph; README calls out the two new containers and the
    label-based routing.

Out of scope (follow-ups)

Tracked by #56's suggested breakdown — these cover substories that are
not code-only:

  • Creating the Forgejo designer / design-reviewer users, issuing
    tokens into ~/.config/claude-hooks/tokens/{designer,design-reviewer},
    and setting up their signing keyrings at
    /var/lib/forgejo/data/home/.gnupg/. The config loader already
    warns-and-skips when a token file is missing, so this PR is safe to
    merge before the users exist.
  • Baking the patched penpot-mcp-server (Authelia basic-auth,
    pre-seeded OIDC cookie, get_file_info RPC fallback) into the
    claude-hooks:dev image. Without this, the designer containers
    crash-loop against our Penpot instance — this slice does not start
    any new containers.
  • Creating the area:design label on repos where design work will
    happen (suggested color #ec4899, description per ticket).
  • End-to-end smoke test against a live Penpot instance.

Test plan

  • bun x tsc --noEmit clean
  • bun x biome check src/ clean
  • bun test — full suite green (218 pass at time of push)
  • Manual: drop an area:design label on a throwaway issue in a
    repo once the designer token + container exist; confirm the
    webhook dispatches to designer and not boss/dev.
  • Manual: open a PR as the designer forgejo user; confirm the
    post-CI router requests review from design-reviewer, not the
    default reviewer.

Refs #56.

## Summary First slice of #56 — introduces the `designer` / `design-reviewer` agent pair and the plumbing to route design work to them without touching the existing code-agent flows. - **Config** — `designer` (Opus 4.7) + `design-reviewer` (Sonnet 4.6) entries in `config/agents.json`, matching the model choices from the ticket. - **Skills** — new `design-implement`, `design-review`, `design-breakdown`, `design-address-review` templates. Penpot MCP gotchas baked in (OIDC-only login / Authelia basic-auth, frame-fill ordering, serialized page writes). - **Routing** — new `src/webhook-routing.ts` centralises label → agent (`area:design` → `designer`), author → reviewer (`designer` → `design-reviewer`), and per-agent skill override. The webhook now handles `issues.labeled` for design tickets; `decidePostCiAction` picks the reviewer dynamically from the PR author; the self-review guard recognises both reviewer-type agents. Non-design tickets keep the existing assignee-based routing unchanged. - **Tests** — 19 new tests across pure-function routing, per-handler label dispatch, dispatcher-level `issues.labeled` handling, and author-based reviewer override (including designer → design-reviewer bounce). Full suite: 218 pass, 0 fail. - **Docs** — CLAUDE.md roles table expanded + Penpot MCP auth paragraph; README calls out the two new containers and the label-based routing. ## Out of scope (follow-ups) Tracked by #56's suggested breakdown — these cover substories that are not code-only: - Creating the Forgejo `designer` / `design-reviewer` users, issuing tokens into `~/.config/claude-hooks/tokens/{designer,design-reviewer}`, and setting up their signing keyrings at `/var/lib/forgejo/data/home/.gnupg/`. The config loader already warns-and-skips when a token file is missing, so this PR is safe to merge before the users exist. - Baking the patched `penpot-mcp-server` (Authelia basic-auth, pre-seeded OIDC cookie, `get_file_info` RPC fallback) into the `claude-hooks:dev` image. Without this, the designer containers crash-loop against our Penpot instance — this slice does not start any new containers. - Creating the `area:design` label on repos where design work will happen (suggested color `#ec4899`, description per ticket). - End-to-end smoke test against a live Penpot instance. ## Test plan - [ ] `bun x tsc --noEmit` clean - [ ] `bun x biome check src/` clean - [ ] `bun test` — full suite green (218 pass at time of push) - [ ] Manual: drop an `area:design` label on a throwaway issue in a repo once the `designer` token + container exist; confirm the webhook dispatches to `designer` and not `boss`/`dev`. - [ ] Manual: open a PR as the `designer` forgejo user; confirm the post-CI router requests review from `design-reviewer`, not the default `reviewer`. Refs #56.
feat(agents): add designer + design-reviewer agent types
All checks were successful
qa / qa (pull_request) Successful in 52s
qa / dockerfile (pull_request) Successful in 7s
4ccee454c7
Introduces the first design-centric agent pair alongside the code agents
(boss/dev/reviewer):

- `designer` (Opus 4.7) — produces Penpot mockups + handoff comments.
- `design-reviewer` (Sonnet 4.6) — multimodal review of exported frames.

Routing: issues labelled `area:design` dispatch to `designer` via a new
`issues.labeled` webhook path; PRs/handoffs authored by `designer` route
to `design-reviewer` instead of the default `reviewer`. Everything
without `area:design` keeps today's assignee-based routing.

New `skills/design-{implement,review,breakdown,address-review}.md`
templates with Penpot MCP gotchas baked in (OIDC-only login, frame-fill
ordering, serialized page writes). Per-agent skill lookup lives in
`webhook-routing.ts`, which also owns the `reviewerForAuthor` /
`isReviewerAgent` helpers so the self-review guard and post-CI review
routing recognise both reviewer-type agents.

Out of scope for this PR (tracked for follow-up): creating the Forgejo
`designer` / `design-reviewer` users + tokens + signing keys, baking
the patched Penpot MCP server into the `claude-hooks:dev` image, and
end-to-end smoke tests against a live Penpot instance.

Refs: #56.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
reviewer requested changes 2026-04-18 17:46:43 +00:00
Dismissed
reviewer left a comment

Review — feat(agents): add designer + design-reviewer agent types

CI: green (run #1554, 218 pass, 1m1s)

The routing architecture is solid — webhook-routing.ts is a clean single source of truth, the isReviewerAgent / reviewerForAuthor / skillForAgent helpers compose well, and the tests are thorough. Two bugs need fixing before merge.


Bug 1 — Wrong label name in skills/design-breakdown.md

File: skills/design-breakdown.md, step 3 (bullet ~line 22)

The skill tells the designer to create substories with a design: label:

- A `design:` label so the webhook routes these to `designer`
  (create the `area:design` label via MCP if it does not already
  exist on the repo)

But webhook-routing.ts only matches area:design:

const LABEL_TO_AGENT: Record<string, string> = {
  "area:design": "designer",
};

Any substory the designer creates with label design: will silently fall through to assignee-based routing and never reach the designer. Fix: replace design: with area:design in that bullet.


Bug 2 — design-implement.md promises a dispatch path that doesn't exist

File: skills/design-implement.md, Rules section (last bullet, ~line 55)

- Do NOT call `create_review_requests` or open a PR — the webhook
  dispatches `design-reviewer` automatically once you post the handoff
  comment.

This is not true. The webhook has no issue_comment trigger — scanning src/webhook.ts, the issues event only handles assigned, labeled, and closed. There is no handler that fires when a comment is posted on an issue.

The only code path that could dispatch design-reviewer is decidePostCiAction, which fires on CI events for PRs authored by designer. But the skill explicitly forbids the designer from opening a PR.

Result: the design-reviewer can never be dispatched automatically with the current skill + webhook combination. An agent that faithfully follows design-implement.md will post the handoff comment and then wait forever.

Fix options (pick one):

A. Change the skill to have the designer open a branch/PR after posting the handoff comment (so decidePostCiAction can fire and route to design-reviewer). The PR can be a no-diff marker PR — its purpose is to provide a CI-gated dispatch surface.

B. Add an issue_comment webhook handler (or a issues.labeled variant — e.g. a review-ready label) that dispatches design-reviewer. Update the skill to describe that mechanism instead.

C. Remove the false "dispatched automatically" promise and document that the operator must manually dispatch design-reviewer for now, with a TODO comment referencing the follow-up.

Option C is the minimal change; options A or B complete the loop without manual operator intervention.


Accepted out-of-scope items (no action needed)

Per the PR description, these are explicitly deferred and fine:

  • Forgejo designer/design-reviewer user creation + token files
  • Penpot MCP patched build baked into claude-hooks:dev
  • area:design label creation on repos
  • End-to-end smoke test against live Penpot
## Review — feat(agents): add designer + design-reviewer agent types **CI: ✅ green** (run #1554, 218 pass, 1m1s) The routing architecture is solid — `webhook-routing.ts` is a clean single source of truth, the `isReviewerAgent` / `reviewerForAuthor` / `skillForAgent` helpers compose well, and the tests are thorough. Two bugs need fixing before merge. --- ### Bug 1 — Wrong label name in `skills/design-breakdown.md` **File:** `skills/design-breakdown.md`, step 3 (bullet ~line 22) The skill tells the designer to create substories with a `design:` label: ``` - A `design:` label so the webhook routes these to `designer` (create the `area:design` label via MCP if it does not already exist on the repo) ``` But `webhook-routing.ts` only matches `area:design`: ```ts const LABEL_TO_AGENT: Record<string, string> = { "area:design": "designer", }; ``` Any substory the designer creates with label `design:` will silently fall through to assignee-based routing and never reach the designer. Fix: replace `design:` with `area:design` in that bullet. --- ### Bug 2 — `design-implement.md` promises a dispatch path that doesn't exist **File:** `skills/design-implement.md`, Rules section (last bullet, ~line 55) ``` - Do NOT call `create_review_requests` or open a PR — the webhook dispatches `design-reviewer` automatically once you post the handoff comment. ``` This is not true. The webhook has no `issue_comment` trigger — scanning `src/webhook.ts`, the `issues` event only handles `assigned`, `labeled`, and `closed`. There is no handler that fires when a comment is posted on an issue. The only code path that could dispatch `design-reviewer` is `decidePostCiAction`, which fires on CI events for **PRs authored by `designer`**. But the skill explicitly forbids the designer from opening a PR. Result: the design-reviewer can never be dispatched automatically with the current skill + webhook combination. An agent that faithfully follows `design-implement.md` will post the handoff comment and then wait forever. **Fix options (pick one):** A. Change the skill to have the designer open a branch/PR after posting the handoff comment (so `decidePostCiAction` can fire and route to `design-reviewer`). The PR can be a no-diff marker PR — its purpose is to provide a CI-gated dispatch surface. B. Add an `issue_comment` webhook handler (or a `issues.labeled` variant — e.g. a `review-ready` label) that dispatches `design-reviewer`. Update the skill to describe that mechanism instead. C. Remove the false "dispatched automatically" promise and document that the operator must manually dispatch `design-reviewer` for now, with a TODO comment referencing the follow-up. Option C is the minimal change; options A or B complete the loop without manual operator intervention. --- ### Accepted out-of-scope items (no action needed) Per the PR description, these are explicitly deferred and fine: - Forgejo `designer`/`design-reviewer` user creation + token files - Penpot MCP patched build baked into `claude-hooks:dev` - `area:design` label creation on repos - End-to-end smoke test against live Penpot
@ -0,0 +19,4 @@
- A `design:` label so the webhook routes these to `designer`
(create the `area:design` label via MCP if it does not already
exist on the repo)
4. Post a single comment on this issue with the proposed breakdown as a
Collaborator

Label name is wrong. design: should be area:design to match the routing rule in webhook-routing.ts. Substories created with design: will silently skip the designer and fall through to assignee-based routing.

Label name is wrong. `design:` should be `area:design` to match the routing rule in `webhook-routing.ts`. Substories created with `design:` will silently skip the designer and fall through to assignee-based routing.
@ -0,0 +52,4 @@
- One Penpot file per ticket unless the spec explicitly says otherwise.
- Do NOT call `create_review_requests` or open a PR — the webhook
dispatches `design-reviewer` automatically once you post the handoff
Collaborator

"the webhook dispatches design-reviewer automatically once you post the handoff comment" — this is not true. There is no issue_comment trigger in the webhook; the only dispatch path for design-reviewer is through decidePostCiAction on a PR authored by designer, which this rule forbids. The design-reviewer can never be automatically dispatched with this skill as written. Either have the designer open a branch/PR to give the CI gate a trigger, add an issue-comment or label-based dispatch for design-reviewer, or drop the "automatically" claim and document that the operator dispatches manually for now.

"the webhook dispatches `design-reviewer` automatically once you post the handoff comment" — this is not true. There is no `issue_comment` trigger in the webhook; the only dispatch path for `design-reviewer` is through `decidePostCiAction` on a PR authored by `designer`, which this rule forbids. The design-reviewer can never be automatically dispatched with this skill as written. Either have the designer open a branch/PR to give the CI gate a trigger, add an issue-comment or label-based dispatch for `design-reviewer`, or drop the "automatically" claim and document that the operator dispatches manually for now.
fix(skills): correct design-breakdown label + honest design-implement handoff note
All checks were successful
qa / qa (pull_request) Successful in 50s
qa / dockerfile (pull_request) Successful in 8s
483f10b1c0
Review on #59 caught two doc bugs that would silently break the design
flow if an agent followed the skills literally.

- `design-breakdown.md` told the designer to tag substories with
  `design:`, but `webhook-routing.ts` only matches `area:design`.
  Substories tagged with the wrong label would fall through to
  assignee-based routing and never reach the designer. Fix: use
  `area:design` in the bullet.
- `design-implement.md` promised the webhook would dispatch
  `design-reviewer` automatically after the handoff comment. The
  webhook has no `issue_comment` handler and the skill explicitly
  forbids opening a PR, so no dispatch path exists. Rewrite the rule
  to describe the actual state: designer stops at the handoff
  comment, operator dispatches `design-reviewer` manually, and the
  auto-close (via a new comment/label trigger) is tracked as a
  follow-up.

Code-flow routing is unchanged; this only edits the two skill
templates. 218 tests pass, biome/tsc clean.
Author
Collaborator

Both bugs fixed in 483f10b.

Bug 1 — design-breakdown.md label: you're right, the skill said design: but the router only matches area:design. Replaced the bullet so the two agree. Any substory the designer now creates with the documented label will hit the area:designdesigner rule.

Bug 2 — design-implement.md false dispatch promise: also right. The webhook has no issue_comment handler and the skill forbids opening a PR, so the "dispatched automatically" line was a lie. Went with option C (minimal change): rewrote the rule to describe what actually happens — designer stops at the handoff comment, operator dispatches design-reviewer manually. Called out the gap explicitly so the follow-up (add a review-ready label trigger or an issue_comment event handler) isn't forgotten. Options A (marker PR) and B (new webhook trigger) are both viable; leaving that design call for a dedicated ticket rather than folding it into this slice.

QA: bun x tsc --noEmit clean, bun x biome check src/ clean, 218/218 tests still pass. Changes are doc-only.

Both bugs fixed in 483f10b. **Bug 1 — `design-breakdown.md` label**: you're right, the skill said `design:` but the router only matches `area:design`. Replaced the bullet so the two agree. Any substory the designer now creates with the documented label will hit the `area:design` → `designer` rule. **Bug 2 — `design-implement.md` false dispatch promise**: also right. The webhook has no `issue_comment` handler and the skill forbids opening a PR, so the "dispatched automatically" line was a lie. Went with option C (minimal change): rewrote the rule to describe what actually happens — designer stops at the handoff comment, operator dispatches `design-reviewer` manually. Called out the gap explicitly so the follow-up (add a `review-ready` label trigger or an `issue_comment` event handler) isn't forgotten. Options A (marker PR) and B (new webhook trigger) are both viable; leaving that design call for a dedicated ticket rather than folding it into this slice. QA: `bun x tsc --noEmit` clean, `bun x biome check src/` clean, 218/218 tests still pass. Changes are doc-only.
reviewer requested changes 2026-04-18 17:52:06 +00:00
Dismissed
reviewer left a comment

Review: feat(agents): add designer + design-reviewer agent types

CI: Green (run #1555, success, 1m0s)

Overall: This is a well-structured first slice. The routing extraction into webhook-routing.ts is clean, the pure-function test coverage is thorough (101 routing-unit tests + 19 new across the handlers and CI gate), and the skill templates have the right Penpot MCP gotchas baked in. One behavioural bug needs fixing before merge.


🔴 Bug — double-dispatch when a second label is added to an area:design issue

File: src/webhook.ts (labeled handler) + src/webhook-handlers.ts (handleIssueLabeled)

What's wrong:

The issues.labeled handler passes payload.issue.labels — the full label set on the issue — to handleIssueLabeled, which feeds it into agentForLabels. agentForLabels iterates the full set and returns "designer" whenever area:design appears anywhere in it.

Consequence: once the designer token exists, adding any label to an issue that already has area:design fires a second dispatch to designer. A human adding priority:high to an open design ticket re-queues the entire design job.

The issues.labeled payload includes payload.label — the single newly-attached label — precisely to support this distinction. The fix is to route only on that label, not the full set.

Fix in src/webhook.ts:

// Before
const id = await handleIssueLabeled(repo, payload.issue);

// After
const id = payload.label
    ? await handleIssueLabeled(repo, payload.issue, payload.label.name)
    : null;

Fix in src/webhook-handlers.ts:

export async function handleIssueLabeled(
    repo: string,
    issue: { number: number; title: string; labels?: { name: string }[] },
    addedLabelName?: string,
): Promise<string | null> {
    // Route only on the label that was just added, not the full set.
    // Checking the full set would re-dispatch on every subsequent label addition
    // to an issue that already carries a routing label (e.g. area:design).
    const agentName = addedLabelName
        ? (agentForLabels([{ name: addedLabelName }]))
        : agentForLabels(issue.labels);
    if (!agentName) return null;
    ...
}

And add a test case in webhook-handlers.test.ts:

test("returns null when area:design is already present but a different label is newly added", async () => {
    // Simulates adding priority:high to an issue that already has area:design.
    // Only the newly-added label should trigger routing — not the full set.
    const result = await handleIssueLabeled("charles/my-repo", {
        number: 13,
        title: "Design dashboard",
        labels: [{ name: "area:design" }, { name: "priority:high" }],
    }, "priority:high");
    expect(result).toBeNull();
});

Everything else looks correct

  • reviewerForAuthor / isReviewerAgent correctly generalise the self-review guard and reviewer routing — the stale-bounce test with designerdesign-reviewer is convincing.
  • skillForAgent override table with fallthrough is the right pattern; no risk of existing agents picking up design skills.
  • dispatchImplementForIssue shared helper cleanly removes the duplication from handleIssueAssigned.
  • agentForLabels returning null on empty/undefined input + the if (!agent?.token) return null guard in handleIssueLabeled mean the PR is safe to merge before the designer token and container exist — the out-of-scope items are honestly declared.
  • Skill templates are thorough; the Penpot auth gotchas and the login-disabled/401 surface-don't-retry rule are exactly what the designer agent needs.
  • Docs (CLAUDE.md + README.md) accurately describe the new routing and the container boundary.

Fix the double-dispatch bug + add the regression test, then this is ready to merge.

## Review: feat(agents): add designer + design-reviewer agent types **CI:** ✅ Green (run #1555, success, 1m0s) **Overall:** This is a well-structured first slice. The routing extraction into `webhook-routing.ts` is clean, the pure-function test coverage is thorough (101 routing-unit tests + 19 new across the handlers and CI gate), and the skill templates have the right Penpot MCP gotchas baked in. One behavioural bug needs fixing before merge. --- ### 🔴 Bug — double-dispatch when a second label is added to an `area:design` issue **File:** `src/webhook.ts` (labeled handler) + `src/webhook-handlers.ts` (`handleIssueLabeled`) **What's wrong:** The `issues.labeled` handler passes `payload.issue.labels` — the **full** label set on the issue — to `handleIssueLabeled`, which feeds it into `agentForLabels`. `agentForLabels` iterates the full set and returns `"designer"` whenever `area:design` appears anywhere in it. Consequence: once the designer token exists, adding *any* label to an issue that already has `area:design` fires a second dispatch to `designer`. A human adding `priority:high` to an open design ticket re-queues the entire design job. The `issues.labeled` payload includes `payload.label` — the single newly-attached label — precisely to support this distinction. The fix is to route only on that label, not the full set. **Fix in `src/webhook.ts`:** ```ts // Before const id = await handleIssueLabeled(repo, payload.issue); // After const id = payload.label ? await handleIssueLabeled(repo, payload.issue, payload.label.name) : null; ``` **Fix in `src/webhook-handlers.ts`:** ```ts export async function handleIssueLabeled( repo: string, issue: { number: number; title: string; labels?: { name: string }[] }, addedLabelName?: string, ): Promise<string | null> { // Route only on the label that was just added, not the full set. // Checking the full set would re-dispatch on every subsequent label addition // to an issue that already carries a routing label (e.g. area:design). const agentName = addedLabelName ? (agentForLabels([{ name: addedLabelName }])) : agentForLabels(issue.labels); if (!agentName) return null; ... } ``` And add a test case in `webhook-handlers.test.ts`: ```ts test("returns null when area:design is already present but a different label is newly added", async () => { // Simulates adding priority:high to an issue that already has area:design. // Only the newly-added label should trigger routing — not the full set. const result = await handleIssueLabeled("charles/my-repo", { number: 13, title: "Design dashboard", labels: [{ name: "area:design" }, { name: "priority:high" }], }, "priority:high"); expect(result).toBeNull(); }); ``` --- ### ✅ Everything else looks correct - `reviewerForAuthor` / `isReviewerAgent` correctly generalise the self-review guard and reviewer routing — the stale-bounce test with `designer` → `design-reviewer` is convincing. - `skillForAgent` override table with fallthrough is the right pattern; no risk of existing agents picking up design skills. - `dispatchImplementForIssue` shared helper cleanly removes the duplication from `handleIssueAssigned`. - `agentForLabels` returning `null` on empty/undefined input + the `if (!agent?.token) return null` guard in `handleIssueLabeled` mean the PR is safe to merge before the designer token and container exist — the out-of-scope items are honestly declared. - Skill templates are thorough; the Penpot auth gotchas and the `login-disabled`/401 surface-don't-retry rule are exactly what the designer agent needs. - Docs (CLAUDE.md + README.md) accurately describe the new routing and the container boundary. Fix the double-dispatch bug + add the regression test, then this is ready to merge.
src/webhook.ts Outdated
@ -136,0 +151,4 @@
// `issues.assigned` as before.
const id = await handleIssueLabeled(repo, payload.issue);
if (id) {
taskId = id;
Collaborator

The issues.labeled handler checks the full label set on the issue, not the specific label that was just added (payload.label). Once the designer token exists, adding any label to an issue that already carries area:design will re-dispatch the designer.

Fix: use payload.label.name (the newly-added label) as the routing input instead of the full set:

const id = payload.label
    ? await handleIssueLabeled(repo, payload.issue, payload.label.name)
    : null;

And update handleIssueLabeled to accept an optional addedLabelName parameter and route on that, not issue.labels.

The `issues.labeled` handler checks the **full** label set on the issue, not the specific label that was just added (`payload.label`). Once the designer token exists, adding any label to an issue that already carries `area:design` will re-dispatch the designer. Fix: use `payload.label.name` (the newly-added label) as the routing input instead of the full set: ```ts const id = payload.label ? await handleIssueLabeled(repo, payload.issue, payload.label.name) : null; ``` And update `handleIssueLabeled` to accept an optional `addedLabelName` parameter and route on that, not `issue.labels`.
fix(webhook): route issues.labeled on the newly-added label, not the full set
Some checks failed
qa / qa (pull_request) Successful in 48s
qa / dockerfile (pull_request) Failing after 18s
a5491dad03
Adding any label to an issue that already carries `area:design` was
re-firing the designer dispatch — because `handleIssueLabeled` fed
`issue.labels` (the full set) into `agentForLabels`, which returns
`"designer"` whenever `area:design` appears anywhere in the list. A
human tagging `priority:high` on an open design ticket would re-queue
the whole design job.

Forgejo's `issues.labeled` event exposes the single just-attached
label at the payload top level (`payload.label`) precisely to let
handlers make this distinction. Route on that alone:

- `webhook.ts` now only enters the labeled branch when `payload.label`
  is set, and passes `payload.label.name` down as the routing input.
  A malformed delivery without `payload.label` is a safe no-op —
  falling back to the full set would re-introduce the same bug.
- `handleIssueLabeled` takes a required `addedLabelName` and calls
  `agentForLabels([{ name: addedLabelName }])`. The `labels?` field
  on the issue parameter is no longer read and has been dropped from
  the signature; the interface now matches `handleIssueAssigned`.

Tests:
- handler unit test: routes only on the newly-added label when the
  issue already has `area:design`.
- dispatcher end-to-end test: same scenario through the HTTP entry
  point — no fetch calls, no cleanup, no dispatch.
- dispatcher test for the malformed-payload path: missing
  `payload.label` returns 200 and does nothing.

220 tests pass (was 218), biome + tsc clean.
Author
Collaborator

Double-dispatch bug fixed in a5491da.

  • webhook.ts now guards on payload.label and passes its name into handleIssueLabeled. Missing label → safe no-op (dispatcher has no correct fallback — full-set routing would just re-introduce the bug).
  • handleIssueLabeled takes a required addedLabelName and routes on agentForLabels([{ name: addedLabelName }]). Dropped the unused labels? field from the issue param so the interface matches handleIssueAssigned.
  • Made addedLabelName required rather than optional-with-fallback: the reviewer's sketch fell back to agentForLabels(issue.labels) when the arg was missing, but that's the exact code path the fix is trying to eliminate. Required arg means a future caller can't re-enable the bug by forgetting to pass it.

Regression coverage:

  • Handler unit test: handleIssueLabeled("...", {#13, "Design dashboard"}, "priority:high") → null (no re-dispatch).
  • Dispatcher end-to-end: same scenario via handleForgejoWebhook, with issue.labels = [area:design, priority:high] and payload.label = priority:high — 200, zero fetch calls, zero cleanup calls.
  • Malformed-payload path: issues.labeled without payload.label → 200, no dispatch.

QA: bun x tsc --noEmit clean, bun x biome check src/ clean, 220/220 tests (was 218).

Double-dispatch bug fixed in a5491da. - `webhook.ts` now guards on `payload.label` and passes its `name` into `handleIssueLabeled`. Missing `label` → safe no-op (dispatcher has no correct fallback — full-set routing would just re-introduce the bug). - `handleIssueLabeled` takes a required `addedLabelName` and routes on `agentForLabels([{ name: addedLabelName }])`. Dropped the unused `labels?` field from the `issue` param so the interface matches `handleIssueAssigned`. - Made `addedLabelName` required rather than optional-with-fallback: the reviewer's sketch fell back to `agentForLabels(issue.labels)` when the arg was missing, but that's the exact code path the fix is trying to eliminate. Required arg means a future caller can't re-enable the bug by forgetting to pass it. Regression coverage: - Handler unit test: `handleIssueLabeled("...", {#13, "Design dashboard"}, "priority:high")` → null (no re-dispatch). - Dispatcher end-to-end: same scenario via `handleForgejoWebhook`, with `issue.labels = [area:design, priority:high]` and `payload.label = priority:high` — 200, zero fetch calls, zero cleanup calls. - Malformed-payload path: `issues.labeled` without `payload.label` → 200, no dispatch. QA: `bun x tsc --noEmit` clean, `bun x biome check src/` clean, 220/220 tests (was 218).
fix(ci): retry hadolint download to survive github.com CDN 504s
All checks were successful
qa / qa (pull_request) Successful in 48s
qa / dockerfile (pull_request) Successful in 7s
eb4bee86d7
Run #114's `dockerfile` job failed on:

    curl: (22) The requested URL returned error: 504

downloading hadolint from github.com/hadolint/hadolint/releases.
The preceding run (#1555 at 483f10b) passed the exact same step, and
nothing on this branch has touched the Dockerfile or this workflow
since — the failure is a transient 5xx from GitHub's release CDN,
not a real regression.

Harden the step instead of accepting the flake:
- `--retry 5` retries the request up to five times.
- `--retry-all-errors` makes curl retry on HTTP response codes too
  (default only retries connection-level errors and a handful of 4xx).
  This is the flag that catches 504.
- `--retry-delay 2` paces retries at 2 s so a real outage doesn't get
  hammered.

Leaves `-fsSL` in place so the step still fails loudly if every
retry returns a real error (wrong URL, removed release, etc.).
reviewer requested changes 2026-04-18 18:01:33 +00:00
Dismissed
reviewer left a comment

Review — feat(agents): add designer + design-reviewer agent types

CI: Green (run #1561, eb4bee8, 55 s)


One bug to fix before merge

src/webhook.ts — misleading comment on ForgejoPayload.label will cause a future regression

The comment on the label? field (added in the ForgejoPayload type extension) reads:

// On `issues.labeled` Forgejo adds the newly-attached label at the top level
// (in addition to the full set on `issue.labels`). Routing only cares about
// the full set, so this is kept optional for completeness.
label?: { name: string };

"Routing only cares about the full set" is backwards. The routing explicitly uses payload.label.name (the single newly-added label), not issue.labels, precisely to prevent the double-dispatch bug the PR describes and tests for. A future maintainer reading this comment could "simplify" the dispatch by using issue.labels instead and unknowingly reintroduce the bug.

Fix — replace the last sentence of that comment, e.g.:

// On `issues.labeled` Forgejo adds the newly-attached label at the top level
// (in addition to the full set on `issue.labels`). Routing acts on THIS field
// (the just-added label), not the full set — routing on the full set would
// re-dispatch the designer every time any subsequent label is added to an
// already-area:design issue.
label?: { name: string };

Acceptance criteria — verdict

AC item Status
config/agents.jsondesigner (Opus 4.7) + design-reviewer (Sonnet 4.6)
Skills: design-implement, design-review, design-breakdown, design-address-review
area:design label → designer routing
designer-authored PR → design-reviewer routing
Non-design tickets unaffected
Self-review guard covers both reviewer types
Tests: 19 new tests, routing/handler/dispatcher/author-override
CLAUDE.md roles table + Penpot auth paragraph
README.md two new containers + label routing
Forgejo users / tokens / signing keys ⏭ explicitly deferred, safe (loader warns-and-skips)
Container image with patched Penpot MCP ⏭ explicitly deferred
area:design label creation on repos ⏭ explicitly deferred
E2E smoke test ⏭ explicitly deferred

The deferred items are all infrastructure/ops work that cannot live in a code PR and are well-reasoned in the PR body. The warn-and-skip path on missing tokens means this is safe to merge before the users exist.


Everything else looks good

  • REVIEWER_AGENTS derived from Object.values(AUTHOR_TO_REVIEWER) is clean — new pairs stay consistent automatically.
  • skillForAgent fall-through is correct and tested for the non-design agents.
  • dispatchImplementForIssue refactor correctly centralises the issue→task path without changing existing behaviour.
  • Double-dispatch regression test (adding priority:high to an area:design issue) is exactly the right regression to pin down.
  • hadolint curl retry fix is a good bonus.

One comment fix, then this is ready.

## Review — feat(agents): add designer + design-reviewer agent types **CI:** ✅ Green (run #1561, `eb4bee8`, 55 s) --- ### One bug to fix before merge **`src/webhook.ts` — misleading comment on `ForgejoPayload.label` will cause a future regression** The comment on the `label?` field (added in the `ForgejoPayload` type extension) reads: ```typescript // On `issues.labeled` Forgejo adds the newly-attached label at the top level // (in addition to the full set on `issue.labels`). Routing only cares about // the full set, so this is kept optional for completeness. label?: { name: string }; ``` "Routing only cares about the full set" is **backwards**. The routing explicitly uses `payload.label.name` (the *single newly-added label*), *not* `issue.labels`, precisely to prevent the double-dispatch bug the PR describes and tests for. A future maintainer reading this comment could "simplify" the dispatch by using `issue.labels` instead and unknowingly reintroduce the bug. **Fix** — replace the last sentence of that comment, e.g.: ```typescript // On `issues.labeled` Forgejo adds the newly-attached label at the top level // (in addition to the full set on `issue.labels`). Routing acts on THIS field // (the just-added label), not the full set — routing on the full set would // re-dispatch the designer every time any subsequent label is added to an // already-area:design issue. label?: { name: string }; ``` --- ### Acceptance criteria — verdict | AC item | Status | |---|---| | `config/agents.json` — `designer` (Opus 4.7) + `design-reviewer` (Sonnet 4.6) | ✅ | | Skills: `design-implement`, `design-review`, `design-breakdown`, `design-address-review` | ✅ | | `area:design` label → `designer` routing | ✅ | | `designer`-authored PR → `design-reviewer` routing | ✅ | | Non-design tickets unaffected | ✅ | | Self-review guard covers both reviewer types | ✅ | | Tests: 19 new tests, routing/handler/dispatcher/author-override | ✅ | | CLAUDE.md roles table + Penpot auth paragraph | ✅ | | README.md two new containers + label routing | ✅ | | Forgejo users / tokens / signing keys | ⏭ explicitly deferred, safe (loader warns-and-skips) | | Container image with patched Penpot MCP | ⏭ explicitly deferred | | `area:design` label creation on repos | ⏭ explicitly deferred | | E2E smoke test | ⏭ explicitly deferred | The deferred items are all infrastructure/ops work that cannot live in a code PR and are well-reasoned in the PR body. The warn-and-skip path on missing tokens means this is safe to merge before the users exist. --- ### Everything else looks good - `REVIEWER_AGENTS` derived from `Object.values(AUTHOR_TO_REVIEWER)` is clean — new pairs stay consistent automatically. - `skillForAgent` fall-through is correct and tested for the non-design agents. - `dispatchImplementForIssue` refactor correctly centralises the issue→task path without changing existing behaviour. - Double-dispatch regression test (adding `priority:high` to an `area:design` issue) is exactly the right regression to pin down. - `hadolint` curl retry fix is a good bonus. One comment fix, then this is ready.
@ -67,0 +69,4 @@
title: string;
assignees?: { login: string }[];
labels?: { name: string }[];
};
Collaborator

This comment says "Routing only cares about the full set" which is the opposite of what the code does. Routing uses payload.label.name (the single newly-added label), NOT issue.labels (the full set) — that distinction is the whole point of the double-dispatch guard. Replace with something like:

// On `issues.labeled` Forgejo adds the newly-attached label at the top level
// (in addition to the full set on `issue.labels`). Routing acts on THIS field
// (the just-added label), not the full set — routing on the full set would
// re-dispatch the designer every time any subsequent label is added to an
// already-area:design issue.

As written, a future maintainer could read this and "simplify" by switching to issue.labels, silently reintroducing the double-dispatch bug.

This comment says "Routing only cares about the full set" which is the opposite of what the code does. Routing uses `payload.label.name` (the single newly-added label), NOT `issue.labels` (the full set) — that distinction is the whole point of the double-dispatch guard. Replace with something like: ``` // On `issues.labeled` Forgejo adds the newly-attached label at the top level // (in addition to the full set on `issue.labels`). Routing acts on THIS field // (the just-added label), not the full set — routing on the full set would // re-dispatch the designer every time any subsequent label is added to an // already-area:design issue. ``` As written, a future maintainer could read this and "simplify" by switching to `issue.labels`, silently reintroducing the double-dispatch bug.
docs(webhook): fix ForgejoPayload.label comment that contradicted the routing fix
All checks were successful
qa / qa (pull_request) Successful in 53s
qa / dockerfile (pull_request) Successful in 7s
3e7ed33779
The comment on the optional `label?` field still read:

    Routing only cares about the full set, so this is kept optional
    for completeness.

but that's the opposite of what the labeled dispatcher now does. After
a5491da the dispatcher routes on `payload.label.name` — the single
just-added label — precisely because routing on the full set would
re-dispatch the designer every time any subsequent label lands on an
`area:design` issue.

Rewrite the comment to state the real invariant, name the bug it
prevents, and explain why the field stays optional (other `issues.*`
actions don't populate it). No code change.
Author
Collaborator

Fixed in 3e7ed33 — agreed, the comment was a stale footgun after the a5491da routing fix. Rewrote it to name the real invariant (routing acts on payload.label.name, not issue.labels), reference the double-dispatch bug the invariant prevents, and explain why the field remains optional (other issues.* actions don't populate it). No code change.

QA: tsc clean, biome clean, 220/220 tests.

Fixed in 3e7ed33 — agreed, the comment was a stale footgun after the a5491da routing fix. Rewrote it to name the real invariant (routing acts on `payload.label.name`, **not** `issue.labels`), reference the double-dispatch bug the invariant prevents, and explain why the field remains optional (other `issues.*` actions don't populate it). No code change. QA: tsc clean, biome clean, 220/220 tests.
reviewer approved these changes 2026-04-18 18:05:03 +00:00
reviewer left a comment

Review: feat(agents): add designer + design-reviewer agent types

CI green (run #1564, 1m1s, success).

Acceptance criteria check (issue #56)

Criterion Status
designer config entry (Opus 4.7, correct fields)
design-reviewer config entry (Sonnet 4.6, correct fields)
design-implement skill
design-review skill
design-breakdown skill
design-address-review skill
area:design label → designer dispatch
designer-authored PRs → design-reviewer (not default reviewer)
Existing non-design tickets unaffected
Self-review guard covers both reviewer-type agents
Unit tests (routing, handlers, dispatcher, CI gate) 19 new tests, 218 total
CLAUDE.md roles table expanded
CLAUDE.md Penpot MCP auth paragraph
README.md new containers + label routing
Forgejo users / tokens / signing / container image / label creation 🔖 Acknowledged follow-ups, explicitly safe to defer

Code notes

webhook-routing.ts — clean single-responsibility module. Deriving REVIEWER_AGENTS from Object.values(AUTHOR_TO_REVIEWER) ensures the self-review set and the author→reviewer map can't drift out of sync. Good.

Double-dispatch guard — routing on payload.label.name (the just-added label) rather than issue.labels (the full set) is the correct fix, and it's tested with a dedicated regression case (priority:high on an already-area:design issue → no-op). The malformed-payload guard (missing label) is also covered.

dispatchImplementForIssue extraction — the shared dispatcher removes duplication between assignee-based and label-based dispatch cleanly. skillForAgent threading through handleReviewRequested and handleChangesRequested is a non-invasive touch.

REVIEWER_AGENT removal — every reference replaced with isReviewerAgent() / reviewerForAuthor(). Grep-clean, no stragglers.

hadolint retry in qa.yml — sensible hardening against CDN flakiness; --retry-all-errors is the right flag for HTTP 5xx (not just connection errors).

No logic bugs, no unhandled error paths, no security concerns, no scope creep. This is a solid first slice.

## Review: feat(agents): add designer + design-reviewer agent types CI green (run #1564, 1m1s, success). ✅ ### Acceptance criteria check (issue #56) | Criterion | Status | |---|---| | `designer` config entry (Opus 4.7, correct fields) | ✅ | | `design-reviewer` config entry (Sonnet 4.6, correct fields) | ✅ | | `design-implement` skill | ✅ | | `design-review` skill | ✅ | | `design-breakdown` skill | ✅ | | `design-address-review` skill | ✅ | | `area:design` label → `designer` dispatch | ✅ | | `designer`-authored PRs → `design-reviewer` (not default `reviewer`) | ✅ | | Existing non-design tickets unaffected | ✅ | | Self-review guard covers both reviewer-type agents | ✅ | | Unit tests (routing, handlers, dispatcher, CI gate) | ✅ 19 new tests, 218 total | | `CLAUDE.md` roles table expanded | ✅ | | `CLAUDE.md` Penpot MCP auth paragraph | ✅ | | `README.md` new containers + label routing | ✅ | | Forgejo users / tokens / signing / container image / label creation | 🔖 Acknowledged follow-ups, explicitly safe to defer | ### Code notes **`webhook-routing.ts`** — clean single-responsibility module. Deriving `REVIEWER_AGENTS` from `Object.values(AUTHOR_TO_REVIEWER)` ensures the self-review set and the author→reviewer map can't drift out of sync. Good. **Double-dispatch guard** — routing on `payload.label.name` (the just-added label) rather than `issue.labels` (the full set) is the correct fix, and it's tested with a dedicated regression case (`priority:high` on an already-`area:design` issue → no-op). The malformed-payload guard (missing `label`) is also covered. **`dispatchImplementForIssue` extraction** — the shared dispatcher removes duplication between assignee-based and label-based dispatch cleanly. `skillForAgent` threading through `handleReviewRequested` and `handleChangesRequested` is a non-invasive touch. **`REVIEWER_AGENT` removal** — every reference replaced with `isReviewerAgent()` / `reviewerForAuthor()`. Grep-clean, no stragglers. **`hadolint` retry in `qa.yml`** — sensible hardening against CDN flakiness; `--retry-all-errors` is the right flag for HTTP 5xx (not just connection errors). No logic bugs, no unhandled error paths, no security concerns, no scope creep. This is a solid first slice.
code-lead deleted branch boss/56 2026-04-18 18:07:52 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks!59
No description provided.