From 4a6156a405cd8c995111b7fc6c9e3de03b1d1f19 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 17 Jun 2026 08:36:01 +0000 Subject: [PATCH 1/7] feat: add issue triage and simplify workflows --- .mux/actions/github/ensureIssueLabels.js | 76 ++ .../actions/github/getIssueAutomationState.js | 92 ++ .mux/actions/github/getIssueConversation.js | 163 +++ .mux/actions/github/listIssues.js | 117 ++ .mux/actions/github/upsertIssueComment.js | 94 ++ .mux/actions/github/verifyIssueCommentUrl.js | 98 ++ .mux/workflow-action-lib/github.cjs | 191 +++ .mux/workflows/github-issue-triage.js | 709 +++++++++++ .mux/workflows/simplify.js | 1070 +++++++++++++++++ 9 files changed, 2610 insertions(+) create mode 100644 .mux/actions/github/ensureIssueLabels.js create mode 100644 .mux/actions/github/getIssueAutomationState.js create mode 100644 .mux/actions/github/getIssueConversation.js create mode 100644 .mux/actions/github/listIssues.js create mode 100644 .mux/actions/github/upsertIssueComment.js create mode 100644 .mux/actions/github/verifyIssueCommentUrl.js create mode 100644 .mux/workflow-action-lib/github.cjs create mode 100644 .mux/workflows/github-issue-triage.js create mode 100644 .mux/workflows/simplify.js diff --git a/.mux/actions/github/ensureIssueLabels.js b/.mux/actions/github/ensureIssueLabels.js new file mode 100644 index 00000000..5bd8cd5c --- /dev/null +++ b/.mux/actions/github/ensureIssueLabels.js @@ -0,0 +1,76 @@ +const { + getIssueView, + inputObject, + normalizeIssue, + repositoryFromInput, + requiredIssueNumber, + stringList, +} = require('../../workflow-action-lib/github.cjs'); + +export const metadata = { + version: 1, + description: 'Idempotently add and remove GitHub issue labels', + effect: 'external', + inputSchema: mux.schema.object( + { + repository: mux.schema.optional(mux.schema.string()), + owner: mux.schema.optional(mux.schema.string()), + repo: mux.schema.optional(mux.schema.string()), + number: mux.schema.integer(), + addLabels: mux.schema.optional(mux.schema.array(mux.schema.string())), + removeLabels: mux.schema.optional(mux.schema.array(mux.schema.string())), + }, + { additionalProperties: false }, + ), + outputSchema: mux.schema.object( + { + changed: mux.schema.boolean(), + before: mux.schema.array(mux.schema.string()), + after: mux.schema.array(mux.schema.string()), + added: mux.schema.array(mux.schema.string()), + removed: mux.schema.array(mux.schema.string()), + }, + { additionalProperties: false }, + ), + permissions: [ + { kind: 'command', command: 'gh issue edit' }, + { kind: 'command', command: 'gh issue view' }, + ], + timeoutMs: 60000, +}; + +async function getLabelNames(ctx, repository, number) { + const issue = await getIssueView(ctx, repository, number, ['labels']); + return normalizeIssue(issue).labelNames; +} + +export async function execute(rawInput, ctx) { + const input = inputObject(rawInput); + const repository = repositoryFromInput(input); + const number = requiredIssueNumber(input.number); + const addLabels = stringList(input.addLabels); + const removeLabels = stringList(input.removeLabels); + const before = await getLabelNames(ctx, repository, number); + const missingAddLabels = addLabels.filter((label) => !before.includes(label)); + const presentRemoveLabels = removeLabels.filter((label) => + before.includes(label), + ); + if (missingAddLabels.length === 0 && presentRemoveLabels.length === 0) { + return { changed: false, before, after: before, added: [], removed: [] }; + } + const args = ['issue', 'edit', String(number)]; + if (repository) args.push('--repo', repository); + for (const label of missingAddLabels) args.push('--add-label', label); + for (const label of presentRemoveLabels) args.push('--remove-label', label); + await ctx.execChecked('gh', args); + const after = await getLabelNames(ctx, repository, number); + return { + changed: true, + before, + after, + added: missingAddLabels, + removed: presentRemoveLabels, + }; +} + +export const reconcile = execute; diff --git a/.mux/actions/github/getIssueAutomationState.js b/.mux/actions/github/getIssueAutomationState.js new file mode 100644 index 00000000..51b8476d --- /dev/null +++ b/.mux/actions/github/getIssueAutomationState.js @@ -0,0 +1,92 @@ +const { + getIssueView, + inputObject, + isMatchingMarker, + listComments, + markerStatus, + normalizeIssue, + optionalString, + requiredIssueNumber, + requiredRepository, + requiredString, + splitRepository, + stringList, +} = require('../../workflow-action-lib/github.cjs'); + +export const metadata = { + version: 1, + description: 'Read GitHub issue automation marker comments and done labels', + effect: 'read', + inputSchema: mux.schema.object( + { + repository: mux.schema.optional(mux.schema.string()), + owner: mux.schema.optional(mux.schema.string()), + repo: mux.schema.optional(mux.schema.string()), + number: mux.schema.integer(), + doneLabels: mux.schema.optional(mux.schema.array(mux.schema.string())), + marker: mux.schema.string(), + markerKey: mux.schema.string(), + promptVersion: mux.schema.optional(mux.schema.string()), + }, + { additionalProperties: false }, + ), + outputSchema: mux.schema.object( + { + done: mux.schema.boolean(), + promptStarted: mux.schema.boolean(), + reportPosted: mux.schema.boolean(), + labelNames: mux.schema.array(mux.schema.string()), + markerComments: mux.schema.array( + mux.schema.object( + { + id: mux.schema.integer(), + url: mux.schema.nullable(mux.schema.string()), + status: mux.schema.string(), + }, + { additionalProperties: false }, + ), + ), + }, + { additionalProperties: false }, + ), + permissions: [ + { kind: 'command', command: 'gh api' }, + { kind: 'command', command: 'gh issue view' }, + ], + timeoutMs: 60000, +}; + +export async function execute(rawInput, ctx) { + const input = inputObject(rawInput); + const repository = requiredRepository(input); + const parts = splitRepository(repository); + const number = requiredIssueNumber(input.number); + const doneLabels = stringList(input.doneLabels); + const marker = requiredString(input.marker, 'marker'); + const markerKey = requiredString(input.markerKey, 'markerKey'); + const promptVersion = optionalString(input.promptVersion) || 'v1'; + const [issue, comments] = await Promise.all([ + getIssueView(ctx, repository, number, ['labels']), + listComments(ctx, parts.owner, parts.repo, number), + ]); + const labelNames = normalizeIssue(issue).labelNames; + const matching = comments.filter((comment) => + isMatchingMarker(comment.body, marker, markerKey, promptVersion), + ); + const statuses = matching + .map((comment) => markerStatus(comment.body)) + .filter(Boolean); + return { + done: + doneLabels.length > 0 && + doneLabels.every((label) => labelNames.includes(label)), + promptStarted: statuses.includes('prompt-started'), + reportPosted: statuses.includes('report-posted'), + labelNames, + markerComments: matching.map((comment) => ({ + id: comment.id, + url: comment.html_url || null, + status: markerStatus(comment.body), + })), + }; +} diff --git a/.mux/actions/github/getIssueConversation.js b/.mux/actions/github/getIssueConversation.js new file mode 100644 index 00000000..bd4c4b5d --- /dev/null +++ b/.mux/actions/github/getIssueConversation.js @@ -0,0 +1,163 @@ +const { + boundedCharBudget, + boundedLimit, + getIssueView, + inputObject, + listComments, + normalizeIssue, + repositoryFromInput, + requiredIssueNumber, + splitRepository, + truncateText, +} = require('../../workflow-action-lib/github.cjs'); + +export const metadata = { + version: 1, + description: 'Read a GitHub issue body and comments as markdown', + effect: 'read', + inputSchema: mux.schema.object( + { + repository: mux.schema.optional(mux.schema.string()), + owner: mux.schema.optional(mux.schema.string()), + repo: mux.schema.optional(mux.schema.string()), + number: mux.schema.integer(), + maxComments: mux.schema.optional(mux.schema.integer()), + issueBodyCharBudget: mux.schema.optional(mux.schema.integer()), + bodyCharBudget: mux.schema.optional(mux.schema.integer()), + commentBodyCharBudget: mux.schema.optional(mux.schema.integer()), + }, + { additionalProperties: false }, + ), + outputSchema: mux.schema.object( + { + repository: mux.schema.nullable(mux.schema.string()), + number: mux.schema.integer(), + issue: mux.schema.object( + { + number: mux.schema.integer(), + safeId: mux.schema.string(), + title: mux.schema.string(), + url: mux.schema.string(), + state: mux.schema.string(), + body: mux.schema.string(), + author: mux.schema.nullable(mux.schema.string()), + createdAt: mux.schema.nullable(mux.schema.string()), + updatedAt: mux.schema.nullable(mux.schema.string()), + labelNames: mux.schema.array(mux.schema.string()), + }, + { additionalProperties: false }, + ), + conversationMarkdown: mux.schema.string(), + limits: mux.schema.object( + { + maxComments: mux.schema.integer(), + issueBodyBudget: mux.schema.integer(), + commentBodyBudget: mux.schema.integer(), + hasOmittedComments: mux.schema.boolean(), + }, + { additionalProperties: false }, + ), + }, + { additionalProperties: false }, + ), + permissions: [ + { kind: 'command', command: 'gh issue view' }, + { kind: 'command', command: 'gh api' }, + ], + timeoutMs: 60000, +}; + +function repositoryPartsForComments(repository, issue) { + if (repository) return splitRepository(repository); + const match = + typeof issue.url === 'string' + ? issue.url.match( + /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/\d+$/, + ) + : null; + if (!match) + throw new Error( + 'repository or a GitHub issue URL is required to read comments', + ); + return { owner: match[1], repo: match[2] }; +} + +function formatConversation(comments, commentBodyBudget, hasOmittedComments) { + const visibleComments = Array.isArray(comments) ? comments : []; + if (visibleComments.length === 0) return '(no issue comments)'; + const markdown = visibleComments + .map( + (comment) => + '### Comment by ' + + ((comment.author && comment.author.login) || 'unknown') + + '\n\n' + + truncateText(comment.body || '', commentBodyBudget), + ) + .join('\n\n---\n\n'); + return hasOmittedComments + ? markdown + '\n\n[omitted additional comments]' + : markdown; +} + +export async function execute(rawInput, ctx) { + const input = inputObject(rawInput); + const repository = repositoryFromInput(input); + const number = requiredIssueNumber(input.number); + const maxComments = boundedLimit(input.maxComments, 100); + const issueBodyBudget = boundedCharBudget( + input.issueBodyCharBudget ?? input.bodyCharBudget, + 10000, + ); + const commentBodyBudget = boundedCharBudget( + input.commentBodyCharBudget, + 10000, + ); + const issueFields = [ + 'number', + 'title', + 'url', + 'state', + 'body', + 'author', + 'labels', + ]; + let issue; + let comments; + if (repository) { + const parts = splitRepository(repository); + [issue, comments] = await Promise.all([ + getIssueView(ctx, repository, number, issueFields), + listComments(ctx, parts.owner, parts.repo, number, { + limit: maxComments + 1, + }), + ]); + } else { + issue = await getIssueView(ctx, repository, number, issueFields); + const parts = repositoryPartsForComments(repository, issue); + comments = await listComments(ctx, parts.owner, parts.repo, number, { + limit: maxComments + 1, + }); + } + const visibleComments = comments.slice(0, maxComments); + const hasOmittedComments = comments.length > visibleComments.length; + const normalizedIssue = normalizeIssue(issue); + return { + repository: repository || null, + number, + issue: { + ...normalizedIssue, + body: truncateText(normalizedIssue.body, issueBodyBudget), + }, + conversationMarkdown: formatConversation( + visibleComments, + commentBodyBudget, + hasOmittedComments, + ), + limits: { + maxComments, + issueBodyBudget, + commentBodyBudget, + hasOmittedComments, + }, + }; +} diff --git a/.mux/actions/github/listIssues.js b/.mux/actions/github/listIssues.js new file mode 100644 index 00000000..a58d01f6 --- /dev/null +++ b/.mux/actions/github/listIssues.js @@ -0,0 +1,117 @@ +const { + boundedCharBudget, + boundedLimit, + inputObject, + normalizeIssue, + optionalString, + repositoryFromInput, + stringList, + truncateText, +} = require('../../workflow-action-lib/github.cjs'); + +export const metadata = { + version: 1, + description: 'List GitHub issues with reusable label/state filters', + effect: 'read', + inputSchema: mux.schema.object( + { + repository: mux.schema.optional(mux.schema.string()), + owner: mux.schema.optional(mux.schema.string()), + repo: mux.schema.optional(mux.schema.string()), + state: mux.schema.optional(mux.schema.string()), + includeLabels: mux.schema.optional(mux.schema.array(mux.schema.string())), + excludeLabels: mux.schema.optional(mux.schema.array(mux.schema.string())), + limit: mux.schema.optional(mux.schema.integer()), + includeBody: mux.schema.optional(mux.schema.boolean()), + bodyCharBudget: mux.schema.optional(mux.schema.integer()), + }, + { additionalProperties: false }, + ), + outputSchema: mux.schema.object( + { + repository: mux.schema.nullable(mux.schema.string()), + filters: mux.schema.object( + { + state: mux.schema.string(), + includeLabels: mux.schema.array(mux.schema.string()), + excludeLabels: mux.schema.array(mux.schema.string()), + limit: mux.schema.integer(), + includeBody: mux.schema.boolean(), + bodyCharBudget: mux.schema.integer(), + }, + { additionalProperties: false }, + ), + issues: mux.schema.array( + mux.schema.object( + { + number: mux.schema.integer(), + safeId: mux.schema.string(), + title: mux.schema.string(), + url: mux.schema.string(), + state: mux.schema.string(), + body: mux.schema.string(), + author: mux.schema.nullable(mux.schema.string()), + createdAt: mux.schema.nullable(mux.schema.string()), + updatedAt: mux.schema.nullable(mux.schema.string()), + labelNames: mux.schema.array(mux.schema.string()), + }, + { additionalProperties: false }, + ), + ), + }, + { additionalProperties: false }, + ), + permissions: [{ kind: 'command', command: 'gh issue list' }], + timeoutMs: 60000, +}; + +export async function execute(rawInput, ctx) { + const input = inputObject(rawInput); + const repository = repositoryFromInput(input); + const state = optionalString(input.state) || 'open'; + const includeLabels = stringList(input.includeLabels); + const excludeLabels = stringList(input.excludeLabels); + const limit = boundedLimit(input.limit, 1000); + const includeBody = input.includeBody === true; + const bodyCharBudget = boundedCharBudget(input.bodyCharBudget, 2000); + const jsonFields = + 'number,title,url,state,labels,author,createdAt,updatedAt' + + (includeBody ? ',body' : ''); + const args = [ + 'issue', + 'list', + '--state', + state, + '--limit', + String(limit), + '--json', + jsonFields, + ]; + if (repository) args.push('--repo', repository); + for (const label of includeLabels) args.push('--label', label); + const issues = (await ctx.execJson('gh', args)) + .map(normalizeIssue) + .map((issue) => ({ + ...issue, + body: includeBody ? truncateText(issue.body, bodyCharBudget) : '', + })) + .filter((issue) => + includeLabels.every((label) => issue.labelNames.includes(label)), + ) + .filter((issue) => + excludeLabels.every((label) => !issue.labelNames.includes(label)), + ) + .sort((a, b) => a.number - b.number); + return { + repository: repository || null, + filters: { + state, + includeLabels, + excludeLabels, + limit, + includeBody, + bodyCharBudget, + }, + issues, + }; +} diff --git a/.mux/actions/github/upsertIssueComment.js b/.mux/actions/github/upsertIssueComment.js new file mode 100644 index 00000000..8fdc909c --- /dev/null +++ b/.mux/actions/github/upsertIssueComment.js @@ -0,0 +1,94 @@ +const { + findComment, + inputObject, + requiredIssueNumber, + requiredRepository, + requiredString, + splitRepository, +} = require('../../workflow-action-lib/github.cjs'); + +export const metadata = { + version: 1, + description: 'Create or update a GitHub issue comment selected by marker', + effect: 'external', + inputSchema: mux.schema.object( + { + repository: mux.schema.optional(mux.schema.string()), + owner: mux.schema.optional(mux.schema.string()), + repo: mux.schema.optional(mux.schema.string()), + number: mux.schema.integer(), + marker: mux.schema.string(), + body: mux.schema.string(), + }, + { additionalProperties: false }, + ), + outputSchema: mux.schema.object( + { + action: mux.schema.enum(['created', 'updated']), + commentId: mux.schema.nullable(mux.schema.integer()), + url: mux.schema.nullable(mux.schema.string()), + }, + { additionalProperties: false }, + ), + permissions: [{ kind: 'command', command: 'gh api' }], + timeoutMs: 60000, +}; + +export async function execute(rawInput, ctx) { + const input = inputObject(rawInput); + const repository = requiredRepository(input); + const parts = splitRepository(repository); + const number = requiredIssueNumber(input.number); + const marker = requiredString(input.marker, 'marker'); + const body = requiredString(input.body, 'body'); + const existing = await findComment( + ctx, + parts.owner, + parts.repo, + number, + (comment) => + typeof comment.body === 'string' && comment.body.includes(marker), + ); + const payload = await ctx.writeTempJson({ body }); + if (existing) { + await ctx.execChecked('gh', [ + 'api', + '-X', + 'PATCH', + 'repos/' + + parts.owner + + '/' + + parts.repo + + '/issues/comments/' + + existing.id, + '--input', + payload.path, + ]); + return { + action: 'updated', + commentId: existing.id, + url: existing.html_url || null, + }; + } + const created = await ctx.execJson('gh', [ + 'api', + '-X', + 'POST', + 'repos/' + + parts.owner + + '/' + + parts.repo + + '/issues/' + + number + + '/comments', + '--input', + payload.path, + ]); + return { + action: 'created', + commentId: created.id || null, + url: created.html_url || null, + }; +} + +export const reconcile = execute; diff --git a/.mux/actions/github/verifyIssueCommentUrl.js b/.mux/actions/github/verifyIssueCommentUrl.js new file mode 100644 index 00000000..93972b76 --- /dev/null +++ b/.mux/actions/github/verifyIssueCommentUrl.js @@ -0,0 +1,98 @@ +const { + inputObject, + requiredIssueNumber, + requiredRepository, + requiredString, + splitRepository, + stringList, +} = require('../../workflow-action-lib/github.cjs'); + +export const metadata = { + version: 1, + description: + 'Verify that a GitHub issue comment URL belongs to an issue and contains expected text', + effect: 'read', + inputSchema: mux.schema.object( + { + repository: mux.schema.optional(mux.schema.string()), + owner: mux.schema.optional(mux.schema.string()), + repo: mux.schema.optional(mux.schema.string()), + number: mux.schema.integer(), + url: mux.schema.string(), + requiredBodyIncludes: mux.schema.optional( + mux.schema.array(mux.schema.string()), + ), + }, + { additionalProperties: false }, + ), + outputSchema: mux.schema.object( + { + verified: mux.schema.boolean(), + reason: mux.schema.string(), + missing: mux.schema.optional(mux.schema.array(mux.schema.string())), + url: mux.schema.optional(mux.schema.string()), + commentId: mux.schema.optional( + mux.schema.union([mux.schema.integer(), mux.schema.string()]), + ), + }, + { additionalProperties: false }, + ), + permissions: [{ kind: 'command', command: 'gh api' }], + timeoutMs: 60000, +}; + +function parseCommentUrl(url) { + const match = String(url || '').match( + /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)#issuecomment-(\d+)$/, + ); + return match + ? { + owner: match[1], + repo: match[2], + number: Number(match[3]), + commentId: match[4], + } + : null; +} + +export async function execute(rawInput, ctx) { + const input = inputObject(rawInput); + const repository = requiredRepository(input); + const parts = splitRepository(repository); + const number = requiredIssueNumber(input.number); + const parsed = parseCommentUrl(requiredString(input.url, 'url')); + if ( + !parsed || + parsed.owner !== parts.owner || + parsed.repo !== parts.repo || + parsed.number !== number + ) { + return { verified: false, reason: 'comment-url-does-not-match-issue' }; + } + const comment = await ctx.execJson('gh', [ + 'api', + 'repos/' + + parts.owner + + '/' + + parts.repo + + '/issues/comments/' + + parsed.commentId, + ]); + const includes = stringList(input.requiredBodyIncludes); + const missing = includes.filter( + (text) => typeof comment.body !== 'string' || !comment.body.includes(text), + ); + if (missing.length > 0) + return { + verified: false, + reason: 'missing-required-body-text', + missing, + url: comment.html_url || input.url, + }; + return { + verified: true, + reason: '', + url: comment.html_url || input.url, + commentId: comment.id || parsed.commentId, + }; +} diff --git a/.mux/workflow-action-lib/github.cjs b/.mux/workflow-action-lib/github.cjs new file mode 100644 index 00000000..41ad62f3 --- /dev/null +++ b/.mux/workflow-action-lib/github.cjs @@ -0,0 +1,191 @@ +function inputObject(input) { + return input != null && typeof input === 'object' && !Array.isArray(input) + ? input + : {}; +} + +function optionalString(value) { + return typeof value === 'string' && value.trim().length > 0 + ? value.trim() + : undefined; +} + +function requiredString(value, name) { + const text = optionalString(value); + if (!text) throw new Error(name + ' must be a non-empty string'); + return text; +} + +function stringList(value) { + if (!Array.isArray(value)) return []; + return value + .filter((item) => typeof item === 'string' && item.trim().length > 0) + .map((item) => item.trim()); +} + +function repositoryFromInput(input) { + const repository = optionalString(input.repository); + if (repository) return repository; + const owner = optionalString(input.owner); + const repo = optionalString(input.repo); + return owner && repo ? owner + '/' + repo : undefined; +} + +function requiredRepository(input) { + const repository = repositoryFromInput(input); + if (!repository) throw new Error('repository or owner/repo is required'); + return repository; +} + +function splitRepository(repository) { + const parts = repository.split('/'); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error('repository must use owner/repo format'); + } + return { owner: parts[0], repo: parts[1] }; +} + +function requiredIssueNumber(value) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error('number must be a positive integer issue number'); + } + return value; +} + +function boundedLimit(value, fallback) { + if (!Number.isInteger(value)) return fallback; + return Math.max(1, Math.min(value, 1000)); +} + +function boundedCharBudget(value, fallback) { + if (!Number.isInteger(value)) return fallback; + return Math.max(0, Math.min(value, 100000)); +} + +function truncateText(value, budget) { + const text = typeof value === 'string' ? value : ''; + if (text.length <= budget) return text; + return ( + text.slice(0, budget) + + '\n\n[truncated ' + + (text.length - budget) + + ' chars]' + ); +} + +function normalizeIssue(issue) { + const labelNames = Array.isArray(issue.labels) + ? issue.labels + .map((label) => (typeof label === 'string' ? label : label.name)) + .filter((name) => typeof name === 'string' && name.length > 0) + : []; + return { + number: issue.number, + safeId: 'issue-' + issue.number, + title: issue.title || '', + url: issue.url || '', + state: issue.state || '', + body: issue.body || '', + author: issue.author && issue.author.login ? issue.author.login : null, + createdAt: issue.createdAt || null, + updatedAt: issue.updatedAt || null, + labelNames, + }; +} + +function markerCommentNeedle(marker, markerKey, promptVersion) { + return ( + '\n\n' + + 'Mux triage has started in workspace `' + + workspaceId + + '`.\n\n' + + 'If no triage report appears, remove this marker comment or bump `promptVersion` before rerunning the workflow.' + ); +} + +function hasAssistantText(result) { + return ( + result && + result.found === true && + typeof result.text === 'string' && + result.text.trim().length > 0 + ); +} + +function publishReportWithWorkspaceLoop(action, cfg, item, triageReport) { + const issue = item.issue; + let latestText = triageReport; + let lastReason = 'missing-structured-output'; + + const alreadyPosted = extractPublishResult(latestText); + if (alreadyPosted) { + const verified = verifyPublishedReport( + action, + cfg, + item, + alreadyPosted.commentUrl, + 'initial', + ); + if (verified.completed) return verified; + lastReason = verified.reason; + } + + for (let attempt = 1; attempt <= cfg.publishAttemptCount; attempt += 1) { + action.workspace.sendMessage({ + id: + 'send-publish-prompt-' + + issue.safeId + + '-' + + cfg.promptVersion + + '-' + + attempt, + input: { + workspaceId: item.workspaceId, + agentId: cfg.agentId, + model: cfg.model, + message: buildPublishPrompt( + issue, + triageReport, + attempt, + lastReason, + cfg.doneLabel, + ), + }, + }); + + const idle = actionOutput( + action.workspace.awaitIdle({ + id: + 'await-publish-prompt-' + + issue.safeId + + '-' + + cfg.promptVersion + + '-' + + attempt, + input: { + workspaceId: item.workspaceId, + timeoutMs: cfg.awaitTimeoutMs, + }, + }), + ); + + if (!idle.idle) { + return { completed: false, reason: 'publish-workspace-still-running' }; + } + + const latest = actionOutput( + action.workspace.getLatestAssistantMessage({ + id: + 'latest-publish-prompt-' + + issue.safeId + + '-' + + cfg.promptVersion + + '-' + + attempt, + input: { workspaceId: item.workspaceId }, + }), + ); + + if (!hasAssistantText(latest)) { + lastReason = 'no-publish-output'; + continue; + } + + latestText = latest.text; + const published = extractPublishResult(latestText); + if (!published) { + lastReason = 'missing-structured-output'; + continue; + } + + const verified = verifyPublishedReport( + action, + cfg, + item, + published.commentUrl, + 'attempt-' + attempt, + ); + if (verified.completed) return verified; + lastReason = verified.reason; + } + + return { completed: false, reason: 'publish-not-verified-' + lastReason }; +} + +function verifyPublishedReport(action, cfg, item, commentUrl, suffix) { + const issue = item.issue; + const comment = actionOutput( + action.github.verifyIssueCommentUrl({ + id: 'verify-published-comment-' + issue.safeId + '-' + suffix, + input: { + repository: cfg.repository, + number: issue.number, + url: commentUrl, + requiredBodyIncludes: ['This triage report is AI-generated using Mux'], + }, + }), + ); + + if (!comment.verified) { + return { + completed: false, + reason: 'comment-not-verified-' + (comment.reason || 'unknown'), + }; + } + + const state = actionOutput( + action.github.getIssueAutomationState({ + id: 'verify-done-label-' + issue.safeId + '-' + suffix, + input: { + repository: cfg.repository, + number: issue.number, + doneLabels: [cfg.doneLabel], + marker: cfg.marker, + markerKey: item.markerKey, + promptVersion: cfg.promptVersion, + }, + }), + ); + + if (!state.done) { + return { completed: false, reason: 'done-label-missing' }; + } + + return { completed: true, commentUrl: comment.url || commentUrl }; +} + +function extractPublishResult(text) { + const candidates = []; + const fence = /```(?:json)?\s*([\s\S]*?)```/g; + let match; + while ((match = fence.exec(text)) !== null) { + candidates.push(match[1]); + } + + const firstBrace = text.indexOf('{'); + const lastBrace = text.lastIndexOf('}'); + if (firstBrace >= 0 && lastBrace > firstBrace) { + candidates.push(text.slice(firstBrace, lastBrace + 1)); + } + + for (let index = candidates.length - 1; index >= 0; index -= 1) { + try { + const parsed = JSON.parse(candidates[index]); + if (parsed && typeof parsed.commentUrl === 'string') { + return { commentUrl: parsed.commentUrl.trim() }; + } + } catch { + // Try the next candidate. + } + } + + return null; +} + +function summaryMarkdown(completed, dispatched, deferred, skippedDone) { + return [ + '# GitHub issue triage reconcile', + '', + '- Completed: ' + formatCompletedList(completed), + '- Dispatched: ' + formatIssueList(dispatched), + '- Deferred: ' + formatDeferredList(deferred), + '- Already done: ' + formatIssueList(skippedDone), + ].join('\n'); +} + +function formatCompletedList(completed) { + if (completed.length === 0) return '(none)'; + return completed + .map(function (item) { + return '#' + item.issue + ' (' + item.commentUrl + ')'; + }) + .join(', '); +} + +function formatIssueList(numbers) { + return numbers.length === 0 + ? '(none)' + : numbers + .map(function (number) { + return '#' + number; + }) + .join(', '); +} + +function formatDeferredList(deferred) { + if (deferred.length === 0) return '(none)'; + return deferred + .map(function (item) { + return '#' + item.issue + ' (' + item.reason + ')'; + }) + .join(', '); +} diff --git a/.mux/workflows/simplify.js b/.mux/workflows/simplify.js new file mode 100644 index 00000000..59174f96 --- /dev/null +++ b/.mux/workflows/simplify.js @@ -0,0 +1,1070 @@ +const s = mux.schema; + +export const metadata = { + description: + 'Review current changes for reuse, quality, and efficiency, then fix actionable issues.', + argsSchema: s.object({ + target: s.optional(s.string({ positional: true })), + fix: s.optional( + s.boolean({ + default: true, + aliases: ['--fix'], + negatedAliases: ['--no-fix', '--review-only'], + }), + ), + reviewOnly: s.optional(s.boolean({ default: false })), + baseRef: s.optional(s.string({ aliases: ['--base'] })), + base: s.optional(s.string()), + trunkRef: s.optional(s.string({ aliases: ['--trunk'] })), + trunk: s.optional(s.string()), + headRef: s.optional(s.string({ aliases: ['--head'] })), + head: s.optional(s.string()), + maxFindings: s.optional( + s.integer({ default: 20, minimum: 1, aliases: ['--max-findings'] }), + ), + help: s.optional(s.boolean({ default: false, aliases: ['--help', '-h'] })), + }), +}; + +// Workflow files execute as self-contained JavaScript; keep small helpers inline instead of importing repo utilities. +const DEFAULT_MAX_FINDINGS = 20; +// Review agents get bounded diff text; synthesis/fix phases get metadata only. +const REVIEW_DIFF_CHAR_BUDGET = 60000; +const METADATA_ARRAY_ITEM_BUDGET = 200; +const DIFF_STAT_CHAR_BUDGET = 20000; +const REVIEW_EVIDENCE_ITEM_BUDGET = 3; +const REVIEW_EVIDENCE_CHAR_BUDGET = 500; +const NO_REVIEWABLE_CHANGES_SUMMARY = 'No reviewable changes found.'; +const GIT_CONTEXT_SOURCE = 'parent-workflow-checkout'; +const GIT_CONTEXT_PROVENANCE_NOTE = + 'Git context was captured from the parent workflow checkout before child agent workspaces were spawned. Child agent branches may differ; status.branch/upstream describe the reviewed parent checkout.'; +const READ_ONLY_PROMPT = + 'This is a read-only review step. Do not edit files, create commits, apply patches, push branches, or open PRs. Inspect repository evidence only as needed and report findings.'; +const VALUE_FLAGS = [ + { name: '--base', key: 'baseRef' }, + { name: '--trunk', key: 'trunkRef' }, + { name: '--head', key: 'headRef' }, + { name: '--max-findings', key: 'maxFindings' }, +]; +const STATUS_ARRAY_FIELDS = ['staged', 'unstaged', 'untracked', 'ignored']; +const CHANGED_FILE_ARRAY_FIELDS = ['branch', 'staged', 'unstaged', 'untracked']; +const DIFF_FIELDS = ['branch', 'staged', 'unstaged']; +const REVIEW_AGENT_ID = 'explore'; +const EXEC_AGENT_ID = 'exec'; +const REVIEW_LANES = [ + { + id: 'reuse', + title: 'Simplify: code reuse review', + instructions: [ + 'Search for existing utilities and helpers that could replace newly written code.', + 'Flag new functions that duplicate existing functionality and name the existing function to use instead.', + 'Flag inline logic that could use an existing utility: string handling, path handling, environment checks, type guards, and similar patterns.', + ], + }, + { + id: 'quality', + title: 'Simplify: code quality review', + instructions: [ + 'Find redundant state, cached values that could be derived, and observers/effects that could be direct calls.', + 'Find parameter sprawl, copy-paste with slight variation, and leaky abstractions.', + 'Find stringly-typed code and unnecessary JSX wrappers that add no layout value.', + ], + }, + { + id: 'efficiency', + title: 'Simplify: efficiency review', + instructions: [ + 'Find redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns, and missed concurrency.', + 'Find hot-path bloat, recurring no-op updates, and updater wrappers that defeat same-reference no-op returns.', + 'Find TOCTOU existence pre-checks, unbounded memory, missing cleanup, and overly broad reads or loads.', + ], + }, +]; + +const SCHEMA = s; +const SEVERITY_SCHEMA = SCHEMA.enum(['high', 'medium', 'low']); +const FINDING_CORE_PROPERTIES = { + id: SCHEMA.string(), + title: SCHEMA.string(), + severity: SEVERITY_SCHEMA, + filePaths: SCHEMA.array(SCHEMA.string()), + rationale: SCHEMA.string(), +}; +const FINDING_SCHEMA = SCHEMA.object({ + ...FINDING_CORE_PROPERTIES, + recommendation: SCHEMA.string(), + evidence: SCHEMA.array(SCHEMA.string()), +}); +const REVIEW_SCHEMA = SCHEMA.object({ + summary: SCHEMA.string(), + findings: SCHEMA.array(FINDING_SCHEMA), +}); +const SYNTHESIS_FINDING_SCHEMA = SCHEMA.object({ + ...FINDING_CORE_PROPERTIES, + fixPlan: SCHEMA.string(), +}); +const SKIPPED_FINDING_SCHEMA = SCHEMA.object({ + id: SCHEMA.string(), + title: SCHEMA.string(), + reason: SCHEMA.string(), +}); +const SYNTHESIS_SCHEMA = SCHEMA.object({ + summary: SCHEMA.string(), + shouldFix: SCHEMA.boolean(), + actionableFindings: SCHEMA.array(SYNTHESIS_FINDING_SCHEMA), + skippedFindings: SCHEMA.array(SKIPPED_FINDING_SCHEMA), + validationPlan: SCHEMA.array(SCHEMA.string()), +}); +const FIXER_SCHEMA = SCHEMA.object({ + madeChanges: SCHEMA.boolean(), + fixedFindingIds: SCHEMA.array(SCHEMA.string()), + skippedFindings: SCHEMA.array( + SCHEMA.object({ + id: SCHEMA.string(), + reason: SCHEMA.string(), + }), + ), + validation: SCHEMA.array( + SCHEMA.object({ + command: SCHEMA.string(), + status: SCHEMA.string(), + summary: SCHEMA.string(), + }), + ), +}); + +const EMPTY_SYNTHESIS = { + summary: NO_REVIEWABLE_CHANGES_SUMMARY, + shouldFix: false, + actionableFindings: [], + skippedFindings: [], + validationPlan: [], +}; + +export default async function simplifyWorkflow({ + args, + phase, + log, + action, + agent, +}) { + assert(action && agent, 'workflow runtime APIs are required'); + + const parsed = parseArgs(args); + if (parsed.error) return usageResult(parsed.error); + + const input = parsed.input; + if (input.help) return usageResult(); + + phase('capture-context', { + target: input.target || 'current git changes', + fix: input.fix, + }); + const gitContext = collectGitContext(action, input, log); + const contexts = promptContexts(input, gitContext); + log('Captured simplify context', { + target: input.target || 'current git changes', + gitFailures: gitContext.failures.length, + diffCompactions: mux.utils.asArray( + contexts.outputGitContext.diff?.workflowCompactions, + ).length, + }); + + if (shouldSkipForUntrackedContent(input, gitContext)) { + const reason = untrackedChangesSkipReason(); + return { + reportMarkdown: '## Simplify workflow result\n\n' + reason, + structuredOutput: { + mode: 'untracked-changes-skip-review', + gitContext: contexts.outputGitContext, + reviews: [], + synthesis: { ...EMPTY_SYNTHESIS, summary: reason }, + }, + }; + } + + if (!hasReviewableContext(input, gitContext)) { + return { + reportMarkdown: + '## Simplify workflow result\n\n' + NO_REVIEWABLE_CHANGES_SUMMARY, + structuredOutput: { + mode: 'no-reviewable-changes', + gitContext: contexts.outputGitContext, + reviews: [], + synthesis: EMPTY_SYNTHESIS, + }, + }; + } + + phase('review', { + lanes: REVIEW_LANES.map(function (lane) { + return lane.id; + }), + }); + const reviewOutputs = mux + .parallelMap({ + items: REVIEW_LANES, + stepId: function (lane) { + return lane.id + '-review'; + }, + title: function (lane) { + return lane.title; + }, + agentId: REVIEW_AGENT_ID, + prompt: function (lane) { + return reviewPrompt(lane, input, contexts.review); + }, + outputSchema: REVIEW_SCHEMA, + maxParallel: REVIEW_LANES.length, + }) + .map(function (review) { + return mux.utils.mustObject( + review.structuredOutput, + 'review structured output is required', + ); + }); + + const rawFindingCount = reviewOutputs.reduce(function (count, output) { + return count + output.findings.length; + }, 0); + + phase('synthesize', { rawFindingCount: rawFindingCount }); + const synthesis = agent({ + id: 'synthesize-simplify-findings', + title: 'Simplify: synthesize findings', + agentId: EXEC_AGENT_ID, + prompt: synthesisPrompt(input, contexts.compact, reviewOutputs), + outputSchema: SYNTHESIS_SCHEMA, + }); + const synthesized = mux.utils.mustObject( + synthesis.structuredOutput, + 'synthesis structured output is required', + ); + const actionableFindings = synthesized.actionableFindings; + + if (!input.fix || !synthesized.shouldFix || actionableFindings.length === 0) { + return { + reportMarkdown: reviewOnlyReport(input, synthesis.reportMarkdown), + structuredOutput: { + mode: input.fix ? 'no-actionable-fixes' : 'review-only', + gitContext: contexts.outputGitContext, + reviews: reviewOutputs, + synthesis: synthesized, + }, + }; + } + + // Workflow child workspaces do not inherit parent dirt; keep the review result but skip auto-fix. + if (hasUncommittedChanges(gitContext)) { + return skipFixResult( + synthesis.reportMarkdown, + uncommittedChangesSkipReason(), + 'uncommitted-changes-skip-fix', + contexts.outputGitContext, + reviewOutputs, + synthesized, + ); + } + if (!isRequestedHeadCurrent(gitContext.status, input)) { + return skipFixResult( + synthesis.reportMarkdown, + nonCurrentHeadSkipReason(), + 'non-current-head-skip-fix', + contexts.outputGitContext, + reviewOutputs, + synthesized, + ); + } + + phase('fix', { actionableFindingCount: actionableFindings.length }); + const fixer = agent({ + id: 'fix-simplify-findings', + title: 'Simplify: fix actionable findings', + agentId: EXEC_AGENT_ID, + prompt: fixPrompt(contexts.compact, synthesized), + outputSchema: FIXER_SCHEMA, + }); + const fixerOutput = mux.utils.mustObject( + fixer.structuredOutput, + 'fixer structured output is required', + ); + + if (!fixerOutput.madeChanges) { + return { + reportMarkdown: + synthesis.reportMarkdown + + '\n\n---\n\n## Fix pass\n\nThe fixer did not make file changes.\n\n' + + fixer.reportMarkdown, + structuredOutput: { + mode: 'fixer-made-no-changes', + gitContext: contexts.outputGitContext, + reviews: reviewOutputs, + synthesis: synthesized, + fix: { fixer: fixerOutput, applied: null }, + }, + }; + } + + phase('apply-fixes', { madeChanges: true }); + const applyPreflight = collectApplyPreflight(action, log, input, gitContext); + if (applyPreflight.skippedReason) { + return { + reportMarkdown: + synthesis.reportMarkdown + + '\n\n---\n\n## Fix pass\n\n' + + fixer.reportMarkdown + + '\n\n### Patch application\n\n' + + applyPreflight.skippedReason, + structuredOutput: { + mode: 'apply-preflight-skip', + gitContext: contexts.outputGitContext, + reviews: reviewOutputs, + synthesis: synthesized, + fix: { fixer: fixerOutput, applied: applyPreflight }, + }, + }; + } + + const applied = await mux.patch.applySafely({ + id: 'apply-simplify-fixes', + source: fixer, + expectedHeadSha: applyPreflight.expectedHeadSha, + }); + + return { + reportMarkdown: fixReport( + synthesis.reportMarkdown, + fixer.reportMarkdown, + applied, + ), + structuredOutput: { + mode: 'fix-attempted', + gitContext: contexts.outputGitContext, + reviews: reviewOutputs, + synthesis: synthesized, + fix: { fixer: fixerOutput, applied: applied }, + }, + }; +} + +function skipFixResult( + synthesisMarkdown, + reason, + mode, + outputGitContext, + reviewOutputs, + synthesized, +) { + return { + reportMarkdown: + synthesisMarkdown + '\n\n---\n\n## Simplify workflow result\n\n' + reason, + structuredOutput: { + mode: mode, + gitContext: outputGitContext, + reviews: reviewOutputs, + synthesis: synthesized, + }, + }; +} + +function collectApplyPreflight(action, log, input, gitContext) { + const reviewedHeadSha = gitContext.status && gitContext.status.headSha; + if (typeof reviewedHeadSha !== 'string' || !reviewedHeadSha) { + return failedApplyPreflight( + 'Auto-fix was skipped because the reviewed HEAD snapshot is unavailable.', + ); + } + + try { + const preflightInput = { + head: input.headRef || 'HEAD', + expectedHeadSha: reviewedHeadSha, + requireClean: true, + }; + const expectedBranch = reviewedBranchForPreflight(gitContext.status, input); + // Commit-SHA heads have no branch invariant; omit the optional schema key + // rather than passing QuickJS `undefined` through action input validation. + if (expectedBranch !== undefined) + preflightInput.expectedBranch = expectedBranch; + + const preflight = action.git.preflight({ + id: 'apply-git-preflight', + input: preflightInput, + builtInOnly: true, + cache: false, + }).output; + if (preflight && preflight.ok === true) { + return { + success: true, + status: 'ready', + expectedHeadSha: reviewedHeadSha, + }; + } + return failedApplyPreflight( + (preflight && preflight.reason) || + 'Auto-fix was skipped because fresh Git preflight did not pass.', + ); + } catch (error) { + const message = formatError(error); + log('Git preflight unavailable for simplify auto-fix', { error: message }); + return failedApplyPreflight( + 'Auto-fix was skipped because fresh Git preflight was unavailable.', + ); + } +} + +function reviewedBranchForPreflight(reviewedStatus, input) { + if (input.headRef && isGitCommitSha(input.headRef)) return undefined; + const branch = + reviewedStatus && typeof reviewedStatus.branch === 'string' + ? reviewedStatus.branch.trim() + : ''; + return branch && branch !== 'HEAD (no branch)' ? branch : undefined; +} + +function failedApplyPreflight(reason) { + return { + success: false, + status: 'failed', + skippedReason: reason, + error: reason, + }; +} + +function nonCurrentHeadSkipReason() { + return 'Auto-fix was skipped because the requested `--head` is not the current checkout. Check out that branch/ref or pass the current commit SHA before applying fixes.'; +} + +function untrackedChangesSkipReason() { + return 'Review was skipped because untracked file contents are not available to workflow child workspaces. Add them with `git add -N` or commit them, then rerun `/workflow simplify`.'; +} + +function uncommittedChangesSkipReason() { + return 'Auto-fix was skipped because uncommitted changes are present. Commit or stash them, then rerun `/workflow simplify --fix`.'; +} + +function collectGitContext(action, input, log) { + const requestedRefs = gitRefs(input); + try { + const context = action.git.reviewContext({ + id: 'git-review-context', + input: { + ...requestedRefs, + diffCharBudget: REVIEW_DIFF_CHAR_BUDGET, + metadataCharBudget: DIFF_STAT_CHAR_BUDGET, + }, + builtInOnly: true, + }).output; + if (!context || typeof context !== 'object') { + return emptyGitContext(input, requestedRefs, [ + { + name: 'reviewContext', + error: 'git.reviewContext returned no context', + }, + ]); + } + return { + ...context, + target: input.target, + refs: refsWithResolvedBase(requestedRefs, context.changedFiles), + failures: mux.utils.asArray(context.failures), + }; + } catch (error) { + const failure = { name: 'reviewContext', error: formatError(error) }; + log( + 'Git review context action failed; continuing with empty simplify context', + failure, + ); + return emptyGitContext(input, requestedRefs, [failure]); + } +} + +function emptyGitContext(input, refs, failures) { + return { + target: input.target, + refs: refs, + failures: failures, + status: null, + changedFiles: null, + diffStat: null, + diff: null, + }; +} + +function gitRefs(input) { + const refs = {}; + if (input.baseRef) refs.base = input.baseRef; + if (input.trunkRef) refs.trunk = input.trunkRef; + if (input.headRef) refs.head = input.headRef; + return refs; +} + +function refsWithResolvedBase(refs, changedFiles) { + const base = + changedFiles && + typeof changedFiles === 'object' && + typeof changedFiles.base === 'string' + ? changedFiles.base + : ''; + if (refs.base || refs.trunk || !base) return refs; + return { ...refs, base }; +} + +// The diff actions only return branch/staged/unstaged hunks; untracked-only contexts +// are captured by changedFiles. + +function isRequestedHeadCurrent(status, input) { + if (!status || typeof status !== 'object') return false; + const currentHeadSha = + typeof status.headSha === 'string' ? status.headSha : ''; + const requestedHead = input && input.headRef ? input.headRef : ''; + const requestedHeadSha = + typeof status.requestedHeadSha === 'string' + ? status.requestedHeadSha + : requestedHead + ? '' + : currentHeadSha; + if ( + !currentHeadSha || + !requestedHeadSha || + currentHeadSha !== requestedHeadSha + ) + return false; + + if (!requestedHead || requestedHead === 'HEAD') return true; + + const currentBranch = typeof status.branch === 'string' ? status.branch : ''; + const currentBranchRef = currentBranch ? 'refs/heads/' + currentBranch : ''; + const requestedHeadRef = + typeof status.requestedHeadRef === 'string' ? status.requestedHeadRef : ''; + if (requestedHeadRef) + return Boolean(currentBranchRef && requestedHeadRef === currentBranchRef); + if ( + currentBranch && + (requestedHead === currentBranch || requestedHead === currentBranchRef) + ) { + return true; + } + return isGitCommitSha(requestedHead); +} + +function isGitCommitSha(value) { + return /^[0-9a-f]{7,64}$/i.test(value); +} + +function shouldSkipForUntrackedContent(input, gitContext) { + if (!hasUntrackedChanges(gitContext)) return false; + return input.target + ? targetNeedsUntrackedContent(input, gitContext) + : hasOnlyUntrackedChanges(gitContext); +} + +function targetNeedsUntrackedContent(input, gitContext) { + if (!input.target) return true; + const target = normalizedPath(input.target); + if (!target) return false; + if (target === '.') return true; + return untrackedPaths(gitContext).some(function (path) { + const untracked = normalizedPath(path); + return untracked === target || untracked.startsWith(target + '/'); + }); +} + +function untrackedPaths(gitContext) { + const paths = mux.utils + .asArray(gitContext.status && gitContext.status.untracked) + .concat( + mux.utils.asArray( + gitContext.changedFiles && gitContext.changedFiles.untracked, + ), + ) + .map(filePath) + .filter(Boolean); + return Array.from(new Set(paths)); +} + +function filePath(value) { + if (typeof value === 'string') return value; + return value && typeof value.path === 'string' ? value.path : ''; +} + +function normalizedPath(value) { + if (typeof value !== 'string') return ''; + const trimmed = value.trim().replace(/\\/g, '/'); + if (trimmed === '.' || trimmed === './') return '.'; + return trimmed.replace(/^\.\//, '').replace(/\/+$/, ''); +} + +function hasOnlyUntrackedChanges(gitContext) { + return ( + hasUntrackedChanges(gitContext) && + !hasArrayItems(gitContext.changedFiles && gitContext.changedFiles.branch) && + !hasArrayItems(gitContext.changedFiles && gitContext.changedFiles.staged) && + !hasArrayItems( + gitContext.changedFiles && gitContext.changedFiles.unstaged, + ) && + !hasText(gitContext.diff && gitContext.diff.branch) && + !hasText(gitContext.diff && gitContext.diff.staged) && + !hasText(gitContext.diff && gitContext.diff.unstaged) + ); +} + +function hasUntrackedChanges(gitContext) { + return ( + hasArrayItems(gitContext.status && gitContext.status.untracked) || + hasArrayItems(gitContext.changedFiles && gitContext.changedFiles.untracked) + ); +} + +function hasUncommittedChanges(gitContext) { + return ( + hasUncommittedStatus(gitContext.status) || + hasArrayItems(gitContext.changedFiles && gitContext.changedFiles.staged) || + hasArrayItems( + gitContext.changedFiles && gitContext.changedFiles.unstaged, + ) || + hasArrayItems(gitContext.changedFiles && gitContext.changedFiles.untracked) + ); +} + +function hasUncommittedStatus(status) { + return ( + hasArrayItems(status && status.staged) || + hasArrayItems(status && status.unstaged) || + hasArrayItems(status && status.untracked) + ); +} + +function hasReviewableContext(input, gitContext) { + if (input.target) return true; + if (mux.utils.asArray(gitContext.failures).length > 0) return true; + if (!gitContext.status || gitContext.status.clean !== true) return true; + return ( + hasArrayItems(gitContext.changedFiles && gitContext.changedFiles.branch) || + hasArrayItems(gitContext.changedFiles && gitContext.changedFiles.staged) || + hasArrayItems( + gitContext.changedFiles && gitContext.changedFiles.unstaged, + ) || + hasArrayItems( + gitContext.changedFiles && gitContext.changedFiles.untracked, + ) || + hasText(gitContext.diff && gitContext.diff.branch) || + hasText(gitContext.diff && gitContext.diff.staged) || + hasText(gitContext.diff && gitContext.diff.unstaged) + ); +} + +function promptContexts(input, gitContext) { + const compactedGitContext = compactMetadata(gitContext); + const reviewDiff = compactDiff(gitContext.diff, REVIEW_DIFF_CHAR_BUDGET); + const reviewGitContext = { ...compactedGitContext, diff: reviewDiff }; + const outputGitContext = { + ...compactedGitContext, + diff: diffSummary(gitContext.diff, reviewDiff), + }; + return { + review: renderContext(input, reviewGitContext), + compact: renderContext(input, outputGitContext), + outputGitContext: outputGitContext, + }; +} + +function renderContext(input, gitContext) { + return mux.utils.fencedJson({ + input: { + target: input.target, + fix: input.fix, + maxFindings: input.maxFindings, + }, + gitContextSource: GIT_CONTEXT_SOURCE, + gitContext: gitContext, + }); +} + +function compactMetadata(gitContext) { + return { + ...gitContext, + status: compactStatus(gitContext.status), + changedFiles: compactChangedFiles(gitContext.changedFiles), + diffStat: compactDiffStat(gitContext.diffStat), + }; +} + +function compactStatus(status) { + return compactFields( + status, + STATUS_ARRAY_FIELDS, + compactArray, + METADATA_ARRAY_ITEM_BUDGET, + ); +} + +function compactChangedFiles(changedFiles) { + return compactFields( + changedFiles, + CHANGED_FILE_ARRAY_FIELDS, + compactArray, + METADATA_ARRAY_ITEM_BUDGET, + ); +} + +function compactDiffStat(diffStat) { + return compactFields( + diffStat, + DIFF_FIELDS, + mux.utils.compactText, + DIFF_STAT_CHAR_BUDGET, + ); +} + +function compactFields(value, fields, compactor, limit) { + if (!value || typeof value !== 'object') return value; + const compacted = { ...value }; + fields.forEach(function (field) { + compacted[field] = compactor(value[field], limit); + }); + return compacted; +} + +function compactArray(value, limit) { + if (!Array.isArray(value) || value.length <= limit) return value; + return { + total: value.length, + shown: value.slice(0, limit), + omitted: value.length - limit, + }; +} + +function compactDiff(diff, budget) { + if (!diff || typeof diff !== 'object') return diff; + + const compacted = { + base: diff.base, + head: diff.head, + mergeBase: diff.mergeBase, + truncated: diff.truncated, + workflowBudgetChars: budget, + workflowCompactions: [], + }; + let remaining = budget; + + DIFF_FIELDS.forEach(function (field) { + const value = diff[field]; + if (typeof value !== 'string') { + compacted[field] = value; + return; + } + + const included = Math.max(0, Math.min(value.length, remaining)); + compacted[field] = + included === value.length + ? value + : value.slice(0, included) + diffOmittedMessage(field); + remaining -= included; + if (included < value.length) { + compacted.workflowCompactions.push({ + field: field, + originalChars: value.length, + includedChars: included, + }); + } + }); + + return compacted; +} + +function diffSummary(diff, compactedDiff) { + if (!diff || typeof diff !== 'object') return diff; + return { + base: diff.base, + head: diff.head, + mergeBase: diff.mergeBase, + truncated: diff.truncated, + workflowBudgetChars: compactedDiff && compactedDiff.workflowBudgetChars, + workflowCompactions: compactedDiff + ? mux.utils.asArray(compactedDiff.workflowCompactions) + : [], + chars: diffFieldLengths(diff), + }; +} + +function diffFieldLengths(diff) { + const lengths = {}; + DIFF_FIELDS.forEach(function (field) { + lengths[field] = stringLength(diff[field]); + }); + return lengths; +} + +function diffOmittedMessage(field) { + return ( + '\n\n[Workflow prompt budget omitted the rest of the ' + + field + + ' diff. Inspect the file directly before making claims about omitted hunks.]' + ); +} + +function reviewPrompt(lane, input, reviewContext) { + return [ + READ_ONLY_PROMPT, + 'You are the ' + + lane.title + + ' lane. Review every changed file in the supplied Git context.', + 'If an explicit target is provided and the Git diff is empty, inspect that target path in the workspace before making claims.', + 'Diff text is capped by workflowBudgetChars; if workflowCompactions or built-in truncated flags are present, inspect files directly before making claims about omitted hunks.', + 'Untracked paths in Git metadata are names only. Do not make findings about untracked files unless their contents are visible in a diff or explicit target.', + 'Allowed severity values are: high, medium, low. Return high-signal, actionable findings only; an empty findings array is fine.', + 'The synthesis step will keep at most ' + + input.maxFindings + + ' actionable findings. Use stable finding ids and arrays for filePaths/evidence.', + 'Lane checklist:\n- ' + lane.instructions.join('\n- '), + GIT_CONTEXT_PROVENANCE_NOTE, + 'Review context:\n' + reviewContext, + ].join('\n\n'); +} + +function synthesisPrompt(input, compactContext, reviewOutputs) { + return [ + READ_ONLY_PROMPT, + 'Deduplicate and triage these simplify review findings. Keep actionableFindings to the ' + + input.maxFindings + + ' highest-value issues.', + 'Do not edit files in this step. Produce triage and fix plans for the later fixer step. If a finding is false positive or not worth addressing, put it in skippedFindings without debating it.', + 'Allowed severity values are: high, medium, low. Prefer minimal cleanup over broad refactors.', + GIT_CONTEXT_PROVENANCE_NOTE, + 'Compact review context without raw diff text:\n' + compactContext, + 'Compacted lane outputs:\n' + + mux.utils.fencedJson(compactReviewOutputs(reviewOutputs)), + ].join('\n\n'); +} + +function fixPrompt(compactContext, synthesized) { + return [ + 'Fix the actionable simplify findings with minimal, correct, reviewable changes. Do not push or open a PR.', + 'If you change files, create one local commit containing only those changes so the workflow can export a patch artifact.', + 'Use the compact context for file lists and diff metadata; inspect files directly instead of relying on raw diff text being embedded in this prompt.', + 'Preserve existing style and functionality. Run targeted validation for touched code when feasible and report exact commands/results.', + 'If a finding is false positive or not worth addressing, skip it and note why. Set madeChanges true only when files changed.', + GIT_CONTEXT_PROVENANCE_NOTE, + 'Compact review context:\n' + compactContext, + 'Actionable findings:\n' + mux.utils.fencedJson(fixerPayload(synthesized)), + ].join('\n\n'); +} + +function compactReviewOutputs(reviewOutputs) { + return reviewOutputs.map(function (output) { + return { + summary: output.summary, + findings: output.findings.map(compactReviewFinding), + }; + }); +} + +function compactReviewFinding(finding) { + const evidence = finding.evidence; + return { + id: finding.id, + title: finding.title, + severity: finding.severity, + filePaths: finding.filePaths, + rationale: finding.rationale, + recommendation: finding.recommendation, + evidenceCount: evidence.length, + evidenceSamples: evidence + .slice(0, REVIEW_EVIDENCE_ITEM_BUDGET) + .map(function (evidenceItem) { + return mux.utils.compactText(evidenceItem, REVIEW_EVIDENCE_CHAR_BUDGET); + }), + }; +} + +function fixerPayload(synthesized) { + return { + summary: synthesized.summary, + shouldFix: synthesized.shouldFix, + actionableFindings: synthesized.actionableFindings, + validationPlan: synthesized.validationPlan, + }; +} + +function parseArgs(args) { + const raw = args && typeof args === 'object' ? args : {}; + const input = { + help: Boolean(raw.help), + fix: raw.fix !== false && raw.reviewOnly !== true, + target: text(raw.target), + baseRef: text(raw.baseRef || raw.base), + trunkRef: text(raw.trunkRef || raw.trunk), + headRef: text(raw.headRef || raw.head), + maxFindings: mux.utils.boundedInt( + raw.maxFindings, + DEFAULT_MAX_FINDINGS, + 1, + Number.MAX_SAFE_INTEGER, + ), + }; + const tokenized = tokenize(String(raw.input || '')); + if (tokenized.error) return { input: input, error: tokenized.error }; + + const targetParts = []; + let index = 0; + while (index < tokenized.tokens.length) { + const token = tokenized.tokens[index]; + const valueFlag = parseValueFlag(tokenized.tokens, index); + if (token === '--help' || token === '-h') input.help = true; + else if (token === '--review-only' || token === '--no-fix') + input.fix = false; + else if (token === '--fix') input.fix = true; + else if (valueFlag && valueFlag.error) + return { input: input, error: valueFlag.error }; + else if (valueFlag) { + input[valueFlag.key] = + valueFlag.key === 'maxFindings' + ? mux.utils.boundedInt( + valueFlag.value, + DEFAULT_MAX_FINDINGS, + 1, + Number.MAX_SAFE_INTEGER, + ) + : valueFlag.value; + index = valueFlag.nextIndex; + continue; + } else targetParts.push(token); + index += 1; + } + + if (!input.target) input.target = targetParts.join(' ').trim(); + return { input: input, error: '' }; +} + +function parseValueFlag(tokens, index) { + const token = tokens[index]; + for (let flagIndex = 0; flagIndex < VALUE_FLAGS.length; flagIndex += 1) { + const flag = VALUE_FLAGS[flagIndex]; + if (token === flag.name) { + if (index + 1 >= tokens.length) + return { error: flag.name + ' requires a value' }; + return { key: flag.key, value: tokens[index + 1], nextIndex: index + 2 }; + } + if (token.startsWith(flag.name + '=')) { + const value = token.slice(flag.name.length + 1); + if (!value) return { error: flag.name + ' requires a value' }; + return { key: flag.key, value: value, nextIndex: index + 1 }; + } + } + return null; +} + +function tokenize(input) { + const tokens = []; + let current = ''; + let quote = ''; + let escaped = false; + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + if (escaped) { + current += char; + escaped = false; + } else if (quote && char === '\\') { + const next = input[index + 1]; + if ( + next === '\\' || + (next === quote && !isClosingQuote(input, index + 1)) + ) { + escaped = true; + } else current += char; + } else if (quote) { + if (char === quote) quote = ''; + else current += char; + } else if (char === '"' || char === "'") { + quote = char; + } else if (/\s/.test(char)) { + if (current) tokens.push(current); + current = ''; + } else current += char; + } + if (quote) return { tokens: tokens, error: 'unterminated quoted argument' }; + if (escaped) current += '\\'; + if (current) tokens.push(current); + return { tokens: tokens, error: '' }; +} + +function isClosingQuote(input, quoteIndex) { + return quoteIndex + 1 >= input.length || /\s/.test(input[quoteIndex + 1]); +} + +function reviewOnlyReport(input, markdown) { + const mode = input.fix + ? 'No actionable fixes were selected.' + : 'Review-only mode; no fixes were applied.'; + return markdown + '\n\n---\n\n## Simplify workflow result\n\n' + mode; +} + +function fixReport(synthesisMarkdown, fixerMarkdown, applied) { + const status = applied && applied.status ? applied.status : 'unknown'; + const success = Boolean(applied && applied.success); + return ( + synthesisMarkdown + + '\n\n---\n\n## Fix pass\n\n' + + fixerMarkdown + + '\n\n### Patch application\n\n- Status: ' + + status + + '\n- Success: ' + + String(success) + ); +} + +function usageResult(error) { + const lines = [ + '# simplify workflow', + '', + 'Review current git changes for code reuse, quality, and efficiency, then fix actionable issues.', + '', + '## Usage', + '', + '- `/workflow simplify` — review current git changes and apply fixes.', + '- `/workflow simplify --review-only` — review and synthesize findings without applying fixes.', + '- `/workflow simplify --base main --head HEAD` — review a specific ref range.', + '- `/workflow simplify path/or/context` — provide an explicit target when there are no Git changes.', + '', + '## Options', + '', + '- `--review-only` / `--no-fix`', + '- `--fix`', + '- `--base `', + '- `--trunk `', + '- `--head `', + '- `--max-findings `', + ]; + if (error) lines.splice(2, 0, '', '**Argument error:** ' + error); + return { + reportMarkdown: lines.join('\n'), + structuredOutput: { help: true, error: error || '' }, + }; +} + +function text(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function hasArrayItems(value) { + return Array.isArray(value) && value.length > 0; +} + +function hasText(value) { + return stringLength(value) > 0; +} + +function stringLength(value) { + return typeof value === 'string' ? value.length : 0; +} + +function formatError(error) { + return error && typeof error.message === 'string' + ? error.message + : String(error); +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} From 636ae164759d6c8fe8048d9dec6241d8e929c48b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 17 Jun 2026 08:46:32 +0000 Subject: [PATCH 2/7] fix: skip comment fetch for done-label checks Signed-off-by: Thomas Kosiewski --- .../actions/github/getIssueAutomationState.js | 31 ++++++++++++++++--- .mux/workflows/github-issue-triage.js | 1 + 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/.mux/actions/github/getIssueAutomationState.js b/.mux/actions/github/getIssueAutomationState.js index 51b8476d..77516b19 100644 --- a/.mux/actions/github/getIssueAutomationState.js +++ b/.mux/actions/github/getIssueAutomationState.js @@ -24,6 +24,7 @@ export const metadata = { repo: mux.schema.optional(mux.schema.string()), number: mux.schema.integer(), doneLabels: mux.schema.optional(mux.schema.array(mux.schema.string())), + includeComments: mux.schema.optional(mux.schema.boolean()), marker: mux.schema.string(), markerKey: mux.schema.string(), promptVersion: mux.schema.optional(mux.schema.string()), @@ -56,17 +57,39 @@ export const metadata = { timeoutMs: 60000, }; +function labelsIncludeAll(doneLabels, labelNames) { + return ( + doneLabels.length > 0 && + doneLabels.every((label) => labelNames.includes(label)) + ); +} + export async function execute(rawInput, ctx) { const input = inputObject(rawInput); const repository = requiredRepository(input); - const parts = splitRepository(repository); const number = requiredIssueNumber(input.number); const doneLabels = stringList(input.doneLabels); + const includeComments = input.includeComments !== false; + const issuePromise = getIssueView(ctx, repository, number, ['labels']); + + if (!includeComments) { + const issue = await issuePromise; + const labelNames = normalizeIssue(issue).labelNames; + return { + done: labelsIncludeAll(doneLabels, labelNames), + promptStarted: false, + reportPosted: false, + labelNames, + markerComments: [], + }; + } + + const parts = splitRepository(repository); const marker = requiredString(input.marker, 'marker'); const markerKey = requiredString(input.markerKey, 'markerKey'); const promptVersion = optionalString(input.promptVersion) || 'v1'; const [issue, comments] = await Promise.all([ - getIssueView(ctx, repository, number, ['labels']), + issuePromise, listComments(ctx, parts.owner, parts.repo, number), ]); const labelNames = normalizeIssue(issue).labelNames; @@ -77,9 +100,7 @@ export async function execute(rawInput, ctx) { .map((comment) => markerStatus(comment.body)) .filter(Boolean); return { - done: - doneLabels.length > 0 && - doneLabels.every((label) => labelNames.includes(label)), + done: labelsIncludeAll(doneLabels, labelNames), promptStarted: statuses.includes('prompt-started'), reportPosted: statuses.includes('report-posted'), labelNames, diff --git a/.mux/workflows/github-issue-triage.js b/.mux/workflows/github-issue-triage.js index a5df4d47..e8127551 100644 --- a/.mux/workflows/github-issue-triage.js +++ b/.mux/workflows/github-issue-triage.js @@ -627,6 +627,7 @@ function verifyPublishedReport(action, cfg, item, commentUrl, suffix) { repository: cfg.repository, number: issue.number, doneLabels: [cfg.doneLabel], + includeComments: false, marker: cfg.marker, markerKey: item.markerKey, promptVersion: cfg.promptVersion, From 22167a04a31a3c764897f06cca1b9ce6da629222 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 17 Jun 2026 10:16:24 +0000 Subject: [PATCH 3/7] fix: use labels for triage workflow state --- .mux/actions/github/findIssueComment.js | 79 ++++++++++ .../actions/github/getIssueAutomationState.js | 17 ++- .mux/actions/github/listIssues.js | 6 +- .mux/actions/github/upsertIssueComment.js | 94 ------------ .mux/workflow-action-lib/github.cjs | 17 --- .mux/workflows/github-issue-triage.js | 143 +++++++++--------- 6 files changed, 168 insertions(+), 188 deletions(-) create mode 100644 .mux/actions/github/findIssueComment.js delete mode 100644 .mux/actions/github/upsertIssueComment.js diff --git a/.mux/actions/github/findIssueComment.js b/.mux/actions/github/findIssueComment.js new file mode 100644 index 00000000..784d3fc8 --- /dev/null +++ b/.mux/actions/github/findIssueComment.js @@ -0,0 +1,79 @@ +const { + inputObject, + listComments, + requiredIssueNumber, + requiredRepository, + splitRepository, + stringList, +} = require('../../workflow-action-lib/github.cjs'); + +export const metadata = { + version: 1, + description: 'Find the latest GitHub issue comment containing expected text', + effect: 'read', + inputSchema: mux.schema.object( + { + repository: mux.schema.optional(mux.schema.string()), + owner: mux.schema.optional(mux.schema.string()), + repo: mux.schema.optional(mux.schema.string()), + number: mux.schema.integer(), + requiredBodyIncludes: mux.schema.optional( + mux.schema.array(mux.schema.string()), + ), + }, + { additionalProperties: false }, + ), + outputSchema: mux.schema.object( + { + found: mux.schema.boolean(), + reason: mux.schema.string(), + url: mux.schema.nullable(mux.schema.string()), + commentId: mux.schema.nullable( + mux.schema.union([mux.schema.integer(), mux.schema.string()]), + ), + updatedAt: mux.schema.nullable(mux.schema.string()), + }, + { additionalProperties: false }, + ), + permissions: [{ kind: 'command', command: 'gh api' }], + timeoutMs: 60000, +}; + +export async function execute(rawInput, ctx) { + const input = inputObject(rawInput); + const repository = requiredRepository(input); + const parts = splitRepository(repository); + const number = requiredIssueNumber(input.number); + const includes = stringList(input.requiredBodyIncludes); + if (includes.length === 0) { + throw new Error('requiredBodyIncludes must include at least one string'); + } + + const comments = await listComments(ctx, parts.owner, parts.repo, number); + const match = comments + .slice() + .reverse() + .find( + (comment) => + typeof comment.body === 'string' && + includes.every((text) => comment.body.includes(text)), + ); + + if (!match) { + return { + found: false, + reason: 'not-found', + url: null, + commentId: null, + updatedAt: null, + }; + } + + return { + found: true, + reason: '', + url: match.html_url || null, + commentId: match.id || null, + updatedAt: match.updated_at || null, + }; +} diff --git a/.mux/actions/github/getIssueAutomationState.js b/.mux/actions/github/getIssueAutomationState.js index 77516b19..ac6a10ce 100644 --- a/.mux/actions/github/getIssueAutomationState.js +++ b/.mux/actions/github/getIssueAutomationState.js @@ -24,6 +24,7 @@ export const metadata = { repo: mux.schema.optional(mux.schema.string()), number: mux.schema.integer(), doneLabels: mux.schema.optional(mux.schema.array(mux.schema.string())), + ongoingLabels: mux.schema.optional(mux.schema.array(mux.schema.string())), includeComments: mux.schema.optional(mux.schema.boolean()), marker: mux.schema.string(), markerKey: mux.schema.string(), @@ -57,18 +58,22 @@ export const metadata = { timeoutMs: 60000, }; -function labelsIncludeAll(doneLabels, labelNames) { +function labelsIncludeAll(labels, labelNames) { return ( - doneLabels.length > 0 && - doneLabels.every((label) => labelNames.includes(label)) + labels.length > 0 && labels.every((label) => labelNames.includes(label)) ); } +function labelsIncludeAny(labels, labelNames) { + return labels.some((label) => labelNames.includes(label)); +} + export async function execute(rawInput, ctx) { const input = inputObject(rawInput); const repository = requiredRepository(input); const number = requiredIssueNumber(input.number); const doneLabels = stringList(input.doneLabels); + const ongoingLabels = stringList(input.ongoingLabels); const includeComments = input.includeComments !== false; const issuePromise = getIssueView(ctx, repository, number, ['labels']); @@ -77,7 +82,7 @@ export async function execute(rawInput, ctx) { const labelNames = normalizeIssue(issue).labelNames; return { done: labelsIncludeAll(doneLabels, labelNames), - promptStarted: false, + promptStarted: labelsIncludeAny(ongoingLabels, labelNames), reportPosted: false, labelNames, markerComments: [], @@ -101,7 +106,9 @@ export async function execute(rawInput, ctx) { .filter(Boolean); return { done: labelsIncludeAll(doneLabels, labelNames), - promptStarted: statuses.includes('prompt-started'), + promptStarted: + labelsIncludeAny(ongoingLabels, labelNames) || + statuses.includes('prompt-started'), reportPosted: statuses.includes('report-posted'), labelNames, markerComments: matching.map((comment) => ({ diff --git a/.mux/actions/github/listIssues.js b/.mux/actions/github/listIssues.js index a58d01f6..e705e34e 100644 --- a/.mux/actions/github/listIssues.js +++ b/.mux/actions/github/listIssues.js @@ -77,13 +77,14 @@ export async function execute(rawInput, ctx) { const jsonFields = 'number,title,url,state,labels,author,createdAt,updatedAt' + (includeBody ? ',body' : ''); + const fetchLimit = excludeLabels.length > 0 ? 1000 : limit; const args = [ 'issue', 'list', '--state', state, '--limit', - String(limit), + String(fetchLimit), '--json', jsonFields, ]; @@ -101,7 +102,8 @@ export async function execute(rawInput, ctx) { .filter((issue) => excludeLabels.every((label) => !issue.labelNames.includes(label)), ) - .sort((a, b) => a.number - b.number); + .sort((a, b) => a.number - b.number) + .slice(0, limit); return { repository: repository || null, filters: { diff --git a/.mux/actions/github/upsertIssueComment.js b/.mux/actions/github/upsertIssueComment.js deleted file mode 100644 index 8fdc909c..00000000 --- a/.mux/actions/github/upsertIssueComment.js +++ /dev/null @@ -1,94 +0,0 @@ -const { - findComment, - inputObject, - requiredIssueNumber, - requiredRepository, - requiredString, - splitRepository, -} = require('../../workflow-action-lib/github.cjs'); - -export const metadata = { - version: 1, - description: 'Create or update a GitHub issue comment selected by marker', - effect: 'external', - inputSchema: mux.schema.object( - { - repository: mux.schema.optional(mux.schema.string()), - owner: mux.schema.optional(mux.schema.string()), - repo: mux.schema.optional(mux.schema.string()), - number: mux.schema.integer(), - marker: mux.schema.string(), - body: mux.schema.string(), - }, - { additionalProperties: false }, - ), - outputSchema: mux.schema.object( - { - action: mux.schema.enum(['created', 'updated']), - commentId: mux.schema.nullable(mux.schema.integer()), - url: mux.schema.nullable(mux.schema.string()), - }, - { additionalProperties: false }, - ), - permissions: [{ kind: 'command', command: 'gh api' }], - timeoutMs: 60000, -}; - -export async function execute(rawInput, ctx) { - const input = inputObject(rawInput); - const repository = requiredRepository(input); - const parts = splitRepository(repository); - const number = requiredIssueNumber(input.number); - const marker = requiredString(input.marker, 'marker'); - const body = requiredString(input.body, 'body'); - const existing = await findComment( - ctx, - parts.owner, - parts.repo, - number, - (comment) => - typeof comment.body === 'string' && comment.body.includes(marker), - ); - const payload = await ctx.writeTempJson({ body }); - if (existing) { - await ctx.execChecked('gh', [ - 'api', - '-X', - 'PATCH', - 'repos/' + - parts.owner + - '/' + - parts.repo + - '/issues/comments/' + - existing.id, - '--input', - payload.path, - ]); - return { - action: 'updated', - commentId: existing.id, - url: existing.html_url || null, - }; - } - const created = await ctx.execJson('gh', [ - 'api', - '-X', - 'POST', - 'repos/' + - parts.owner + - '/' + - parts.repo + - '/issues/' + - number + - '/comments', - '--input', - payload.path, - ]); - return { - action: 'created', - commentId: created.id || null, - url: created.html_url || null, - }; -} - -export const reconcile = execute; diff --git a/.mux/workflow-action-lib/github.cjs b/.mux/workflow-action-lib/github.cjs index 41ad62f3..0050afc9 100644 --- a/.mux/workflow-action-lib/github.cjs +++ b/.mux/workflow-action-lib/github.cjs @@ -152,22 +152,6 @@ async function listComments(ctx, owner, repo, number, options) { return comments; } -async function findComment(ctx, owner, repo, number, predicate) { - for (let page = 1; page <= 10; page += 1) { - const pageComments = await fetchCommentsPage( - ctx, - owner, - repo, - number, - page, - ); - const match = pageComments.find(predicate); - if (match) return match; - if (pageComments.length < 100) break; - } - return undefined; -} - module.exports = { inputObject, optionalString, @@ -187,5 +171,4 @@ module.exports = { getIssueView, fetchCommentsPage, listComments, - findComment, }; diff --git a/.mux/workflows/github-issue-triage.js b/.mux/workflows/github-issue-triage.js index e8127551..1a8ddf84 100644 --- a/.mux/workflows/github-issue-triage.js +++ b/.mux/workflows/github-issue-triage.js @@ -1,4 +1,5 @@ const s = mux.schema; +const PUBLISHED_REPORT_NOTE = 'This triage report is AI-generated using Mux'; export const metadata = { description: @@ -8,6 +9,7 @@ export const metadata = { owner: s.optional(s.string()), repo: s.optional(s.string()), doneLabel: s.optional(s.string({ default: 'triage:done' })), + ongoingLabel: s.optional(s.string({ default: 'triage:ongoing' })), excludeLabels: s.optional(s.array(s.string(), { default: [] })), includeLabels: s.optional(s.array(s.string(), { default: [] })), projectPath: s.string(), @@ -19,7 +21,7 @@ export const metadata = { model: s.optional(s.string()), limit: s.optional(s.integer({ default: 1000, minimum: 1, maximum: 1000 })), awaitTimeoutMs: s.optional( - s.integer({ default: 1000, minimum: 0, maximum: 21600000 }), + s.integer({ default: 1000, minimum: 0, maximum: 600000 }), ), preSendIdleTimeoutMs: s.optional( s.integer({ default: 5000, minimum: 0, maximum: 600000 }), @@ -33,20 +35,14 @@ export const metadata = { }), }; -function buildPublishPrompt( - issue, - triageReport, - attempt, - lastReason, - doneLabel, -) { +function buildPublishPrompt(issue, attempt, lastReason) { return `Please go ahead and post your triage report to the GitHub issue. Make sure that you lead with a note that this is an AI generated triage using Mux. \`\`\`markdown > [!NOTE] -> This triage report is AI-generated using Mux +> ${PUBLISHED_REPORT_NOTE} \`\`\` When posting to GitHub, be aware that the issue creator and folks in the @@ -83,26 +79,20 @@ You can add an image or a code block, too. \`\`\`\` -After you posted the report to GitHub, make sure to attach the \`${doneLabel}\` label to the issue. -If the previous verification failure says the label is missing, do not repost the same report; attach the label and return the existing comment URL. +Do not change issue labels. The workflow will add the done label and remove the ongoing label after it verifies the posted comment. -Use the triage report from this workspace history. The workflow-observed report/output is included below for reference. +Use the triage report from the previous assistant message in this workspace history. +Do not ask for the report again, and do not paste/requote the report back into this chat before posting it. Issue URL: ${issue.url} Issue number: #${issue.number} Publish attempt: ${attempt} Previous verification failure: ${lastReason} -Triage report to post: - - -${triageReport} - - -After posting the comment and attaching the label, finish with exactly one fenced JSON block in this shape: +After posting the comment, finish with exactly one fenced JSON block in this shape: \`\`\`json -{"commentUrl":"https://github.com/OWNER/REPO/issues/ISSUE_NUMBER#issuecomment-COMMENT_ID","triageDoneLabelAttached":true} +{"commentUrl":"https://github.com/OWNER/REPO/issues/ISSUE_NUMBER#issuecomment-COMMENT_ID"} \`\`\``; } @@ -121,13 +111,13 @@ If this is a bug report, then: - Use the agent-tty CLI to reproduce the bug. - Feel free to use any of the fixtures in this repo to create a report that lets us reproduce the user's bug, along with a test environment and, if required, a new fixture. - This is purely a reproduction task to identify a minimal reproducible example so that I, as a human, and you, as an agent, can both verify that this issue exists. -- Later on, run a deep research investigation workflow to determine what is causing those issues and how to resolve them. +- After reproducing the bug, explicitly run the Mux deep-research workflow (workflow name: deep-research; slash form if available: /workflow deep-research ...) with the issue URL, reproduction steps, observed behavior, and relevant repo facts. Do not substitute an ad hoc research section for running the workflow. Include the deep-research workflow findings in your final triage report. If the workflow is unavailable, state the exact error. If this is a feature request, then: -- Perform a deep research workflow into the request. -- Gather prior art and comparable implementations as references. -- Assess whether the feature makes sense in the context of the Claudecode.nvim extension; some feature requests may be outside the scope of this third-party code implementation. -- Provide a recommendation to the maintainer on whether the feature request is sensible, whether a sensible workaround already exists that can be configured in their own config files, or whether there is a documentation gap. +- Explicitly run the Mux deep-research workflow (workflow name: deep-research; slash form if available: /workflow deep-research ...) with the issue URL, requested behavior, repo context, and prior-art questions. Do not substitute an ad hoc research section for running the workflow. +- Gather prior art and comparable implementations from the deep-research workflow results and any supporting investigation. +- Assess whether the feature makes sense in the context of this repo; some feature requests may be outside the scope of this implementation. +- Provide a recommendation to the maintainer on whether the feature request is sensible, whether a sensible workaround already exists that can be configured by users, or whether there is a documentation gap. - Feel free to create prototypes if they help you decide on a proposal or better ground your assumptions. --- @@ -192,6 +182,7 @@ export default function workflow({ repository: cfg.repository, number: issue.number, doneLabels: [cfg.doneLabel], + ongoingLabels: [cfg.ongoingLabel], marker, markerKey, promptVersion: cfg.promptVersion, @@ -284,18 +275,12 @@ export default function workflow({ parallelActions, cfg, needingConversation.map((item) => ({ - id: 'mark-prompt-started-' + item.issue.safeId + '-' + cfg.promptVersion, - action: 'github.upsertIssueComment', + id: 'mark-triage-ongoing-' + item.issue.safeId + '-' + cfg.promptVersion, + action: 'github.ensureIssueLabels', input: { repository: cfg.repository, number: item.issue.number, - marker: markerCommentNeedle(marker, item.markerKey, cfg.promptVersion), - body: promptStartedComment( - marker, - item.markerKey, - cfg.promptVersion, - item.workspaceId, - ), + addLabels: [cfg.ongoingLabel], }, })), ); @@ -429,6 +414,10 @@ function resolveArgs(args) { const excludeLabels = args.excludeLabels.length > 0 ? args.excludeLabels : [args.doneLabel]; + if (args.doneLabel === args.ongoingLabel) { + throw new Error('doneLabel and ongoingLabel must be different labels'); + } + if (!repository) { throw new Error( 'repository or owner/repo is required for stable issue keys', @@ -471,25 +460,6 @@ function issueMarkerKey(cfg, issue) { return cfg.repository + '#' + issue.number; } -function markerCommentNeedle(marker, markerKey, promptVersion) { - return ( - '\n\n' + - 'Mux triage has started in workspace `' + - workspaceId + - '`.\n\n' + - 'If no triage report appears, remove this marker comment or bump `promptVersion` before rerunning the workflow.' - ); -} - function hasAssistantText(result) { return ( result && @@ -530,13 +500,7 @@ function publishReportWithWorkspaceLoop(action, cfg, item, triageReport) { workspaceId: item.workspaceId, agentId: cfg.agentId, model: cfg.model, - message: buildPublishPrompt( - issue, - triageReport, - attempt, - lastReason, - cfg.doneLabel, - ), + message: buildPublishPrompt(issue, attempt, lastReason), }, }); @@ -575,6 +539,14 @@ function publishReportWithWorkspaceLoop(action, cfg, item, triageReport) { if (!hasAssistantText(latest)) { lastReason = 'no-publish-output'; + const recovered = findPublishedReport( + action, + cfg, + item, + 'attempt-' + attempt, + ); + if (recovered.completed) return recovered; + lastReason = recovered.reason; continue; } @@ -582,6 +554,14 @@ function publishReportWithWorkspaceLoop(action, cfg, item, triageReport) { const published = extractPublishResult(latestText); if (!published) { lastReason = 'missing-structured-output'; + const recovered = findPublishedReport( + action, + cfg, + item, + 'attempt-' + attempt, + ); + if (recovered.completed) return recovered; + lastReason = recovered.reason; continue; } @@ -599,6 +579,29 @@ function publishReportWithWorkspaceLoop(action, cfg, item, triageReport) { return { completed: false, reason: 'publish-not-verified-' + lastReason }; } +function findPublishedReport(action, cfg, item, suffix) { + const issue = item.issue; + const found = actionOutput( + action.github.findIssueComment({ + id: 'find-published-comment-' + issue.safeId + '-' + suffix, + input: { + repository: cfg.repository, + number: issue.number, + requiredBodyIncludes: [PUBLISHED_REPORT_NOTE], + }, + }), + ); + + if (!found.found || typeof found.url !== 'string' || found.url.length === 0) { + return { + completed: false, + reason: 'published-comment-not-found-' + (found.reason || 'unknown'), + }; + } + + return verifyPublishedReport(action, cfg, item, found.url, suffix + '-found'); +} + function verifyPublishedReport(action, cfg, item, commentUrl, suffix) { const issue = item.issue; const comment = actionOutput( @@ -608,7 +611,7 @@ function verifyPublishedReport(action, cfg, item, commentUrl, suffix) { repository: cfg.repository, number: issue.number, url: commentUrl, - requiredBodyIncludes: ['This triage report is AI-generated using Mux'], + requiredBodyIncludes: [PUBLISHED_REPORT_NOTE], }, }), ); @@ -620,24 +623,24 @@ function verifyPublishedReport(action, cfg, item, commentUrl, suffix) { }; } - const state = actionOutput( - action.github.getIssueAutomationState({ - id: 'verify-done-label-' + issue.safeId + '-' + suffix, + const labels = actionOutput( + action.github.ensureIssueLabels({ + id: 'complete-triage-labels-' + issue.safeId + '-' + suffix, input: { repository: cfg.repository, number: issue.number, - doneLabels: [cfg.doneLabel], - includeComments: false, - marker: cfg.marker, - markerKey: item.markerKey, - promptVersion: cfg.promptVersion, + addLabels: [cfg.doneLabel], + removeLabels: [cfg.ongoingLabel], }, }), ); - if (!state.done) { + if (!labels.after.includes(cfg.doneLabel)) { return { completed: false, reason: 'done-label-missing' }; } + if (labels.after.includes(cfg.ongoingLabel)) { + return { completed: false, reason: 'ongoing-label-still-present' }; + } return { completed: true, commentUrl: comment.url || commentUrl }; } From 3f38f12818445d6bde6f1eb549d6495992038b00 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 17 Jun 2026 10:55:26 +0000 Subject: [PATCH 4/7] fix: infer triage workflow context --- .mux/actions/project/context.js | 95 +++++++++++++++++++++++++++ .mux/workflows/github-issue-triage.js | 46 +++++++++++-- 2 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 .mux/actions/project/context.js diff --git a/.mux/actions/project/context.js b/.mux/actions/project/context.js new file mode 100644 index 00000000..806df913 --- /dev/null +++ b/.mux/actions/project/context.js @@ -0,0 +1,95 @@ +export const metadata = { + version: 1, + description: 'Resolve the current Mux project path and GitHub repository', + effect: 'read', + inputSchema: mux.schema.object({}, { additionalProperties: false }), + outputSchema: mux.schema.object( + { + cwd: mux.schema.nullable(mux.schema.string()), + gitRoot: mux.schema.nullable(mux.schema.string()), + projectPath: mux.schema.nullable(mux.schema.string()), + projectPathSource: mux.schema.string(), + repository: mux.schema.nullable(mux.schema.string()), + repositorySource: mux.schema.string(), + }, + { additionalProperties: false }, + ), + permissions: [ + { kind: 'command', command: 'pwd' }, + { kind: 'command', command: 'git rev-parse' }, + { kind: 'command', command: 'git remote get-url' }, + { kind: 'command', command: 'gh repo view' }, + ], + timeoutMs: 30000, +}; + +function cleanString(value) { + return typeof value === 'string' && value.trim().length > 0 + ? value.trim() + : null; +} + +async function execStdout(ctx, command, args) { + try { + const result = await ctx.execChecked(command, args); + return cleanString(result.stdout); + } catch { + return null; + } +} + +async function repoFromGh(ctx) { + try { + const result = await ctx.execJson('gh', [ + 'repo', + 'view', + '--json', + 'nameWithOwner', + ]); + return cleanString(result && result.nameWithOwner); + } catch { + return null; + } +} + +function repoFromRemoteUrl(url) { + const text = cleanString(url); + if (!text) return null; + const match = text.match( + /(?:github\.com[:/])([^/]+)\/([^/.]+)(?:\.git)?(?:\/?$)/, + ); + return match ? match[1] + '/' + match[2] : null; +} + +export async function execute(_input, ctx) { + const cwd = cleanString(ctx.cwd) || (await execStdout(ctx, 'pwd', [])); + const gitRoot = await execStdout(ctx, 'git', [ + 'rev-parse', + '--show-toplevel', + ]); + const envProjectPath = cleanString(process.env.MUX_PROJECT_PATH); + const repositoryFromGh = await repoFromGh(ctx); + const remoteUrl = repositoryFromGh + ? null + : await execStdout(ctx, 'git', ['remote', 'get-url', 'origin']); + const repositoryFromGit = repoFromRemoteUrl(remoteUrl); + + return { + cwd, + gitRoot, + projectPath: envProjectPath || gitRoot || cwd, + projectPathSource: envProjectPath + ? 'MUX_PROJECT_PATH' + : gitRoot + ? 'git-root' + : cwd + ? 'cwd' + : 'unresolved', + repository: repositoryFromGh || repositoryFromGit, + repositorySource: repositoryFromGh + ? 'gh-repo-view' + : repositoryFromGit + ? 'git-origin' + : 'unresolved', + }; +} diff --git a/.mux/workflows/github-issue-triage.js b/.mux/workflows/github-issue-triage.js index 1a8ddf84..c6644933 100644 --- a/.mux/workflows/github-issue-triage.js +++ b/.mux/workflows/github-issue-triage.js @@ -12,7 +12,7 @@ export const metadata = { ongoingLabel: s.optional(s.string({ default: 'triage:ongoing' })), excludeLabels: s.optional(s.array(s.string(), { default: [] })), includeLabels: s.optional(s.array(s.string(), { default: [] })), - projectPath: s.string(), + projectPath: s.optional(s.string()), state: s.optional(s.string({ default: 'open' })), marker: s.optional(s.string({ default: 'mux-github-issue-triage' })), promptVersion: s.optional(s.string({ default: 'v1' })), @@ -137,9 +137,27 @@ export default function workflow({ action, parallelActions, }) { - const cfg = resolveArgs(args); + phase('resolve-context', { + hasRepository: Boolean(mux.utils.optionalString(args.repository)), + hasProjectPath: Boolean(mux.utils.optionalString(args.projectPath)), + }); + + const context = actionOutput( + action.project.context({ + id: 'project-context', + input: {}, + }), + ); + const cfg = resolveArgs(args, context); const marker = cfg.marker; + log('Resolved triage context', { + repository: cfg.repository, + repositorySource: cfg.repositorySource, + projectPath: cfg.projectPath, + projectPathSource: cfg.projectPathSource, + }); + phase('fetch-issues', { repository: cfg.repository, includeLabels: cfg.includeLabels, @@ -406,11 +424,14 @@ function actionOutput(result) { return result.output; } -function resolveArgs(args) { +function resolveArgs(args, context) { const repository = mux.utils.optionalString(args.repository) || - repositoryFromOwnerRepo(args.owner, args.repo); - const projectPath = mux.utils.optionalString(args.projectPath); + repositoryFromOwnerRepo(args.owner, args.repo) || + mux.utils.optionalString(context.repository); + const projectPath = + mux.utils.optionalString(args.projectPath) || + mux.utils.optionalString(context.projectPath); const excludeLabels = args.excludeLabels.length > 0 ? args.excludeLabels : [args.doneLabel]; @@ -430,7 +451,20 @@ function resolveArgs(args) { ); } - return { ...args, repository, projectPath, excludeLabels }; + return { + ...args, + repository, + repositorySource: mux.utils.optionalString(args.repository) + ? 'args.repository' + : repositoryFromOwnerRepo(args.owner, args.repo) + ? 'args.owner/repo' + : context.repositorySource, + projectPath, + projectPathSource: mux.utils.optionalString(args.projectPath) + ? 'args.projectPath' + : context.projectPathSource, + excludeLabels, + }; } function repositoryFromOwnerRepo(owner, repo) { From 43ca69f05fc81f187c80e3b16dc01e6d4b96566b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 17 Jun 2026 12:17:20 +0000 Subject: [PATCH 5/7] fix: harden github issue triage workflow --- .mux/actions/project/context.js | 53 +++++++++++++--- .mux/workflows/github-issue-triage.js | 89 ++++++++++++++------------- 2 files changed, 92 insertions(+), 50 deletions(-) diff --git a/.mux/actions/project/context.js b/.mux/actions/project/context.js index 806df913..6fa1d589 100644 --- a/.mux/actions/project/context.js +++ b/.mux/actions/project/context.js @@ -1,3 +1,5 @@ +const path = require('path'); + export const metadata = { version: 1, description: 'Resolve the current Mux project path and GitHub repository', @@ -61,13 +63,54 @@ function repoFromRemoteUrl(url) { return match ? match[1] + '/' + match[2] : null; } +function projectPathFromGitCommonDir(commonDir, baseDir) { + const text = cleanString(commonDir); + if (!text) return null; + const absolute = path.isAbsolute(text) + ? text + : path.resolve(baseDir || '.', text); + return path.basename(absolute) === '.git' ? path.dirname(absolute) : null; +} + +function chooseProjectPath(envProjectPath, commonProjectPath, gitRoot, cwd) { + if (envProjectPath) { + return { + projectPath: envProjectPath, + projectPathSource: 'MUX_PROJECT_PATH', + }; + } + if (commonProjectPath) { + return { + projectPath: commonProjectPath, + projectPathSource: 'git-common-dir', + }; + } + if (gitRoot) return { projectPath: gitRoot, projectPathSource: 'git-root' }; + if (cwd) return { projectPath: cwd, projectPathSource: 'cwd' }; + return { projectPath: null, projectPathSource: 'unresolved' }; +} + export async function execute(_input, ctx) { const cwd = cleanString(ctx.cwd) || (await execStdout(ctx, 'pwd', [])); const gitRoot = await execStdout(ctx, 'git', [ 'rev-parse', '--show-toplevel', ]); + const gitCommonDir = await execStdout(ctx, 'git', [ + 'rev-parse', + '--git-common-dir', + ]); const envProjectPath = cleanString(process.env.MUX_PROJECT_PATH); + const commonProjectPath = projectPathFromGitCommonDir( + gitCommonDir, + gitRoot || cwd, + ); + const { projectPath, projectPathSource } = chooseProjectPath( + envProjectPath, + commonProjectPath, + gitRoot, + cwd, + ); const repositoryFromGh = await repoFromGh(ctx); const remoteUrl = repositoryFromGh ? null @@ -77,14 +120,8 @@ export async function execute(_input, ctx) { return { cwd, gitRoot, - projectPath: envProjectPath || gitRoot || cwd, - projectPathSource: envProjectPath - ? 'MUX_PROJECT_PATH' - : gitRoot - ? 'git-root' - : cwd - ? 'cwd' - : 'unresolved', + projectPath, + projectPathSource, repository: repositoryFromGh || repositoryFromGit, repositorySource: repositoryFromGh ? 'gh-repo-view' diff --git a/.mux/workflows/github-issue-triage.js b/.mux/workflows/github-issue-triage.js index c6644933..92541850 100644 --- a/.mux/workflows/github-issue-triage.js +++ b/.mux/workflows/github-issue-triage.js @@ -36,48 +36,67 @@ export const metadata = { }; function buildPublishPrompt(issue, attempt, lastReason) { - return `Please go ahead and post your triage report to the GitHub issue. -Make sure that you lead with a note that this is an AI generated triage using -Mux. + return `Please post a concise public triage comment to the GitHub issue. + +Start the GitHub comment with exactly this note: \`\`\`markdown -> [!NOTE] +> [!NOTE] > ${PUBLISHED_REPORT_NOTE} \`\`\` -When posting to GitHub, be aware that the issue creator and folks in the -conversation will be pinged. -Writing in a third person might be seen as rude, so consider rephrasing section -into a passive form. -Do not ping people. +Write for maintainers and issue participants. Summarize the triage outcome; +do not describe how the triage was performed. + +Editorial requirements: +- Do not mention internal workflow mechanics. +- Do not include workflow names, workflow run IDs, agent IDs, model names, or phrases like "the workflow concluded", "deep-research workflow", or "I ran deep-research". +- Treat research results as supporting analysis only. Fold them into normal sections such as Findings, Recommendation, or Suggested next steps. +- Do not include a standalone "Deep-research" section. +- Do not include process/provenance sections unless the detail is directly needed to reproduce or evaluate the issue. +- Do not ping people. +- Prefer passive or neutral wording over third-person references to issue participants. + +Use this structure unless the issue clearly needs a smaller one: -Your triage report will be reviewed by the maintainer and is posted publicly, -so that issue creator and the maintainers can have an open discussion. +\`\`\`markdown +> [!NOTE] +> ${PUBLISHED_REPORT_NOTE} -If you've created files during triage, run an explore agent on each of them to -identify if there are secrets or other sensitive information. -Redact them, or don't post. -If those files pass that screening, feel free to paste their contents into -collapsible boxes, so that one can review the steps you took to reproduce the issue -or how you conducted your investigation. +## Summary -\`\`\`\`markdown -
+1-3 bullets describing what was triaged and the outcome. -FILE_NAME +## Findings -### You can add a header +Actionable repo facts, reproduction observations, or prior-art findings. +Keep this focused on what maintainers need to know. -You can add text within a collapsed section. +## Recommendation -You can add an image or a code block, too. +State the recommended disposition or implementation direction. +If there are tradeoffs, include only the important ones. -\`\`\`ruby - puts "Hello World" +## Suggested next steps + +Concrete follow-up items, such as tests, docs, or code paths to update. \`\`\` -
-\`\`\`\` +For bug reports, include reproduction details only if they help a maintainer verify the issue. Prefer a short command snippet plus observed result. Avoid long logs. + +For feature requests or design decisions, focus on: +- whether the request fits the repo, +- what existing behavior or architecture supports the recommendation, +- what gap remains, +- and what the smallest useful next step is. + +If files, logs, screenshots, or generated artifacts are included, first ensure they contain no secrets or sensitive information. Only include them when they materially help review the issue. Put long supporting material in a
block. + +Before posting, self-edit the comment: +- Remove duplicated headings. +- Remove any "how the triage was done" prose. +- Merge research/provenance sections into Findings or Recommendation. +- Keep the comment concise and maintainer-actionable. Do not change issue labels. The workflow will add the done label and remove the ongoing label after it verifies the posted comment. @@ -259,23 +278,9 @@ export default function workflow({ })), ); - const idleBeforeSend = needsPrompt.filter( + const needingConversation = needsPrompt.filter( (item, index) => actionOutput(preSendIdleResults[index]).idle, ); - const latestBeforeSendResults = runParallelActions( - parallelActions, - cfg, - idleBeforeSend.map((item) => ({ - id: 'pre-send-latest-' + item.issue.safeId, - action: 'workspace.getLatestAssistantMessage', - input: { workspaceId: item.workspaceId }, - })), - ); - - const needingConversation = idleBeforeSend.filter( - (item, index) => - !hasAssistantText(actionOutput(latestBeforeSendResults[index])), - ); const conversationResults = runParallelActions( parallelActions, cfg, From a4eae1c6f7b897400af34597d79099d017c01a5f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 17 Jun 2026 13:13:47 +0000 Subject: [PATCH 6/7] fix: improve github issue triage prompts --- .mux/workflows/github-issue-triage.js | 101 +++++++++++++++++++++----- 1 file changed, 82 insertions(+), 19 deletions(-) diff --git a/.mux/workflows/github-issue-triage.js b/.mux/workflows/github-issue-triage.js index 92541850..c97d4168 100644 --- a/.mux/workflows/github-issue-triage.js +++ b/.mux/workflows/github-issue-triage.js @@ -51,13 +51,15 @@ do not describe how the triage was performed. Editorial requirements: - Do not mention internal workflow mechanics. - Do not include workflow names, workflow run IDs, agent IDs, model names, or phrases like "the workflow concluded", "deep-research workflow", or "I ran deep-research". -- Treat research results as supporting analysis only. Fold them into normal sections such as Findings, Recommendation, or Suggested next steps. +- Treat research results as supporting analysis only. Fold them into normal sections such as Findings, Root cause, Recommendation, or Suggested next steps. - Do not include a standalone "Deep-research" section. - Do not include process/provenance sections unless the detail is directly needed to reproduce or evaluate the issue. - Do not ping people. - Prefer passive or neutral wording over third-person references to issue participants. -Use this structure unless the issue clearly needs a smaller one: +Use a structure that matches the issue type. + +For confirmed bug reports, prefer: \`\`\`markdown > [!NOTE] @@ -65,23 +67,48 @@ Use this structure unless the issue clearly needs a smaller one: ## Summary -1-3 bullets describing what was triaged and the outcome. +1-3 bullets describing the bug and triage outcome. -## Findings +## What was reproduced -Actionable repo facts, reproduction observations, or prior-art findings. -Keep this focused on what maintainers need to know. +The minimal reproduction, observed behavior, and important command/output evidence. -## Recommendation +## Root cause + +The verified cause. If not fully verified, title this section "Likely root cause" and state what remains uncertain. + +## Suggested direction for a fix + +The smallest implementation direction that addresses the cause. -State the recommended disposition or implementation direction. -If there are tradeoffs, include only the important ones. +## How this was verified + +Commands, tests, fixtures, or artifacts used to verify the behavior. ## Suggested next steps -Concrete follow-up items, such as tests, docs, or code paths to update. +Concrete follow-up items. +\`\`\` + +For feature requests or design decisions, prefer: + +\`\`\`markdown +> [!NOTE] +> ${PUBLISHED_REPORT_NOTE} + +## Summary + +## Findings + +## Recommendation + +## Tradeoffs + +## Suggested next steps \`\`\` +Do not force every section if it would add noise. Prefer a shorter comment over a complete but repetitive template. + For bug reports, include reproduction details only if they help a maintainer verify the issue. Prefer a short command snippet plus observed result. Avoid long logs. For feature requests or design decisions, focus on: @@ -95,7 +122,7 @@ If files, logs, screenshots, or generated artifacts are included, first ensure t Before posting, self-edit the comment: - Remove duplicated headings. - Remove any "how the triage was done" prose. -- Merge research/provenance sections into Findings or Recommendation. +- Merge research/provenance sections into Findings, Root cause, or Recommendation. - Keep the comment concise and maintainer-actionable. Do not change issue labels. The workflow will add the done label and remove the ongoing label after it verifies the posted comment. @@ -124,21 +151,57 @@ function buildPrompt(issue, conversation) { ? conversation.conversationMarkdown : '(no issue comments)'; - return `Please triage the GitHub issue below. + return `Please triage the GitHub issue below. If this is a bug report, then: -- Use the agent-tty CLI to reproduce the bug. -- Feel free to use any of the fixtures in this repo to create a report that lets us reproduce the user's bug, along with a test environment and, if required, a new fixture. -- This is purely a reproduction task to identify a minimal reproducible example so that I, as a human, and you, as an agent, can both verify that this issue exists. -- After reproducing the bug, explicitly run the Mux deep-research workflow (workflow name: deep-research; slash form if available: /workflow deep-research ...) with the issue URL, reproduction steps, observed behavior, and relevant repo facts. Do not substitute an ad hoc research section for running the workflow. Include the deep-research workflow findings in your final triage report. If the workflow is unavailable, state the exact error. +- Use the agent-tty CLI to reproduce the bug when the issue involves terminal, CLI, renderer, wait, snapshot, screenshot, replay, export, or artifact behavior. +- Build the smallest practical reproduction. Prefer repo fixtures, temporary isolated AGENT_TTY_HOME directories, and targeted commands over broad manual exploration. +- Capture the exact commands, observed output, exit codes, relevant files, and artifacts needed for a maintainer to verify the issue. +- Investigate root cause after reproduction. Trace the behavior through the relevant repo code paths and tests. +- Distinguish clearly between confirmed facts, likely root cause, hypotheses, and open questions. If root cause is not verified, call it "Likely root cause" or "Hypothesis" and explain what evidence is missing. +- Explicitly run the Mux deep-research workflow (workflow name: deep-research; slash form if available: /workflow deep-research ...) with the issue URL, reproduction steps, observed behavior, suspected root cause, and relevant repo facts. Do not substitute an ad hoc research section for running the workflow. +- Treat deep-research output as supporting analysis, not as a separate public section. Do not create a standalone "Deep-research workflow" section in the maintainer-facing draft. +- If the issue cannot be reproduced, explain what was tried and what evidence is still missing. + +Your final triage report should be maintainer-facing and suitable for later public posting. Prefer this structure for bugs: + +## Summary + +## What was reproduced + +## Root cause + +Use "Likely root cause" if the cause is not fully verified. + +## Suggested direction for a fix + +## How this was verified + +## Open questions -If this is a feature request, then: +If this is a feature request or design decision, then: - Explicitly run the Mux deep-research workflow (workflow name: deep-research; slash form if available: /workflow deep-research ...) with the issue URL, requested behavior, repo context, and prior-art questions. Do not substitute an ad hoc research section for running the workflow. - Gather prior art and comparable implementations from the deep-research workflow results and any supporting investigation. -- Assess whether the feature makes sense in the context of this repo; some feature requests may be outside the scope of this implementation. -- Provide a recommendation to the maintainer on whether the feature request is sensible, whether a sensible workaround already exists that can be configured by users, or whether there is a documentation gap. +- Assess whether the request fits this repo; some requests may be outside the supported scope. +- Identify the current behavior or architecture that matters for the request. +- Provide a recommendation on whether the request is sensible, whether a workaround already exists, or whether there is a documentation gap. +- Include tradeoffs and the smallest useful next step. - Feel free to create prototypes if they help you decide on a proposal or better ground your assumptions. +Your final triage report should be maintainer-facing and suitable for later public posting. Prefer this structure for feature requests or design decisions: + +## Summary + +## Current behavior / repo context + +## Prior art or comparable behavior + +## Recommendation + +## Tradeoffs + +## Suggested next steps + --- URL: ${issueDetails.url || issue.url} From 3a2926201641b696642ce4fac7d86f7aed2ed9ff Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 17 Jun 2026 13:36:24 +0000 Subject: [PATCH 7/7] fix: normalize triage slash project path --- .mux/workflows/github-issue-triage.js | 66 ++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/.mux/workflows/github-issue-triage.js b/.mux/workflows/github-issue-triage.js index c97d4168..4857bea4 100644 --- a/.mux/workflows/github-issue-triage.js +++ b/.mux/workflows/github-issue-triage.js @@ -497,9 +497,8 @@ function resolveArgs(args, context) { mux.utils.optionalString(args.repository) || repositoryFromOwnerRepo(args.owner, args.repo) || mux.utils.optionalString(context.repository); - const projectPath = - mux.utils.optionalString(args.projectPath) || - mux.utils.optionalString(context.projectPath); + const resolvedProject = resolveProjectPath(args, context); + const projectPath = resolvedProject.projectPath; const excludeLabels = args.excludeLabels.length > 0 ? args.excludeLabels : [args.doneLabel]; @@ -528,13 +527,68 @@ function resolveArgs(args, context) { ? 'args.owner/repo' : context.repositorySource, projectPath, - projectPathSource: mux.utils.optionalString(args.projectPath) - ? 'args.projectPath' - : context.projectPathSource, + projectPathSource: resolvedProject.projectPathSource, excludeLabels, }; } +function resolveProjectPath(args, context) { + const argProjectPath = mux.utils.optionalString(args.projectPath); + const contextProjectPath = mux.utils.optionalString(context.projectPath); + if ( + argProjectPath && + contextProjectPath && + shouldPreferContextProjectPath(argProjectPath, context) + ) { + return { + projectPath: contextProjectPath, + projectPathSource: + context.projectPathSource + ' (normalized from args.projectPath)', + }; + } + if (argProjectPath) { + return { + projectPath: argProjectPath, + projectPathSource: 'args.projectPath', + }; + } + return { + projectPath: contextProjectPath, + projectPathSource: context.projectPathSource, + }; +} + +function shouldPreferContextProjectPath(argProjectPath, context) { + const contextProjectPath = mux.utils.optionalString(context.projectPath); + if (!contextProjectPath || samePath(argProjectPath, contextProjectPath)) { + return false; + } + + const source = mux.utils.optionalString(context.projectPathSource); + if (source !== 'git-common-dir' && source !== 'MUX_PROJECT_PATH') { + return false; + } + + return ( + samePath(argProjectPath, context.cwd) || + samePath(argProjectPath, context.gitRoot) + ); +} + +function samePath(left, right) { + const normalizedLeft = normalizePathString(left); + const normalizedRight = normalizePathString(right); + return Boolean( + normalizedLeft && normalizedRight && normalizedLeft === normalizedRight, + ); +} + +function normalizePathString(value) { + const text = mux.utils.optionalString(value); + if (!text) return undefined; + return text.length > 1 ? text.replace(/\/+$/, '') : text; +} + function repositoryFromOwnerRepo(owner, repo) { const ownerName = mux.utils.optionalString(owner); const repoName = mux.utils.optionalString(repo);