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
5 changes: 5 additions & 0 deletions .agents/briefs/implement-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ edited after its `test:` commit:

Run the "Project checks" in `toolchain.md` that the diff triggers. Then the
tests from mode `build` pass, and you push.
Every test command runs in the foreground with `timeout: 600000` set on
the Bash call, and nothing is left running: no `run_in_background`, no
trailing `&`, no loop that sleeps, no bare `cat > file`. The tool guard
refuses each; a background job would hold your report until it ends or
hits its 30-minute cap, which stalls the whole delivery.

**A failing test you did not touch**, locally or in a failed CI run
(`gh run view <id> --log-failed`):
Expand Down
5 changes: 5 additions & 0 deletions .agents/briefs/implement-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ targets that branch. The PR body, in this order: `Closes #<N>`; a
as an unchecked list; `## Assumptions` ("none", or one `- Assumption:` line
per gap); the line `*Written by an agent.*`. Keep it current as you go.

- Every test command runs in the foreground with `timeout: 600000` set on
the Bash call, and nothing is left running: no `run_in_background`, no
trailing `&`, no loop that sleeps, no bare `cat > file`. The tool guard
refuses each; a background job would hold your report until it ends or
hits its 30-minute cap, which stalls the whole delivery.
- Run only the commands under "Tests agents run" in `toolchain.md`. Never
run a check listed under "Checks agents never run", nor a tool behind
it, from any path. A type or lint error shows in the commit hook's
Expand Down
9 changes: 9 additions & 0 deletions .agents/briefs/implement-fix.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,20 @@ mechanical. Inputs include immutable `base`, `merge-base`, `previous` and
test;
- a `ci:` line: the named test failed in CI; a line asking to merge the
base branch: merge it and resolve the conflicts.
- an `adr: <nnnn> to <status>` line: the feature is merging (delivery
rule 10); set that ADR's `**Status:**` line and its row in
`docs/internal/adr/README.md` to the status named, and change nothing
else.
- a `stack:` line naming a parent branch: in your worktree, `git merge
origin/<parent>` into the branch, resolve the conflicts, run the tests
of the files the resolution touched, commit the merge and push. Never
rebase, never force-push, never touch another branch: the orchestrator
carries the merge up the stack with `stack-sync.sh`.
Every test command runs in the foreground with `timeout: 600000` set on
the Bash call, and nothing is left running: no `run_in_background`, no
trailing `&`, no loop that sleeps, no bare `cat > file`. The tool guard
refuses each; a background job would hold your report until it ends or
hits its 30-minute cap, which stalls the whole delivery.
3. Run only the commands under "Tests agents run" in `toolchain.md`. Never
run a check listed under "Checks agents never run", nor a tool behind it,
from any path: the commit and push hooks run them, and their errors show
Expand Down
5 changes: 4 additions & 1 deletion .agents/briefs/review-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ code to see what a test catches: one edit, one run of its test file, then
`git checkout .`. You may also write one new test file in the worktree to
prove an interleaving or an input the suite does not cover, run it, and
delete it. Probe budget: `lens: behaviour` four probes, `lens: tests` six,
`lens: both` nine. Restore the tree before you report.
`lens: both` nine. Restore the tree before you report. Every test run is a
foreground Bash call with `timeout: 600000` set, never a background job or
a loop that sleeps: the tool guard refuses those, because a background job
holds your report until it ends.

## Lens: behaviour (`lens: behaviour` or `both`)

