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
16 changes: 16 additions & 0 deletions .agents/scripts/awake.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Milliseconds of awake time since boot, from a clock that stops while the computer sleeps
// (time.monotonic(): mach_absolute_time on macOS, CLOCK_MONOTONIC on Linux); null when python3
// is missing. clock.mjs reports sleep with it, and budget-guard.mjs counts active time with it.
import { execFileSync } from "node:child_process";

export function awakeMs() {
try {
const out = execFileSync("python3", ["-I", "-c", "import time; print(time.monotonic())"], {
encoding: "utf8",
timeout: 2_000,
});
return Number(out) * 1000;
} catch {
return null;
}
}
16 changes: 13 additions & 3 deletions .agents/scripts/budget-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
// group is killed.
//
// Run time counts only active time: a gap of more than 30 minutes between two tool calls (a
// session waiting overnight for a person) is not counted.
// session waiting overnight for a person) is not counted, and neither is time the computer
// slept, measured on the same monotonic clock clock.mjs uses (a laptop with its lid closed
// wakes for seconds at a time, so wall-clock gaps stay short while hours pass).
//
// Limits come from the environment (set them in settings.local.json "env"):
// CLAUDE_BUDGET_AGENT_TOKENS default 15000000 (0 disables the agent token limit)
Expand All @@ -27,6 +29,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { awakeMs } from "./awake.mjs";
import { emptyState, scan, sessionFiles } from "./usage.mjs";

const LIMIT_AGENT_TOKENS = Number(process.env.CLAUDE_BUDGET_AGENT_TOKENS ?? 15_000_000);
Expand All @@ -44,7 +47,7 @@ if (!input.transcript_path || !input.session_id) process.exit(0);
const { main, subDir, files } = sessionFiles(input.transcript_path);

const stateFile = path.join(os.tmpdir(), `claude-budget-${input.session_id}.json`);
let state = { activeMs: 0, lastSeen: null, files: {} };
let state = { activeMs: 0, lastSeen: null, lastAwake: null, files: {} };
try {
state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
} catch {}
Expand All @@ -55,9 +58,16 @@ for (const file of files) {
scan(file, s);
}
const now = Date.now();
const gap = typeof state.lastSeen === "number" ? now - state.lastSeen : 0;
const awake = awakeMs();
const wallGap = typeof state.lastSeen === "number" ? now - state.lastSeen : 0;
// why: the awake gap excludes sleep; the wall gap is the fallback when python3 is missing.
const gap =
awake !== null && typeof state.lastAwake === "number"
? Math.max(0, awake - state.lastAwake)
: wallGap;
if (gap > 0 && gap <= IDLE_GAP_MS) state.activeMs = (state.activeMs ?? 0) + gap;
state.lastSeen = now;
state.lastAwake = awake;
fs.writeFileSync(stateFile, JSON.stringify(state));

const fmt = (n) => `${(n / 1e6).toFixed(1)}M`;
Expand Down
6 changes: 6 additions & 0 deletions .agents/scripts/cli-args.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// The one-line option parser the measuring scripts share: `option("--pr")` is the word after
// `--pr` on the command line, or null when it is absent.
export function option(name, args = process.argv.slice(2)) {
const at = args.indexOf(name);
return at === -1 ? null : (args[at + 1] ?? null);
}
14 changes: 1 addition & 13 deletions .agents/scripts/clock.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
// while the computer sleeps (mach_absolute_time on macOS, CLOCK_MONOTONIC on Linux), Date.now()
// does not. A gap under a minute is noise and counts as none. State lives in
// $TMPDIR/claude-clock-<session>.json. Sub-agents get nothing: they print no status lines.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { awakeMs } from "./awake.mjs";

const NOISE_MS = 60_000;

Expand Down Expand Up @@ -54,18 +54,6 @@ console.log(
}),
);

function awakeMs() {
try {
const out = execFileSync("python3", ["-I", "-c", "import time; print(time.monotonic())"], {
encoding: "utf8",
timeout: 2_000,
});
return Number(out) * 1000;
} catch {
return null;
}
}

