Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions examples/software-factory/README.md
Original file line number Diff line number Diff line change
@@ -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-<id>` 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":[]}}'
```
79 changes: 79 additions & 0 deletions examples/software-factory/software-factory.flow.ts
Original file line number Diff line number Diff line change
@@ -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/<name>-<id> 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<Input>("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");
});
25 changes: 25 additions & 0 deletions examples/stale-issues/README.md
Original file line number Diff line number Diff line change
@@ -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.
84 changes: 84 additions & 0 deletions examples/stale-issues/stale-issues.flow.ts
Original file line number Diff line number Diff line change
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

export default flow<Input>("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) =>
`• <https://github.com/${input.repo}/issues/${i.number}|#${i.number}> ${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");
});
2 changes: 2 additions & 0 deletions examples/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
},
"include": [
"social-post-pipeline/*.ts",
"software-factory/*.ts",
"stale-issues/*.ts",
"pr-review-pipeline/*.ts",
"dependency-upgrade-bot/*.ts"
]
Expand Down
Loading