diff --git a/examples/software-factory/README.md b/examples/software-factory/README.md new file mode 100644 index 000000000..fc7d046c5 --- /dev/null +++ b/examples/software-factory/README.md @@ -0,0 +1,25 @@ +# software-factory + +A ticket becomes a pull request: implementation agent → deterministic tests → +adversarial review agent → PR opened for a human. The review verdict is a file +the agent must write (`review.passed`), and the tests run outside any agent, so +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 +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 +`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`. + +Locally, from a checkout on a scratch branch: + +```sh +GH_TOKEN=$(gh auth token) flows run examples/software-factory/software-factory.flow.ts --local-agent \ + --input '{"approver":"you","issue":{"source":"local","title":"Add a health endpoint","body":"GET /healthz returns 200","labels":[]}}' +``` diff --git a/examples/software-factory/software-factory.flow.ts b/examples/software-factory/software-factory.flow.ts new file mode 100644 index 000000000..43c37a8f4 --- /dev/null +++ b/examples/software-factory/software-factory.flow.ts @@ -0,0 +1,79 @@ +// software-factory — a Linear (or GitHub/Jira/Shortcut) ticket becomes a pull +// request: an implementation agent, a deterministic test run, an adversarial +// review agent that must sign off, then the PR is opened for a human. +// +// Deploy (live in relayflows >= 2.0.16): +// flows deploy examples/software-factory/software-factory.flow.ts \ +// --repo acme/api --on linear:team=ENG --approver you +// +// Cloud launches one run per matching ticket, cloned into a fresh +// relayflow/- branch of --repo, with { approver, issue, event } as +// the input. The same body runs locally from a checkout: +// flows run software-factory.flow.ts --local-agent \ +// --input '{"approver":"you","issue":{"source":"linear","title":"…","body":"…","labels":[]}}' +import { flow } from "@relayflows/surface"; + +type Issue = { source: string; title: string; body: string; labels: string[]; url?: string }; +type Input = { issue: Issue; approver: string }; + +// Every deterministic step runs under /bin/sh. Ticket text is attacker- +// controlled input, so it never reaches a command unquoted: `shellWord` is +// the one way a string becomes a shell argument here. +const shellWord = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; + +// Flow artifacts live outside the repository's tracked tree, so `git add -A` +// cannot pick them up and a stale verdict from a previous run cannot survive. +const WORK = ".relayflow"; + +// The test command is a deterministic step: the agent never reports its own +// test result, the exit code does. Skips honestly when there is nothing to run. +const TEST = 'if [ -f package.json ] && node -e \'p=require("./package.json");process.exit(p.scripts&&p.scripts.test?0:1)\'; then npm ci --no-audit --no-fund && npm test; else echo "no test script; skipping"; fi'; + +export default flow("software-factory", { budget: { dollars: 10, wallclock: "1h" } }, async (f, input) => { + const { issue } = input; + if (!issue?.title?.trim()) { + // Parked, not canceled: a body cannot declare a kernel outcome, and the + // printed reason is what a human reads on the parked run. + await f.run("echo 'Stopped: no ticket arrived with this run.' >&2"); + return f.done("needs_human"); + } + const title = issue.title.trim().slice(0, 200); + const ticket = `${issue.title}\n\n${issue.body ?? ""}${issue.url ? `\n\n${issue.url}` : ""}`; + + // Fresh work dir, excluded from git, no leftover verdicts. + await f.run(`rm -rf ${WORK} && mkdir -p ${WORK} && { grep -qxF '${WORK}/' .git/info/exclude 2>/dev/null || echo '${WORK}/' >> .git/info/exclude; }`); + + await f.agent("implementer", { + cli: "claude", + task: `Implement this ticket in the current repository, on the current branch, with regression tests. Commit as you go.\n` + + `Write a PR description to ${WORK}/summary.md (what changed, how it was verified). Do not touch ${WORK}/ otherwise.\n\nTicket:\n${ticket}`, + }).gate({ type: "subprocess_gate", command: `test -s ${WORK}/summary.md` }); + + await f.run(TEST, { timeout: "15m" }); + + // Adversarial review: a fresh agent tries to break the change. It may fix + // what it finds; it must end with an explicit verdict file, not prose. + await f.agent("adversary", { + cli: "claude", + task: `Review the diff against the base branch as an adversary: find bugs, missing tests, unsafe defaults, and scope creep. ` + + `Fix what is mechanical and re-run the tests. Write ${WORK}/review.md with your findings, then write ${WORK}/review.passed ` + + `ONLY if the change is ready for a human to merge; otherwise write ${WORK}/review.blocked with the blocking findings.`, + }).gate({ type: "subprocess_gate", command: `test -s ${WORK}/review.md` }); + + await f.run(TEST, { timeout: "15m" }); + + // Passed means exactly one verdict, and it is the pass marker. + const verdict = await f.run(`if [ -f ${WORK}/review.passed ] && [ ! -f ${WORK}/review.blocked ]; then echo PASSED; else echo BLOCKED; fi`); + await f.run("git add -A && (git diff --cached --quiet || git commit -qm 'Software factory: implementation and review fixes')"); + await f.run("git push --set-upstream origin HEAD"); + + // Deterministic step, not an agent decision: the PR is opened either way, + // but a blocked review opens it as a draft with the findings attached. + if (verdict.trim() === "PASSED") { + await f.run(`gh pr create --title ${shellWord(title)} --body-file ${WORK}/summary.md`); + return f.done("success"); + } + await f.run(`{ cat ${WORK}/summary.md; printf '\\n\\n## Adversarial review: BLOCKED\\n\\n'; cat ${WORK}/review.md; } > ${WORK}/pr-body.md`); + await f.run(`gh pr create --draft --title ${shellWord(`[blocked] ${title}`)} --body-file ${WORK}/pr-body.md`); + f.done("step_failed"); +}); diff --git a/examples/stale-issues/README.md b/examples/stale-issues/README.md new file mode 100644 index 000000000..19a62dc6e --- /dev/null +++ b/examples/stale-issues/README.md @@ -0,0 +1,25 @@ +# stale-issues + +A scheduled automation: fetch every open issue (deterministic, journaled), let +one LLM step classify them as stale / needs-attention with a JSON schema gate, +and post a single Slack digest. + +```sh +RELAYFLOWS_SLACK_MOCK=1 flows check examples/stale-issues/stale-issues.flow.ts # mock until Slack is connected +flows schedule examples/stale-issues/stale-issues.flow.ts \ + --cron "0 9 * * 1-5" --tz Europe/Oslo \ + --input '{"repo":"acme/api","channel":"#eng","staleDays":14}' +flows schedules +``` + +`flows schedule` ships in the release after 2.0.16. Until then the same flow +runs on demand from anywhere with a token: + +```sh +GH_TOKEN=… flows run --cloud --wait examples/stale-issues/stale-issues.flow.ts \ + --input '{"repo":"acme/api","channel":"#eng"}' +``` + +The Slack helper needs `tools: { slack: true }` in the header and a connected +Slack workspace (`flows.json` / Cloud connections); locally, +`RELAYFLOWS_SLACK_MOCK=1` records the post instead of sending it. diff --git a/examples/stale-issues/stale-issues.flow.ts b/examples/stale-issues/stale-issues.flow.ts new file mode 100644 index 000000000..a5945cabc --- /dev/null +++ b/examples/stale-issues/stale-issues.flow.ts @@ -0,0 +1,84 @@ +// stale-issues — on a schedule, look at every open issue in a repository, +// decide which are stale or need attention, and post one Slack digest. +// +// Deploy on a schedule (flows schedule ships in the release after 2.0.16; +// until then run it locally or from any cron with `flows run --cloud`): +// flows schedule examples/stale-issues/stale-issues.flow.ts \ +// --cron "0 9 * * 1-5" --tz Europe/Oslo \ +// --input '{"repo":"acme/api","channel":"#eng","staleDays":14}' +// +// The issue list is fetched deterministically (journaled, replayable); only the +// judgement is delegated to an LLM step, which must return schema-valid JSON. +import { flow } from "@relayflows/surface"; + +type Input = { repo: string; channel: string; staleDays?: number }; +type Finding = { number: number; title: string; reason: string }; +type Triage = { stale: Finding[]; attention: Finding[] }; + +const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/; + +// Follows GitHub's pagination (oldest-updated first) so a repository with more +// than 100 open issues is triaged in full; pull requests are dropped. Runs +// under node, not the shell: the repository name never touches /bin/sh +// unquoted, and it is validated before it gets here at all. +const FETCH_ISSUES = `node -e ' +// Under node -e, argv[1] is "[eval]"; the repository is the last argument. + +const repo = process.argv.at(-1); +const headers = { authorization: "Bearer " + process.env.GH_TOKEN, "user-agent": "relayflows-stale-issues", accept: "application/vnd.github+json" }; +(async () => { + const out = []; const now = Date.now(); + for (let page = 1; page <= 20; page++) { + const res = await fetch("https://api.github.com/repos/" + repo + "/issues?state=open&sort=updated&direction=asc&per_page=100&page=" + page, { headers }); + if (!res.ok) { console.error("GitHub " + res.status); process.exit(1); } + const batch = await res.json(); + for (const i of batch) if (!i.pull_request) out.push({ number: i.number, title: i.title, labels: i.labels.map(l => l.name), updatedDaysAgo: Math.floor((now - Date.parse(i.updated_at)) / 864e5), comments: i.comments }); + if (batch.length < 100) break; + } + console.log(JSON.stringify(out)); +})();'`; + +const FINDING_SCHEMA = { + type: "object", required: ["number", "title", "reason"], additionalProperties: false, + properties: { number: { type: "integer", minimum: 1 }, title: { type: "string", maxLength: 200 }, reason: { type: "string", maxLength: 300 } }, +}; + +// Slack mrkdwn: text from the model is escaped so it cannot forge links, +// mentions or control sequences; links are built from the validated repo and +// integer issue number only. +const mrkdwn = (text: string): string => text.replace(/&/g, "&").replace(//g, ">"); + +export default flow("stale-issues", { budget: { dollars: 2, wallclock: "10m" }, tools: { slack: true } }, async (f, input) => { + if (!REPO.test(input.repo)) { + await f.run("echo 'Stopped: repo must be owner/name.' >&2"); + return f.done("needs_human"); + } + const staleDays = Math.max(1, Math.min(365, Math.trunc(input.staleDays ?? 14))); + const issues = await f.run(`${FETCH_ISSUES} '${input.repo}'`, { timeout: "3m" }); + const list = JSON.parse(issues) as { number: number }[]; + if (list.length === 0) return f.done("success"); + const known = new Set(list.map(i => i.number)); + + const triage = await f.llm( + `Here are the open issues of ${input.repo} as JSON: ${issues}\n` + + `An issue is stale if it has had no update for ${staleDays}+ days and no clear owner or next step. ` + + `An issue needs attention if it is recent but blocked, unanswered, or contradicts another. ` + + `Return JSON { stale: [{number,title,reason}], attention: [{number,title,reason}] }; keep reasons to one sentence.`, + { output: { type: "object", required: ["stale", "attention"], additionalProperties: false, properties: { + stale: { type: "array", maxItems: 50, items: FINDING_SCHEMA }, attention: { type: "array", maxItems: 50, items: FINDING_SCHEMA } } } }, + ) as Triage; + + // Only issues that were actually fetched can appear in the digest. + const line = (i: Finding) => + `• ${mrkdwn(i.title)} — ${mrkdwn(i.reason)}`; + const stale = triage.stale.filter(i => known.has(i.number)); + const attention = triage.attention.filter(i => known.has(i.number)); + const digest = [ + `*${mrkdwn(input.repo)}: ${list.length} open issues* (stale after ${staleDays} days)`, + stale.length ? `\n*Stale (${stale.length})*\n${stale.map(line).join("\n")}` : "\nNothing stale.", + attention.length ? `\n*Needs attention (${attention.length})*\n${attention.map(line).join("\n")}` : "", + ].join("\n"); + + await f.slack.post(input.channel, digest); + f.done("success"); +}); diff --git a/examples/tsconfig.json b/examples/tsconfig.json index a0acc16ec..8efd724f8 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -18,6 +18,8 @@ }, "include": [ "social-post-pipeline/*.ts", + "software-factory/*.ts", + "stale-issues/*.ts", "pr-review-pipeline/*.ts", "dependency-upgrade-bot/*.ts" ]