function minutes(ms) {
return Math.round(ms / 60_000);
}
134 changes: 134 additions & 0 deletions .agents/scripts/delivery-counters.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env node
// `node .agents/scripts/delivery-counters.mjs --since YYYY-MM-DD`: the counters that judge the
// delivery changes in PROCESS-REVIEW-2026-10-21.md (items 4 to 12), one row per PR merged since
// the date, from the PR's tagged runs on this machine and its files on GitHub:
//
// ci lines `ci:` lines the orchestrator put into fix prompts (CI failures sent to a fix run)
// syncs fix runs asked to merge the base or sync the stack
// empty builds build runs that built no cycle, or ended with no report at all
// no-change fix runs whose report fixed nothing (`found 0 fixed 0` on every line)
// Tests lines lines under the issue's `### Tests`
// tests added `it(`/`test(` blocks the PR adds in *.test.ts and *.spec.ts
// test/src LOC lines added to test files over lines added to other .ts files
//
// Unknown values print as `-`. Replays (stage=replay) never count. The repo is GH_REPO, else
// the origin remote's owner/name.
import { execFileSync } from "node:child_process";
import { runOf, taggedRuns } from "./transcripts.mjs";

const args = process.argv.slice(2);
const at = args.indexOf("--since");
const since = at === -1 ? null : args[at + 1];
if (since === null || Number.isNaN(Date.parse(since))) {
console.error("usage: delivery-counters.mjs --since YYYY-MM-DD");
process.exit(64);
}

const repo =
process.env.GH_REPO ??
execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" })
.trim()
.replace(/\.git$/, "")
.replace(/.*[/:]([^/:]+\/[^/]+)$/, "$1");
const api = (path, ...extra) =>
JSON.parse(
execFileSync("gh", ["api", "-X", "GET", `repos/${repo}/${path}`, ...extra], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
}),
);

const merged = [];
for (let page = 1; page <= 5; page += 1) {
const pulls = api(
"pulls",
"-f",
"state=closed",
"-f",
"sort=updated",
"-f",
"direction=desc",
"-f",
"per_page=100",
"-f",
`page=${page}`,
);
for (const p of pulls) if (p.merged_at && p.merged_at >= since) merged.push(p);
if (pulls.length < 100 || pulls.every((p) => (p.updated_at ?? "") < since)) break;
}
merged.sort((a, b) => a.number - b.number);