Expand Down
87 changes: 85 additions & 2 deletions .agents/scripts/ci-triage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,17 @@
// reach the test file, or a source file in its directory, is in the PR's diff: a fix run
// unknown none of the above: rerun the failed jobs once; a second failure gets a fix run
//
// A failed job whose log names no test (lint, typecheck, format) prints as `<class> job <name>`
// followed by up to 8 indented lines from its log: the lines naming a file:line or an error, else
// its last 3 lines (none for Quality and Console, which only sum up other jobs); nobody needs
// `gh run view --log-failed` by hand.
//
// Ends with `rerun <run id>` lines for the runs whose failures are all flaky or unknown, and
// `none` when nothing failed. The repo is GH_REPO, else the origin remote's owner/name.
// `none` when nothing failed. Exits 0, or 3 when the result is stale: when a workflow's newest
// finished run on the branch is for a commit that is not the PR's head (a push cancels the run
// before it, so the head's own run may not have finished), one line `stale: run <id> is <sha7>,
// head is <sha7>; checks for the head are <pending|missing>` comes first, and the lines after it
// describe that older commit. The repo is GH_REPO, else the origin remote's owner/name.
import { execFileSync } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -47,14 +56,18 @@ function failures(args) {
}
return runs;
}
// why: GitHub's stored logs spell the escape byte as the two characters `^[`.
const ANSI = new RegExp(`(?:${String.fromCharCode(27)}|\\^\\[)\\[[0-9;]*m`, "g");
const testFile = (test) =>
/^((?:e2e|src|ui)\/[\w./-]+\.(?:test|spec)\.tsx?)/.exec(test)?.[1] ?? null;

const pull = api(`pulls/${pr}`);
const stale = staleRuns();
for (const line of stale) console.log(line);
const mine = failures([pr]);
if (mine.length === 0) {
console.log("none");
process.exit(0);
process.exit(stale.length > 0 ? 3 : 0);
}
const onBase = new Set(failures(["--branch", pull.base.ref]).flatMap((r) => r.tests));
const flakyIssues = api(
Expand Down Expand Up @@ -92,7 +105,77 @@ for (const r of mine) {
else if (reached(file)) cls = "reach";
if (cls === "reach" || cls === "base") needsFix = true;
console.log(`${cls} ${test} (${r.workflow} run ${r.id})`);
if (test.startsWith("job ")) for (const l of excerpt(r.id, test)) console.log(` ${l}`);
}
if (!needsFix) reruns.push(r.id);
}
for (const id of reruns) console.log(`rerun ${id}`);
process.exit(stale.length > 0 ? 3 : 0);

// why: ci-failures.mjs reads only a workflow's newest run that ended in success or failure, so
// while the head's run is pending or was cancelled it reports an older commit as if current.
function staleRuns() {
const listed = api(
"actions/runs",
"-f",
`branch=${pull.head.ref}`,
"-f",
"per_page=100",
).workflow_runs;
const head = pull.head.sha;
const finished = (r) => r.conclusion === "success" || r.conclusion === "failure";
const newest = new Map();
for (const r of listed.filter(finished)) {
const seen = newest.get(r.name);
if (seen === undefined || r.created_at > seen.created_at) newest.set(r.name, r);
}
// why: a run GitHub skipped on the head (Issue state skips most events) is that workflow's
// whole answer for the head, so an older run of it is not stale.
const settled = (name) =>
listed.some(
(r) =>
r.name === name &&
r.head_sha === head &&
r.status === "completed" &&
r.conclusion !== "cancelled",
);
return [...newest.values()]
.filter((r) => r.head_sha !== head && !settled(r.name))
.map((r) => {
const pending = listed.some(
(x) => x.name === r.name && x.head_sha === head && x.status !== "completed",
);
return `stale: run ${r.id} is ${r.head_sha.slice(0, 7)}, head is ${head.slice(0, 7)}; checks for the head are ${pending ? "pending" : "missing"}`;
});
}

