From 77373a37b70c032b6a387c148c0735162291d2db Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 24 Sep 2026 11:21:29 -0700 Subject: [PATCH 1/6] Widen --on trigger grammar; write a Linear closing reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cloud-deploy.ts: linear gains project/labels, jira gains labels, shortcut gains team/labels, github gains the reviews/checks/comments subscription opt-outs — matching what Cloud's deploy link and launcher prefilter already accept. - software-factory: a Linear identifier (TECH-42) produces "Fixes TECH-42" in the PR body, the reference Linear's GitHub integration reads to link the pull request to the issue and move it on merge, with the same fail-closed count check the GitHub closing line already had. - README: team= matches the Linear team name or its key, and the deploy page's ticket filters are editable. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- examples/software-factory/README.md | 13 ++++++++++-- .../software-factory/software-factory.flow.ts | 19 ++++++++++++----- packages/sdk/src/cloud-deploy.ts | 20 +++++++++++++----- .../tests/canonical-software-factory.test.ts | 21 +++++++++++++++++++ packages/sdk/tests/cloud-deploy.test.ts | 6 +++++- 5 files changed, 66 insertions(+), 13 deletions(-) diff --git a/examples/software-factory/README.md b/examples/software-factory/README.md index 517945073..73923ccd3 100644 --- a/examples/software-factory/README.md +++ b/examples/software-factory/README.md @@ -17,13 +17,22 @@ flows deployments ``` `--on` also takes `github:labels=agent`, `jira:project=OPS`, `shortcut:workspace=…` -or `slack:channel=#eng`. Each matching ticket launches one Cloud run in a fresh +or `slack:channel=#eng`, and `linear` accepts `team`, `project`, `labels` and +`contains` (`linear:team=TECH,labels=agent`); `team` matches the Linear team +name or its key, so `team=Engineering` and `team=TECH` are the same filter. The +deploy page shows every filter as an editable field — the badge's `team=ENG` is +just the starting value. + +Each matching ticket launches one Cloud run in a fresh `relayflow/software-factory-` branch of `--repo`; a passing review opens a PR, a blocked one opens a draft PR carrying the findings and ends `step_failed`. The pull-request title is the ticket title (whitespace-normalized and capped at 240 Unicode code points). GitHub inputs must carry `identifier: "#"`; the flow appends exactly one `Fixes #` line and validates the final -title and body before it pushes the branch or opens the pull request. +title and body before it pushes the branch or opens the pull request. A Linear +identifier (`TECH-42`) gets the same treatment — `Fixes TECH-42` is what +Linear's GitHub integration reads to link the pull request to the issue and +move it when the PR merges. Locally, from a checkout on a scratch branch: diff --git a/examples/software-factory/software-factory.flow.ts b/examples/software-factory/software-factory.flow.ts index fe8b61a4d..abcf3ab64 100644 --- a/examples/software-factory/software-factory.flow.ts +++ b/examples/software-factory/software-factory.flow.ts @@ -41,6 +41,10 @@ const VALIDATE_CHANGE_METADATA = [ "elif [ \"$(printf %s \"$title\" | tr \"[:upper:]\" \"[:lower:]\")\" = \"software factory change\" ] || [ \"$(printf %s \"$title\" | tr \"[:upper:]\" \"[:lower:]\")\" = \"replace with your ticket title\" ]; then echo placeholder-title", "elif [ \"$source\" = github ] && ! printf \"%s\\n\" \"$identifier\" | grep -Eq \"^#[1-9][0-9]*$\"; then echo malformed-github-identifier", `elif [ "$source" = github ]; then expected="Fixes $identifier"; count=$(grep -xcF "$expected" ${WORK}/pr-body.md || true); if [ "$count" -eq 0 ]; then echo missing-github-closing-reference; elif [ "$count" -ne 1 ]; then echo duplicate-github-closing-reference; else echo valid; fi`, + // A Linear identifier ("TECH-42") earns the same contract: "Fixes TECH-42" + // is what Linear's GitHub integration reads to link the pull request back + // to the issue and move it when the PR merges. + `elif [ "$source" = linear ] && printf "%s\\n" "$identifier" | grep -Eq "^[A-Za-z]+-[0-9]+$"; then expected="Fixes $identifier"; count=$(grep -xcF "$expected" ${WORK}/pr-body.md || true); if [ "$count" -eq 0 ]; then echo missing-linear-closing-reference; elif [ "$count" -ne 1 ]; then echo duplicate-linear-closing-reference; else echo valid; fi`, "else echo valid", "fi", ].join("; "); @@ -77,15 +81,20 @@ export default flow("software-factory", { await f.run("echo 'Stopped: a GitHub ticket must carry its normalized identifier in # form.' >&2"); return f.done("needs_human"); } + // A Linear identifier is the write-back hook: Linear's GitHub integration + // links the pull request to the issue and moves it on merge when the body + // carries `Fixes TECH-42`. A bare ticket URL does not link anything. const changeReference = issueSource === "github" ? `Fixes ${issueIdentifier}` : issueSource === "gitlab" && /^#[1-9]\d*$/.test(issueIdentifier) ? `Closes ${issueIdentifier}` - : issueUrl - ? `Ticket: ${issueUrl}` - : issueIdentifier - ? `Ticket: ${issueIdentifier}` - : ""; + : issueSource === "linear" && /^[A-Za-z]+-[0-9]+$/.test(issueIdentifier) + ? `Fixes ${issueIdentifier}` + : issueUrl + ? `Ticket: ${issueUrl}` + : issueIdentifier + ? `Ticket: ${issueIdentifier}` + : ""; const ticket = `${issue.title}\n\n${issue.body ?? ""}${issue.url ? `\n\n${issue.url}` : ""}`; const openPullRequest = async (bodyCommand: string, draft: boolean): Promise => { diff --git a/packages/sdk/src/cloud-deploy.ts b/packages/sdk/src/cloud-deploy.ts index a83d3f94e..376b919de 100644 --- a/packages/sdk/src/cloud-deploy.ts +++ b/packages/sdk/src/cloud-deploy.ts @@ -29,12 +29,13 @@ export type FlowTriggerProvider = (typeof FLOW_TRIGGER_PROVIDERS)[number]; /** Settings Cloud's launcher prefilter reads per provider (`flow-trigger-sources.ts`). */ const PROVIDER_SETTINGS: Record = { // `events`: `issues` (default) or `pull_request` — which GitHub records - // wake the listener (AgentWorkforce/cloud#3772). - github: ['repository', 'labels', 'contains', 'events'], + // wake the listener (AgentWorkforce/cloud#3772). `reviews`, `checks` and + // `comments` opt a pull_request source out of its extra subscriptions. + github: ['repository', 'labels', 'contains', 'events', 'reviews', 'checks', 'comments'], slack: ['channel', 'contains'], - linear: ['team', 'contains'], - jira: ['project', 'contains'], - shortcut: ['workspace', 'contains'], + linear: ['team', 'project', 'labels', 'contains'], + jira: ['project', 'labels', 'contains'], + shortcut: ['workspace', 'team', 'labels', 'contains'], }; const MAX_SOURCE_BYTES = 256_000; const MAX_SETTING_LENGTH = 500; @@ -141,6 +142,15 @@ export function parseTriggerSource(value: string): FlowTriggerSource { settings[key] = events; continue; } + if (provider === 'github' && (key === 'reviews' || key === 'checks' || key === 'comments')) { + // Subscription opt-outs are boolean; Cloud refuses any other value. + const toggle = setting.toLowerCase(); + if (!['true', 'false'].includes(toggle)) { + throw new CloudFlowError('invalid_input', `github ${key} must be "true" or "false", got "${setting}".`); + } + settings[key] = toggle; + continue; + } settings[key] = setting; } } diff --git a/packages/sdk/tests/canonical-software-factory.test.ts b/packages/sdk/tests/canonical-software-factory.test.ts index 038712e2f..22cea0df6 100644 --- a/packages/sdk/tests/canonical-software-factory.test.ts +++ b/packages/sdk/tests/canonical-software-factory.test.ts @@ -95,6 +95,27 @@ describe('canonical software-factory metadata contract', () => { expect(push).toBeLessThan(open); }); + it('writes a Linear closing reference that the GitHub integration links back', async () => { + const result = await runCanonical({ + source: 'linear', title: 'Rate-limit the webhook queue', body: 'Per-connection 429 budget.', labels: ['agent'], + identifier: 'TECH-42', url: 'https://linear.app/wepost/issue/TECH-42', + }); + expect(result.completionReason).toBe('success'); + expect(result.body.split('\n').filter(line => line === 'Fixes TECH-42')).toHaveLength(1); + }); + + it('fails closed before push when the Linear closing reference is missing from the final body', async () => { + // The summary is written by the implementer; PREPARE_CHANGE_METADATA appends + // the reference only when absent, so a summary that already carries a + // different line for the same slot must stop the run. + const result = await runCanonical({ + source: 'linear', title: 'Rate-limit the webhook queue', body: 'body', labels: [], + identifier: 'TECH-42', + }, '## Summary\n\nFixes TECH-42\n\nFixes TECH-42\n'); + expect(result.completionReason).toBe('needs_human'); + expect(result.commands.some(command => command.startsWith('git push') || command.startsWith('gh pr create'))).toBe(false); + }); + it('normalizes whitespace and caps the title at 240 Unicode code points', async () => { const result = await runCanonical({ source: 'github', title: ` Repair ${'修'.repeat(250)} `, body: 'body', labels: [], identifier: '#7', diff --git a/packages/sdk/tests/cloud-deploy.test.ts b/packages/sdk/tests/cloud-deploy.test.ts index b9e8584af..6bc1b96f2 100644 --- a/packages/sdk/tests/cloud-deploy.test.ts +++ b/packages/sdk/tests/cloud-deploy.test.ts @@ -63,13 +63,17 @@ describe('trigger source and repository parsing', () => { ['github:labels=agent,contains=urgent', { provider: 'github', settings: { labels: 'agent', contains: 'urgent' } }], ['slack:channel=#eng', { provider: 'slack', settings: { channel: '#eng' } }], ['linear:team=ENG', { provider: 'linear', settings: { team: 'ENG' } }], + ['linear:team=Engineering,labels=agent,contains=urgent', { provider: 'linear', settings: { team: 'Engineering', labels: 'agent', contains: 'urgent' } }], + ['jira:project=OPS,labels=agent', { provider: 'jira', settings: { project: 'OPS', labels: 'agent' } }], + ['shortcut:workspace=acme,team=Platform', { provider: 'shortcut', settings: { workspace: 'acme', team: 'Platform' } }], ['github:events=pull_request,labels=agent', { provider: 'github', settings: { events: 'pull_request', labels: 'agent' } }], ['github:events=PULL_REQUEST', { provider: 'github', settings: { events: 'pull_request' } }], + ['github:events=pull_request,reviews=False', { provider: 'github', settings: { events: 'pull_request', reviews: 'false' } }], ])('parses %s', (value, expected) => { expect(parseTriggerSource(value)).toEqual(expected); }); - it.each(['gitlab', 'github:channel=x', 'github:labels=', 'github:labels=a,labels=b', 'slack:labels=x', 'github:events=releases']) + it.each(['gitlab', 'github:channel=x', 'github:labels=', 'github:labels=a,labels=b', 'slack:labels=x', 'github:events=releases', 'github:reviews=maybe', 'linear:events=issues']) ('refuses %s', (value) => { expect(() => parseTriggerSource(value)).toThrow(expect.objectContaining({ code: 'invalid_input' })); }); From efa8bed5abbe57444da2df04fbda7d1d26bbb4c7 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 24 Sep 2026 12:00:19 -0700 Subject: [PATCH 2/6] =?UTF-8?q?fix(sdk):=20address=20review=20=E2=80=94=20?= =?UTF-8?q?gitlab=20triggers,=20strict=20Linear=20identifiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FLOW_TRIGGER_PROVIDERS gains gitlab (project/labels/contains/events with merge_request as its pull-request event), matching what Cloud's deploy link and onboarding already accept. - A Linear ticket with a missing or malformed identifier now stops needs_human instead of falling through to an unlinked `Ticket:` reference; the closing contract (`Fixes TEAM-123` exactly once in the body) is unchanged. Team keys carrying digits (PLA4-42) are accepted. - docs/CLOUD.md's `--on` grammar documents the widened settings; the canonical test that exercises a duplicated closing line is named for what it checks. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/CLOUD.md | 12 ++++--- .../software-factory/software-factory.flow.ts | 33 +++++++++++-------- packages/sdk/src/cloud-deploy.ts | 9 +++-- .../tests/canonical-software-factory.test.ts | 27 +++++++++++++-- packages/sdk/tests/cloud-deploy.test.ts | 4 ++- 5 files changed, 62 insertions(+), 23 deletions(-) diff --git a/docs/CLOUD.md b/docs/CLOUD.md index 38c797fb5..d0418c1d0 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -452,11 +452,15 @@ listener's rules match them there. The digest form, decides which form is meant. `--on [:key=value,…]` takes `github` (`repository`, `labels`, -`contains`, `events`), `slack` (`channel`, `contains`), `linear` (`team`, -`contains`), `jira` (`project`, `contains`) or `shortcut` (`workspace`, -`contains`), each at most once. A GitHub source without `repository` is +`contains`, `events`, and the pull-request subscription opt-outs `reviews`, +`checks`, `comments`), `gitlab` (`project`, `labels`, `contains`, `events`), +`slack` (`channel`, `contains`), `linear` (`team`, `project`, `labels`, +`contains`), `jira` (`project`, `labels`, `contains`) or `shortcut` +(`workspace`, `team`, `labels`, `contains`), each at most once. A Linear +`team` matches the team's name or its key. A GitHub source without +`repository` is scoped to `--repo`. `events` is `issues` (the default: `issues.opened` and -`issues.labeled`) or `pull_request`, which wakes on a pull request being +`issues.labeled`) or `pull_request` — `merge_request` for `gitlab` — which wakes on a pull request being opened, receiving commits, being reopened, or being reviewed; a pull-request run checks out the pull request's own head and receives `input.pullRequest` (`owner`, `repo`, `number`, `action`, `title`, `body`, `headRef`, `headSha`, diff --git a/examples/software-factory/software-factory.flow.ts b/examples/software-factory/software-factory.flow.ts index abcf3ab64..bff46d2b7 100644 --- a/examples/software-factory/software-factory.flow.ts +++ b/examples/software-factory/software-factory.flow.ts @@ -41,10 +41,13 @@ const VALIDATE_CHANGE_METADATA = [ "elif [ \"$(printf %s \"$title\" | tr \"[:upper:]\" \"[:lower:]\")\" = \"software factory change\" ] || [ \"$(printf %s \"$title\" | tr \"[:upper:]\" \"[:lower:]\")\" = \"replace with your ticket title\" ]; then echo placeholder-title", "elif [ \"$source\" = github ] && ! printf \"%s\\n\" \"$identifier\" | grep -Eq \"^#[1-9][0-9]*$\"; then echo malformed-github-identifier", `elif [ "$source" = github ]; then expected="Fixes $identifier"; count=$(grep -xcF "$expected" ${WORK}/pr-body.md || true); if [ "$count" -eq 0 ]; then echo missing-github-closing-reference; elif [ "$count" -ne 1 ]; then echo duplicate-github-closing-reference; else echo valid; fi`, - // A Linear identifier ("TECH-42") earns the same contract: "Fixes TECH-42" - // is what Linear's GitHub integration reads to link the pull request back - // to the issue and move it when the PR merges. - `elif [ "$source" = linear ] && printf "%s\\n" "$identifier" | grep -Eq "^[A-Za-z]+-[0-9]+$"; then expected="Fixes $identifier"; count=$(grep -xcF "$expected" ${WORK}/pr-body.md || true); if [ "$count" -eq 0 ]; then echo missing-linear-closing-reference; elif [ "$count" -ne 1 ]; then echo duplicate-linear-closing-reference; else echo valid; fi`, + // A Linear identifier ("TECH-42" — a team key can carry digits, like + // "PLA4-42") earns the same contract: "Fixes TECH-42" is what Linear's + // GitHub integration reads to link the pull request back to the issue and + // move it when the PR merges. A missing or malformed one stops the run + // rather than open a pull request nothing can link. + `elif [ "$source" = linear ] && ! printf "%s\\n" "$identifier" | grep -Eq "^[A-Za-z][A-Za-z0-9]*-[0-9]+$"; then echo malformed-linear-identifier`, + `elif [ "$source" = linear ]; then expected="Fixes $identifier"; count=$(grep -xcF "$expected" ${WORK}/pr-body.md || true); if [ "$count" -eq 0 ]; then echo missing-linear-closing-reference; elif [ "$count" -ne 1 ]; then echo duplicate-linear-closing-reference; else echo valid; fi`, "else echo valid", "fi", ].join("; "); @@ -83,18 +86,22 @@ export default flow("software-factory", { } // A Linear identifier is the write-back hook: Linear's GitHub integration // links the pull request to the issue and moves it on merge when the body - // carries `Fixes TECH-42`. A bare ticket URL does not link anything. - const changeReference = issueSource === "github" + // carries `Fixes TECH-42`. A bare ticket URL does not link anything, so a + // Linear ticket without one (team keys can carry digits: "PLA4-42") stops + // here rather than open a pull request nothing links. + if (issueSource === "linear" && !/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(issueIdentifier)) { + await f.run("echo 'Stopped: a Linear ticket must carry its identifier (like TECH-42) so the pull request can link back.' >&2"); + return f.done("needs_human"); + } + const changeReference = issueSource === "github" || issueSource === "linear" ? `Fixes ${issueIdentifier}` : issueSource === "gitlab" && /^#[1-9]\d*$/.test(issueIdentifier) ? `Closes ${issueIdentifier}` - : issueSource === "linear" && /^[A-Za-z]+-[0-9]+$/.test(issueIdentifier) - ? `Fixes ${issueIdentifier}` - : issueUrl - ? `Ticket: ${issueUrl}` - : issueIdentifier - ? `Ticket: ${issueIdentifier}` - : ""; + : issueUrl + ? `Ticket: ${issueUrl}` + : issueIdentifier + ? `Ticket: ${issueIdentifier}` + : ""; const ticket = `${issue.title}\n\n${issue.body ?? ""}${issue.url ? `\n\n${issue.url}` : ""}`; const openPullRequest = async (bodyCommand: string, draft: boolean): Promise => { diff --git a/packages/sdk/src/cloud-deploy.ts b/packages/sdk/src/cloud-deploy.ts index 376b919de..1339e1b4f 100644 --- a/packages/sdk/src/cloud-deploy.ts +++ b/packages/sdk/src/cloud-deploy.ts @@ -23,7 +23,7 @@ import { assertNoUseDependencies, collectExtensionSubmissions } from './flow-ext * inside a fresh branch of the deployment's repository. */ -export const FLOW_TRIGGER_PROVIDERS = ['github', 'linear', 'jira', 'shortcut', 'slack'] as const; +export const FLOW_TRIGGER_PROVIDERS = ['github', 'gitlab', 'linear', 'jira', 'shortcut', 'slack'] as const; export type FlowTriggerProvider = (typeof FLOW_TRIGGER_PROVIDERS)[number]; /** Settings Cloud's launcher prefilter reads per provider (`flow-trigger-sources.ts`). */ @@ -32,6 +32,7 @@ const PROVIDER_SETTINGS: Record = { // wake the listener (AgentWorkforce/cloud#3772). `reviews`, `checks` and // `comments` opt a pull_request source out of its extra subscriptions. github: ['repository', 'labels', 'contains', 'events', 'reviews', 'checks', 'comments'], + gitlab: ['project', 'labels', 'contains', 'events'], slack: ['channel', 'contains'], linear: ['team', 'project', 'labels', 'contains'], jira: ['project', 'labels', 'contains'], @@ -136,8 +137,10 @@ export function parseTriggerSource(value: string): FlowTriggerSource { if (key === 'events') { // Cloud's enum is lowercase; send it that way whatever the shell typed. const events = setting.toLowerCase(); - if (!['issues', 'pull_request'].includes(events)) { - throw new CloudFlowError('invalid_input', `github events must be "issues" or "pull_request", got "${setting}".`); + const valid = provider === 'gitlab' ? ['issues', 'merge_request'] : ['issues', 'pull_request']; + if (!valid.includes(events)) { + throw new CloudFlowError('invalid_input', + `${provider} events must be ${valid.map(v => `"${v}"`).join(' or ')}, got "${setting}".`); } settings[key] = events; continue; diff --git a/packages/sdk/tests/canonical-software-factory.test.ts b/packages/sdk/tests/canonical-software-factory.test.ts index 22cea0df6..07225f6c0 100644 --- a/packages/sdk/tests/canonical-software-factory.test.ts +++ b/packages/sdk/tests/canonical-software-factory.test.ts @@ -72,7 +72,9 @@ function runCanonical(issue: Issue, summary = '## Summary\n\nImplemented the tic commands, completionReason, ghArgs: existsSync(capture) ? readFileSync(capture, 'utf8').trim().split('\n') : [], - body: readFileSync(join(root, '.relayflow/pr-body.md'), 'utf8'), + body: existsSync(join(root, '.relayflow/pr-body.md')) + ? readFileSync(join(root, '.relayflow/pr-body.md'), 'utf8') + : '', })); } @@ -104,7 +106,28 @@ describe('canonical software-factory metadata contract', () => { expect(result.body.split('\n').filter(line => line === 'Fixes TECH-42')).toHaveLength(1); }); - it('fails closed before push when the Linear closing reference is missing from the final body', async () => { + it('accepts a Linear team key that carries digits', async () => { + const result = await runCanonical({ + source: 'linear', title: 'Rate-limit the webhook queue', body: 'body', labels: [], + identifier: 'PLA4-42', url: 'https://linear.app/wepost/issue/PLA4-42', + }); + expect(result.completionReason).toBe('success'); + expect(result.body.split('\n').filter(line => line === 'Fixes PLA4-42')).toHaveLength(1); + }); + + it.each(['', 'not-an-issue'])( + 'stops before push when a Linear ticket has no linkable identifier (%s)', + async (identifier) => { + const result = await runCanonical({ + source: 'linear', title: 'Rate-limit the webhook queue', body: 'body', labels: [], + identifier, url: 'https://linear.app/wepost/issue/TECH-42', + }); + expect(result.completionReason).toBe('needs_human'); + expect(result.commands.some(command => command.startsWith('git push') || command.startsWith('gh pr create'))).toBe(false); + }, + ); + + it('fails closed before push when the Linear closing reference is duplicated in the final body', async () => { // The summary is written by the implementer; PREPARE_CHANGE_METADATA appends // the reference only when absent, so a summary that already carries a // different line for the same slot must stop the run. diff --git a/packages/sdk/tests/cloud-deploy.test.ts b/packages/sdk/tests/cloud-deploy.test.ts index 6bc1b96f2..87d641e7a 100644 --- a/packages/sdk/tests/cloud-deploy.test.ts +++ b/packages/sdk/tests/cloud-deploy.test.ts @@ -66,6 +66,8 @@ describe('trigger source and repository parsing', () => { ['linear:team=Engineering,labels=agent,contains=urgent', { provider: 'linear', settings: { team: 'Engineering', labels: 'agent', contains: 'urgent' } }], ['jira:project=OPS,labels=agent', { provider: 'jira', settings: { project: 'OPS', labels: 'agent' } }], ['shortcut:workspace=acme,team=Platform', { provider: 'shortcut', settings: { workspace: 'acme', team: 'Platform' } }], + ['gitlab:project=acme/platform/web', { provider: 'gitlab', settings: { project: 'acme/platform/web' } }], + ['gitlab:events=merge_request,labels=agent', { provider: 'gitlab', settings: { events: 'merge_request', labels: 'agent' } }], ['github:events=pull_request,labels=agent', { provider: 'github', settings: { events: 'pull_request', labels: 'agent' } }], ['github:events=PULL_REQUEST', { provider: 'github', settings: { events: 'pull_request' } }], ['github:events=pull_request,reviews=False', { provider: 'github', settings: { events: 'pull_request', reviews: 'false' } }], @@ -73,7 +75,7 @@ describe('trigger source and repository parsing', () => { expect(parseTriggerSource(value)).toEqual(expected); }); - it.each(['gitlab', 'github:channel=x', 'github:labels=', 'github:labels=a,labels=b', 'slack:labels=x', 'github:events=releases', 'github:reviews=maybe', 'linear:events=issues']) + it.each(['github:channel=x', 'github:labels=', 'github:labels=a,labels=b', 'slack:labels=x', 'github:events=releases', 'gitlab:events=pull_request', 'gitlab:repository=acme/web', 'github:reviews=maybe', 'linear:events=issues']) ('refuses %s', (value) => { expect(() => parseTriggerSource(value)).toThrow(expect.objectContaining({ code: 'invalid_input' })); }); From d62e9f6693c90dfcd2b90f96717606a37cf0bb60 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 24 Sep 2026 12:05:41 -0700 Subject: [PATCH 3/6] docs(software-factory): drop the hardcoded team from the deploy badge The badge hardcoded linear:team=ENG, which silently scoped the trigger to a team most deploys don't have. The deploy page now renders every filter as an editable field, so the badge fixes only the provider; team, project and labels are set there. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- examples/software-factory/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/software-factory/README.md b/examples/software-factory/README.md index 73923ccd3..6873559af 100644 --- a/examples/software-factory/README.md +++ b/examples/software-factory/README.md @@ -1,8 +1,8 @@ # software-factory -[![Deploy Flow](https://agentrelay.com/launch-agent_small.svg)](https://agentrelay.com/cloud/flows/deploy?flow=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Fblob%2Fmain%2Fexamples%2Fsoftware-factory%2Fsoftware-factory.flow.ts&on=linear%3Ateam%3DENG) +[![Deploy Flow](https://agentrelay.com/launch-agent_small.svg)](https://agentrelay.com/cloud/flows/deploy?flow=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Fblob%2Fmain%2Fexamples%2Fsoftware-factory%2Fsoftware-factory.flow.ts&on=linear) -One click deploys this flow to [Agent Relay Cloud](https://agentrelay.com/cloud), running on every new Linear issue in team `ENG`. +One click deploys this flow to [Agent Relay Cloud](https://agentrelay.com/cloud), running on new Linear issues — set your team on the deploy page. A ticket becomes a pull request: implementation agent → deterministic tests → adversarial review agent → PR opened for a human. The review verdict is a file @@ -12,7 +12,7 @@ neither can be talked into a green result. ```sh flows check examples/software-factory/software-factory.flow.ts flows deploy examples/software-factory/software-factory.flow.ts \ - --repo acme/api --on linear:team=ENG --approver you + --repo acme/api --on linear:team=TECH --approver you flows deployments ``` @@ -20,8 +20,8 @@ flows deployments or `slack:channel=#eng`, and `linear` accepts `team`, `project`, `labels` and `contains` (`linear:team=TECH,labels=agent`); `team` matches the Linear team name or its key, so `team=Engineering` and `team=TECH` are the same filter. The -deploy page shows every filter as an editable field — the badge's `team=ENG` is -just the starting value. +deploy page shows every filter as an editable field — the badge only fixes the +provider; team, project and labels are filled in there. Each matching ticket launches one Cloud run in a fresh `relayflow/software-factory-` branch of `--repo`; a passing review opens a From 3d53d127bbb5e531f6584955cfe27e7ed4ec3bec Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 24 Sep 2026 12:57:00 -0700 Subject: [PATCH 4/6] fix(sdk): re-pin reviewed flow hash; reject foreign closing references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - babysitter-native-extension failed in CI: hosted capability isolation pins the reviewed software-factory flow SHA-256, which the Linear identifier changes invalidated. Re-pin to the new source. - VALIDATE_CHANGE_METADATA counted only the expected Fixes line, so a body carrying a foreign closing reference (Fixes OTHER-9, Closes #7) alongside it still validated. Count all GitHub closing-keyword lines; anything but exactly one — which must be the expected reference — stops before push. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- examples/software-factory/software-factory.flow.ts | 4 ++-- packages/sdk/src/hosted-extension-runtime.ts | 2 +- .../sdk/tests/canonical-software-factory.test.ts | 12 ++++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/examples/software-factory/software-factory.flow.ts b/examples/software-factory/software-factory.flow.ts index bff46d2b7..133d5bfa3 100644 --- a/examples/software-factory/software-factory.flow.ts +++ b/examples/software-factory/software-factory.flow.ts @@ -40,14 +40,14 @@ const VALIDATE_CHANGE_METADATA = [ "elif [ \"$title_length\" -gt 240 ]; then echo title-too-long", "elif [ \"$(printf %s \"$title\" | tr \"[:upper:]\" \"[:lower:]\")\" = \"software factory change\" ] || [ \"$(printf %s \"$title\" | tr \"[:upper:]\" \"[:lower:]\")\" = \"replace with your ticket title\" ]; then echo placeholder-title", "elif [ \"$source\" = github ] && ! printf \"%s\\n\" \"$identifier\" | grep -Eq \"^#[1-9][0-9]*$\"; then echo malformed-github-identifier", - `elif [ "$source" = github ]; then expected="Fixes $identifier"; count=$(grep -xcF "$expected" ${WORK}/pr-body.md || true); if [ "$count" -eq 0 ]; then echo missing-github-closing-reference; elif [ "$count" -ne 1 ]; then echo duplicate-github-closing-reference; else echo valid; fi`, + `elif [ "$source" = github ]; then expected="Fixes $identifier"; count=$(grep -xcF "$expected" ${WORK}/pr-body.md || true); closing=$(grep -icE "^(fix(es|ed)?|close[sd]?|resolve[sd]?) " ${WORK}/pr-body.md || true); if [ "$count" -eq 0 ]; then echo missing-github-closing-reference; elif [ "$count" -ne 1 ] || [ "$closing" -ne 1 ]; then echo duplicate-github-closing-reference; else echo valid; fi`, // A Linear identifier ("TECH-42" — a team key can carry digits, like // "PLA4-42") earns the same contract: "Fixes TECH-42" is what Linear's // GitHub integration reads to link the pull request back to the issue and // move it when the PR merges. A missing or malformed one stops the run // rather than open a pull request nothing can link. `elif [ "$source" = linear ] && ! printf "%s\\n" "$identifier" | grep -Eq "^[A-Za-z][A-Za-z0-9]*-[0-9]+$"; then echo malformed-linear-identifier`, - `elif [ "$source" = linear ]; then expected="Fixes $identifier"; count=$(grep -xcF "$expected" ${WORK}/pr-body.md || true); if [ "$count" -eq 0 ]; then echo missing-linear-closing-reference; elif [ "$count" -ne 1 ]; then echo duplicate-linear-closing-reference; else echo valid; fi`, + `elif [ "$source" = linear ]; then expected="Fixes $identifier"; count=$(grep -xcF "$expected" ${WORK}/pr-body.md || true); closing=$(grep -icE "^(fix(es|ed)?|close[sd]?|resolve[sd]?) " ${WORK}/pr-body.md || true); if [ "$count" -eq 0 ]; then echo missing-linear-closing-reference; elif [ "$count" -ne 1 ] || [ "$closing" -ne 1 ]; then echo duplicate-linear-closing-reference; else echo valid; fi`, "else echo valid", "fi", ].join("; "); diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index 9fa18aabd..0a4a1a0ba 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -26,7 +26,7 @@ const REALPATH = realpath; const PATH_DIRNAME = dirname; const PATH_JOIN = join; const PATH_RESOLVE = resolve; -const SOFTWARE_FACTORY_SHA256 = '49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6'; +const SOFTWARE_FACTORY_SHA256 = '7ba3e21093f4aab98935f9d56891c782a1006e4f41d480117e32d0dbed87dd80'; const ARRAY_IS_ARRAY = Array.isArray; const OBJECT_FREEZE = Object.freeze; const WEAK_MAP_GET = Function.prototype.call.bind(WeakMap.prototype.get) as ( diff --git a/packages/sdk/tests/canonical-software-factory.test.ts b/packages/sdk/tests/canonical-software-factory.test.ts index 07225f6c0..62bffe0ad 100644 --- a/packages/sdk/tests/canonical-software-factory.test.ts +++ b/packages/sdk/tests/canonical-software-factory.test.ts @@ -139,6 +139,18 @@ describe('canonical software-factory metadata contract', () => { expect(result.commands.some(command => command.startsWith('git push') || command.startsWith('gh pr create'))).toBe(false); }); + it('fails closed before push when the body carries a foreign closing reference', async () => { + // A summary naming a different ticket would auto-link the pull request to + // the wrong issue on merge. PREPARE appends the expected line, leaving two + // closing-keyword lines — the run must stop rather than ship both. + const result = await runCanonical({ + source: 'linear', title: 'Rate-limit the webhook queue', body: 'body', labels: [], + identifier: 'TECH-42', + }, '## Summary\n\nFixes OTHER-9\n'); + expect(result.completionReason).toBe('needs_human'); + expect(result.commands.some(command => command.startsWith('git push') || command.startsWith('gh pr create'))).toBe(false); + }); + it('normalizes whitespace and caps the title at 240 Unicode code points', async () => { const result = await runCanonical({ source: 'github', title: ` Repair ${'修'.repeat(250)} `, body: 'body', labels: [], identifier: '#7', From 4f71ab5fa3002dc31d03624d928fe6f8fcb70446 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 24 Sep 2026 12:57:29 -0700 Subject: [PATCH 5/6] test(sdk): match the duplicate-closing-reference comment to its fixture Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/sdk/tests/canonical-software-factory.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/sdk/tests/canonical-software-factory.test.ts b/packages/sdk/tests/canonical-software-factory.test.ts index 62bffe0ad..6a99a6515 100644 --- a/packages/sdk/tests/canonical-software-factory.test.ts +++ b/packages/sdk/tests/canonical-software-factory.test.ts @@ -128,9 +128,8 @@ describe('canonical software-factory metadata contract', () => { ); it('fails closed before push when the Linear closing reference is duplicated in the final body', async () => { - // The summary is written by the implementer; PREPARE_CHANGE_METADATA appends - // the reference only when absent, so a summary that already carries a - // different line for the same slot must stop the run. + // The summary is written by the implementer; a body that repeats the + // closing line must stop the run rather than ship a doubled reference. const result = await runCanonical({ source: 'linear', title: 'Rate-limit the webhook queue', body: 'body', labels: [], identifier: 'TECH-42', From d7214ce509a9e4be232696aa61294bbd6d2afad5 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 24 Sep 2026 13:52:39 -0700 Subject: [PATCH 6/6] fix(sdk): closing-keyword count requires an actual reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Fixed the retry loop" matched the closing count and, after PREPARE appended the real Fixes line, left the body with two counted lines — a successful run would never open its PR. GitHub links a closing keyword only when a reference follows it, so the count now requires #, a KEY- identifier, or a URL after the keyword. Flow source re-pinned. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- examples/software-factory/software-factory.flow.ts | 4 ++-- packages/sdk/src/hosted-extension-runtime.ts | 2 +- .../sdk/tests/canonical-software-factory.test.ts | 12 ++++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/examples/software-factory/software-factory.flow.ts b/examples/software-factory/software-factory.flow.ts index 133d5bfa3..307a4e2b7 100644 --- a/examples/software-factory/software-factory.flow.ts +++ b/examples/software-factory/software-factory.flow.ts @@ -40,14 +40,14 @@ const VALIDATE_CHANGE_METADATA = [ "elif [ \"$title_length\" -gt 240 ]; then echo title-too-long", "elif [ \"$(printf %s \"$title\" | tr \"[:upper:]\" \"[:lower:]\")\" = \"software factory change\" ] || [ \"$(printf %s \"$title\" | tr \"[:upper:]\" \"[:lower:]\")\" = \"replace with your ticket title\" ]; then echo placeholder-title", "elif [ \"$source\" = github ] && ! printf \"%s\\n\" \"$identifier\" | grep -Eq \"^#[1-9][0-9]*$\"; then echo malformed-github-identifier", - `elif [ "$source" = github ]; then expected="Fixes $identifier"; count=$(grep -xcF "$expected" ${WORK}/pr-body.md || true); closing=$(grep -icE "^(fix(es|ed)?|close[sd]?|resolve[sd]?) " ${WORK}/pr-body.md || true); if [ "$count" -eq 0 ]; then echo missing-github-closing-reference; elif [ "$count" -ne 1 ] || [ "$closing" -ne 1 ]; then echo duplicate-github-closing-reference; else echo valid; fi`, + `elif [ "$source" = github ]; then expected="Fixes $identifier"; count=$(grep -xcF "$expected" ${WORK}/pr-body.md || true); closing=$(grep -icE "^(fix(es|ed)?|close[sd]?|resolve[sd]?) +([^[:space:]]*#[0-9]+|[A-Za-z][A-Za-z0-9]*-[0-9]+|https?://)" ${WORK}/pr-body.md || true); if [ "$count" -eq 0 ]; then echo missing-github-closing-reference; elif [ "$count" -ne 1 ] || [ "$closing" -ne 1 ]; then echo duplicate-github-closing-reference; else echo valid; fi`, // A Linear identifier ("TECH-42" — a team key can carry digits, like // "PLA4-42") earns the same contract: "Fixes TECH-42" is what Linear's // GitHub integration reads to link the pull request back to the issue and // move it when the PR merges. A missing or malformed one stops the run // rather than open a pull request nothing can link. `elif [ "$source" = linear ] && ! printf "%s\\n" "$identifier" | grep -Eq "^[A-Za-z][A-Za-z0-9]*-[0-9]+$"; then echo malformed-linear-identifier`, - `elif [ "$source" = linear ]; then expected="Fixes $identifier"; count=$(grep -xcF "$expected" ${WORK}/pr-body.md || true); closing=$(grep -icE "^(fix(es|ed)?|close[sd]?|resolve[sd]?) " ${WORK}/pr-body.md || true); if [ "$count" -eq 0 ]; then echo missing-linear-closing-reference; elif [ "$count" -ne 1 ] || [ "$closing" -ne 1 ]; then echo duplicate-linear-closing-reference; else echo valid; fi`, + `elif [ "$source" = linear ]; then expected="Fixes $identifier"; count=$(grep -xcF "$expected" ${WORK}/pr-body.md || true); closing=$(grep -icE "^(fix(es|ed)?|close[sd]?|resolve[sd]?) +([^[:space:]]*#[0-9]+|[A-Za-z][A-Za-z0-9]*-[0-9]+|https?://)" ${WORK}/pr-body.md || true); if [ "$count" -eq 0 ]; then echo missing-linear-closing-reference; elif [ "$count" -ne 1 ] || [ "$closing" -ne 1 ]; then echo duplicate-linear-closing-reference; else echo valid; fi`, "else echo valid", "fi", ].join("; "); diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts index 0a4a1a0ba..7c0507f46 100644 --- a/packages/sdk/src/hosted-extension-runtime.ts +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -26,7 +26,7 @@ const REALPATH = realpath; const PATH_DIRNAME = dirname; const PATH_JOIN = join; const PATH_RESOLVE = resolve; -const SOFTWARE_FACTORY_SHA256 = '7ba3e21093f4aab98935f9d56891c782a1006e4f41d480117e32d0dbed87dd80'; +const SOFTWARE_FACTORY_SHA256 = 'b97a3466c2affabb61afa655d4b2f726942d753c0466740b64ba26a78da359c6'; const ARRAY_IS_ARRAY = Array.isArray; const OBJECT_FREEZE = Object.freeze; const WEAK_MAP_GET = Function.prototype.call.bind(WeakMap.prototype.get) as ( diff --git a/packages/sdk/tests/canonical-software-factory.test.ts b/packages/sdk/tests/canonical-software-factory.test.ts index 6a99a6515..50af06eea 100644 --- a/packages/sdk/tests/canonical-software-factory.test.ts +++ b/packages/sdk/tests/canonical-software-factory.test.ts @@ -150,6 +150,18 @@ describe('canonical software-factory metadata contract', () => { expect(result.commands.some(command => command.startsWith('git push') || command.startsWith('gh pr create'))).toBe(false); }); + it('does not mistake ordinary closing-verb prose for a reference', async () => { + // A summary sentence that starts with a closing verb is not a ticket + // reference — GitHub only links the keyword when an issue reference + // follows it. Counting "Fixed the retry loop" would block the run. + const result = await runCanonical({ + source: 'linear', title: 'Rate-limit the webhook queue', body: 'body', labels: [], + identifier: 'TECH-42', + }, '## Summary\n\nFixed the retry loop in the dispatcher.\n'); + expect(result.completionReason).toBe('success'); + expect(result.body.split('\n').filter(line => line === 'Fixes TECH-42')).toHaveLength(1); + }); + it('normalizes whitespace and caps the title at 240 Unicode code points', async () => { const result = await runCanonical({ source: 'github', title: ` Repair ${'修'.repeat(250)} `, body: 'body', labels: [], identifier: '#7',