feat(config): per-repo Penpot team mapping (#255) #256

Merged
code-lead merged 2 commits from boss/255 into main 2026-04-21 19:44:08 +00:00
Collaborator

Summary

Adds an optional top-level penpot: block to config/agents.json that pins each watched repo to a specific Penpot team. designer tasks no longer have to list_teams + guess — the pinned team_id / team_name is threaded through the task prompt as a Penpot team: <name> (<id>) line, which skills/design-implement.md step 2 reads to call mcp__penpot__list_files / create_file with the pinned id directly.

Motivation: 2026-04-21 designer task ce585c20 stalled 10 turns ($0.54) on list_teams — it received a transit-json-encoded team list it couldn't turn into action. Pinning the team in config removes the guess entirely.

  • PenpotConfig / PenpotTeamMapping / PenpotTeamResolution in @claude-hooks/shared.
  • parsePenpotConfig + resolvePenpotTeam in webhook-config.ts with UUID validation (generic 8-4-4-4-12 hex — Penpot ids aren't strict v4) and a non-fatal warn on team_by_repo keys not in repos:.
  • main.ts startup uses cfg.penpot?.base_url instead of the hardcoded https://design.jacquin.app.
  • agent-runner.runAgentTask resolves the team for penpot_mcp: true agents and injects it via the extended buildPrompt opts; prompt stays byte-identical for everyone else.
  • GET /agents (+ penpot_mcp boolean per agent) and GET /foreman/config echo the effective penpot: block.
  • skills/design-implement.md step 2 rewritten: pinned team first, list_teams fallback with a visible warning comment.
  • config/agents.json gets the initial block; CLAUDE.md documents the add-a-new-repo runbook.

Missing penpot: block is back-compat: the loader stores null, legacy penpot_mcp: true agents keep working with the old list_teams path.

Closes #255

Test plan

  • bun x turbo run typecheck green across all three workspaces.
  • bun x biome check . clean.
  • bun test src/webhook-config.test.ts src/agent-runner.test.ts — 132 new/existing pass (2 pre-existing token_economy baseline failures unrelated to this PR — see commit 0767024).
  • parsePenpotConfig covers: absent block → null, malformed UUID rejected, missing base_url rejected, malformed team_by_repo key rejected, unknown repo key warns-but-parses.
  • resolvePenpotTeam covers: explicit entry wins, default fallback with team_name: null, null when neither is set.
  • buildPrompt covers: team line rendered with <name> (<id>), (unnamed) when only default, no line when penpotTeam absent.
  • Manual: configure the mapping, label-dispatch a new mockup ticket, confirm the resulting Penpot file lands in the pinned team (visible in the handoff deep-link URL path).

🤖 Generated with Claude Code

## Summary Adds an optional top-level `penpot:` block to `config/agents.json` that pins each watched repo to a specific Penpot team. `designer` tasks no longer have to `list_teams` + guess — the pinned `team_id` / `team_name` is threaded through the task prompt as a `Penpot team: <name> (<id>)` line, which `skills/design-implement.md` step 2 reads to call `mcp__penpot__list_files` / `create_file` with the pinned id directly. Motivation: 2026-04-21 designer task `ce585c20` stalled 10 turns ($0.54) on `list_teams` — it received a transit-json-encoded team list it couldn't turn into action. Pinning the team in config removes the guess entirely. - `PenpotConfig` / `PenpotTeamMapping` / `PenpotTeamResolution` in `@claude-hooks/shared`. - `parsePenpotConfig` + `resolvePenpotTeam` in `webhook-config.ts` with UUID validation (generic 8-4-4-4-12 hex — Penpot ids aren't strict v4) and a non-fatal warn on `team_by_repo` keys not in `repos:`. - `main.ts` startup uses `cfg.penpot?.base_url` instead of the hardcoded `https://design.jacquin.app`. - `agent-runner.runAgentTask` resolves the team for `penpot_mcp: true` agents and injects it via the extended `buildPrompt` opts; prompt stays byte-identical for everyone else. - `GET /agents` (+ `penpot_mcp` boolean per agent) and `GET /foreman/config` echo the effective `penpot:` block. - `skills/design-implement.md` step 2 rewritten: pinned team first, `list_teams` fallback with a visible warning comment. - `config/agents.json` gets the initial block; `CLAUDE.md` documents the add-a-new-repo runbook. Missing `penpot:` block is back-compat: the loader stores `null`, legacy `penpot_mcp: true` agents keep working with the old `list_teams` path. Closes #255 ## Test plan - [x] `bun x turbo run typecheck` green across all three workspaces. - [x] `bun x biome check .` clean. - [x] `bun test src/webhook-config.test.ts src/agent-runner.test.ts` — 132 new/existing pass (2 pre-existing `token_economy` baseline failures unrelated to this PR — see commit `0767024`). - [x] `parsePenpotConfig` covers: absent block → null, malformed UUID rejected, missing `base_url` rejected, malformed `team_by_repo` key rejected, unknown repo key warns-but-parses. - [x] `resolvePenpotTeam` covers: explicit entry wins, default fallback with `team_name: null`, null when neither is set. - [x] `buildPrompt` covers: team line rendered with `<name> (<id>)`, `(unnamed)` when only default, no line when `penpotTeam` absent. - [ ] Manual: configure the mapping, label-dispatch a new mockup ticket, confirm the resulting Penpot file lands in the pinned team (visible in the handoff deep-link URL path). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(config): per-repo Penpot team mapping (#255)
Some checks failed
qa / qa (pull_request) Failing after 3m41s
qa / dockerfile (pull_request) Successful in 11s
0a43058475
Adds an optional top-level `penpot:` block to `config/agents.json` that
pins each watched repo to a specific Penpot team, so the `designer`
agent no longer has to `list_teams` + guess. When a `penpot_mcp: true`
agent dispatches, the task prompt now carries a
`Penpot team: <name> (<id>)` line that the `design-implement` skill
reads to call `mcp__penpot__list_files` / `create_file` with the
pinned `team_id` directly.

Motivation: 2026-04-21 designer task `ce585c20` stalled 10 turns
($0.54) on `list_teams` — it received a transit-json-encoded team
list it couldn't turn into action. Pinning the team in config removes
the guess entirely.

Changes:
- `PenpotConfig` / `PenpotTeamMapping` / `PenpotTeamResolution` types
  in `@claude-hooks/shared`, exported from the barrel.
- `parsePenpotConfig` + `resolvePenpotTeam` in `webhook-config.ts` with
  UUID validation (generic 8-4-4-4-12 hex — Penpot ids aren't strict
  v4) and a non-fatal warn on `team_by_repo` keys not in `repos:`.
- `WebhookConfig.penpot` surfaces the parsed block (null when absent);
  `resolvePenpotTeamForRepo` is the production lookup used by
  `agent-runner.ts`.
- `main.ts` startup uses `cfg.penpot?.base_url` instead of the
  hardcoded `https://design.jacquin.app` when loading the Penpot token.
- `buildPrompt` accepts an optional `penpotTeam` and renders the
  `Penpot team: <name> (<id>)` line; `runAgentTask` resolves and
  passes it only for `penpot_mcp: true` agents (prompt stays
  byte-identical for everyone else).
- `GET /agents` echoes the effective `penpot` block; per-agent
  response grows a `penpot_mcp` boolean. `GET /foreman/config` gains
  the same `penpot` field via `ForemanHttpContext`.
- `skills/design-implement.md` step 2 updated: read the pinned team
  first, fall back to `list_teams` with a visible warning comment.
- `config/agents.json` gets the initial block for `charles/claude-hooks`.
- `CLAUDE.md` §"Per-repo Penpot team mapping" documents the runbook.

Missing `penpot:` block is back-compat: the loader stores `null`,
legacy `penpot_mcp: true` agents keep working with the old
`list_teams` path.

Closes #255
fix(ci): update token_economy tests for opt-in cost cap
All checks were successful
qa / qa (pull_request) Successful in 4m2s
qa / dockerfile (pull_request) Successful in 7s
1f4d15cf4e
Commit 0767024 ("chore(agents): make cost cap opt-in") emptied
`DEFAULT_MAX_COST_USD_BY_TYPE` in webhook-config.ts so that missing
`max_cost_usd_per_task` leaves the cap `null` by default, but the two
paired tests in webhook-config.test.ts still asserted the pre-opt-in
baked defaults (boss $20, dev $5). They've been failing every CI run
since — PR #256 just happens to surface it.

Retarget both tests to the current contract: an absent `token_economy`
block yields `max_cost_usd_per_task: null` regardless of type name
(cost cap is opt-in, not opt-out). The rest of the block's defaults
(`warn_at_pct: 0.5`, empty caveman_labels, empty pricing) still apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
reviewer approved these changes 2026-04-21 19:43:24 +00:00
reviewer left a comment

Review — APPROVED

CI: green (fix(ci): update token_economy tests run #1930, sha 1f4d15c, 4m9s).

Acceptance criteria — all met

Criterion Status
config/agents.json gets penpot: block with base_url, default_team_id, team_by_repo
base_url replaces hardcoded https://design.jacquin.app in main.ts startup
default_team_id fallback when repo has no explicit entry
Missing penpot: block → loader stores null, legacy list_teams path unchanged
webhook-config.ts parses + validates (parsePenpotConfig, UUID shape, unknown-repo-key warning)
agent-runner.ts injects Penpot team: <name> (<id>) line for penpot_mcp: true agents
/foreman/config + GET /agents echo the effective penpot: block
skills/design-implement.md step 2 updated: pinned team first, list_teams fallback with warning
CLAUDE.md §"Penpot MCP auth" gains the per-repo team config runbook
Unit tests in webhook-config.test.ts (10 parsePenpotConfig cases + 4 resolvePenpotTeam cases + 3 integration cases)
Unit tests in agent-runner.test.ts (buildPrompt team-line cases)

Code walkthrough — no issues found

packages/shared/src/penpot.ts — Three clean, well-documented interfaces. PenpotTeamResolution.team_name: string | null correctly captures the "default-fallback has no name" case. Exported from packages/shared/src/index.ts.

webhook-config.tsparsePenpotConfig / resolvePenpotTeamForRepo — Validation is thorough: base_url required, UUID shape checked on both default_team_id and every team_by_repo[*].team_id, malformed owner/name keys rejected, unknown repo keys warn-but-parse (non-fatal, right call). resolvePenpotTeamForRepo reads the frozen module-level config singleton, correct pattern for this codebase.

agent-runner.tsbuildPromptpenpotTeam?: PenpotTeamResolution | null is optional so callers that don't pass it get byte-identical output — confirmed by the "no line when penpotTeam absent" test. Renders (unnamed) when team_name is null (default-fallback path). Team resolution gated on penpot_mcp: true at dispatch time.

skills/design-implement.md — step 2 — Pinned-team path calls mcp__penpot__list_files with the injected team_id to reuse an existing file, then falls back to create_file. Legacy path logs ⚠ no team pinned — used fallback <name> in the handoff comment so the operator sees the signal without the agent silently eating the gap. Clean.

config/agents.json — The charles/claude-hooks team_by_repo entry intentionally has team_id == default_team_id — this is correct because the explicit entry is what provides the human-readable team_name: "peon-manager" for the prompt line; the bare default_team_id alone would only get (unnamed).

foreman.ts / main.tspenpot?: PenpotConfig | null threaded through ForemanHttpContext and surfaced in the GET /foreman/config response. main.ts uses cfg.penpot?.base_url instead of the hardcoded URL.

Test coverage — All branches exercised: absent block, minimal block, full block, bad UUID (both fields), missing base_url, malformed key shape, unknown-repo warning, explicit-entry wins, default fallback with null name, null when neither set, module-state read after load, malformed UUID trips loadWebhookConfig.

Nothing to request changes on.

## Review — APPROVED CI: ✅ green (`fix(ci): update token_economy tests` run #1930, sha `1f4d15c`, 4m9s). ### Acceptance criteria — all met | Criterion | Status | |---|---| | `config/agents.json` gets `penpot:` block with `base_url`, `default_team_id`, `team_by_repo` | ✅ | | `base_url` replaces hardcoded `https://design.jacquin.app` in `main.ts` startup | ✅ | | `default_team_id` fallback when repo has no explicit entry | ✅ | | Missing `penpot:` block → loader stores `null`, legacy `list_teams` path unchanged | ✅ | | `webhook-config.ts` parses + validates (`parsePenpotConfig`, UUID shape, unknown-repo-key warning) | ✅ | | `agent-runner.ts` injects `Penpot team: <name> (<id>)` line for `penpot_mcp: true` agents | ✅ | | `/foreman/config` + `GET /agents` echo the effective `penpot:` block | ✅ | | `skills/design-implement.md` step 2 updated: pinned team first, `list_teams` fallback with warning | ✅ | | `CLAUDE.md` §"Penpot MCP auth" gains the per-repo team config runbook | ✅ | | Unit tests in `webhook-config.test.ts` (10 `parsePenpotConfig` cases + 4 `resolvePenpotTeam` cases + 3 integration cases) | ✅ | | Unit tests in `agent-runner.test.ts` (`buildPrompt` team-line cases) | ✅ | ### Code walkthrough — no issues found **`packages/shared/src/penpot.ts`** — Three clean, well-documented interfaces. `PenpotTeamResolution.team_name: string | null` correctly captures the "default-fallback has no name" case. Exported from `packages/shared/src/index.ts`. **`webhook-config.ts` — `parsePenpotConfig` / `resolvePenpotTeamForRepo`** — Validation is thorough: `base_url` required, UUID shape checked on both `default_team_id` and every `team_by_repo[*].team_id`, malformed `owner/name` keys rejected, unknown repo keys warn-but-parse (non-fatal, right call). `resolvePenpotTeamForRepo` reads the frozen module-level config singleton, correct pattern for this codebase. **`agent-runner.ts` — `buildPrompt`** — `penpotTeam?: PenpotTeamResolution | null` is optional so callers that don't pass it get byte-identical output — confirmed by the "no line when `penpotTeam` absent" test. Renders `(unnamed)` when `team_name` is null (default-fallback path). Team resolution gated on `penpot_mcp: true` at dispatch time. **`skills/design-implement.md` — step 2** — Pinned-team path calls `mcp__penpot__list_files` with the injected `team_id` to reuse an existing file, then falls back to `create_file`. Legacy path logs `⚠ no team pinned — used fallback <name>` in the handoff comment so the operator sees the signal without the agent silently eating the gap. Clean. **`config/agents.json`** — The `charles/claude-hooks` `team_by_repo` entry intentionally has `team_id == default_team_id` — this is correct because the explicit entry is what provides the human-readable `team_name: "peon-manager"` for the prompt line; the bare `default_team_id` alone would only get `(unnamed)`. **`foreman.ts` / `main.ts`** — `penpot?: PenpotConfig | null` threaded through `ForemanHttpContext` and surfaced in the `GET /foreman/config` response. `main.ts` uses `cfg.penpot?.base_url` instead of the hardcoded URL. **Test coverage** — All branches exercised: absent block, minimal block, full block, bad UUID (both fields), missing `base_url`, malformed key shape, unknown-repo warning, explicit-entry wins, default fallback with null name, null when neither set, module-state read after load, malformed UUID trips `loadWebhookConfig`. Nothing to request changes on.
code-lead deleted branch boss/255 2026-04-21 19:44:08 +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!256
No description provided.