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
69 changes: 43 additions & 26 deletions skills/babysit/SKILL.md

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion skills/babysit/references/helper.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@ Use the helper shipped with the modified t3code CLI: `t3code babysit --help`. Di

## Evidence

Readiness is an aid to final verification, never merge authorization. A submitted review must match the full head commit and configured reviewer; it does not establish exhaustive completion. Record corroborating completed-task evidence explicitly. Code review and security checks remain separate. Unbound reactions, missing checks, incomplete pages, failed API reads, unknown mergeability, and stale tested commits cannot establish readiness. All unresolved findings need a recorded decision; later edits or new comments reopen them.
Readiness is an aid to final verification, never merge authorization. A submitted review must match the full head commit and configured reviewer; it does not establish exhaustive completion. Record corroborating completed-task evidence explicitly. Code review and security checks remain separate. Unbound reactions, missing checks, incomplete pages, failed API reads, unknown mergeability, and stale tested commits cannot establish readiness. All unresolved findings need a recorded decision: `fixed`, `refuted`, or `deferred`. Later edits or new comments reopen them.

## Findings and rounds

Each open finding carries `severity`, read from the Codex priority badge (`P0` to `P3`) on the thread's first comment. It is null when there is no badge or when someone other than a configured reviewer opened the thread. The helper keeps that token and no other comment text. Reason details start with the severity, such as `P2 src/app.ts:12`.

`decide --decision deferred` discharges a finding without a code change, so it stops blocking readiness. It accepts several `--finding` ids at once and records all or none. It works on its own for P2 and P3. For P0, P1, and unrated findings it also needs `--user-approved`, which is recorded as `userApproved` on those findings only. The helper cannot verify the approval; pass the flag only after the user gave it. A deferral reopens when the finding's severity changes to one it was not allowed for, such as a P2 that becomes unrated after `init` changes the reviewer.

`codeReview.rounds` counts the commits the configured reviewer submitted a review for. A clean pass that Codex reports only as a comment or reaction is not counted. The skill's round budget uses it. `next` lists actions for reasons agents tend to stall on, such as requesting a review of a repaired head.

## Durable delivery

Expand Down
6 changes: 4 additions & 2 deletions skills/babysit/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ node babysit.mjs <command> --pr owner/repo#N [--state-dir DIR] [options]
| `record --review-request --head SHA --url COMMENT_URL` | Records an `@codex review` request. The URL must point into this PR. |
| `record --review-evidence --head SHA --url SOURCE --note TEXT` | Records corroborating evidence that the review of that exact head is complete. |
| `record --watcher t3-native\|os-schedule\|session-wait\|none [--watcher-id ID] [--cancel-command CMD]` | Records how the watch wakes the agent. |
| `decide --finding THREAD_ID --decision fixed\|refuted --evidence TEXT [--commit SHA]` | Discharges a review thread as it reads now. A later comment or an edit reopens it. |
| `decide --finding THREAD_ID… --decision fixed\|refuted\|deferred --evidence TEXT [--commit SHA] [--user-approved]` | Discharges review threads as they read now, all or none. A later comment or an edit reopens one. Deferring a P0, P1, or unrated finding also needs `--user-approved`. |
| `tick` | One scheduled poll. Reads GitHub, creates an event when there is news, and delivers any due event. It never acknowledges an event. |
| `wait [--timeout 30m] [--interval 60s]` | In-session polling. Prints the next event; printing it counts as delivery. |
| `ack --event ID… [--note TEXT]` | Marks events handled once the agent has finished the work they called for. |
Expand All @@ -33,9 +33,11 @@ node babysit.mjs <command> --pr owner/repo#N [--state-dir DIR] [options]
- No required check is missing, and none was skipped.
- The tested SHA is the head.
- Recorded review evidence names the exact head, and the reviewer has not been active since it was recorded.
- No unresolved thread lacks a matching decision.
- No unresolved thread lacks a matching decision. A deferred finding counts as decided.
- GitHub's merge state is clean.

Open findings report their Codex priority (`P0` to `P3`) as `severity`, null when the thread has no badge or a non-reviewer opened it; only that token is read from comment bodies. `codeReview.rounds` counts the commits the reviewer reviewed, and `next` lists the actions the current reasons call for.