function excerpt(id, test) {
const job = /^job (.+?)(?: \(log unavailable: .*\))?$/.exec(test)[1];
if (/^(Quality|Console)$/.test(job)) return [];
let log;
try {
log = run("gh", ["run", "view", id, "--log-failed"]);
} catch {
return [];
}
const lines = log
.split("\n")
.filter((l) => l.startsWith(`${job}\t`))
.map((l) =>
l
.replace(ANSI, "")
.replace(/^[^\t]*\t[^\t]*\t\uFEFF?\S+Z ?/, "")
.trimEnd(),
)
.filter(
(l) =>
l.trim() !== "" &&
!/Process completed with exit code|ELIFECYCLE|^##\[(?:end)?group\]/.test(l),
);
const named = lines.filter((l) =>
/(?:^|[\s[(])[\w./-]+\.(?:[cm]?[jt]sx?|mdx?|json|sh|ya?ml)(?::\d|\(\d|: )|\berror\b|^\s*[×✖]/i.test(
l,
),
);
return (named.length > 0 ? named : lines.slice(-3)).slice(0, 8).map((l) => l.trim());
}
215 changes: 215 additions & 0 deletions .agents/scripts/issue-body.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
#!/usr/bin/env node
// `node .agents/scripts/issue-body.mjs <n> [--pr] [--dry-run] <op> ...`: edits one issue's or
// PR's body in place, so nobody rewrites a body by hand to change one line.
//
// replace <old> <new> <old> must occur exactly once, else exit 1 saying how often
// set-section <heading> <file> the section becomes the file's text under the same heading
// get-section <heading> prints the section's text, without its heading line
// append <file> adds the file's text at the end
//
// <old>, <new> may be `@<file>` to read the text from a file; a file's one trailing newline is
// dropped. A section runs from its heading line to the next heading of the same or a higher
// level, code blocks skipped. <heading> `Status` matches `## Status` or `### Status`; `###
// Status` matches that level only. Two matches: exit 1. A missing section is created as `##
// <heading>` (or the level given) at the end. The body's last line stays `*Written by an
// agent.*` (`_Written by an agent._` is kept as it is), added when missing, and the end of the
// body means just above it.
//
// Refuses (exit 1) to touch an issue labelled `request:*` or `bug:*`: its body is the reporter's
// (delivery rule 5); a PR is never refused. `--pr` says <n> is a PR; a mismatch exits 2. Before
// writing it reads the body again and exits 1 when someone changed it meanwhile. Prints `updated
// #<n>`, or `unchanged #<n>`. `--dry-run` prints the new body and writes nothing. `--body-file
// <f>` reads the body from a file instead of GitHub and implies --dry-run (an offline test).
// Usage errors exit 2. Writes through REST, PATCH issues/<n>; the repo is GH_REPO, else the
// origin remote's owner/name.
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const FOOTER = "*Written by an agent.*";
const IS_FOOTER = /^[*_]Written by an agent\.[*_]$/;
const USAGE =
"usage: issue-body.mjs <n> [--pr] [--dry-run] [--body-file <f>] replace <old> <new> | set-section <heading> <file> | get-section <heading> | append <file>";

const argv = process.argv.slice(2);
const flag = (name) => {
const at = argv.indexOf(name);
if (at === -1) return false;
argv.splice(at, 1);
return true;
};
const isPr = flag("--pr");
let dryRun = flag("--dry-run");
let bodyFile = null;
const fileAt = argv.indexOf("--body-file");
if (fileAt !== -1) {
bodyFile = argv[fileAt + 1] ?? null;
argv.splice(fileAt, 2);
dryRun = true;
}
const [n, op, ...rest] = argv;
const arity = { replace: 2, "set-section": 2, "get-section": 1, append: 1 };
if (
n === undefined ||
!/^\d+$/.test(n) ||
!(op in arity) ||
rest.length !== arity[op] ||
(fileAt !== -1 && bodyFile === null)
) {
usage();
}

const run = (cmd, args) =>
execFileSync(cmd, args, {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
const nwo = () =>
process.env.GH_REPO ??
run("git", ["remote", "get-url", "origin"])
.trim()
.replace(/\.git$/, "")
.replace(/.*[/:]([^/:]+\/[^/]+)$/, "$1");
// why: a cloud session's GitHub proxy refuses GraphQL, which `gh issue edit` and `gh pr edit`
// use; issues/<n> on REST reads and writes a PR's body as well as an issue's.
const fetchIssue = () => JSON.parse(run("gh", ["api", "-X", "GET", `repos/${nwo()}/issues/${n}`]));

let original;
if (bodyFile !== null) {
original = readFileSync(bodyFile, "utf8");
} else {
const issue = fetchIssue();
const pull = issue.pull_request !== undefined;
if (pull !== isPr) {
console.error(`issue-body: #${n} is ${pull ? "a PR: pass --pr" : "an issue: drop --pr"}`);
process.exit(2);
}
const reporter = (issue.labels ?? []).map((l) => l.name).find((l) => /^(request|bug):/.test(l));
if (!pull && reporter !== undefined) {
die(
`refusing: #${n} is labelled ${reporter}, and its body stays the reporter's (delivery rule 5); post a comment instead`,
);
}
original = issue.body ?? "";
}

const { main, footer } = split(original);
let next = main;
if (op === "replace") {
const [old, now] = rest.map(text);
if (old === "") die("<old> is empty");
const count = main.split(old).length - 1;
if (count !== 1) die(`<old> occurs ${count} times in #${n}'s body; it must occur once`);
next = main.replace(old, () => now);
} else if (op === "append") {
next = joined([main, text(`@${rest[0]}`).trim()]);
} else {
const want = target(rest[0]);
const lines = main.split("\n");
const found = headings(lines).filter(
(h) =>
h.text === want.text &&
(want.level === null ? h.level === 2 || h.level === 3 : h.level === want.level),
);
if (found.length > 1) {
die(`${found.length} sections are headed "${want.text}"; name its level: "### ${want.text}"`);
}
const at = found[0];
if (op === "get-section") {
if (at === undefined) die(`no section headed "${want.text}" in #${n}'s body`);
console.log(
lines
.slice(at.at + 1, end(lines, at))
.join("\n")
.trim(),
);
process.exit(0);
}
let content = text(`@${rest[1]}`).replace(/\s+$/, "");
const first = /^\s*(#{1,6})\s+(.*\S)\s*(?:\n|$)/.exec(content);
if (first !== null && first[2] === want.text) content = content.slice(first[0].length);
const level = at?.level ?? want.level ?? 2;
const section = joined([`${"#".repeat(level)} ${want.text}`, content.trim()]);
next =
at === undefined
? joined([main, section])
: joined([lines.slice(0, at.at).join("\n"), section, lines.slice(end(lines, at)).join("\n")]);
}

const body = joined([next.replace(/\s+$/, ""), footer]);
if (dryRun) {
console.log(body);
process.exit(0);
}
if (body === original.replace(/\r/g, "")) {
console.log(`unchanged #${n}`);
process.exit(0);
}
if ((fetchIssue().body ?? "") !== original) {
die(`#${n}'s body changed while this edit was being made; run it again`);
}
const dir = mkdtempSync(join(tmpdir(), "issue-body-"));
try {
const payload = join(dir, "body.json");
writeFileSync(payload, JSON.stringify({ body }));
run("gh", ["api", "-X", "PATCH", `repos/${nwo()}/issues/${n}`, "--input", payload]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
console.log(`updated #${n}`);

function usage() {
console.error(USAGE);
process.exit(2);
}

function die(message) {
console.error(`issue-body: ${message}`);
process.exit(1);
}

function text(value) {
if (!value.startsWith("@")) return value;
return readFileSync(value.slice(1), "utf8").replace(/\r/g, "").replace(/\n$/, "");
}

function split(raw) {
const lines = raw.replace(/\r/g, "").replace(/\s+$/, "").split("\n");
const last = (lines.at(-1) ?? "").trim();
if (IS_FOOTER.test(last)) {
return { main: lines.slice(0, -1).join("\n").replace(/\s+$/, ""), footer: last };
}
return { main: lines.join("\n"), footer: FOOTER };
}

function joined(parts) {
return parts
.filter((p) => p.trim() !== "")
.map((p) => p.replace(/^\n+|\s+$/g, ""))
.join("\n\n");
}

function target(heading) {
const h = /^(#{1,6})\s+(.*\S)\s*$/.exec(heading);
return h === null ? { level: null, text: heading.trim() } : { level: h[1].length, text: h[2] };
}

function headings(lines) {
const out = [];
let fence = false;
lines.forEach((l, at) => {
if (/^\s*(```|~~~)/.test(l)) fence = !fence;
else if (!fence) {
const h = /^(#{1,6})\s+(.*\S)\s*$/.exec(l);
if (h !== null) out.push({ at, level: h[1].length, text: h[2] });
}
});
return out;
}

function end(lines, at) {
const after = headings(lines).find((h) => h.at > at.at && h.level <= at.level);
return after === undefined ? lines.length : after.at;
}
Loading
Loading