const runs = taggedRuns().filter(({ tag }) => tag.stage !== "replay");
const rows = [];
for (const p of merged) {
const issue = /^(?:bug|feature|task)\/(\d+)$/.exec(p.head.ref)?.[1] ?? null;
const mine = runs
.filter(({ tag }) => tag.pr === String(p.number) || (issue !== null && tag.issue === issue))
.map(({ file, tag }) => ({ tag, ...runOf(file) }));
const fixes = mine.filter((r) => r.tag.stage === "fix");
const ci = fixes.reduce((n, r) => n + (r.prompt.match(/^\s*-?\s*ci:/gm) ?? []).length, 0);
const syncs = fixes.filter((r) =>
/gh stack sync|merge (?:the |its )?base|stack: /i.test(r.prompt),
).length;
// why: a build that could not start (a base behind main, a missing brief) ends with no report
// at all, which is the other way a build run builds nothing.
const empty = mine.filter(
(r) =>
r.tag.stage === "build" && (r.lastText.trim() === "" || /^cycles:\s*0\//m.test(r.lastText)),
).length;
const noChange = fixes.filter((r) => {
const found = [...r.lastText.matchAll(/-> found (\d+) fixed (\d+)/g)];
return found.length > 0 && found.every((m) => m[2] === "0");
}).length;

let testsLines = null;
if (issue !== null) {
const body = api(`issues/${issue}`).body ?? "";
const section = /^### Tests\s*$([\s\S]*?)(?=^#|$(?![\s\S]))/m.exec(body)?.[1] ?? "";
testsLines = (section.match(/^- /gm) ?? []).length;
}
let testsAdded = 0;
let testLoc = 0;
let srcLoc = 0;
for (let page = 1; page <= 3; page += 1) {
const files = api(`pulls/${p.number}/files`, "-f", "per_page=100", "-f", `page=${page}`);
for (const f of files) {
if (!f.filename.endsWith(".ts")) continue;
if (/\.(test|spec)\.ts$/.test(f.filename)) {
testLoc += f.additions;
testsAdded += (f.patch?.match(/^\+\s*(?:it|test)(?:\.each)?\(/gm) ?? []).length;
} else srcLoc += f.additions;
}
if (files.length < 100) break;
}
rows.push({
pr: p.number,
issue,
runs: mine.length,
ci,
syncs,
empty,
noChange,
testsLines,
testsAdded,
testLoc,
srcLoc,
});
}

const show = (v) => (v === null ? "-" : String(v));
const ratio = (a, b) => (b === 0 || b === null ? "-" : (a / b).toFixed(1));
console.log(`# Delivery counters, PRs merged since ${since} (${rows.length})\n`);
console.log(
"| PR | Issue | Runs | ci lines | syncs | empty builds | no-change fixes | Tests lines | tests added | tests/line | test/src LOC |",
);
console.log("| --- | --- | --: | --: | --: | --: | --: | --: | --: | --: | --: |");
for (const r of rows) {
console.log(
`| #${r.pr} | ${r.issue ? `#${r.issue}` : "-"} | ${r.runs} | ${r.ci} | ${r.syncs} | ${r.empty} | ${r.noChange} | ${show(r.testsLines)} | ${r.testsAdded} | ${ratio(r.testsAdded, r.testsLines)} | ${ratio(r.testLoc, r.srcLoc)} |`,
);
}
const sum = (k) => rows.reduce((n, r) => n + (r[k] ?? 0), 0);
console.log(
`\nTotals: ci lines ${sum("ci")}, syncs ${sum("syncs")}, empty builds ${sum("empty")}, no-change fixes ${sum("noChange")}, tests added ${sum("testsAdded")} over ${sum("testsLines")} Tests lines, test/src LOC ${ratio(sum("testLoc"), sum("srcLoc"))}.`,
);
66 changes: 66 additions & 0 deletions .agents/scripts/instruction-reads.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env node
// `node .agents/scripts/instruction-reads.mjs [--since YYYY-MM-DD] [--role <stage/role>]`: how often
// each role's runs open each instruction file, from Claude Code's transcripts on this machine.
//
// A run is a ledger-tagged transcript (`toolchain.md`, "Review measurement"); its role is
// `<stage>/<role>`. A file counts as opened when any tool call of the run names it: a rule
// under docs/internal/agent-rules/, a brief under .agents/briefs/ (or the rules/ and briefs/
// copies a review's inputs directory holds), REVIEW.md, EXPLORATION.md, a flow doc, or a
// module manual (AGENTS.md). The agent's own definition is not a file it opens: the harness
// loads it. Prints one Markdown table per role: runs, then each file with the runs that
// opened it, most opened first. This is the number that judges a rewrite of the rules, the
// briefs or the router (PROCESS-REVIEW-2026-10-21.md, items 16 to 18).
import { runOf, taggedRuns } from "./transcripts.mjs";
import { option } from "./cli-args.mjs";

const since = option("--since");
const only = option("--role");
const sinceMs = since === null ? 0 : Date.parse(since);
if (since !== null && Number.isNaN(sinceMs)) {
console.error("usage: instruction-reads.mjs [--since YYYY-MM-DD] [--role <stage/role>]");
process.exit(64);
}

const FILE = new RegExp(
String.raw`(?:docs/internal/agent-rules/[\w.-]+\.md|\.agents/briefs/[\w.-]+\.md|(?:^|["'\s/])(?:rules|briefs)/[\w.-]+\.md|review-protocol\.md|docs/internal/REVIEW\.md|docs/internal/EXPLORATION\.md|docs/internal/flows/[\w.-]+\.md|(?:src/[\w/.-]*)?AGENTS\.md)`,
"g",
);

const byRole = new Map();
for (const { file, tag } of taggedRuns()) {
if (tag.stage === "replay") continue;
const role = `${tag.stage}/${tag.role}`;
if (only !== null && role !== only) continue;
const run = runOf(file);
if (Date.parse(run.startedAt ?? "") < sinceMs) continue;
const opened = new Set();
for (const input of run.toolInputs) {
for (const m of input.matchAll(FILE)) {
opened.add(
m[0]
.replace(/^["'\s/]/, "")
.replace(/^(?:rules|briefs)\//, (p) =>
p === "rules/" ? "docs/internal/agent-rules/" : ".agents/briefs/",
),
);
}
}
const entry = byRole.get(role) ?? { runs: 0, files: new Map() };
entry.runs += 1;
for (const f of opened) entry.files.set(f, (entry.files.get(f) ?? 0) + 1);
byRole.set(role, entry);
}

if (byRole.size === 0) {
console.log(`No tagged runs${since ? ` since ${since}` : ""}.`);
process.exit(0);
}
console.log(`# Instruction files opened per role${since ? `, runs since ${since}` : ""}\n`);
for (const [role, { runs, files }] of [...byRole].sort()) {
console.log(`## ${role}: ${runs} runs\n`);
console.log("| File | Runs that opened it |\n| --- | --: |");
for (const [f, n] of [...files].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))) {
console.log(`| ${f} | ${n}/${runs} |`);
}
console.log("");
}
41 changes: 40 additions & 1 deletion .agents/scripts/pr-body-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
// (`skipped` in place of the counts for a review the diff's kind left out) (rule 14);
// - a line under `Rejected:` is not tagged `spec:`, `code:` or `claims:` (rule 14);
// - the body says "spec needs" anywhere outside a code block (rule 15);
// - on a <kind>/<n> branch, `### Status` or `## Assumptions` is missing (coding rule 2).
// - on a <kind>/<n> branch, `### Status` or `## Assumptions` is missing (coding rule 2);
// - on a <kind>/<n> branch, the Review counts line names a review that ran and the PR has no
// comment headed `## Review ledger` (REVIEW.md, "Measurement"); checked only with a PR number.
//
// It warns (exit 0) when the Review section has no `Rounds: <n>. Tokens: <n>M.` line, or the
// prose runs over 200 words: everything outside code blocks, checklist lines and the Status,
Expand All @@ -28,6 +30,7 @@ const args = process.argv.slice(2);
const draftOk = args.includes("--draft-ok");
let body;
let branch = "";
let prNumber = null;
const fileAt = args.indexOf("--body-file");
if (fileAt !== -1) {
body = readFileSync(args[fileAt + 1], "utf8");
Expand All @@ -49,6 +52,7 @@ if (fileAt !== -1) {
);
body = pull.body ?? "";
branch = pull.head.ref;
prNumber = pr;
}
body = body.replace(/\r/g, "");

Expand Down Expand Up @@ -110,6 +114,41 @@ if (/^(bug|feature|task)\/\d+$/.test(branch)) {
}
}

// why: only a delivery PR (<kind>/<n>) has a ledger; a routine's or a person's chore PR gets
// the reviewer's findings comment instead.
if (
prNumber !== null &&
/^(bug|feature|task)\/\d+$/.test(branch) &&
lines.some(
(l) =>
REVIEW_COUNTS.test(l) &&
!/^(Spec|Code|Claims) review: skipped\. (Code|Claims) review: skipped\. Claims review: skipped\.$/.test(
l,
),
)
) {
const comments = JSON.parse(
execFileSync(
"gh",
[
"api",
"-X",
"GET",
`repos/${repository()}/issues/${prNumber}/comments`,
"-f",
"per_page=100",
],
{
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
},
),
);
if (!comments.some((c) => /^## Review ledger\s*$/m.test(c.body ?? ""))) {
failures.push("no comment headed `## Review ledger` (REVIEW.md, Measurement)");
}
}

const words = budgetedWords(lines);
if (words > BUDGET) {
warnings.push(
Expand Down
Loading
Loading