Anything not finished is `pending`. Anything unexplained is `unknown`. A review submitted at the head proves only that a review was submitted. Comments and reactions carry no commit, so they are reported as unbound signals and are never assigned to a head. Readiness is news, not merge approval; skill section 6 still applies.

Recording new review evidence reads a complete live snapshot of the same head and captures its reviewer reactions. A later reaction outside that set withdraws readiness. Repeating a head/source URL keeps its original timestamp, note, and reaction set; a new completed review needs a new source URL.
Expand Down
68 changes: 46 additions & 22 deletions skills/babysit/scripts/babysit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";

import { fetchPullRequestSnapshot, ghRunner } from "./github.mjs";
import { classify, invalidateResolvedDecisions, reviewerReactions, threadVersion } from "./inspect.mjs";
import { classify, DEFERRABLE_SEVERITIES, findingSeverity, invalidateResolvedDecisions, reviewerReactions, threadVersion } from "./inspect.mjs";
import { quotePowerShell, quoteShell, scheduleCommands } from "./schedule.mjs";
import {
describeLock,
Expand Down Expand Up @@ -38,6 +38,7 @@ const RATE_LIMIT_FLOOR = 100;
const RATE_LIMIT_FALLBACK_MS = 15 * 60_000;
const WATCHERS = new Set(["t3-native", "os-schedule", "session-wait", "none"]);
const STOP_REASONS = new Set(["merged", "closed", "cancelled"]);
const DECISIONS = new Set(["fixed", "refuted", "deferred"]);
const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})(?:\[bot\])?$/u;

