From a8b66230433d1d4d93fb0b266d6e91a103162851 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Fri, 3 Apr 2026 22:16:00 -0500 Subject: [PATCH 1/2] feat(gastown): add PR-fixup workflow to polecat and mayor system prompts (#1989) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gastown): add PR-fixup workflow to polecat and mayor system prompts Add instructions for the gt:pr-fixup workflow: - Polecat prompt: how to handle PR fixup beads (check out branch, address review comments, resolve threads, push to existing branch) - Mayor prompt: how to dispatch PR fixup beads using gt_sling with the gt:pr-fixup label and appropriate metadata * WIP: container eviction save * fix: address PR #1989 review comments — metadata type and branch conflict - Remove JSON.stringify wrapper from metadata example in mayor prompt so it passes gt_sling's z.record(z.string(), z.unknown()) validation as a plain object. - Clarify polecat PR fixup workflow as the explicit exception to the 'do not switch branches' rule, and add exception note in Commit & Push Hygiene section. --------- Co-authored-by: John Fawcett --- .../src/prompts/mayor-system.prompt.ts | 20 +++++++++++++++++++ .../src/prompts/polecat-system.prompt.ts | 16 ++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/cloudflare-gastown/src/prompts/mayor-system.prompt.ts b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts index 10ce483c4e..78c3b6d347 100644 --- a/cloudflare-gastown/src/prompts/mayor-system.prompt.ts +++ b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts @@ -284,6 +284,26 @@ For large convoys (>5 beads) where the decomposition is non-obvious, consider using staged=true by default to give the user a chance to review before agents start spending compute. +## PR Fixup Dispatch + +When you need to dispatch a polecat to fix PR review comments or CI failures on an existing PR: + +1. Use \`gt_sling\` with the \`labels\` parameter set to \`["gt:pr-fixup"]\` +2. Include the PR URL, branch name, and target branch in the bead metadata: + \`\`\` + metadata: { + pr_url: "https://github.com/org/repo/pull/123", + branch: "gt/toast/abc123", + target_branch: "main" + } + \`\`\` +3. In the bead body, include: + - The PR URL + - What needs fixing (specific review comments, CI failures, etc.) + - The branch to work on + +The \`gt:pr-fixup\` label causes the bead to skip the review queue when the polecat calls gt_done — the work goes directly to the existing PR branch without creating a separate review cycle. + ## Bug Reporting If a user reports a bug or you encounter a repeating error, you can file a bug report diff --git a/cloudflare-gastown/src/prompts/polecat-system.prompt.ts b/cloudflare-gastown/src/prompts/polecat-system.prompt.ts index 28eca8ecfd..8815dea0e0 100644 --- a/cloudflare-gastown/src/prompts/polecat-system.prompt.ts +++ b/cloudflare-gastown/src/prompts/polecat-system.prompt.ts @@ -82,12 +82,26 @@ After all gates pass and your work is complete, create a pull request before cal ` : '' } +## PR Fixup Workflow + +When your hooked bead has the \`gt:pr-fixup\` label, you are fixing an existing PR rather than creating new work. **This is the ONE exception to the "do not switch branches" rule.** You MUST check out the PR branch from your bead metadata instead of using the default worktree branch. + +1. Check out the PR branch specified in your bead metadata (e.g. \`git fetch origin && git checkout \`). This overrides the default worktree branch for this bead. +2. Look at ALL comments on the PR using \`gh pr view --comments\` and the GitHub API. +3. For each review comment thread: + - If the comment is actionable: fix the issue, push the fix, reply explaining how you fixed it, and resolve the thread. + - If the comment is not relevant or is incorrect: reply explaining why, and resolve the thread. +4. **Important**: Resolve the entire thread, not just the individual comment. Use \`gh api\` to resolve review threads. +5. After addressing all comments, push your changes and call gt_done. + +Do NOT create a new PR. Push to the existing branch. + ## Commit & Push Hygiene - Commit after every meaningful unit of work (new function, passing test, config change). - Push after every commit. Do not batch pushes. - Use descriptive commit messages referencing the bead if applicable. -- Branch naming: your branch is pre-configured in your worktree. Do not switch branches. +- Branch naming: your branch is pre-configured in your worktree. Do not switch branches — **unless** your bead has the \`gt:pr-fixup\` label (see PR Fixup Workflow above). ## Escalation From 3c2b7b15c0d056f8a882b389f0ada343f4c138ab Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 4 Apr 2026 10:08:51 -0500 Subject: [PATCH 2/2] fix(gastown): gt_sling and gt_escalate accept metadata as object instead of JSON string The metadata parameter on gt_sling (mayor) and gt_escalate (polecat) now uses z.record(z.string(), z.unknown()) so the LLM can pass a plain object directly. Removes the parseJsonObject indirection and dead code. Updates tests to match. --- .../container/plugin/mayor-tools.ts | 22 +++------------ .../container/plugin/tools.test.ts | 27 +++++-------------- cloudflare-gastown/container/plugin/tools.ts | 17 +++--------- 3 files changed, 12 insertions(+), 54 deletions(-) diff --git a/cloudflare-gastown/container/plugin/mayor-tools.ts b/cloudflare-gastown/container/plugin/mayor-tools.ts index 48ad586449..d581941265 100644 --- a/cloudflare-gastown/container/plugin/mayor-tools.ts +++ b/cloudflare-gastown/container/plugin/mayor-tools.ts @@ -27,21 +27,6 @@ function parseUiAction(value: unknown): UiActionInput { return value as UiActionInput; } -function parseJsonObject(value: string, label: string): Record { - let parsed: unknown; - try { - parsed = JSON.parse(value); - } catch { - throw new Error(`Invalid JSON in "${label}"`); - } - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error( - `"${label}" must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}` - ); - } - return parsed as Record; -} - /** * Mayor-specific tools for cross-rig delegation. * These are only registered when `GASTOWN_AGENT_ROLE=mayor`. @@ -64,8 +49,8 @@ export function createMayorTools(client: MayorGastownClient) { ) .optional(), metadata: tool.schema - .string() - .describe('JSON-encoded metadata object for additional context') + .record(tool.schema.string(), tool.schema.unknown()) + .describe('Metadata object for additional context (e.g. { pr_url, branch, target_branch })') .optional(), labels: tool.schema .array(tool.schema.string()) @@ -73,12 +58,11 @@ export function createMayorTools(client: MayorGastownClient) { .optional(), }, async execute(args) { - const metadata = args.metadata ? parseJsonObject(args.metadata, 'metadata') : undefined; const result = await client.sling({ rig_id: args.rig_id, title: args.title, body: args.body, - metadata, + metadata: args.metadata, labels: args.labels, }); return [ diff --git a/cloudflare-gastown/container/plugin/tools.test.ts b/cloudflare-gastown/container/plugin/tools.test.ts index 4e8780013f..2c55de041c 100644 --- a/cloudflare-gastown/container/plugin/tools.test.ts +++ b/cloudflare-gastown/container/plugin/tools.test.ts @@ -232,33 +232,18 @@ describe('tools', () => { expect(result).toContain('priority: high'); }); - it('parses metadata JSON string', async () => { - await tools.gt_escalate.execute({ title: 'Test', metadata: '{"key": "value"}' }, CTX); + it('passes metadata object through to createEscalation', async () => { + await tools.gt_escalate.execute( + { title: 'Test', metadata: { key: 'value', nested: 123 } }, + CTX + ); expect(client.createEscalation).toHaveBeenCalledWith({ title: 'Test', body: undefined, priority: undefined, - metadata: { key: 'value' }, + metadata: { key: 'value', nested: 123 }, }); }); - - it('throws on invalid metadata JSON', async () => { - await expect( - tools.gt_escalate.execute({ title: 'Test', metadata: 'not json' }, CTX) - ).rejects.toThrow('Invalid JSON in "metadata"'); - }); - - it('throws when metadata is a JSON array instead of object', async () => { - await expect( - tools.gt_escalate.execute({ title: 'Test', metadata: '[1, 2]' }, CTX) - ).rejects.toThrow('"metadata" must be a JSON object, got array'); - }); - - it('throws when metadata is a JSON string instead of object', async () => { - await expect( - tools.gt_escalate.execute({ title: 'Test', metadata: '"hello"' }, CTX) - ).rejects.toThrow('"metadata" must be a JSON object, got string'); - }); }); describe('gt_checkpoint', () => { diff --git a/cloudflare-gastown/container/plugin/tools.ts b/cloudflare-gastown/container/plugin/tools.ts index 1c360c0f20..770115a6d8 100644 --- a/cloudflare-gastown/container/plugin/tools.ts +++ b/cloudflare-gastown/container/plugin/tools.ts @@ -9,16 +9,6 @@ function parseJsonArg(value: string, label: string): unknown { } } -function parseJsonObject(value: string, label: string): Record { - const parsed = parseJsonArg(value, label); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error( - `"${label}" must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}` - ); - } - return Object.fromEntries(Object.entries(parsed as object)); -} - export function createTools(client: GastownClient) { return { gt_prime: tool({ @@ -155,17 +145,16 @@ export function createTools(client: GastownClient) { .describe('Severity level (defaults to medium)') .optional(), metadata: tool.schema - .string() - .describe('JSON-encoded metadata object for additional context') + .record(tool.schema.string(), tool.schema.unknown()) + .describe('Metadata object for additional context') .optional(), }, async execute(args) { - const metadata = args.metadata ? parseJsonObject(args.metadata, 'metadata') : undefined; const bead = await client.createEscalation({ title: args.title, body: args.body, priority: args.priority, - metadata, + metadata: args.metadata, }); return `Escalation created: ${bead.bead_id} (priority: ${bead.priority})`; },