@saadnvd1/agent-os 0.3.470 → 0.3.471

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { MERGED_AS, type MergeMethod } from "../tasks/merge-policy";
11
11
  import { prFor } from "../tasks/session";
12
- import { codeReviewRefusal, signOffTask } from "../tasks";
12
+ import { codeReviewGate, signOffTask } from "../tasks";
13
13
  import { signOffRefusal } from "../stacks/guard";
14
14
  import { getStack, landInBackground } from "../stacks";
15
15
  import { LAND_DEFAULTS } from "../stacks/land";
@@ -91,11 +91,12 @@ export async function judge(
91
91
  fromCard: !!task.lh_card_id,
92
92
  scope: getCheck(task.id, sha, "scope"),
93
93
  stackRefusal: landing ? null : signOffRefusal(task.id),
94
- codeReviewRefusal: codeReviewRefusal(
95
- pr.codeReview,
96
- sha,
97
- restackedOf(task)
98
- ),
94
+ codeReview: codeReviewGate({
95
+ head: sha,
96
+ attribution: pr.attribution,
97
+ review: (at) => getCheck(task.id, at, "review"),
98
+ restacked: restackedOf(task),
99
+ }),
99
100
  }),
100
101
  opts
101
102
  );
@@ -129,7 +130,8 @@ export async function gateVerdict(
129
130
  const held = holdingEscalations(work.id);
130
131
  // Saad approved merging this exact commit. On an ask the orchestrator
131
132
  // raised (ask_saad with its task and sha), held or not, it merges once
132
- // whatever the gates say, but only with a code review of that commit and
133
+ // whatever the gates say, but only once an independent review of that
134
+ // commit finished (its findings are his to override, not that it ran) and
133
135
  // its stack parent merged: the orchestrator wrote that ask, and no answer
134
136
  // to it stands in for those. An approval of the gates' own ask on a held
135
137
  // task merges as it always has (heldVerdict).
@@ -139,7 +141,10 @@ export async function gateVerdict(
139
141
  (approval.raised_by === "orchestrator" || !held.length)
140
142
  ) {
141
143
  const i = await inputs();
142
- const refusal = i.codeReviewRefusal ?? i.stackRefusal;
144
+ const refusal =
145
+ i.codeReview.refusal ??
146
+ (i.codeReview.ran ? null : i.codeReview.wait) ??
147
+ i.stackRefusal;
143
148
  if (refusal)
144
149
  return no(
145
150
  `${work.name} (PR #${pr.number} at ${short(sha)}): Saad approved this commit, but ${refusal}`
@@ -427,7 +427,7 @@ export function renderSummary(
427
427
  )
428
428
  );
429
429
  out.push(
430
- "\nThe reviewer the orchestrator runs (`review`) uses `claude -p --no-session-persistence` and leaves no transcript; reviews are counted from the checks above and from do-code-review subagents."
430
+ "\nThe reviewer the orchestrator runs (`review`) uses `claude -p --no-session-persistence` and leaves no transcript; each run's tokens are recorded in `check_runs` and shown in Usage."
431
431
  );
432
432
  return out.join("\n") + "\n";
433
433
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Land a stack: merge every PR bottom-up, ported from dispatch's `land`.
3
- * A preflight checks EVERY PR before anything merges: CI, and a Code review
4
- * section in its body for its head commit. Each sign-off restacks
3
+ * A preflight checks EVERY PR before anything merges: CI, and a passing
4
+ * independent review of its head commit (tasks/code-review.ts). Each sign-off restacks
5
5
  * what sits on it, so a restacked PR's checks are waited out again (its green
6
6
  * was against the parent's branch). It stops at the first failure and says
7
7
  * what landed. A chat item's agent is stopped between turns before anything
@@ -17,6 +17,8 @@ import {
17
17
  codeReviewRefusal,
18
18
  type TaskPR,
19
19
  } from "../tasks";
20
+ import type { ReviewState } from "../tasks/code-review";
21
+ import { getCheck } from "../orchestrator/checks";
20
22
  import { itemName } from "./guard";
21
23
  import { refreshItems } from "./tick";
22
24
  import { outputOf } from "./git";
@@ -34,6 +36,8 @@ export interface LandDeps {
34
36
  signOff: (sessionId: string, head?: string) => Promise<void>;
35
37
  beforeMerge?: MergeGate;
36
38
  prOf: (session: Session) => Promise<TaskPR | null>;
39
+ // The independent review of a task at a commit.
40
+ reviewOf: (sessionId: string, sha: string) => ReviewState | null;
37
41
  sleep: (ms: number) => Promise<void>;
38
42
  refresh: (stackId: string) => Promise<void>;
39
43
  waitMs: number;
@@ -52,6 +56,7 @@ export const LAND_DEFAULTS: LandDeps = {
52
56
  await signOffTask(id, { wait: true, head });
53
57
  },
54
58
  prOf: (s) => taskPR(s, true),
59
+ reviewOf: (id, sha) => getCheck(id, sha, "review"),
55
60
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
56
61
  refresh: refreshItems,
57
62
  waitMs: 45 * 60_000,
@@ -90,8 +95,20 @@ async function waitGreen(
90
95
  }
91
96
  }
92
97
 
98
+ // Why an item can't merge on its review: none passed of its head, or of
99
+ // the head the task pushed before a restack moved it.
100
+ function unreviewed(item: StackItemRow, pr: TaskPR, deps: LandDeps) {
101
+ return codeReviewRefusal({
102
+ head: pr.head,
103
+ attribution: pr.attribution,
104
+ review: (sha) =>
105
+ item.session_id ? deps.reviewOf(item.session_id, sha) : null,
106
+ restacked: { from: item.restacked_from, to: item.restacked_to },
107
+ });
108
+ }
109
+
93
110
  // Without the orchestrator's gate: the item's head right before merging,
94
- // only if its Code review section covers that commit. A push since the
111
+ // only if a passing review covers that commit. A push since the
95
112
  // preflight needs a new review; the merge is pinned to this head.
96
113
  async function reviewedHead(
97
114
  item: StackItemRow,
@@ -100,12 +117,7 @@ async function reviewedHead(
100
117
  const now = q.item(db, item.id) ?? item;
101
118
  const session = sessionOf(now.session_id);
102
119
  const pr = session ? await deps.prOf(session) : null;
103
- const refusal = pr
104
- ? codeReviewRefusal(pr.codeReview, pr.head, {
105
- from: now.restacked_from,
106
- to: now.restacked_to,
107
- })
108
- : "its PR is gone";
120
+ const refusal = pr ? unreviewed(now, pr, deps) : "its PR is gone";
109
121
  if (refusal) throw new Stop(`stopped at ${itemName(now)}: ${refusal}`);
110
122
  return pr!.head!;
111
123
  }
@@ -157,14 +169,9 @@ export async function landStack(
157
169
  const pr =
158
170
  session && item.status === "pr" ? await deps.prOf(session) : null;
159
171
  const verdict = canSignOff(pr);
160
- const unreviewed =
161
- pr &&
162
- codeReviewRefusal(pr.codeReview, pr.head, {
163
- from: item.restacked_from,
164
- to: item.restacked_to,
165
- });
172
+ const refusal = pr && unreviewed(item, pr, deps);
166
173
  if (!verdict.ok) missing.push(`${itemName(item)}: ${verdict.reason}`);
167
- else if (unreviewed) missing.push(`${itemName(item)}: ${unreviewed}`);
174
+ else if (refusal) missing.push(`${itemName(item)}: ${refusal}`);
168
175
  else if (item.error) missing.push(`${itemName(item)}: ${item.error}`);
169
176
  hadChecks.set(item.id, pr?.checks !== "none");
170
177
  }
@@ -16,18 +16,9 @@ function stackedLines(baseBranch: string, stack: StackedOn): string {
16
16
  return `
17
17
  - This task is STACKED on ${stack.name} (${on}), which has not merged. Its commits are already in your branch and are not yours: don't change or revert them.
18
18
  - Open your pull request against ${baseBranch} (\`gh pr create --base ${baseBranch}\`) so its diff is only your commits, and start the body with the line "Stacked on ${on} (${stack.name})".
19
- - Never merge or rebase onto the default branch yourself. When the parent merges, AgentOS rebases your branch and retargets your PR, then tells you; re-run your checks when it does. Your code review still counts for the head AgentOS's restack left; if you push after that, review again and update the section.${also}`;
19
+ - Never merge or rebase onto the default branch yourself. When the parent merges, AgentOS rebases your branch and retargets your PR, then tells you; re-run your checks when it does. AgentOS reviews your PR itself; a review of your head still counts for the head its restack left, and a push after that gets a new one.${also}`;
20
20
  }
21
21
 
22
- // What the PR body's review section looks like. lib/tasks/code-review.ts
23
- // parses it, and merges refuse a PR without one for its head commit.
24
- export const CODE_REVIEW_SECTION = `
25
- ## Code review
26
- Reviewed: <the full sha of the commit you reviewed, which is the PR's head>
27
- Agents: <the review agents that ran>
28
- Fixed: <each finding fixed, one line each, or "none">
29
- Deferred: <each finding not fixed and why, or "none">`;
30
-
31
22
  // The system brief every task agent runs under. It finishes by opening a PR
32
23
  // and stopping: merging is the human's sign-off, never the agent's.
33
24
  export function buildTaskBrief(opts: {
@@ -39,10 +30,8 @@ export function buildTaskBrief(opts: {
39
30
 
40
31
  - Branch: ${opts.branch} (based on ${opts.baseBranch}). Stay in this directory and on this branch.
41
32
  - Do the task fully. Run the project's own checks (typecheck, tests, lint) when they exist and keep them passing.
42
- - When the work is done, commit with a conventional commit message, then review it before any pull request: run \`/do-code-review\` (the project's skill in .claude/skills/do-code-review; if the project has none, \`/code-review\`). Fix every Blocking and High finding, commit, and review again until none are left (two rounds at most; past that, list what's still open in the PR body).
43
- - Then run \`git push -u origin ${opts.branch}\` and open a pull request against ${opts.baseBranch} with \`gh pr create\`, using a clear title and a short body that says what changed and how you verified it, and ends with this section:
44
- ${CODE_REVIEW_SECTION}
45
- Merges are refused without it, and when its "Reviewed:" commit is not the PR's head: if you push again, review again and update the body (\`gh pr edit --body-file\`). Then stop and wait.
33
+ - When the work is done, commit with a conventional commit message, run \`git push -u origin ${opts.branch}\` and open a pull request against ${opts.baseBranch} with \`gh pr create\`, using a clear title and a short body that says what changed and how you verified it. Then stop and wait.
34
+ - You don't need to review it yourself: AgentOS runs an independent review of the PR's exact head commit before it can merge, and the orchestrator sends you any blocking findings to fix. For a risky change you may still run \`/do-code-review\` (or \`/code-review\`) before the PR.
46
35
  - Never merge the pull request, never push to ${opts.baseBranch}, and never delete branches or worktrees. A human reviews and merges.${opts.stack ? stackedLines(opts.baseBranch, opts.stack) : ""}
47
36
  - Write scratch files (PR bodies, review notes, logs, test output) outside the worktree, in your scratchpad or \$TMPDIR. Anything uncommitted left in the worktree keeps it from being removed when the task is done.
48
37
  - Do not add any AI or assistant attribution to commits or the pull request.
@@ -1,27 +1,13 @@
1
- // The "Code review" section every task's PR body carries (the task brief
2
- // asks for it, after /do-code-review). Merges refuse a PR without one for
3
- // its head commit. No imports: CI runs this file on its own
4
- // (scripts/check-code-review.ts).
5
-
6
- export interface CodeReviewSection {
7
- // The commit the review covered, as written in the section.
8
- sha: string | null;
9
- // A "Restacked: <from> -> <to>" line AgentOS wrote after rebasing the
10
- // branch itself. Only CI reads it: AgentOS's own gates go by what it
11
- // recorded, never by what a body says.
12
- restacked?: Restacked;
13
- // What kind of AI attribution the body carries, when it does: a fixed
14
- // label, never the body's own text, which goes into gate messages.
15
- attribution?: AttributionKind;
16
- }
1
+ // The code-review gate, the same for every PR: a passing independent
2
+ // review (lib/orchestrator/review) of its exact head, and a body with no AI
3
+ // attribution. Tasks don't write a review section in the body any more: the
4
+ // orchestrator's review is the one review.
17
5
 
18
6
  // Matched one line at a time, never across lines: the body is written by
19
7
  // the task, and a regex that backtracks over it would block the server.
20
8
  // Only what renders counts: fenced code and HTML comments are skipped, so an
21
- // example of the section isn't read as the section.
9
+ // example isn't read as the real thing.
22
10
  const MAX_BODY = 65_536;
23
- const HEADING = /^ {0,3}(#{1,6}) *code review *:?$/i;
24
- const ANY_HEADING = /^ {0,3}(#{1,6}) +\S/;
25
11
  const FENCE = /^ {0,3}(`{3,}|~{3,})/;
26
12
  // AI attribution ("Co-Authored-By: Claude", "Generated with [Claude Code]").
27
13
  // The same rule as the commit-msg hook's ATTRIBUTION_ERE in
@@ -45,10 +31,6 @@ function attributionKind(line: string): AttributionKind | undefined {
45
31
  if (/^claude-session:/i.test(line)) return "a Claude-Session link";
46
32
  return 'a "Generated with" AI footer';
47
33
  }
48
- const REVIEWED =
49
- /^[>*_ -]*reviewed(?: (?:commit|sha|at))?[*_ ]*:[*_` ]*([0-9a-f]{12,40})\b/i;
50
- const RESTACKED =
51
- /^[>*_ -]*restacked[*_ ]*:[*_` ]*([0-9a-f]{40})[*_` ]*(?:->|→)[*_` ]*([0-9a-f]{40})\b/i;
52
34
 
53
35
  export function renderedLines(body: string): string[] {
54
36
  let text = body.slice(0, MAX_BODY);
@@ -70,45 +52,7 @@ export function renderedLines(body: string): string[] {
70
52
  return lines;
71
53
  }
72
54
 
73
- // The last Code review section that names a commit (or the last one, when
74
- // none does); null when the body has none. A section runs to the next
75
- // heading of its level or higher, so its own subheadings stay inside it.
76
- export function parseCodeReview(
77
- body: string | null | undefined
78
- ): CodeReviewSection | null {
79
- if (!body) return null;
80
- const sections: CodeReviewSection[] = [];
81
- let level = 0;
82
- let attribution: AttributionKind | undefined;
83
- for (const line of renderedLines(body)) {
84
- attribution ??= attributionKind(line.trimStart());
85
- const heading = HEADING.exec(line);
86
- if (heading) {
87
- level = heading[1].length;
88
- sections.push({ sha: null });
89
- continue;
90
- }
91
- const other = ANY_HEADING.exec(line);
92
- if (other && other[1].length <= level) level = 0;
93
- const current = sections.at(-1);
94
- if (!level || !current) continue;
95
- if (current.sha) {
96
- const moved = RESTACKED.exec(line);
97
- if (moved)
98
- current.restacked = {
99
- from: moved[1].toLowerCase(),
100
- to: moved[2].toLowerCase(),
101
- };
102
- continue;
103
- }
104
- const sha = REVIEWED.exec(line)?.[1];
105
- if (sha) current.sha = sha.toLowerCase();
106
- }
107
- const found = sections.findLast((s) => s.sha) ?? sections.at(-1) ?? null;
108
- return found && attribution ? { ...found, attribution } : found;
109
- }
110
-
111
- // The AI attribution a body carries anywhere, Code review section or not.
55
+ // The AI attribution a body carries anywhere.
112
56
  export function attributionIn(
113
57
  body: string | null | undefined
114
58
  ): AttributionKind | undefined {
@@ -119,57 +63,75 @@ export function attributionIn(
119
63
  }
120
64
  }
121
65
 
122
- // A branch AgentOS rebased itself (a stack restack): the head the task
123
- // pushed and reviewed, and the head the restack left.
66
+ // A branch AgentOS rebased itself (a stack restack, or the automatic
67
+ // rebase onto its base): the head the task pushed, and the head AgentOS
68
+ // left.
124
69
  export interface Restacked {
125
70
  from: string | null;
126
71
  to: string | null;
127
72
  }
128
73
 
129
- // Why a PR can't merge on its code review, or null when it can. Fails
130
- // closed: an unread body or an unknown head refuses. A review of the head
131
- // the task pushed still counts after AgentOS restacked it, but only while
132
- // the head is exactly the one the restack left: a push by the task after
133
- // that needs a new review.
134
- export function codeReviewRefusal(
135
- section: CodeReviewSection | null | undefined,
136
- head: string | undefined,
137
- restacked?: Restacked | null,
138
- fix = "the task has to run /do-code-review and put a Code review section naming the reviewed commit in the PR body"
139
- ): string | null {
140
- if (section === undefined) return `the PR body couldn't be read: ${fix}`;
141
- if (!head) return `the PR's head commit is unknown: ${fix}`;
142
- if (!section) return `the PR body has no Code review section: ${fix}`;
143
- if (section.attribution)
144
- return `the PR body carries AI attribution (${section.attribution}): remove it with \`gh pr edit --body-file\``;
145
- if (!section.sha)
146
- return `the PR's Code review section doesn't name the reviewed commit ("Reviewed: <sha>"): ${fix}`;
147
- const covers = (sha: string | null | undefined) =>
148
- !!sha && sha.toLowerCase().startsWith(section.sha!);
149
- if (covers(head)) return null;
150
- if (restacked?.to === head && covers(restacked.from)) return null;
151
- return `the PR's code review covers ${section.sha.slice(0, 7)}, not its head ${head.slice(0, 7)}: ${fix}`;
74
+ export interface ReviewState {
75
+ sha: string;
76
+ status: "running" | "pass" | "block" | "error";
77
+ }
78
+
79
+ export interface CodeReviewGate {
80
+ // Fails the gate: counts against the task.
81
+ refusal: string | null;
82
+ // Not covered yet, but a review will cover it.
83
+ wait: string | null;
84
+ // A review covering the head finished, passing or not. Saad's approval
85
+ // of the commit overrides what it found, never whether one ran.
86
+ ran?: boolean;
152
87
  }
153
88
 
154
- // The body with its Code review section saying AgentOS rebased the
155
- // reviewed head `from` to `to`: a "Restacked:" line right after the
156
- // section's "Reviewed:" line, replacing one already there. Null when the
157
- // body has no section, or its review isn't of `from`.
158
- export function withRestackedLine(
159
- body: string,
160
- from: string,
161
- to: string
89
+ const short = (sha: string) => sha.slice(0, 7);
90
+
91
+ // Fails closed: an unread body or an unknown head refuses. A review of the
92
+ // head the task pushed still counts after AgentOS restacked or rebased it
93
+ // cleanly, but only while the head is exactly the one AgentOS left: a push
94
+ // by the task after that needs a new review.
95
+ export function codeReviewGate(opts: {
96
+ head: string | undefined;
97
+ // The AI attribution in the PR body (attributionIn): null for none,
98
+ // undefined when the body couldn't be read.
99
+ attribution: AttributionKind | null | undefined;
100
+ review: (sha: string) => ReviewState | null;
101
+ restacked?: Restacked | null;
102
+ }): CodeReviewGate {
103
+ const { head, restacked } = opts;
104
+ const no = (refusal: string) => ({ refusal, wait: null });
105
+ const { attribution } = opts;
106
+ if (attribution === undefined) return no("the PR body couldn't be read");
107
+ if (!head) return no("the PR's head commit is unknown");
108
+ if (attribution)
109
+ return no(
110
+ `the PR body carries AI attribution (${attribution}): remove it with \`gh pr edit --body-file\``
111
+ );
112
+ // The reviews that cover the head: its own, and the one of the head the
113
+ // task pushed while the head is the one AgentOS left.
114
+ const covering = [
115
+ opts.review(head),
116
+ restacked?.to === head && restacked.from
117
+ ? opts.review(restacked.from)
118
+ : null,
119
+ ];
120
+ if (covering.some((r) => r?.status === "pass"))
121
+ return { refusal: null, wait: null, ran: true };
122
+ const now = covering[0];
123
+ return {
124
+ refusal: null,
125
+ wait: `no passing independent review of ${short(head)}${now?.status === "running" ? " yet (it's running)" : now?.status === "block" ? " (it has blocking findings)" : ": call review"}`,
126
+ ran: covering.some((r) => r?.status === "block"),
127
+ };
128
+ }
129
+
130
+ // The gate as one reason, for paths that only merge or refuse (a stack
131
+ // landing, an approved merge): a wait refuses there too.
132
+ export function codeReviewRefusal(
133
+ opts: Parameters<typeof codeReviewGate>[0]
162
134
  ): string | null {
163
- const section = parseCodeReview(body);
164
- if (!section?.sha || !from.toLowerCase().startsWith(section.sha)) return null;
165
- const lines = body.split("\n");
166
- const at = lines.findLastIndex(
167
- (l) =>
168
- REVIEWED.exec(l.replace(/\t/g, " "))?.[1]?.toLowerCase() === section.sha
169
- );
170
- if (at < 0) return null;
171
- const line = `Restacked: ${from} -> ${to} (AgentOS rebased the reviewed commit onto its base; the PR's own changes are the ones reviewed)`;
172
- const next = lines[at + 1]?.replace(/\t/g, " ") ?? "";
173
- lines.splice(at + 1, RESTACKED.test(next) ? 1 : 0, line);
174
- return lines.join("\n");
135
+ const gate = codeReviewGate(opts);
136
+ return gate.refusal ?? gate.wait;
175
137
  }
package/lib/tasks/gh.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import { git } from "../git-exec";
4
- import { parseCodeReview } from "./code-review";
4
+ import { attributionIn } from "./code-review";
5
5
  import { parseScopeChange } from "./scope-change";
6
6
  import { assertGhAllowed, noteGhFailure, noteGhSuccess } from "./gh-limit";
7
7
  import {
@@ -114,7 +114,8 @@ export async function toTaskPR(repoDir: string, pr: ListedPR): Promise<TaskPR> {
114
114
  failing: failingCheck(rollup),
115
115
  failingUrl: failingUrl(rollup),
116
116
  checkCount: (pr.statusCheckRollup ?? []).length,
117
- codeReview: pr.body === undefined ? undefined : parseCodeReview(pr.body),
117
+ attribution:
118
+ pr.body === undefined ? undefined : (attributionIn(pr.body) ?? null),
118
119
  scopeChange: parseScopeChange(pr.body),
119
120
  ...(pr.mergeable && { mergeable: pr.mergeable }),
120
121
  };
@@ -49,7 +49,7 @@ import { chatStateNow } from "../chat/runner";
49
49
  import { saidSinceLastMessage } from "../chat/store";
50
50
 
51
51
  export * from "./state";
52
- export { codeReviewRefusal, parseCodeReview } from "./code-review";
52
+ export { codeReviewGate, codeReviewRefusal } from "./code-review";
53
53
  export { signOffTask, dropTask, signingOff, mergeSettled } from "./finish";
54
54
  export { prFor as taskPR } from "./session";
55
55
  export { moveTask } from "./move-flow";
@@ -1,4 +1,4 @@
1
- import type { CodeReviewSection } from "./code-review";
1
+ import type { AttributionKind } from "./code-review";
2
2
 
3
3
  export type TaskStatus =
4
4
  | "running"
@@ -22,9 +22,9 @@ export interface TaskPR {
22
22
  failingUrl?: string | null;
23
23
  // How many checks are registered on the head.
24
24
  checkCount?: number;
25
- // The body's Code review section: null when it has none, undefined
26
- // when the body wasn't read.
27
- codeReview?: CodeReviewSection | null;
25
+ // The AI attribution the body carries (tasks/code-review.ts): null for
26
+ // none, undefined when the body wasn't read.
27
+ attribution?: AttributionKind | null;
28
28
  // The body's "Scope change" note (tasks/scope-change.ts), when it has one.
29
29
  scopeChange?: string | null;
30
30
  // GitHub's word on merging it into its base: CONFLICTING once the base
@@ -0,0 +1,167 @@
1
+ /**
2
+ * What the orchestrator's one-shot checks cost: each `claude -p` run of the
3
+ * independent reviewer and the scope check, read from its JSON result.
4
+ * They run with --no-session-persistence and leave no transcript, so this
5
+ * is the only record of their tokens.
6
+ */
7
+
8
+ import { db } from "../db";
9
+ import { formatTokens } from "../chat/context";
10
+
11
+ export interface RunUsage {
12
+ model: string | null;
13
+ inputTokens: number;
14
+ cacheWriteTokens: number;
15
+ cacheReadTokens: number;
16
+ outputTokens: number;
17
+ costUsd: number;
18
+ durationMs: number | null;
19
+ }
20
+
21
+ interface ModelUsage {
22
+ inputTokens?: number;
23
+ outputTokens?: number;
24
+ cacheReadInputTokens?: number;
25
+ cacheCreationInputTokens?: number;
26
+ costUSD?: number;
27
+ }
28
+
29
+ const n = (v: unknown) => (typeof v === "number" && v > 0 ? v : 0);
30
+
31
+ // The usage in a `claude -p --output-format json` result, or null when it
32
+ // carries none. Per-model usage covers every model the run used; the model
33
+ // named is the one that cost the most.
34
+ export function runUsageOf(result: unknown): RunUsage | null {
35
+ if (!result || typeof result !== "object") return null;
36
+ const r = result as {
37
+ usage?: {
38
+ input_tokens?: number;
39
+ output_tokens?: number;
40
+ cache_read_input_tokens?: number;
41
+ cache_creation_input_tokens?: number;
42
+ };
43
+ modelUsage?: Record<string, ModelUsage>;
44
+ total_cost_usd?: number;
45
+ duration_ms?: number;
46
+ };
47
+ const models = Object.entries(r.modelUsage ?? {});
48
+ if (!r.usage && !models.length) return null;
49
+ const durationMs = n(r.duration_ms) || null;
50
+ if (models.length) {
51
+ const sum = (k: keyof ModelUsage) =>
52
+ models.reduce((t, [, m]) => t + n(m[k]), 0);
53
+ const [model] = models.reduce((a, b) =>
54
+ n(b[1].costUSD) > n(a[1].costUSD) ? b : a
55
+ );
56
+ return {
57
+ model,
58
+ inputTokens: sum("inputTokens"),
59
+ cacheWriteTokens: sum("cacheCreationInputTokens"),
60
+ cacheReadTokens: sum("cacheReadInputTokens"),
61
+ outputTokens: sum("outputTokens"),
62
+ costUsd: n(r.total_cost_usd) || sum("costUSD"),
63
+ durationMs,
64
+ };
65
+ }
66
+ const u = r.usage!;
67
+ return {
68
+ model: null,
69
+ inputTokens: n(u.input_tokens),
70
+ cacheWriteTokens: n(u.cache_creation_input_tokens),
71
+ cacheReadTokens: n(u.cache_read_input_tokens),
72
+ outputTokens: n(u.output_tokens),
73
+ costUsd: n(r.total_cost_usd),
74
+ durationMs,
75
+ };
76
+ }
77
+
78
+ export interface CheckRunTarget {
79
+ workspaceId: string;
80
+ // The task's session id, or an external PR's id.
81
+ sessionId: string;
82
+ // How the Usage page names it: "task add-a", "PR o/r#5".
83
+ label: string;
84
+ prNumber: number | null;
85
+ sha: string;
86
+ kind: "review" | "scope";
87
+ }
88
+
89
+ export function recordCheckRun(
90
+ target: CheckRunTarget,
91
+ u: RunUsage,
92
+ at = Date.now()
93
+ ): void {
94
+ db.prepare(
95
+ `INSERT INTO check_runs (workspace_id, session_id, label, pr_number, sha, kind, model,
96
+ input_tokens, cache_write_tokens, cache_read_tokens, output_tokens, cost_usd, duration_ms, at)
97
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
98
+ ).run(
99
+ target.workspaceId,
100
+ target.sessionId,
101
+ target.label,
102
+ target.prNumber,
103
+ target.sha,
104
+ target.kind,
105
+ u.model,
106
+ u.inputTokens,
107
+ u.cacheWriteTokens,
108
+ u.cacheReadTokens,
109
+ u.outputTokens,
110
+ u.costUsd,
111
+ u.durationMs,
112
+ at
113
+ );
114
+ }
115
+
116
+ // A recorder for one check, that never lets a write break the check.
117
+ export function checkRunRecorder(target: CheckRunTarget) {
118
+ return (u: RunUsage) => {
119
+ try {
120
+ recordCheckRun(target, u);
121
+ } catch (e) {
122
+ console.error(`[check-runs] recording ${target.label}:`, e);
123
+ }
124
+ };
125
+ }
126
+
127
+ export interface CheckRunTotals {
128
+ runs: number;
129
+ tokens: number;
130
+ costUsd: number;
131
+ durationMs: number;
132
+ models: string[];
133
+ }
134
+
135
+ // Everything the checks of one commit cost, every part and retry.
136
+ export function checkRunTotals(
137
+ sessionId: string,
138
+ sha: string
139
+ ): CheckRunTotals | null {
140
+ const row = db
141
+ .prepare(
142
+ `SELECT count(*) AS runs,
143
+ coalesce(sum(input_tokens + cache_write_tokens + cache_read_tokens + output_tokens), 0) AS tokens,
144
+ coalesce(sum(cost_usd), 0) AS costUsd,
145
+ coalesce(sum(duration_ms), 0) AS durationMs,
146
+ group_concat(DISTINCT model) AS models
147
+ FROM check_runs WHERE session_id = ? AND sha = ?`
148
+ )
149
+ .get(sessionId, sha) as Omit<CheckRunTotals, "models"> & {
150
+ models: string | null;
151
+ };
152
+ if (!row.runs) return null;
153
+ return { ...row, models: row.models ? row.models.split(",") : [] };
154
+ }
155
+
156
+ // "2 runs · 412k tokens · $0.84 · 3m 10s · claude-opus-5-5"
157
+ export function describeCheckRuns(t: CheckRunTotals): string {
158
+ const s = Math.round(t.durationMs / 1000);
159
+ const time = s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;
160
+ return [
161
+ `${t.runs} run${t.runs === 1 ? "" : "s"}`,
162
+ `${formatTokens(t.tokens)} tokens`,
163
+ `$${t.costUsd.toFixed(2)}`,
164
+ time,
165
+ ...t.models,
166
+ ].join(" · ");
167
+ }
@@ -18,3 +18,28 @@ export function usageReport(range: UsageRange, now = Date.now()) {
18
18
  const names = Object.fromEntries(listWorkspaces().map((w) => [w.id, w.name]));
19
19
  return aggregateUsage(rows, range, names, now);
20
20
  }
21
+
22
+ // What the orchestrator's reviewer and scope check cost over the range
23
+ // (lib/usage/check-runs), by PR: a "turn" is one run.
24
+ export function checkRunReport(range: UsageRange, now = Date.now()) {
25
+ const rows = db
26
+ .prepare(
27
+ `SELECT session_id, label, pr_number, workspace_id, at, cost_usd, input_tokens,
28
+ output_tokens, cache_read_tokens, cache_write_tokens
29
+ FROM check_runs WHERE at >= ? ORDER BY at`
30
+ )
31
+ .all(rangeStart(range, now)) as (Omit<TurnRow, "session_name"> & {
32
+ label: string;
33
+ pr_number: number | null;
34
+ })[];
35
+ const named: TurnRow[] = rows.map(({ label, pr_number, ...r }) => ({
36
+ ...r,
37
+ // An external PR's label already names it.
38
+ session_name:
39
+ pr_number === null || label.startsWith("PR ")
40
+ ? label
41
+ : `${label} · PR #${pr_number}`,
42
+ }));
43
+ const names = Object.fromEntries(listWorkspaces().map((w) => [w.id, w.name]));
44
+ return aggregateUsage(named, range, names, now);
45
+ }