From 3ecbc579c8beacf5a8f82accf30e25de9c3ed59c Mon Sep 17 00:00:00 2001 From: Relayflow Lead Date: Thu, 17 Sep 2026 15:10:45 -0700 Subject: [PATCH 1/3] =?UTF-8?q?examples:=20software-factory=20(ticket=20?= =?UTF-8?q?=E2=86=92=20PR)=20and=20stale-issues=20(scheduled=20digest)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two README use-case flows authored against the real @relayflows/surface package and registered in examples/tsconfig.json. software-factory deploys today: `flows deploy … --on linear:team=ENG` launches implementer → deterministic tests → adversarial review agent whose verdict is a file → tests again → `gh pr create` (draft + findings when the review blocks, ending step_failed). stale-issues is the scheduled automation: deterministic issue fetch, one f.llm step with a JSON schema gate, one Slack digest. `flows schedule` is in flight; until it ships the README shows `flows run --cloud` on demand. Co-Authored-By: Claude Opus 5 (1M context) --- examples/software-factory/README.md | 25 +++++++ .../software-factory/software-factory.flow.ts | 65 +++++++++++++++++++ examples/stale-issues/README.md | 25 +++++++ examples/stale-issues/stale-issues.flow.ts | 46 +++++++++++++ examples/tsconfig.json | 2 + 5 files changed, 163 insertions(+) create mode 100644 examples/software-factory/README.md create mode 100644 examples/software-factory/software-factory.flow.ts create mode 100644 examples/stale-issues/README.md create mode 100644 examples/stale-issues/stale-issues.flow.ts 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..b15893e6f --- /dev/null +++ b/examples/software-factory/software-factory.flow.ts @@ -0,0 +1,65 @@ +// 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 }; + +// 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 ticket = `${issue.title}\n\n${issue.body ?? ""}${issue.url ? `\n\n${issue.url}` : ""}`; + + 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 summary.md (what changed, how it was verified).\n\nTicket:\n${ticket}`, + }).gate({ type: "subprocess_gate", command: "test -s 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 review.md with your findings, then write review.passed ` + + `ONLY if the change is ready for a human to merge; otherwise write review.blocked with the blocking findings.`, + }).gate({ type: "subprocess_gate", command: "test -s review.md" }); + + await f.run(TEST, { timeout: "15m" }); + + const verdict = await f.run("if [ -f review.passed ]; then echo PASSED; else echo BLOCKED; fi"); + await f.run("git add -A ':!summary.md' ':!review.md' ':!review.passed' ':!review.blocked' && (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 ${JSON.stringify(issue.title)} --body-file summary.md`); + return f.done("success"); + } + await f.run("{ cat summary.md; printf '\\n\\n## Adversarial review: BLOCKED\\n\\n'; cat review.md; } > pr-body.md"); + await f.run(`gh pr create --draft --title ${JSON.stringify(`[blocked] ${issue.title}`)} --body-file 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..107ba242a --- /dev/null +++ b/examples/stale-issues/stale-issues.flow.ts @@ -0,0 +1,46 @@ +// 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 agent, and it must return JSON. +import { flow } from "@relayflows/surface"; + +type Input = { repo: string; channel: string; staleDays?: number }; +type Triage = { stale: { number: number; title: string; reason: string }[]; attention: { number: number; title: string; reason: string }[] }; + +export default flow("stale-issues", { budget: { dollars: 2, wallclock: "10m" }, tools: { slack: true } }, async (f, input) => { + const staleDays = input.staleDays ?? 14; + const issues = await f.run( + `curl -sf -H "Authorization: Bearer $GH_TOKEN" "https://api.github.com/repos/${input.repo}/issues?state=open&per_page=100" ` + + `| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const now=Date.now();console.log(JSON.stringify(JSON.parse(s).filter(i=>!i.pull_request).map(i=>({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}))))})'`, + { timeout: "2m" }, + ); + const list = JSON.parse(issues) as { number: number; title: string; updatedDaysAgo: number }[]; + if (list.length === 0) return f.done("success"); + + 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"], properties: { + stale: { type: "array" }, attention: { type: "array" } } } }, + ) as Triage; + + const line = (i: { number: number; title: string; reason: string }) => + `• ${i.title} — ${i.reason}`; + const digest = [ + `*${input.repo}: ${list.length} open issues* (stale after ${staleDays} days)`, + triage.stale.length ? `\n*Stale (${triage.stale.length})*\n${triage.stale.map(line).join("\n")}` : "\nNothing stale.", + triage.attention.length ? `\n*Needs attention (${triage.attention.length})*\n${triage.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" ] From a3fdef06e1312202928af9576b63f1a6cf8053b4 Mon Sep 17 00:00:00 2001 From: Relayflow Lead Date: Thu, 17 Sep 2026 15:31:33 -0700 Subject: [PATCH 2/3] examples: shell-quote ticket text, paginate issues, validate model output, fix verdict and git add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on #450 (Devin, Cursor), all valid: - software-factory: ticket titles reached `gh pr create` through JSON.stringify, which leaves `$(…)` and backticks live under /bin/sh — a crafted Linear/GitHub title ran commands with the worker's token. Titles now pass through `shellWord` (single-quoted, the SURFACE.md idiom) and are capped. Flow artifacts moved to `.relayflow/`, excluded via `.git/info/exclude`, so `git add -A` needs no pathspec (the `:!`-only form errored) and a leftover `review.passed` from a previous run cannot override a fresh `review.blocked`; PASSED now requires the pass marker and the absence of the block marker. - stale-issues: `input.repo` is validated as owner/name before use and the fetch runs under node with the repo as an argv, never interpolated into the shell; GitHub pagination is followed (oldest-updated first) so repositories over 100 issues are triaged in full; the LLM schema now declares `items` (integer number, bounded strings, no extra keys); digest lines only reference issue numbers that were actually fetched and escape model text for Slack mrkdwn, so a response cannot forge links or mentions. Co-Authored-By: Claude Opus 5 (1M context) --- .../software-factory/software-factory.flow.ts | 34 +++++++--- examples/stale-issues/stale-issues.flow.ts | 68 ++++++++++++++----- 2 files changed, 76 insertions(+), 26 deletions(-) diff --git a/examples/software-factory/software-factory.flow.ts b/examples/software-factory/software-factory.flow.ts index b15893e6f..43c37a8f4 100644 --- a/examples/software-factory/software-factory.flow.ts +++ b/examples/software-factory/software-factory.flow.ts @@ -16,6 +16,15 @@ 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'; @@ -28,13 +37,17 @@ export default flow("software-factory", { budget: { dollars: 10, wallcloc 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 summary.md (what changed, how it was verified).\n\nTicket:\n${ticket}`, - }).gate({ type: "subprocess_gate", command: "test -s summary.md" }); + `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" }); @@ -43,23 +56,24 @@ export default flow("software-factory", { budget: { dollars: 10, wallcloc 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 review.md with your findings, then write review.passed ` + - `ONLY if the change is ready for a human to merge; otherwise write review.blocked with the blocking findings.`, - }).gate({ type: "subprocess_gate", command: "test -s review.md" }); + `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" }); - const verdict = await f.run("if [ -f review.passed ]; then echo PASSED; else echo BLOCKED; fi"); - await f.run("git add -A ':!summary.md' ':!review.md' ':!review.passed' ':!review.blocked' && (git diff --cached --quiet || git commit -qm 'Software factory: implementation and review fixes')"); + // 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 ${JSON.stringify(issue.title)} --body-file summary.md`); + await f.run(`gh pr create --title ${shellWord(title)} --body-file ${WORK}/summary.md`); return f.done("success"); } - await f.run("{ cat summary.md; printf '\\n\\n## Adversarial review: BLOCKED\\n\\n'; cat review.md; } > pr-body.md"); - await f.run(`gh pr create --draft --title ${JSON.stringify(`[blocked] ${issue.title}`)} --body-file pr-body.md`); + 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/stale-issues.flow.ts b/examples/stale-issues/stale-issues.flow.ts index 107ba242a..066386c55 100644 --- a/examples/stale-issues/stale-issues.flow.ts +++ b/examples/stale-issues/stale-issues.flow.ts @@ -8,37 +8,73 @@ // --input '{"repo":"acme/api","channel":"#eng","staleDays":14}' // // The issue list is fetched deterministically (journaled, replayable); only the -// judgement is delegated to an agent, and it must return JSON. +// 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 Triage = { stale: { number: number; title: string; reason: string }[]; attention: { number: number; title: string; reason: string }[] }; +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 ' +const [repo] = process.argv.slice(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) => { - const staleDays = input.staleDays ?? 14; - const issues = await f.run( - `curl -sf -H "Authorization: Bearer $GH_TOKEN" "https://api.github.com/repos/${input.repo}/issues?state=open&per_page=100" ` + - `| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const now=Date.now();console.log(JSON.stringify(JSON.parse(s).filter(i=>!i.pull_request).map(i=>({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}))))})'`, - { timeout: "2m" }, - ); - const list = JSON.parse(issues) as { number: number; title: string; updatedDaysAgo: number }[]; + 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"], properties: { - stale: { type: "array" }, attention: { type: "array" } } } }, + { 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; - const line = (i: { number: number; title: string; reason: string }) => - `• ${i.title} — ${i.reason}`; + // 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 = [ - `*${input.repo}: ${list.length} open issues* (stale after ${staleDays} days)`, - triage.stale.length ? `\n*Stale (${triage.stale.length})*\n${triage.stale.map(line).join("\n")}` : "\nNothing stale.", - triage.attention.length ? `\n*Needs attention (${triage.attention.length})*\n${triage.attention.map(line).join("\n")}` : "", + `*${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); From 7306df149786310b9e668f50da0f52773ee29793 Mon Sep 17 00:00:00 2001 From: Relayflow Lead Date: Thu, 17 Sep 2026 15:46:31 -0700 Subject: [PATCH 3/3] examples(stale-issues): read the repository from the last argv under node -e Cursor on #450: node -e may place an [eval] placeholder at argv[1], so argv.slice(1)[0] can be the wrong slot. Use argv.at(-1), which is the quoted repository on every Node version; probed the request URL. Co-Authored-By: Claude Opus 5 (1M context) --- examples/stale-issues/stale-issues.flow.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/stale-issues/stale-issues.flow.ts b/examples/stale-issues/stale-issues.flow.ts index 066386c55..a5945cabc 100644 --- a/examples/stale-issues/stale-issues.flow.ts +++ b/examples/stale-issues/stale-issues.flow.ts @@ -22,7 +22,9 @@ const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/; // 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 ' -const [repo] = process.argv.slice(1); +// 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();