Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1f16ffe
chore(checks): the tool guard checks prompts, bodies, claims, labels,…
V3RON Oct 25, 2026
abe0de1
chore(checks): the report guard checks remaining, open, Rerun, lens c…
V3RON Oct 25, 2026
9af975a
chore(checks): the PR body check reads the ledger, Done when, assumpt…
V3RON Oct 25, 2026
b49bdf4
chore(checks): commit subjects on delivery branches, the ADR flip che…
V3RON Oct 25, 2026
8a34d21
chore(checks): the merge gate refuses a feature's last stack while an…
V3RON Oct 25, 2026
1f5f570
chore(checks): claims go through claim.mjs, the ADR flip through adr-…
V3RON Oct 25, 2026
171fa13
chore(checks): a PR walks its Done when lines in the issue's words, s…
V3RON Oct 25, 2026
dd7132c
chore(checks): the PR body check matches a Done when line by its cont…
V3RON Oct 25, 2026
f8ef6a8
chore(checks): the deliver skill says how the body check matches a Do…
V3RON Oct 25, 2026
cb116ba
chore(checks): a Done when line matches on half its prose words, leav…
V3RON Oct 25, 2026
f69a4ca
chore(checks): split the new guards and checks into rule tables and s…
V3RON Oct 25, 2026
ec05114
test(checks): the guard tests keep the waiting rules' cases after the…
V3RON Oct 25, 2026
0620c1c
test(checks): the claim script's cases get half a minute, since each …
V3RON Oct 25, 2026
082943a
chore(checks): the lens-routing failure of the body check applies to …
V3RON Oct 25, 2026
d1c0fab
Merge remote-tracking branch 'origin/chore/lints-a' into HEAD
V3RON Oct 25, 2026
c6fa1c7
test(checks): the body check's ancestry case reads two commits of its…
V3RON Oct 25, 2026
35ca6ff
Merge remote-tracking branch 'origin/chore/lints-b' into chore/lints-b
V3RON Oct 25, 2026
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
2 changes: 1 addition & 1 deletion .agents/briefs/implement-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ cycle wires in are dead code until then. The last cycle's push runs every hook.
`base` argument (a task stacked on its feature's last reviewed PR), the PR
targets that branch. The PR body, in this order: `Closes #<N>`; a
`### Status` section the orchestrator keeps current; the Done when lines
as an unchecked list; `## Assumptions` ("none", or one `- Assumption:` line
as an unchecked list, in the issue's words; `## 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
Expand Down
78 changes: 78 additions & 0 deletions .agents/scripts/adr-flip-check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env node
// `node .agents/scripts/adr-flip-check.mjs <commit>`: the coordinator's mechanical fix-check of
// an `adr: <nnnn> to Accepted` fix (the deliver skill, "Merging the stack"; delivery rule 10).
// It passes (exit 0, `adr-flip: ok <nnnn> ...`) when <commit> changes only ADR files and, in
// each, only the `- **Status:**` item (its line and the indented lines that continue it), now
// reading `Accepted` and not "not yet implemented"; and in `docs/internal/adr/README.md` only
// index rows, at most one per ADR the commit flips, each the row of one of them. Anything else
// exits 1 with one `adr-flip: <path>: <what>` line per defect, and the coordinator sends the
// fix back instead of recording `verified by: coordinator`.
import { execFileSync } from "node:child_process";

const commit = process.argv[2];
if (commit === undefined) {
console.error("usage: adr-flip-check.mjs <commit>");
process.exit(64);
}
const git = (...args) =>
execFileSync("git", args, { encoding: "utf8", maxBuffer: 1 << 26 }).replace(/\r/g, "");
const ADR = /^docs\/internal\/adr\/(\d{4})-[^/]+\.md$/;
const INDEX = "docs/internal/adr/README.md";

const changed = git("diff-tree", "--no-commit-id", "--name-status", "-r", "--no-renames", commit)
.split("\n")
.filter(Boolean)
.map((row) => row.split("\t"));
const defects = [];
const flipped = [];
for (const [status, path] of changed) {
if (!ADR.test(path) && path !== INDEX) {
defects.push(`${path}: not an ADR file`);
} else if (status !== "M") {
defects.push(`${path}: ${status === "A" ? "added" : "deleted"}, not edited`);
} else if (path !== INDEX) {
const before = git("show", `${commit}^:${path}`);
const after = git("show", `${commit}:${path}`);
const line = statusItem(after)[0] ?? "";
if (withoutStatus(before) !== withoutStatus(after)) {
defects.push(`${path}: changes more than its Status line`);
} else if (
!/^- \*\*Status:\*\* Accepted\b/.test(line) ||
/not yet implemented/.test(statusItem(after).join(" "))
) {
defects.push(`${path}: its Status does not read Accepted: "${line.slice(0, 60)}"`);
} else {
flipped.push(ADR.exec(path)[1]);
}
}
}
if (changed.some(([, path]) => path === INDEX)) {
const before = git("show", `${commit}^:${INDEX}`).split("\n");
const after = git("show", `${commit}:${INDEX}`).split("\n");
const rows = after.filter((l, i) => l !== before[i]);
if (before.length !== after.length) {
defects.push(`${INDEX}: adds or removes lines`);
} else if (
rows.length > flipped.length ||
rows.some((l) => !flipped.some((n) => l.startsWith(`| [${n}]`)))
) {
defects.push(`${INDEX}: changes a line that is not the index row of an ADR this commit flips`);
}
}
if (flipped.length === 0 && defects.length === 0) defects.push(`${commit}: flips no ADR`);
for (const d of defects) console.log(`adr-flip: ${d}`);
if (defects.length > 0) process.exit(1);
console.log(`adr-flip: ok ${flipped.join(" ")}`);

function statusItem(text) {
const lines = text.split("\n");
const at = lines.findIndex((l) => l.startsWith("- **Status:**"));
if (at === -1) return [];
const end = lines.findIndex((l, i) => i > at && !/^\s+\S/.test(l));
return lines.slice(at, end === -1 ? lines.length : end);
}

function withoutStatus(text) {
const item = statusItem(text);
return item.length === 0 ? text : text.replace(item.join("\n"), "");
}
168 changes: 168 additions & 0 deletions .agents/scripts/body-rules.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// `bodyDefects(text)`: what delivery rules 2, 8 and 12 say about a body or comment an agent
// posts, for the tool guard to refuse before it is posted. One sentence per defect, naming the
// rule; an empty list when the body passes. Words are counted outside code blocks, without
// headings and the footer line.
//
// every body the last line is `*Written by an agent.*` (rule 12)
// `## Handoff` Done, Not done, Findings, Blocked on, once each and in that order (rule
// 2); at most 150 words
// `## Reproduction` (a triage report, maybe after `Supersedes the report above.`) the
// headings Reproduction, Root cause, Simplest fix, Alternatives rejected,
// Risk and an optional Side findings, in order (rule 8); a `path:line` in
// Root cause; at most three Alternatives lines and three Risk bullets; at
// most 300 words
// `Spec updated:` one line and the footer
// `## Review notes` every note one bullet on one line
const FOOTER = /^[*_]Written by an agent\.[*_]$/;
const HANDOFF = ["Done", "Not done", "Findings", "Blocked on"];
const TRIAGE = ["Reproduction", "Root cause", "Simplest fix", "Alternatives rejected", "Risk"];

// why: the first lines name what a body is; the first kind whose `at` holds checks it.
const KINDS = [
{ at: (filled) => /^## Handoff\s*$/.test(filled[0] ?? ""), check: handoff },
{ at: isTriageReport, check: triage },
{ at: (filled) => (filled[0] ?? "").startsWith("Spec updated:"), check: specUpdated },
{ at: (filled) => /^## Review notes\s*$/.test(filled[0] ?? ""), check: reviewNotes },
];

export function bodyDefects(raw) {
const text = String(raw ?? "").replace(/\r/g, "");
const lines = withoutCode(text).split("\n");
const filled = text
.split("\n")
.map((l) => l.trim())
.filter((l) => l !== "");
const defects = [];
if (!FOOTER.test(filled.at(-1) ?? "")) {
defects.push(
"Every body and comment an agent writes ends with the line `*Written by an agent.*` (delivery rule 12): add it as the last line.",
);
}
const kind = KINDS.find((k) => k.at(filled));
return kind === undefined ? defects : [...defects, ...kind.check(lines, filled)];
}

function isTriageReport(filled) {
const [first = "", second = ""] = filled;
const report = (l) => /^## Reproduction\s*$/.test(l);
return report(first) || (/^Supersedes the report above\.?$/.test(first) && report(second));
}

function specUpdated(_, filled) {
if (filled.filter((l) => !FOOTER.test(l)).length <= 1) return [];
return [
'A "Spec updated" comment is one line and the footer (delivery rule 12): say in that line which sections moved.',
];
}

function handoff(lines) {
const found = [];
for (const l of lines) {
// why: a label is a heading, emphasised or followed by a colon; prose may start "Done when".
const label =
/^(?:- )?(#{2,6}\s+)?(\*\*|__|\*|_)?(Done|Not done|Findings|Blocked on)(?:\*\*|__|\*|_)?(:)?(?:\*\*|__)?(?=\s|$)/.exec(
l.trim(),
);
if (label !== null && (label[1] || label[2] || label[4])) found.push(label[3]);
}
const out = [];
if (found.join("|") !== HANDOFF.join("|")) {
out.push(
`A \`## Handoff\` has four parts, once each and in this order: Done, Not done, Findings, Blocked on (delivery rule 2); this one has ${found.length === 0 ? "none" : found.join(", ")}.`,
);
}
const words = countWords(lines);
if (words > 150) {
out.push(
`This handoff is ${words} words outside code; the budget is 150 (delivery rule 12): cut it.`,
);
}
return out;
}

const TRIAGE_RULES = [
{
when: (lines) => {
const headings = lines.flatMap((l) => /^##\s+(.+?)\s*$/.exec(l)?.slice(1) ?? []);
const expected = headings.at(-1) === "Side findings" ? [...TRIAGE, "Side findings"] : TRIAGE;
return headings.join("|") !== expected.join("|");
},
reason: () =>
"A triage report has the sections Reproduction, Root cause, Simplest fix, Alternatives rejected, Risk and, when there are any, Side findings, in that order (delivery rule 8).",
},
{
when: (lines) =>
sectionHas(lines, "Root cause", (body) => !/\S+\.\w+:\d+/.test(body.join("\n"))),
reason: () => "The Root cause names the file and line, as `path:line` (delivery rule 8).",
},
{
when: (lines) =>
sectionHas(
lines,
"Alternatives rejected",
(body) => body.filter((l) => l.trim() !== "").length > 3,
),
reason: () =>
"Alternatives rejected holds at most three lines, one per alternative (delivery rule 8).",
},
{
when: (lines) =>
sectionHas(lines, "Risk", (body) => body.filter((l) => /^\s*[-*] /.test(l)).length > 3),
reason: () => "Risk holds at most three bullets (delivery rule 8).",
},
{
when: (lines) => countWords(lines) > 300,
reason: (lines) =>
`This report is ${countWords(lines)} words outside code; the budget is 300 (delivery rule 12): cut it.`,
},
];

function triage(lines) {
return TRIAGE_RULES.filter((r) => r.when(lines)).map((r) => r.reason(lines));
}

function sectionHas(lines, name, test) {
const body = section(lines, name);
return body !== null && test(body);
}

function reviewNotes(lines) {
let inBullet = false;
for (const l of lines) {
if (l.trim() === "" || FOOTER.test(l.trim())) inBullet = false;
else if (/^\s*[-*] /.test(l)) inBullet = true;
else if (inBullet) {
return [
"In `## Review notes` each note is one bullet on one line (delivery rule 12): join or cut the line that continues a bullet.",
];
}
}
return [];
}

function section(lines, name) {
const at = lines.findIndex((l) => new RegExp(`^##\\s+${name}\\s*$`).test(l));
if (at === -1) return null;
const end = lines.findIndex((l, i) => i > at && /^##\s/.test(l));
return lines.slice(at + 1, end === -1 ? lines.length : end);
}

function countWords(lines) {
return lines
.filter((l) => !/^#{1,6}\s/.test(l) && !FOOTER.test(l.trim()))
.reduce((sum, l) => sum + l.split(/\s+/).filter((w) => /\w/.test(w)).length, 0);
}

function withoutCode(text) {
let inside = false;
return text
.split("\n")
.map((l) => {
if (/^\s*(```|~~~)/.test(l)) {
inside = !inside;
return "";
}
return inside ? "" : l;
})
.join("\n");
}
96 changes: 96 additions & 0 deletions .agents/scripts/claim.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env node
// `node .agents/scripts/claim.mjs <n> [--triage]`: claims issue <n> for this session's GitHub
// user, the one way an agent assigns an issue (delivery rule 2; the tool guard refuses `gh
// issue edit --add-assignee`). It refuses, exit 1 with one `claim: #<n> ...` line saying why, an
// issue that is closed, has an assignee, or carries no label an agent may act on (delivery rule
// 1): `bug:new` with `--triage` and only then, `bug:ready`, `task:ready`. A `task:ready` task
// also needs every issue its `## Depends on` names closed, or a sub-issue of the same feature
// whose PR (from `<kind>/<m>`) is open and not a draft. Then it assigns the user, and prints
// `claim: #<n> assigned to <login>`; when someone else got the issue at the same moment it
// unassigns the user again and refuses. The repo is GH_REPO, else the origin remote's owner/name.
import { execFileSync } from "node:child_process";

const [number] = process.argv.slice(2);
const triage = process.argv.includes("--triage");
if (!/^\d+$/.test(number ?? "")) {
console.error("usage: claim.mjs <n> [--triage]");
process.exit(64);
}
const nwo = repository();
const issue = api("GET", `issues/${number}`);
const labels = issue.labels.map((l) => (typeof l === "string" ? l : l.name));
const assignees = issue.assignees.map((a) => a.login);
if (issue.state !== "open") refuse("is closed");
if (assignees.length > 0) refuse(`is assigned to ${assignees.join(", ")}; it is theirs`);
const work = triage ? ["bug:new"] : ["bug:ready", "task:ready"];
const label = labels.find((l) => work.includes(l));
if (label === undefined) {
refuse(
`carries ${labels.filter((l) => /^\w+:/.test(l)).join(", ") || "no state label"}; ` +
(triage
? "triage takes a bug:new issue"
: "an agent acts on bug:ready and task:ready (with --triage, bug:new)"),
);
}
if (label === "task:ready") {
const depends =
/^## Depends on\s*$([\s\S]*?)(?=^## |(?![\s\S]))/m.exec(issue.body ?? "")?.[1] ?? "";
const parent = parentOf(number);
for (const dep of new Set([...depends.matchAll(/#(\d+)/g)].map((m) => m[1]))) {
const other = api("GET", `issues/${dep}`);
if (other.state === "closed") continue;
const kind = other.labels
.map((l) => (typeof l === "string" ? l : l.name))
.find((l) => /^(task|bug):/.test(l))
?.split(":")[0];
const sameFeature = parent !== null && parentOf(dep) === parent;
const reviewed =
kind !== undefined &&
api("GET", `pulls?head=${nwo.split("/")[0]}:${kind}/${dep}&state=open`).some((p) => !p.draft);
if (!sameFeature || !reviewed) {
refuse(
`depends on #${dep}, which is open${sameFeature ? " with no reviewed PR" : " and not a task of the same feature"} (delivery rule 1)`,
);
}
}
}
const login = execFileSync("gh", ["api", "user", "-q", ".login"], { encoding: "utf8" }).trim();
const after = api("POST", `issues/${number}/assignees`, "-f", `assignees[]=${login}`);
const others = after.assignees.map((a) => a.login).filter((a) => a !== login);
if (others.length > 0) {
api("DELETE", `issues/${number}/assignees`, "-f", `assignees[]=${login}`);
refuse(`was claimed by ${others.join(", ")} at the same moment; it is theirs`);
}
if (!after.assignees.some((a) => a.login === login)) refuse(`could not be assigned to ${login}`);
console.log(`claim: #${number} assigned to ${login}`);

function parentOf(n) {
try {
return api("GET", `issues/${n}/parent`).number;
} catch {
return null;
}
}

function api(method, at, ...extra) {
return JSON.parse(
execFileSync("gh", ["api", "-X", method, `repos/${nwo}/${at}`, ...extra], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
}),
);
}

function refuse(why) {
console.error(`claim: #${number} ${why}. Not claimed.`);
process.exit(1);
}

function repository() {
if (process.env.GH_REPO) return process.env.GH_REPO;
const url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }).trim();
const match = /([^/:]+)\/([^/]+?)(?:\.git)?$/.exec(url);
if (match === null) throw new Error(`Cannot read owner/name from origin ${url}; set GH_REPO`);
return `${match[1]}/${match[2]}`;
}
Loading
Loading