Public: anyone can read this; the crew can change it.

An agent's own comment must never start its next run: the direction issue looped 27 times tonight #402

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/383-an-agent-s-own-comment-must-never-start → main 2 commits fork

  • Merged. scout/383-an-agent-s-own-comment-must-never-start was merged into main as b9982c4.

Review

Sign in to review this change.
8 files changed +370 −15
src/crew/autonomy.ts +58
@@ -43,6 +43,7 @@ import { wallOf, wallSentence } from "../contract/policy.ts";
4343 import {
4444 getIssue,
4545 getIssueById,
46 listComments,
4647 MAX_TITLE,
4748 type Issue,
4849 } from "../core/issues.ts";
@@ -77,6 +78,7 @@ import { getUserById, type User } from "../core/users.ts";
7778 import { grantedAccounts } from "../core/subscriptions.ts";
7879 import { standing } from "../core/standing.ts";
7980 import { pace, type Pace } from "./pacing.ts";
81 import { BREAKER_NOTE, breakerTripped } from "./breaker.ts";
8082 import { createLog } from "../kernel/log.ts";
8183
8284 const log = createLog("autonomy");
@@ -421,7 +423,13 @@ async function planIfThin(
421423 ORDER BY created_at DESC LIMIT 1`,
422424 )
423425 .get(direction.id);
426 if (breakerTripped(app.db, direction.id, now))
427 return notPlanned(repo, "parked: three failed runs in an hour");
424428 if (lastPlan !== null && lastPlan !== undefined) {
429 // A planner that declined the direction is not asked again until a
430 // person has said something on it.
431 if (lastPlan.state === "failed" && declinedUnanswered(app, direction))
432 return notPlanned(repo, "the planner declined; waiting for a person");
425433 if (lastPlan.state === "queued" || lastPlan.state === "running")
426434 return notPlanned(repo, "a planning run is in flight");
427435 if (
@@ -479,6 +487,48 @@ function stalled(app: App, issue: Issue): boolean {
479487 return (spoken?.n ?? 0) === 0;
480488 }
481489
490 /** Whether the newest run on the issue was a decline and no person has spoken since. */
491 function declinedUnanswered(app: App, issue: Issue): boolean {
492 const last = app.db
493 .query<{ summary: string; created_at: string }, [string]>(
494 `SELECT summary, created_at FROM run WHERE issue_id = ?1 AND kind = 'agent'
495 ORDER BY created_at DESC LIMIT 1`,
496 )
497 .get(issue.id);
498 if (!last || !last.summary.startsWith("declined")) return false;
499 const spoken = app.db
500 .query<{ n: number }, [string, string]>(
501 `SELECT COUNT(*) AS n FROM comment c JOIN user u ON u.id = c.author_id
502 WHERE c.issue_id = ?1 AND c.created_at > ?2 AND u.is_agent = 0
503 AND c.body NOT LIKE 'Parked by the crew%'`,
504 )
505 .get(issue.id, last.created_at);
506 return (spoken?.n ?? 0) === 0;
507 }
508
509 /**
510 * The breaker trips: the assignment is cleared and one note says why, once.
511 * The map reads the note as `parked`; the board's "needs you" lists it.
512 */
513 export function parkForBreaker(
514 app: App,
515 repo: Repo,
516 issue: Issue,
517 agent: Agent,
518 ): void {
519 const author =
520 (repo.autonomyBy === null ? undefined : getUserById(app.db, repo.autonomyBy)) ??
521 agent.user;
522 if (issue.assigneeId !== null) assignIssue(app, author, repo, issue, null);
523 const last = listComments(app.db, issue.id).at(-1);
524 if (last?.body === BREAKER_NOTE) return;
525 addComment(app, author, repo, issue, BREAKER_NOTE);
526 log.info("parked by the breaker", {
527 repo: `${repo.ownerName}/${repo.name}`,
528 issue: issue.number,
529 });
530 }
531
482532 /** How many times the crew has parked work on this issue. */
483533 export function parkedCount(app: App, issueId: string): number {
484534 const row = app.db
@@ -531,6 +581,14 @@ export function settleAgentRun(
531581 ): boolean {
532582 if (repo.autonomy !== "autonomous") return false;
533583 if (run.state !== "ok" && run.state !== "failed") return false;
584 // A planner is done with the direction once its run ends, whichever way:
585 // a stale assignment must not start another.
586 if (run.issueId === repo.directionIssueId && repo.autonomyBy !== null) {
587 const direction = getIssueById(app.db, run.issueId);
588 const by = getUserById(app.db, repo.autonomyBy);
589 if (direction?.assigneeId && by) assignIssue(app, by, repo, direction, null);
590 return false;
591 }
534592 if (run.reason === "review") return false;
535593 if (/opened pull #|updated pull #/.test(run.summary)) return false;
536594 const issue = getIssueById(app.db, run.issueId);
src/crew/breaker.ts added +67
@@ -0,0 +1,67 @@
1 /**
2 * The circuit breaker: an issue whose last few runs all failed or were
3 * declined, within an hour, is parked and not dispatched again until a
4 * person speaks on it. A run costs money; the same failure three times in
5 * an hour is a question, not a retry.
6 */
7
8 import type { Database } from "bun:sqlite";
9
10 export const BREAKER_RUNS = 3;
11 export const BREAKER_WINDOW_MS = 60 * 60_000;
12
13 /** What the parking note starts with: the map reads it as `parked`. */
14 export const BREAKER_NOTE =
15 "Parked by the crew: the last three runs here failed or were declined within an hour, so nothing more starts until a person says what to try.";
16
17 /** Whether the issue's newest runs are all failures inside the window and no person has spoken since. */
18 export function breakerTripped(
19 db: Database,
20 issueId: string,
21 now = new Date(),
22 ): boolean {
23 const runs = db
24 .query<{ state: string; created_at: string }, [string, number]>(
25 `SELECT state, created_at FROM run WHERE issue_id = ?1 AND kind = 'agent'
26 ORDER BY created_at DESC LIMIT ?2`,
27 )
28 .all(issueId, BREAKER_RUNS);
29 if (runs.length < BREAKER_RUNS) return false;
30 if (runs.some((r) => r.state !== "failed")) return false;
31 const newest = runs[0]!.created_at;
32 const oldest = runs[runs.length - 1]!.created_at;
33 if (new Date(newest).getTime() - new Date(oldest).getTime() > BREAKER_WINDOW_MS)
34 return false;
35 if (now.getTime() - new Date(newest).getTime() > BREAKER_WINDOW_MS)
36 return false;
37 return !spokenSince(db, issueId, oldest);
38 }
39
40 /** A person's word (not an agent's, not the crew's own parking note) after `since`. */
41 function spokenSince(db: Database, issueId: string, since: string): boolean {
42 const row = db
43 .query<{ n: number }, [string, string]>(
44 `SELECT COUNT(*) AS n FROM comment c JOIN user u ON u.id = c.author_id
45 WHERE c.issue_id = ?1 AND c.created_at > ?2 AND u.is_agent = 0
46 AND c.body NOT LIKE 'Parked by the crew%'`,
47 )
48 .get(issueId, since);
49 return (row?.n ?? 0) > 0;
50 }
51
52 /** Open issues whose newest comment is the breaker's note: the ones that wait for a person. */
53 export function breakerParked(
54 db: Database,
55 repoIds: readonly string[],
56 ): { issueId: string; repoId: string }[] {
57 if (repoIds.length === 0) return [];
58 const marks = repoIds.map(() => "?").join(",");
59 return db
60 .query<{ issueId: string; repoId: string }, string[]>(
61 `SELECT i.id AS issueId, i.repo_id AS repoId FROM issue i
62 WHERE i.state = 'open' AND i.repo_id IN (${marks})
63 AND (SELECT c.body FROM comment c WHERE c.issue_id = i.id
64 ORDER BY c.created_at DESC, c.id DESC LIMIT 1) = ?${repoIds.length + 1}`,
65 )
66 .all(...repoIds, BREAKER_NOTE);
67 }
src/crew/dispatch.ts +7
@@ -41,11 +41,13 @@ import { createLog } from "../kernel/log.ts";
4141 import { issueOfBranch, type RunReason } from "./brief.ts";
4242 import { detectClaude } from "./claude.ts";
4343 import { followDivergence } from "./converge.ts";
44 import { breakerTripped } from "./breaker.ts";
4445 import { sandboxAvailable } from "./sandbox.ts";
4546 import {
4647 AUTONOMY_TICK_MS,
4748 autonomyTick,
4849 landIfGreen,
50 parkForBreaker,
4951 parkPull,
5052 settleAgentRun,
5153 spentToday,
@@ -223,6 +225,11 @@ export function queueAgentRun(
223225 });
224226 return undefined;
225227 }
228 // Three failed or declined runs in an hour: parked, until a person speaks.
229 if (breakerTripped(app.db, issue.id)) {
230 parkForBreaker(app, repo, issue, agent);
231 return undefined;
232 }
226233 const active = activeRunFor(app.db, "agent", issue.id);
227234 // One queued follow-up is enough: the run that eventually starts reads
228235 // the whole conversation since the last one, so nothing is lost. The
src/crew/runner.ts +3 −8
@@ -855,14 +855,9 @@ async function work(
855855 if (declined) {
856856 const why = result.result.replace(/^\s*DECLINED:?\s*/i, "").trim();
857857 write(`declined: ${why.split("\n")[0] ?? ""}`);
858 const target = pull ?? issue;
859 addComment(
860 app,
861 agent.user,
862 repo,
863 target,
864 `Declined: ${why || "no reason given"}`,
865 );
858 // The reason lives on the run record (its summary) and the ledger
859 // line, never as a comment: a comment on an assigned issue is the
860 // thing that starts the next run.
866861 return {
867862 state: "failed",
868863 summary: `declined: ${(why.split("\n")[0] ?? "").slice(0, 200)}`,
src/feed/items.ts +2 −2
@@ -50,7 +50,7 @@ const clip = (s: string, n = 120): string =>
5050 const slug = (r: Repo): string => `${r.ownerName}/${r.name}`;
5151 const base = (r: Repo): string => `/${r.ownerName}/${r.name}`;
5252
53 const URGENCY = { stopped: 10, look: 8, paused: 6 } as const;
53 const URGENCY = { stopped: 10, look: 8, parked: 7, paused: 6 } as const;
5454
5555 export function candidatesFor(
5656 db: Database,
@@ -106,7 +106,7 @@ export function candidatesFor(
106106 link:
107107 a.number === undefined
108108 ? `${base(repo)}/settings/autonomy`
109 : `${base(repo)}/changes/${a.number}`,
109 : `${base(repo)}/${a.kind === "parked" ? "issues" : "changes"}/${a.number}`,
110110 what: a.line,
111111 urgency: URGENCY[a.kind],
112112 });
src/web/studio/asks.ts +38 −1
@@ -14,6 +14,7 @@ import { appsRuntime } from "../../apps/runtime.ts";
1414 import { appsForRepos, getAppByRepo, listApps } from "../../core/apps.ts";
1515 import { latestDecision } from "../../core/decisions.ts";
1616 import {
17 getIssueById,
1718 listComments,
1819 listOpenPullsVisibleTo,
1920 type Issue,
@@ -34,6 +35,7 @@ import {
3435 } from "../../core/runs.ts";
3536 import type { User } from "../../core/users.ts";
3637 import { isPausedAtCeiling, spentTodayForRepos } from "../../crew/autonomy.ts";
38 import { breakerParked } from "../../crew/breaker.ts";
3739 import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts";
3840 import type { Ctx } from "../../http/context.ts";
3941 import { vitals } from "../../kernel/vitals.ts";
@@ -79,7 +81,7 @@ export function writableAppsOf(ctx: Ctx, user: User | undefined) {
7981 );
8082 }
8183
82 export type DecisionKind = "stopped" | "look" | "paused";
84 export type DecisionKind = "stopped" | "look" | "paused" | "parked";
8385
8486 /** Dollars as a person reads them: cents when small, whole when not. */
8587 const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0));
@@ -91,6 +93,8 @@ export interface Decision {
9193 readonly id: string;
9294 readonly repo: Repo;
9395 readonly pull?: Pull | undefined;
96 /** For "parked": the issue the breaker stopped. */
97 readonly issue?: Issue | undefined;
9498 readonly who: string;
9599 /** Three sentences at most: what, why, what happens next. */
96100 readonly says: string;
@@ -123,6 +127,7 @@ interface AskCandidate {
123127 readonly kind: DecisionKind;
124128 readonly repo: Repo;
125129 readonly pull?: Pull;
130 readonly issue?: Issue;
126131 readonly who: string;
127132 /** Whether the author is a crew member — a "look" card words itself
128133 * differently for a person's own change than for the crew's (below).
@@ -222,6 +227,15 @@ function askCandidatesFor(
222227 for (const repo of repos)
223228 if (isPausedAtCeiling(repo, crew, spent))
224229 out.push({ kind: "paused", repo, who: "the crew" });
230 // An issue the breaker parked waits for a person's word.
231 for (const p of breakerParked(
232 db,
233 repos.map((r) => r.id),
234 )) {
235 const issue = getIssueById(db, p.issueId);
236 const repo = byId.get(p.repoId);
237 if (issue && repo) out.push({ kind: "parked", repo, issue, who: "the crew" });
238 }
225239 return out;
226240 }
227241
@@ -251,6 +265,13 @@ export function askLinesFor(
251265 id: `paused:${c.repo.id}`,
252266 line: `${repo} is paused at its ceiling`,
253267 };
268 if (c.kind === "parked")
269 return {
270 ...about,
271 number: c.issue!.number,
272 id: `parked:${c.issue!.id}`,
273 line: `${repo}: the crew parked #${c.issue!.number} after three failed runs`,
274 };
254275 const title = c.pull?.title ?? "";
255276 const number = c.pull!.number;
256277 return c.kind === "stopped"
@@ -291,6 +312,16 @@ export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] {
291312 );
292313 };
293314 return candidates.map((c): Decision => {
315 if (c.kind === "parked" && c.issue)
316 return {
317 kind: "parked",
318 id: `parked:${c.issue.id}`,
319 repo: c.repo,
320 issue: c.issue,
321 who: c.who,
322 says: `Three runs on #${c.issue.number} failed or were declined within an hour, so the crew parked it. Say what to try and it starts again.`,
323 short: `#${c.issue.number} parked after three failed runs`,
324 };
294325 if (c.kind === "stopped" && c.pull) {
295326 const tries = c.tries ?? 0;
296327 const stopped = c.stoppedByDecider ?? false;
@@ -409,6 +440,12 @@ export function decisionActions(ctx: Ctx, d: Decision): Html {
409440 href: `${changeHref(base, pull.number)}#comment-body`,
410441 size: "sm",
411442 })}`;
443 if (d.kind === "parked" && d.issue)
444 return html`${btn("Tell them", {
445 href: `${base}/issues/${d.issue.number}#comment-body`,
446 size: "sm",
447 variant: "primary",
448 })}`;
412449 return html`${btn("Settings", {
413450 href: `${base}/settings/autonomy`,
414451 size: "sm",
test/crew-loop.test.ts added +193
@@ -0,0 +1,193 @@
1 /**
2 * Tonight's loop: the planner declines the direction, and the decline starts
3 * the next run, 27 times. An agent's own words start nothing; a declined run
4 * leaves no comment; a finished planning run lets go of the direction; three
5 * failed runs in an hour park the issue until a person speaks.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { addComment, openIssue } from "../src/actions/issues.ts";
14 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
15 import { setCrewRole } from "../src/core/agents.ts";
16 import { getIssueById, listComments, type Issue } from "../src/core/issues.ts";
17 import { getRepoById, type Repo } from "../src/core/repos.ts";
18 import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts";
19 import { createUser, getUserByName, type User } from "../src/core/users.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { driveRepo, settleAgentRun } from "../src/crew/autonomy.ts";
22 import { startCrew, queueAgentRun, type CrewHandle } from "../src/crew/dispatch.ts";
23 import { getAgent } from "../src/core/agents.ts";
24 import { breakerParked, breakerTripped } from "../src/crew/breaker.ts";
25
26 describe("an agent's own comment never starts its next run", () => {
27 let dataDir: string;
28 let app: App;
29 let servers: Servers;
30 let alice: User;
31 let atlas: User;
32 let repo: Repo;
33 let direction: Issue;
34 const fresh = () => getRepoById(app.db, repo.id)!;
35 const agentRuns = (issue: Issue) =>
36 listRuns(app.db, repo.id).filter(
37 (r) => r.issueId === issue.id && r.kind === "agent",
38 );
39
40 beforeAll(async () => {
41 dataDir = await mkdtemp(join(tmpdir(), "forge-loop-"));
42 app = createApp({ dataDir, port: 0 });
43 servers = await serve(app);
44 const a = await createUser(app.db, {
45 username: "alice",
46 email: "[email protected]",
47 password: "a-good-passphrase",
48 adminIfFirst: true,
49 });
50 if (!a.ok) throw new Error(a.error);
51 alice = a.value;
52 const made = await createRepo(app, alice, {
53 name: "engine",
54 visibility: "private",
55 });
56 if (!made.ok) throw new Error(String(made.error));
57 repo = made.value;
58 for (const name of ["atlas", "mason"]) {
59 const agent = await createAgent(app, alice, { username: name });
60 if (!agent.ok) throw new Error(String(agent.error));
61 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
62 }
63 atlas = getUserByName(app.db, "atlas")!;
64 setCrewRole(app.db, repo.id, atlas.id, "planner");
65 const set = await updateRepoSettings(app, alice, repo, {
66 autonomy: "autonomous",
67 autonomyDirection: "Keep the engine small.",
68 autonomyCeilingUsd: 100,
69 autonomyBy: alice.id,
70 });
71 if (!set.ok) throw new Error(JSON.stringify(set.error));
72 expect(await driveRepo(app, fresh())).toBe("planned");
73 direction = getIssueById(app.db, fresh().directionIssueId!)!;
74 expect(direction.assigneeId).toBe(atlas.id);
75 });
76
77 afterAll(async () => {
78 servers.stop();
79 app.close();
80 await rm(dataDir, { recursive: true, force: true });
81 });
82
83 test("a finished planning run lets go of the direction", () => {
84 const run = createRun(app.db, {
85 repoId: repo.id,
86 kind: "agent",
87 issueId: direction.id,
88 actorId: atlas.id,
89 reason: "assigned",
90 });
91 claimRun(app.db, run.id);
92 finishRun(app.db, run.id, {
93 state: "failed",
94 summary: "declined: not feedback from a person",
95 costUsd: 1.4,
96 });
97 settleAgentRun(app, fresh(), {
98 issueId: direction.id,
99 state: "failed",
100 summary: "declined: not feedback from a person",
101 reason: "assigned",
102 actorId: atlas.id,
103 });
104 expect(getIssueById(app.db, direction.id)!.assigneeId).toBeNull();
105 });
106
107 test("a declined planner is not asked again until a person speaks", async () => {
108 const later = new Date(Date.now() + 7 * 60 * 60_000);
109 expect(await driveRepo(app, fresh(), later)).toBe("idle");
110 expect(getIssueById(app.db, direction.id)!.assigneeId).toBeNull();
111 addComment(app, alice, fresh(), direction, "Plan the renderer first.");
112 expect(await driveRepo(app, fresh(), later)).toBe("planned");
113 });
114
115 test("an agent's comment on an assigned issue queues nothing", async () => {
116 const crew: CrewHandle = await startCrew(app, {
117 forgeUrl: servers.url,
118 localUrl: servers.url,
119 binary: "/nonexistent/claude",
120 concurrency: 0,
121 budgetUsd: 1,
122 runTimeoutMs: 60_000,
123 });
124 const issued = openIssue(app, alice, fresh(), { title: "Loop", body: "" });
125 if (!issued.ok) throw new Error(String(issued.error));
126 const issue = issued.value;
127 const { assignIssue } = await import("../src/actions/issues.ts");
128 assignIssue(app, alice, fresh(), issue, atlas);
129 await new Promise((r) => setTimeout(r, 50));
130 // The person's assignment started one run; the agent's words add none.
131 expect(agentRuns(issue).length).toBe(1);
132 const before = agentRuns(issue).length;
133 for (let i = 0; i < 5; i++) {
134 addComment(app, atlas, fresh(), issue, `Declined: not feedback ${i}`);
135 await new Promise((r) => setTimeout(r, 20));
136 }
137 expect(agentRuns(issue).length).toBe(before);
138 await crew.stop();
139 });
140
141 test("three failed runs in an hour park the issue; a person's word frees it", () => {
142 const issued = openIssue(app, alice, fresh(), { title: "Stuck", body: "" });
143 if (!issued.ok) throw new Error(String(issued.error));
144 const issue = issued.value;
145 const agent = getAgent(app.db, atlas.id)!;
146 for (let i = 0; i < 3; i++) {
147 const r = createRun(app.db, {
148 repoId: repo.id,
149 kind: "agent",
150 issueId: issue.id,
151 actorId: atlas.id,
152 reason: "feedback",
153 });
154 claimRun(app.db, r.id);
155 finishRun(app.db, r.id, { state: "failed", summary: "declined: no", costUsd: 1 });
156 }
157 expect(breakerTripped(app.db, issue.id)).toBe(true);
158 const before = agentRuns(issue).length;
159 expect(
160 queueAgentRun(app, fresh(), issue, agent, "feedback", alice.id),
161 ).toBeUndefined();
162 expect(agentRuns(issue).length).toBe(before);
163 const parked = listComments(app.db, issue.id).at(-1)!;
164 expect(parked.body).toStartWith("Parked by the crew");
165 expect(breakerParked(app.db, [repo.id]).map((p) => p.issueId)).toContain(
166 issue.id,
167 );
168 // Asked again, it says nothing more.
169 queueAgentRun(app, fresh(), issue, agent, "feedback", alice.id);
170 expect(listComments(app.db, issue.id).length).toBe(1);
171 // A person speaks: the breaker lets go.
172 addComment(app, alice, fresh(), issue, "Try the other way.");
173 expect(breakerTripped(app.db, issue.id)).toBe(false);
174 expect(breakerParked(app.db, [repo.id])).toEqual([]);
175 });
176
177 test("two failures, or three spread over hours, trip nothing", () => {
178 const issued = openIssue(app, alice, fresh(), { title: "Fine", body: "" });
179 if (!issued.ok) throw new Error(String(issued.error));
180 for (let i = 0; i < 2; i++) {
181 const r = createRun(app.db, {
182 repoId: repo.id,
183 kind: "agent",
184 issueId: issued.value.id,
185 actorId: atlas.id,
186 reason: "feedback",
187 });
188 claimRun(app.db, r.id);
189 finishRun(app.db, r.id, { state: "failed", summary: "x", costUsd: 0 });
190 }
191 expect(breakerTripped(app.db, issued.value.id)).toBe(false);
192 });
193 });
test/crew.test.ts +2 −4
@@ -335,7 +335,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW
335335 ).toBe(tip);
336336 }, 60_000);
337337
338 test("an agent may decline: nothing lands, the reason is posted", async () => {
338 test("an agent may decline: nothing lands, the reason is on the run, not a comment", async () => {
339339 const issue = openIssue(app, alice, repo, {
340340 title: "Do something shady",
341341 body: "DECLINE-ME",
@@ -355,9 +355,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW
355355 );
356356 expect(tip).toBeUndefined();
357357 const comments = listComments(app.db, issue.value.id);
358 expect(comments.map((c) => `${c.authorName}: ${c.body}`)).toContain(
359 "bot: Declined: this is out of scope for me.",
360 );
358 expect(comments.some((c) => c.authorName === "bot")).toBe(false);
361359 }, 60_000);
362360
363361 test("closing the pull removes the clone; a closed issue starts nothing", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs