feat(agents): add designer + design-reviewer agent types #59
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/claude-hooks!59
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "boss/56"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
First slice of #56 — introduces the
designer/design-revieweragent pair and the plumbing to route design work to them without
touching the existing code-agent flows.
designer(Opus 4.7) +design-reviewer(Sonnet 4.6)entries in
config/agents.json, matching the model choices from theticket.
design-implement,design-review,design-breakdown,design-address-reviewtemplates. Penpot MCPgotchas baked in (OIDC-only login / Authelia basic-auth, frame-fill
ordering, serialized page writes).
src/webhook-routing.tscentralises label → agent(
area:design→designer), author → reviewer (designer→design-reviewer), and per-agent skill override. The webhook nowhandles
issues.labeledfor design tickets;decidePostCiActionpicks 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.
label dispatch, dispatcher-level
issues.labeledhandling, andauthor-based reviewer override (including designer → design-reviewer
bounce). Full suite: 218 pass, 0 fail.
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:
designer/design-reviewerusers, issuingtokens into
~/.config/claude-hooks/tokens/{designer,design-reviewer},and setting up their signing keyrings at
/var/lib/forgejo/data/home/.gnupg/. The config loader alreadywarns-and-skips when a token file is missing, so this PR is safe to
merge before the users exist.
penpot-mcp-server(Authelia basic-auth,pre-seeded OIDC cookie,
get_file_infoRPC fallback) into theclaude-hooks:devimage. Without this, the designer containerscrash-loop against our Penpot instance — this slice does not start
any new containers.
area:designlabel on repos where design work willhappen (suggested color
#ec4899, description per ticket).Test plan
bun x tsc --noEmitcleanbun x biome check src/cleanbun test— full suite green (218 pass at time of push)area:designlabel on a throwaway issue in arepo once the
designertoken + container exist; confirm thewebhook dispatches to
designerand notboss/dev.designerforgejo user; confirm thepost-CI router requests review from
design-reviewer, not thedefault
reviewer.Refs #56.
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>Review — feat(agents): add designer + design-reviewer agent types
CI: ✅ green (run #1554, 218 pass, 1m1s)
The routing architecture is solid —
webhook-routing.tsis a clean single source of truth, theisReviewerAgent/reviewerForAuthor/skillForAgenthelpers compose well, and the tests are thorough. Two bugs need fixing before merge.Bug 1 — Wrong label name in
skills/design-breakdown.mdFile:
skills/design-breakdown.md, step 3 (bullet ~line 22)The skill tells the designer to create substories with a
design:label:But
webhook-routing.tsonly matchesarea:design:Any substory the designer creates with label
design:will silently fall through to assignee-based routing and never reach the designer. Fix: replacedesign:witharea:designin that bullet.Bug 2 —
design-implement.mdpromises a dispatch path that doesn't existFile:
skills/design-implement.md, Rules section (last bullet, ~line 55)This is not true. The webhook has no
issue_commenttrigger — scanningsrc/webhook.ts, theissuesevent only handlesassigned,labeled, andclosed. There is no handler that fires when a comment is posted on an issue.The only code path that could dispatch
design-reviewerisdecidePostCiAction, which fires on CI events for PRs authored bydesigner. 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.mdwill 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
decidePostCiActioncan fire and route todesign-reviewer). The PR can be a no-diff marker PR — its purpose is to provide a CI-gated dispatch surface.B. Add an
issue_commentwebhook handler (or aissues.labeledvariant — e.g. areview-readylabel) that dispatchesdesign-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-reviewerfor 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:
designer/design-revieweruser creation + token filesclaude-hooks:devarea:designlabel creation on repos@ -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 alreadyexist on the repo)4. Post a single comment on this issue with the proposed breakdown as aLabel name is wrong.
design:should bearea:designto match the routing rule inwebhook-routing.ts. Substories created withdesign: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 webhookdispatches `design-reviewer` automatically once you post the handoff"the webhook dispatches
design-reviewerautomatically once you post the handoff comment" — this is not true. There is noissue_commenttrigger in the webhook; the only dispatch path fordesign-revieweris throughdecidePostCiActionon a PR authored bydesigner, 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 fordesign-reviewer, or drop the "automatically" claim and document that the operator dispatches manually for now.Both bugs fixed in
483f10b.Bug 1 —
design-breakdown.mdlabel: you're right, the skill saiddesign:but the router only matchesarea:design. Replaced the bullet so the two agree. Any substory the designer now creates with the documented label will hit thearea:design→designerrule.Bug 2 —
design-implement.mdfalse dispatch promise: also right. The webhook has noissue_commenthandler 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 dispatchesdesign-reviewermanually. Called out the gap explicitly so the follow-up (add areview-readylabel trigger or anissue_commentevent 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 --noEmitclean,bun x biome check src/clean, 218/218 tests still pass. Changes are doc-only.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.tsis 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:designissueFile:
src/webhook.ts(labeled handler) +src/webhook-handlers.ts(handleIssueLabeled)What's wrong:
The
issues.labeledhandler passespayload.issue.labels— the full label set on the issue — tohandleIssueLabeled, which feeds it intoagentForLabels.agentForLabelsiterates the full set and returns"designer"wheneverarea:designappears anywhere in it.Consequence: once the designer token exists, adding any label to an issue that already has
area:designfires a second dispatch todesigner. A human addingpriority:highto an open design ticket re-queues the entire design job.The
issues.labeledpayload includespayload.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:Fix in
src/webhook-handlers.ts:And add a test case in
webhook-handlers.test.ts:✅ Everything else looks correct
reviewerForAuthor/isReviewerAgentcorrectly generalise the self-review guard and reviewer routing — the stale-bounce test withdesigner→design-revieweris convincing.skillForAgentoverride table with fallthrough is the right pattern; no risk of existing agents picking up design skills.dispatchImplementForIssueshared helper cleanly removes the duplication fromhandleIssueAssigned.agentForLabelsreturningnullon empty/undefined input + theif (!agent?.token) return nullguard inhandleIssueLabeledmean the PR is safe to merge before the designer token and container exist — the out-of-scope items are honestly declared.login-disabled/401 surface-don't-retry rule are exactly what the designer agent needs.Fix the double-dispatch bug + add the regression test, then this is ready to merge.
@ -136,0 +151,4 @@// `issues.assigned` as before.const id = await handleIssueLabeled(repo, payload.issue);if (id) {taskId = id;The
issues.labeledhandler 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 carriesarea:designwill re-dispatch the designer.Fix: use
payload.label.name(the newly-added label) as the routing input instead of the full set:And update
handleIssueLabeledto accept an optionaladdedLabelNameparameter and route on that, notissue.labels.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.Double-dispatch bug fixed in
a5491da.webhook.tsnow guards onpayload.labeland passes itsnameintohandleIssueLabeled. Missinglabel→ safe no-op (dispatcher has no correct fallback — full-set routing would just re-introduce the bug).handleIssueLabeledtakes a requiredaddedLabelNameand routes onagentForLabels([{ name: addedLabelName }]). Dropped the unusedlabels?field from theissueparam so the interface matcheshandleIssueAssigned.addedLabelNamerequired rather than optional-with-fallback: the reviewer's sketch fell back toagentForLabels(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:
handleIssueLabeled("...", {#13, "Design dashboard"}, "priority:high")→ null (no re-dispatch).handleForgejoWebhook, withissue.labels = [area:design, priority:high]andpayload.label = priority:high— 200, zero fetch calls, zero cleanup calls.issues.labeledwithoutpayload.label→ 200, no dispatch.QA:
bun x tsc --noEmitclean,bun x biome check src/clean, 220/220 tests (was 218).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 onForgejoPayload.labelwill cause a future regressionThe comment on the
label?field (added in theForgejoPayloadtype extension) reads:"Routing only cares about the full set" is backwards. The routing explicitly uses
payload.label.name(the single newly-added label), notissue.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 usingissue.labelsinstead and unknowingly reintroduce the bug.Fix — replace the last sentence of that comment, e.g.:
Acceptance criteria — verdict
config/agents.json—designer(Opus 4.7) +design-reviewer(Sonnet 4.6)design-implement,design-review,design-breakdown,design-address-reviewarea:designlabel →designerroutingdesigner-authored PR →design-reviewerroutingarea:designlabel creation on reposThe 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_AGENTSderived fromObject.values(AUTHOR_TO_REVIEWER)is clean — new pairs stay consistent automatically.skillForAgentfall-through is correct and tested for the non-design agents.dispatchImplementForIssuerefactor correctly centralises the issue→task path without changing existing behaviour.priority:highto anarea:designissue) is exactly the right regression to pin down.hadolintcurl 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 }[];};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), NOTissue.labels(the full set) — that distinction is the whole point of the double-dispatch guard. Replace with something like:As written, a future maintainer could read this and "simplify" by switching to
issue.labels, silently reintroducing the double-dispatch bug.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. Aftera5491dathe 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.Fixed in
3e7ed33— agreed, the comment was a stale footgun after thea5491darouting fix. Rewrote it to name the real invariant (routing acts onpayload.label.name, notissue.labels), reference the double-dispatch bug the invariant prevents, and explain why the field remains optional (otherissues.*actions don't populate it). No code change.QA: tsc clean, biome clean, 220/220 tests.
Review: feat(agents): add designer + design-reviewer agent types
CI green (run #1564, 1m1s, success). ✅
Acceptance criteria check (issue #56)
designerconfig entry (Opus 4.7, correct fields)design-reviewerconfig entry (Sonnet 4.6, correct fields)design-implementskilldesign-reviewskilldesign-breakdownskilldesign-address-reviewskillarea:designlabel →designerdispatchdesigner-authored PRs →design-reviewer(not defaultreviewer)CLAUDE.mdroles table expandedCLAUDE.mdPenpot MCP auth paragraphREADME.mdnew containers + label routingCode notes
webhook-routing.ts— clean single-responsibility module. DerivingREVIEWER_AGENTSfromObject.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 thanissue.labels(the full set) is the correct fix, and it's tested with a dedicated regression case (priority:highon an already-area:designissue → no-op). The malformed-payload guard (missinglabel) is also covered.dispatchImplementForIssueextraction — the shared dispatcher removes duplication between assignee-based and label-based dispatch cleanly.skillForAgentthreading throughhandleReviewRequestedandhandleChangesRequestedis a non-invasive touch.REVIEWER_AGENTremoval — every reference replaced withisReviewerAgent()/reviewerForAuthor(). Grep-clean, no stragglers.hadolintretry inqa.yml— sensible hardening against CDN flakiness;--retry-all-errorsis 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.