const OPTIONS = {
Expand All @@ -64,8 +65,9 @@ const OPTIONS = {
watcher: { type: "string" },
"watcher-id": { type: "string" },
"cancel-command": { type: "string" },
finding: { type: "string" },
finding: { type: "string", multiple: true },
decision: { type: "string" },
"user-approved": { type: "boolean" },
evidence: { type: "string" },
commit: { type: "string" },
event: { type: "string", multiple: true },
Expand All @@ -82,7 +84,7 @@ const ALLOWED = {
init: [...COMMON, ...POLICY, "thread", "wake-settled", "t3code-cli", "gh", "task", "cwd", "branch"],
inspect: [...COMMON, ...POLICY, "gh"],
record: [...COMMON, "tested", "result", "command", "review-request", "review-evidence", "head", "url", "note", "watcher", "watcher-id", "cancel-command"],
decide: [...COMMON, "finding", "decision", "evidence", "commit"],
decide: [...COMMON, "finding", "decision", "evidence", "commit", "user-approved"],
tick: COMMON,
wait: [...COMMON, "timeout", "interval"],
ack: [...COMMON, "event", "note"],
Expand Down Expand Up @@ -569,8 +571,10 @@ async function record(values, deps, context) {
}

async function decide(values, deps, context) {
const findingId = text(values.finding, "--finding", 300);
if (values.decision !== "fixed" && values.decision !== "refuted") throw usage("DECISION_REQUIRED", "--decision must be fixed or refuted.");
const findingIds = [...new Set((values.finding ?? []).map((finding) => text(finding, "--finding", 300)))];
if (findingIds.length === 0) throw usage("VALUE_REQUIRED", "--finding needs a review thread id.");
if (!DECISIONS.has(values.decision)) throw usage("DECISION_REQUIRED", "--decision must be fixed, refuted, or deferred.");
if (values["user-approved"] && values.decision !== "deferred") throw usage("INVALID_USAGE", "--user-approved applies only to --decision deferred.");
const evidence = text(values.evidence, "--evidence", 2000);
const commit = values.commit === undefined ? null : requireFullSha(values.commit, "--commit");

Expand All @@ -583,30 +587,49 @@ async function decide(values, deps, context) {
details: { error: snapshot.error ?? null },
});
}
const thread = snapshot.pr.threads.find((candidate) => candidate.id === findingId);
if (!thread) {
throw new BabysitError("FINDING_NOT_FOUND", `${displayRef(context.ref)} has no review thread ${findingId}.`, { exitCode: 3, details: { finding: findingId } });
}
// Check every thread before recording any, so a batch is all or nothing.
const threads = findingIds.map((findingId) => {
const thread = snapshot.pr.threads.find((candidate) => candidate.id === findingId);
if (!thread) {
throw new BabysitError("FINDING_NOT_FOUND", `${displayRef(context.ref)} has no review thread ${findingId}.`, { exitCode: 3, details: { finding: findingId } });
}
const severity = findingSeverity(thread, state.policy);
if (values.decision === "deferred" && !DEFERRABLE_SEVERITIES.has(severity) && !values["user-approved"]) {
throw new BabysitError(
"DEFERRAL_NEEDS_USER",
`Review thread ${findingId} is ${severity ?? "unrated"}. Fix it, refute it, or defer it only after the user approves, with --user-approved.`,
{ exitCode: 4, details: { finding: findingId, severity } },
);
}
return thread;
});
// The decision covers the thread exactly as it reads now; a later or edited comment reopens it.
invalidateResolvedDecisions(snapshot, state, iso(deps.now()));
const decision = {
decision: values.decision,
evidence,
commit,
at: iso(deps.now()),
threadVersion: threadVersion(thread),
isResolved: thread.isResolved,
comments: thread.comments.length,
headSha: snapshot.pr.headSha,
url: thread.comments[0]?.url ?? null,
};
state.findingDecisions ??= {};
state.findingDecisions[findingId] = decision;
const findings = threads.map((thread) => {
const decision = {
decision: values.decision,
evidence,
commit,
severity: findingSeverity(thread, state.policy),
// Only findings that needed approval carry it, so the report never claims an approval nobody gave.
...(values["user-approved"] && !DEFERRABLE_SEVERITIES.has(findingSeverity(thread, state.policy)) ? { userApproved: true } : {}),
at: iso(deps.now()),
threadVersion: threadVersion(thread),
isResolved: thread.isResolved,
comments: thread.comments.length,
headSha: snapshot.pr.headSha,
url: thread.comments[0]?.url ?? null,
};
state.findingDecisions[thread.id] = decision;
return { threadId: thread.id, ...decision };
});
await save(context, state, lock, deps);
return {
data: {
statePath: context.statePath,
finding: { threadId: findingId, isResolved: thread.isResolved, ...decision },
...(findings.length === 1 ? { finding: findings[0] } : {}),
findings,
note: "Reply in the thread before deciding: any later comment or edit on the thread reopens this finding.",
},
};
Expand Down Expand Up @@ -899,6 +922,7 @@ export async function main(argv, overrides = {}) {
"record requires one of --tested, --review-request, --review-evidence, or --watcher. Tests need --result and --command; review evidence needs --head, --url and --note.",
"wait has a finite timeout. tick sends only changed news and retries ambiguous delivery with the original key and text.",
"schedule-command prints registration/cancellation commands; it does not start a schedule.",
"decide takes one or more --finding ids. --decision deferred works on its own for P2/P3 findings; P0, P1, and unrated findings also need --user-approved.",
"Delivered events require explicit ack --event. Readiness never authorizes a merge."] };
const envelope = { ok: true, data };
deps.stdout(`${JSON.stringify(envelope, null, 2)}\n`);
Expand Down
12 changes: 12 additions & 0 deletions skills/babysit/scripts/github.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ function normalizeContext(node) {
}

// Bodies are hashed, not kept: edits stay detectable, and no comment text reaches state or wake messages.
// The one exception is a review comment's priority badge, reduced to P0-P3.
function normalizeReview(node) {
return {
id: node.id,
Expand All @@ -196,13 +197,24 @@ function normalizeReview(node) {
};
}

/**
* The priority badge a Codex review comment opens with, such as `![P2 Badge](…)`: P0 to P3, or null.
* Only an opening badge counts, inside the bold and `<sub>` wrappers Codex puts around it. A badge quoted
* in prose, a blockquote, or code does not rate the finding. Only this token is kept, never the text around it.
*/
export function reviewSeverity(body) {
const match = /^\s*(?:(?:\*\*|__|<(?:sub|sup|b|strong)>)\s*)*!\[(P[0-3]) Badge\]\(/u.exec(String(body ?? ""));
return match ? match[1] : null;
}

function normalizeComment(node) {
return {
id: node.id,
author: node.author?.login ?? null,
createdAt: node.createdAt ?? null,
lastEditedAt: node.lastEditedAt ?? null,
bodySha256: sha256(node.body ?? ""),
severity: reviewSeverity(node.body),
url: node.url ?? null,
};
}
Expand Down
56 changes: 53 additions & 3 deletions skills/babysit/scripts/inspect.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,48 @@ const FAILED_CONCLUSIONS = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION
const SKIPPED_CONCLUSIONS = new Set(["SKIPPED", "NEUTRAL"]);
const SUBMITTED_REVIEW_STATES = new Set(["COMMENTED", "APPROVED", "CHANGES_REQUESTED"]);
const MERGEABLE_STATES = new Set(["CLEAN", "HAS_HOOKS"]);
/** Reviewer priorities an agent may defer on its own. P0, P1, and unrated findings need the user. */
export const DEFERRABLE_SEVERITIES = new Set(["P2", "P3"]);

/** What the agent should do about a reason, for the reasons agents have stalled on. */
const TRIAGE =
"Triage open findings by severity: fix P0/P1; fix a P2/P3 only when you already push a commit for another reason and the fix is small and safe, otherwise decide --decision deferred.";
Comment on lines +17 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escalate P0/P1 findings after the fourth review round

When codeReview.rounds reaches four and the latest review still has an open P0/P1, nextActions continues to emit the unconditional instruction to fix it. This conflicts with the newly documented round budget, which requires stopping and asking the user before another push, and SKILL.md explicitly tells agents to perform the reported next actions. Include the round count and open severities when selecting this action so the wake directs the agent to escalate instead of beginning a fifth repair cycle.

Useful? React with 👍 / 👎.

Comment on lines +17 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include unrated and refuted findings in triage guidance

When the only open finding is unrated, or a P0/P1 is a false positive, this next action provides no applicable route to clear the gate: it mentions only fixing P0/P1 and fixing or deferring P2/P3, despite decide supporting refutation and requiring user approval to defer unrated findings. Since the skill directs agents to perform reported next actions before yielding, the guidance can stall on an unrated finding or prompt an unnecessary code change for a refutable blocking finding; explicitly cover refutation and the user-approved deferral path for P0/P1 and unrated findings.

Useful? React with 👍 / 👎.

const NEXT_ACTIONS = {
REVIEW_NOT_REQUESTED:
"No review of this head is submitted or requested. Unless the automatic review on PR open is still running, comment `@codex review` once now and record it with record --review-request.",
REVIEW_NOT_REQUESTED_REPAIRED:
"This repaired head has no review and none is requested, and a push does not trigger one. Comment `@codex review` once now and record it with record --review-request.",
REVIEW_COMPLETION_UNCORROBORATED: "A review of this head was submitted. Confirm the review task finished, then record --review-evidence.",
FINDING_UNRESOLVED: TRIAGE,
FINDING_REOPENED: TRIAGE,
TESTED_HEAD_MISSING: "Run the verification commands at this head and record --tested.",
TESTED_HEAD_MISMATCH: "Run the verification commands at this head and record --tested.",
BRANCH_BEHIND: "Update the branch from its base, verify, and push.",
CONFLICTS: "Resolve the conflicts with the base, verify, and push.",
};

function nextActions(reasons, repairedHead) {
return [...new Set(reasons.map((reason) => NEXT_ACTIONS[reason.code === "REVIEW_NOT_REQUESTED" && repairedHead ? "REVIEW_NOT_REQUESTED_REPAIRED" : reason.code]).filter(Boolean))];
}

/**
* A finding's priority: the badge on its first comment, and only when a configured reviewer wrote it.
* A badge quoted by anyone else does not rate the finding.
*/
export function findingSeverity(thread, policy) {
const first = thread.comments[0];
const reviewers = new Set((policy?.codeReviewers ?? []).map(loginKey));
return first && reviewers.has(loginKey(first.author)) ? (first.severity ?? null) : null;
}

/**
* Whether a recorded deferral would still be allowed at the finding's current severity. A policy or badge
* change can make a deferred P2 unrated or P1; the deferral then needs the user's approval for that severity.
*/
function deferralHolds(decision, severity) {
if (decision.decision !== "deferred" || DEFERRABLE_SEVERITIES.has(severity)) return true;
return decision.userApproved === true && (decision.severity ?? null) === severity;
}

/** pass, fail, skipped, pending, or unknown. Anything not finished is pending, never complete. */
export function checkOutcome(check) {
Expand Down Expand Up @@ -87,6 +129,7 @@ function emptySections(state, headSha) {
completeness: null,
caveat: REVIEW_CAVEAT,
reviewers: [...(state.policy?.codeReviewers ?? [])],
rounds: null,
reviewsAtHead: [],
staleReviews: [],
requestsAtHead: [],
Expand All @@ -96,6 +139,7 @@ function emptySections(state, headSha) {
securityReview: { state: "unknown", checks: [] },
findings: { open: [], discharged: [] },
tested: testedView(state, headSha),
next: [],
};
}

Expand Down Expand Up @@ -279,8 +323,11 @@ export function classify(snapshot, state) {
)) add("unknown", "REVIEW_REACTIONS_AFTER_EVIDENCE");
}
const reviewState = reviewsAtHead.length > 0 ? "submitted" : requestsAtHead.length > 0 ? "requested" : unboundSignals.length > 0 ? "unbound_signal" : "none";
// One round per commit the reviewer submitted a review for.
const rounds = new Set(reviewerReviews.map((review) => review.commitSha).filter(Boolean)).size;

// Findings: every unresolved thread blocks until a decision matches its current content.
// Findings: every unresolved thread blocks until a decision (fixed, refuted, or deferred) matches its current content,
// and a deferral also its current severity.
const decisions = state.findingDecisions ?? {};
const open = [];
const discharged = [];
Expand All @@ -291,6 +338,7 @@ export function classify(snapshot, state) {
const entry = {
threadId: thread.id,
author: first?.author ?? null,
severity: findingSeverity(thread, policy),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revalidate deferrals after reviewer policy changes

When a P2/P3 finding is deferred and init is later rerun with a different --code-reviewer, the thread becomes unrated under the current policy, but classification still accepts the old decision solely because its threadVersion matches. The PR can therefore remain ready without the --user-approved authorization now required for that finding; deferred decisions should be invalidated or reauthorized when their recorded severity no longer matches the current severity.

Useful? React with 👍 / 👎.

path: thread.path,
line: thread.line,
isOutdated: thread.isOutdated,
Expand All @@ -300,12 +348,12 @@ export function classify(snapshot, state) {
version,
};
const decision = decisions[thread.id];
if (decision && !decision.invalidatedAt && decision.isResolved !== true && decision.threadVersion === version) {
if (decision && !decision.invalidatedAt && decision.isResolved !== true && decision.threadVersion === version && deferralHolds(decision, entry.severity)) {
discharged.push({ ...entry, decision: decision.decision, evidence: decision.evidence, commit: decision.commit ?? null });
} else {
open.push({ ...entry, reopened: Boolean(decision) });
const where = thread.path ? `${thread.path}${thread.line ? `:${thread.line}` : ""}${thread.isOutdated ? " (outdated)" : ""}` : null;
add("blocked", decision ? "FINDING_REOPENED" : "FINDING_UNRESOLVED", thread.id, where);
add("blocked", decision ? "FINDING_REOPENED" : "FINDING_UNRESOLVED", thread.id, [entry.severity ?? "unrated", where].filter(Boolean).join(" "));
}
}

Expand Down Expand Up @@ -383,6 +431,7 @@ export function classify(snapshot, state) {
completeness: evidenceAtHead.length > 0 ? "agent_recorded_evidence" : reviewsAtHead.length > 0 ? "submitted_review_at_head" : null,
caveat: REVIEW_CAVEAT,
reviewers: [...(policy.codeReviewers ?? [])],
rounds,
reviewsAtHead: reviewsAtHead.map(reviewView),
staleReviews: staleReviews.map(reviewView),
requestsAtHead,
Expand All @@ -396,6 +445,7 @@ export function classify(snapshot, state) {
},
findings: { open, discharged },
tested: testedView(state, head),
next: nextActions(reasons, staleReviews.length > 0),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include next-action changes in the wake key

When a clean review arrives for an older commit while the current head remains unreviewed, staleReviews.length changes this action from the opening-review guidance to the repaired-head instruction to request @codex review, but summary contains neither next nor stale reviews. Because recordObservation suppresses wakes when the summary-derived newsKey is unchanged, a durable watcher emits no event in this scenario and the agent can continue waiting indefinitely instead of requesting review of the current head; include the action-driving state in the summary/news key.

Useful? React with 👍 / 👎.

summary,
newsKey: hashSummary(summary),
fingerprint: sha256(
Expand Down
Loading
Loading