Ask builds and ships something new from one card (#53): asked to build an app from nothing, Ask had no way to get there. It reads only, propose_session needed a repository that already existed, deployments are turned on only in Settings, and the session it handed off was told never to push to the default branch, so an empty workspace couldn't end with anything live. propose_session now takes new_repository (with private, and deploy, on unless asked otherwise) for a repo in the workspace that doesn't exist yet. Its card says what starting it does: it creates the repository as the person (its project described with the issue's title), turns on its deployments, opens the issue and starts the session, and then shows Created <repo> and the production address on g1t.page, or why deployments couldn't be turned on (no g1t plan, no Admin role), in which case the session still builds it. Sent again (a retry, or Copy after Start), the repository already made is used. The session is told the repository is new and empty: push the first working version straight to the default branch, since there's nothing to open a pull request against, close the issue with Closes #, watch `g1t deploy list` until it's live or fix what failed, and end its report with the address. The agent's system prompt allows that first push and says a push to a deploying project's default branch deploys production. Ask's prompt says when to propose a new repository, that the card, not Ask, changes g1t, and how to answer: what will be built, that it goes live by itself once pushed, and to ask how it's going. For an existing repository it points to Settings → Deployments. Ask still changes nothing itself; the person's click does.
- Checked:
- cargo test -p g1t-integrations --lib (76 pass, with a new test for new-repository proposals: names, modes, private/deploy defaults, label) and -p g1t-contracts ai (5); apps/web npm run typecheck and node --test app/lib/ask.test.ts (4). Read services/deployments updateSettings (turning on with no commit yet builds nothing, so a new repository isn't a failed deploy) and its git.push handling (a push to the default branch deploys production), projects.describe going through byRepo, which makes the project at once, and repos create (protected false). Not yet run end to end against production.
- G1t-Session:
- ses_01m4ype025ebxa88d367gx24xv
9 files+212−170/9 viewed
| 1 | − | import { Ban, Check, ChevronRight, CircleAlert, Copy, ExternalLink, LoaderCircle, Play, SquareTerminal } from "lucide-react"; | |
| 1 | + | import { Ban, Check, ChevronRight, CircleAlert, Copy, ExternalLink, LoaderCircle, Play, Rocket, SquareTerminal } from "lucide-react"; | |
| 2 | 2 | import { useEffect, useState } from "react"; | |
| 3 | 3 | ||
| 4 | 4 | import type { AskSource } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { type Proposal, type Step, foldAnswer, proposalTitle } from "../lib/ask"; | |
| 6 | + | import { type Proposal, type Step, foldAnswer, proposalSteps, proposalTitle } from "../lib/ask"; | |
| 7 | 7 | import { Badge } from "./ui/badge"; | |
| 8 | 8 | import { Button } from "./ui/button"; | |
| 9 | 9 | import { Card } from "./ui/card"; | |
| ⋯ | |||
| 116 | 116 | ); | |
| 117 | 117 | } | |
| 118 | 118 | ||
| 119 | − | type Started = { session_url: string | null; provider_session_url: string | null; handoff: boolean; command: string | null; prompt: string }; | |
| 119 | + | type Started = { | |
| 120 | + | session_url: string | null; | |
| 121 | + | provider_session_url: string | null; | |
| 122 | + | handoff: boolean; | |
| 123 | + | command: string | null; | |
| 124 | + | prompt: string; | |
| 125 | + | /** A new repository's proposal: what the card made first. */ | |
| 126 | + | repo_url?: string; | |
| 127 | + | production_url?: string | null; | |
| 128 | + | deploy_error?: string | null; | |
| 129 | + | }; | |
| 120 | 130 | ||
| 121 | 131 | /** | |
| 122 | 132 | * Work g1t proposed to hand to a session in the person's own harness. Ask | |
| ⋯ | |||
| 130 | 140 | const [started, setStarted] = useState<Started | null>(null); | |
| 131 | 141 | const [copied, setCopied] = useState(false); | |
| 132 | 142 | const [error, setError] = useState<string | null>(null); | |
| 143 | + | const [made, setMade] = useState<Pick<Started, "repo_url" | "production_url" | "deploy_error"> | null>(null); | |
| 133 | 144 | ||
| 134 | 145 | useEffect(() => { | |
| 135 | 146 | let live = true; | |
| ⋯ | |||
| 152 | 163 | body: JSON.stringify({ ...proposal, handoff }), | |
| 153 | 164 | }); | |
| 154 | 165 | const body = (await response.json().catch(() => null)) as (Started & { error?: string }) | null; | |
| 166 | + | // The repository may be made even when the session is not: keep where it is. | |
| 167 | + | if (body?.repo_url) setMade({ repo_url: body.repo_url, production_url: body.production_url ?? null, deploy_error: body.deploy_error ?? null }); | |
| 155 | 168 | if (!response.ok || !body) throw new Error(body?.error ?? "The session could not be started."); | |
| 156 | 169 | setStarted(body); | |
| 157 | 170 | if (handoff && body.command) { | |
| ⋯ | |||
| 176 | 189 | <span className="truncate text-sm font-medium text-fg">{proposalTitle(proposal)}</span> | |
| 177 | 190 | <Badge className="px-1.5 py-0 font-normal">Session</Badge> | |
| 178 | 191 | </div> | |
| 179 | − | <p className="mt-0.5 text-xs text-muted"> | |
| 180 | − | {proposal.issue ? "Runs in your own harness, with your skills, tied to the issue." : "Opens the issue, then runs in your own harness, tied to it."} | |
| 181 | − | </p> | |
| 192 | + | <p className="mt-0.5 text-xs text-muted">{proposalSteps(proposal)}</p> | |
| 182 | 193 | {proposal.instruction && <p className="mt-1 line-clamp-2 text-xs text-faint">{proposal.instruction}</p>} | |
| 183 | 194 | </div> | |
| 184 | 195 | </div> | |
| 196 | + | {made && ( | |
| 197 | + | <div className="flex flex-col gap-1 text-xs"> | |
| 198 | + | <span className="flex flex-wrap items-center gap-1.5"> | |
| 199 | + | <Check size={13} aria-hidden="true" className="text-success" /> | |
| 200 | + | <span className="text-muted">Created</span> | |
| 201 | + | <Button asChild variant="link" size="inline" className="text-xs"> | |
| 202 | + | <a href={made.repo_url}>{proposal.repo}</a> | |
| 203 | + | </Button> | |
| 204 | + | </span> | |
| 205 | + | {made.production_url && ( | |
| 206 | + | <span className="flex flex-wrap items-center gap-1.5"> | |
| 207 | + | <Rocket size={13} aria-hidden="true" className="text-accent" /> | |
| 208 | + | <span className="text-muted">Live once the first version is pushed at</span> | |
| 209 | + | <Button asChild variant="link" size="inline" className="text-xs"> | |
| 210 | + | <a href={made.production_url} target="_blank" rel="noreferrer"> | |
| 211 | + | {made.production_url.replace(/^https?:\/\//, "")} <ExternalLink size={11} aria-hidden="true" /> | |
| 212 | + | </a> | |
| 213 | + | </Button> | |
| 214 | + | </span> | |
| 215 | + | )} | |
| 216 | + | {made.deploy_error && ( | |
| 217 | + | <span className="flex items-start gap-1.5 text-warn"> | |
| 218 | + | <CircleAlert size={12} aria-hidden="true" className="mt-0.5 shrink-0" /> | |
| 219 | + | Not deployed: {made.deploy_error} | |
| 220 | + | </span> | |
| 221 | + | )} | |
| 222 | + | </div> | |
| 223 | + | )} | |
| 185 | 224 | {running ? ( | |
| 186 | 225 | <div className="flex flex-wrap items-center gap-2 text-xs"> | |
| 187 | 226 | <Check size={13} aria-hidden="true" className="text-success" /> | |
| 70 | 70 | /** A session g1t proposed in an answer: the person starts it, or copies its command. */ | |
| 71 | 71 | export type Proposal = Extract<AskEvent, { type: "proposal" }>; | |
| 72 | 72 | ||
| 73 | + | /** What starting a proposal does, in a line, under its title. */ | |
| 74 | + | export function proposalSteps(proposal: Proposal): string { | |
| 75 | + | if (proposal.new_repository) { | |
| 76 | + | const made = `Creates ${proposal.private ? "the private" : "the public"} repository`; | |
| 77 | + | return proposal.deploy | |
| 78 | + | ? `${made}, turns on its deployments, then runs in your own harness and pushes the first version, which goes live on g1t.page.` | |
| 79 | + | : `${made}, then runs in your own harness and pushes the first version.`; | |
| 80 | + | } | |
| 81 | + | return proposal.issue ? "Runs in your own harness, with your skills, tied to the issue." : "Opens the issue, then runs in your own harness, tied to it."; | |
| 82 | + | } | |
| 83 | + | ||
| 73 | 84 | /** What a proposal asks the session to do, in a line: "Implement #12 in acme/web". */ | |
| 74 | 85 | export function proposalTitle(proposal: Proposal): string { | |
| 86 | + | if (proposal.new_repository) return `Build “${proposal.title}” in ${proposal.repo}`; | |
| 75 | 87 | const verb = { plan: "Plan", implement: "Implement", review: "Review" }[proposal.mode] ?? "Work on"; | |
| 76 | 88 | return proposal.issue ? `${verb} #${proposal.issue} in ${proposal.repo}` : `${verb} “${proposal.title}” in ${proposal.repo}`; | |
| 77 | 89 | } |
| 10 | 10 | * `POST -/ask` starts a session g1t proposed in an answer (the proposal | |
| 11 | 11 | * card in components/ask-steps): the person's click is the confirmation. | |
| 12 | 12 | * `handoff` asks for the Claude Code command instead of starting one on | |
| 13 | − | * the workspace's Anthropic account. | |
| 13 | + | * the workspace's Anthropic account. A proposal with `new_repository` | |
| 14 | + | * first creates the repository as the person (its project described with | |
| 15 | + | * the issue's title), turns on its deployments when `deploy`, and tells | |
| 16 | + | * the session to push the first version to the default branch, which | |
| 17 | + | * deploys it. Sent again (a retry, or Copy after Start), a repository | |
| 18 | + | * already made is used as it is. | |
| 14 | 19 | */ | |
| 15 | 20 | import { data } from "react-router"; | |
| 16 | 21 | ||
| 17 | − | import { AGENT_MODES } from "@g1t/contracts"; | |
| 22 | + | import { AGENT_MODES, type User } from "@g1t/contracts"; | |
| 18 | 23 | ||
| 19 | 24 | import type { Route } from "./+types/ask"; | |
| 20 | − | import { agentSessions, ai } from "../../lib/services.server"; | |
| 25 | + | import { agentSessions, ai, deployments, projects, repos } from "../../lib/services.server"; | |
| 21 | 26 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 22 | 27 | ||
| 23 | 28 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| ⋯ | |||
| 49 | 54 | const title = issue ? undefined : text("title"); | |
| 50 | 55 | if (!issue && !title) return Response.json({ error: "The proposal names no issue." }, { status: 400 }); | |
| 51 | 56 | const mode = AGENT_MODES.find((one) => one.mode === input?.mode)?.mode ?? "implement"; | |
| 57 | + | let instruction = text("instruction"); | |
| 58 | + | let built: Built | null = null; | |
| 59 | + | if (input?.new_repository === true) { | |
| 60 | + | const made = await newRepository(user, namespace, name, { title: title ?? name, isPrivate: input.private === true, deploy: input.deploy !== false }); | |
| 61 | + | if ("error" in made) return Response.json({ error: made.error }, { status: 400 }); | |
| 62 | + | built = made; | |
| 63 | + | instruction = [firstPush(made), instruction].filter(Boolean).join("\n\n"); | |
| 64 | + | } | |
| 52 | 65 | const result = await agentSessions | |
| 53 | − | .start(user, { namespace, name }, { issue, title, body: issue ? undefined : text("body"), instruction: text("instruction"), mode, handoff: input?.handoff === true }) | |
| 66 | + | .start(user, { namespace, name }, { issue, title, body: issue ? undefined : text("body"), instruction, mode, handoff: input?.handoff === true }) | |
| 54 | 67 | .catch(() => null); | |
| 55 | − | if (!result) return Response.json({ error: "The session could not be started. Try again in a moment." }, { status: 502 }); | |
| 56 | − | if (!result.ok) return Response.json({ error: result.error.message }, { status: 400 }); | |
| 68 | + | if (!result) return Response.json({ error: "The session could not be started. Try again in a moment.", ...built }, { status: 502 }); | |
| 69 | + | if (!result.ok) return Response.json({ error: result.error.message, ...built }, { status: 400 }); | |
| 57 | 70 | const started = result.value; | |
| 58 | 71 | return Response.json({ | |
| 59 | 72 | issue: started.issue, | |
| ⋯ | |||
| 62 | 75 | handoff: started.handoff, | |
| 63 | 76 | command: started.command, | |
| 64 | 77 | prompt: started.prompt, | |
| 78 | + | ...built, | |
| 65 | 79 | }); | |
| 66 | 80 | } | |
| 81 | + | ||
| 82 | + | /** What the card made before the session: the repository, and where it deploys or why it can't. */ | |
| 83 | + | type Built = { repo_url: string; default_branch: string; production_url: string | null; deploy_error: string | null }; | |
| 84 | + | ||
| 85 | + | /** | |
| 86 | + | * The repository a new-repository proposal names, made as the person, or | |
| 87 | + | * the one already made by an earlier click; its project described; its | |
| 88 | + | * deployments on when asked. A deploy that can't be turned on (no plan, no | |
| 89 | + | * Admin role) is said, not fatal: the session still builds it. | |
| 90 | + | */ | |
| 91 | + | async function newRepository( | |
| 92 | + | user: User, | |
| 93 | + | namespace: string, | |
| 94 | + | name: string, | |
| 95 | + | wanted: { title: string; isPrivate: boolean; deploy: boolean }, | |
| 96 | + | ): Promise<Built | { error: string }> { | |
| 97 | + | const created = await repos.create(user, { namespace, name, isPrivate: wanted.isPrivate }).catch(() => null); | |
| 98 | + | let repo = created?.ok ? created.value : null; | |
| 99 | + | if (!repo) { | |
| 100 | + | // Already made: a second click, or Copy after Start. Anything else is the reason it wasn't. | |
| 101 | + | const found = await repos.get({ namespace, name }, user).catch(() => null); | |
| 102 | + | if (!found?.ok) return { error: created && !created.ok ? created.error.message : "The repository could not be created. Try again in a moment." }; | |
| 103 | + | repo = found.value; | |
| 104 | + | } else { | |
| 105 | + | const described = await projects.describe(user, repo.id, wanted.title).catch((error: unknown) => String(error)); | |
| 106 | + | if (typeof described === "string" || !described.ok) console.error("ask: new repository without its description", repo.id, described); | |
| 107 | + | } | |
| 108 | + | const built: Built = { repo_url: `/${repo.namespace}/${repo.name}`, default_branch: repo.defaultBranch, production_url: null, deploy_error: null }; | |
| 109 | + | if (!wanted.deploy) return built; | |
| 110 | + | const saved = await deployments.updateSettings(user, { workspace: repo.namespace, slug: repo.name }, { enabled: true }).catch(() => null); | |
| 111 | + | if (saved?.ok) built.production_url = saved.value.productionUrl; | |
| 112 | + | else built.deploy_error = saved ? saved.error.message : "Deployments could not be turned on. Try again from the project's Settings → Deployments."; | |
| 113 | + | return built; | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /** What the session is told about a repository the card just made. */ | |
| 117 | + | function firstPush(built: Built): string { | |
| 118 | + | const live = built.production_url | |
| 119 | + | ? `Its deployments are on: that push deploys production to ${built.production_url}. Watch it with \`g1t deploy list\` until it is live; if it fails, read why (\`g1t deploy view ID\`), fix it and push again. End your report with the address.` | |
| 120 | + | : "Its deployments are off, so nothing deploys yet; say so in your report."; | |
| 121 | + | return ( | |
| 122 | + | `The repository was just created for this and is empty. Build the first working version and push it straight to the default branch ` + | |
| 123 | + | `(\`git push origin HEAD:${built.default_branch}\`): there is nothing to open a pull request against yet, so open none. ` + | |
| 124 | + | `Keep it small and make it build and serve from the repository's root. Put \`Closes #\` and the issue's number in the commit message, so the push closes it. ${live}` | |
| 125 | + | ); | |
| 126 | + | } | |
| 434 | 434 | /// on an issue, or on a new one with `title` and `body`. Nothing has | |
| 435 | 435 | /// started; the person starts it, or copies the command, from the card | |
| 436 | 436 | /// the answer shows. `mode` is `plan`, `implement` or `review`. | |
| 437 | + | /// | |
| 438 | + | /// With `new_repository`, `repo` does not exist yet: the card creates | |
| 439 | + | /// it (`private` or public), turns on its deployments when `deploy`, | |
| 440 | + | /// then opens the issue and starts the session, which pushes the first | |
| 441 | + | /// version to the default branch, and so deploys it. | |
| 437 | 442 | Proposal { | |
| 438 | 443 | id: String, | |
| 439 | 444 | repo: String, | |
| ⋯ | |||
| 442 | 447 | body: Option<String>, | |
| 443 | 448 | mode: String, | |
| 444 | 449 | instruction: Option<String>, | |
| 450 | + | #[serde(default, skip_serializing_if = "std::ops::Not::not")] | |
| 451 | + | new_repository: bool, | |
| 452 | + | #[serde(default, skip_serializing_if = "std::ops::Not::not")] | |
| 453 | + | private: bool, | |
| 454 | + | #[serde(default, skip_serializing_if = "std::ops::Not::not")] | |
| 455 | + | deploy: bool, | |
| 445 | 456 | }, | |
| 446 | 457 | /// The answer is complete. `stop_reason` as the provider said it, such | |
| 447 | 458 | /// as `end_turn`, `stop` or `max_tokens`. | |
| 246 | 246 | body: string | null; | |
| 247 | 247 | mode: "plan" | "implement" | "review"; | |
| 248 | 248 | instruction: string | null; | |
| 249 | + | /** | |
| 250 | + | * `repo` does not exist yet: the card creates it (`private` or | |
| 251 | + | * public), turns on its deployments when `deploy`, then opens the | |
| 252 | + | * issue and starts the session, which pushes the first version to | |
| 253 | + | * the default branch, and so deploys it. | |
| 254 | + | */ | |
| 255 | + | new_repository?: boolean; | |
| 256 | + | private?: boolean; | |
| 257 | + | deploy?: boolean; | |
| 249 | 258 | } | |
| 250 | 259 | | { type: "done"; stop_reason: string | null } | |
| 251 | 260 | | { type: "error"; message: string }; |
| 115 | 115 | - Follow the work-on-issue skill. Check the work context and the sessions on the issue before you start, and claim only what you take. \ | |
| 116 | 116 | When work splits into independent parts, you may give a part to a copy of yourself; each is recorded on g1t as a subagent and sees the others.\n\ | |
| 117 | 117 | - Put the trailer `G1t-Session: <the session id in your first message>` on every commit, so g1t ties the commits to this session.\n\ | |
| 118 | − | - Work on a branch whose name starts with the issue number, and open a draft pull request early. Never push to the default branch.\n\ | |
| 118 | + | - Work on a branch whose name starts with the issue number, and open a draft pull request early. Never push to the default branch, \ | |
| 119 | + | except a repository's first commit when it has none yet: there is nothing to open a pull request against, so the first version goes \ | |
| 120 | + | straight to it.\n\ | |
| 121 | + | - A push to the default branch of a project with deployments on deploys production. When you push there, watch it with \ | |
| 122 | + | `g1t deploy list` until it is live or failed; if it failed, read why, fix it and push again. Say where it is live in your report.\n\ | |
| 119 | 123 | - Never write `@g1t` in what you post on g1t: that asks g1t for another session.\n\ | |
| 120 | 124 | - When you finish, say in a few sentences what you did, what is left, and the pull request, if any. That is your report on g1t." | |
| 121 | 125 | .to_owned() |
| 377 | 377 | your answer to start it on their workspace's Anthropic account or copy a Claude Code command; nothing \ | |
| 378 | 378 | starts until they choose. If a session already holds the issue, say so and link it instead of proposing \ | |
| 379 | 379 | another. Split large work into a few issues and propose a session for each. \ | |
| 380 | + | The card the person clicks is what changes g1t, never you.\n\ | |
| 381 | + | To build something new from nothing (an app, a site, an API: \"build me…\", \"make a…\", \"let's ship a…\"), \ | |
| 382 | + | propose one session with `new_repository` true and a `repo` of \"{slug}/<name>\", a short lowercase name \ | |
| 383 | + | that does not exist yet (check with the repository tool's `get`; if it exists, pick another or work there), \ | |
| 384 | + | a title and a body saying what to build and what done means (keep it small enough to ship in one go, and \ | |
| 385 | + | say it must build and serve from the repository's root), and mode implement. Leave `deploy` on unless they \ | |
| 386 | + | don't want it live. Starting the card creates the repository, turns on its deployments, opens the issue and \ | |
| 387 | + | starts the session, which pushes the first version straight to the default branch; that push deploys \ | |
| 388 | + | production on g1t.page, and the card shows the address. Say so plainly: what will be built, that it goes live \ | |
| 389 | + | by itself once the session pushes, and that they can ask you how it is going (read the session, then the \ | |
| 390 | + | workflow tool's `list_deployments`). If the workspace isn't on the g1t plan, deployments can't be turned on; \ | |
| 391 | + | the card says so and the session still builds it.\n\ | |
| 392 | + | For an existing repository they want live: if its deployments are off, an admin turns them on at \ | |
| 393 | + | {site}/owner/repo/settings/deployments (Turn on deployments); after that every push to the default branch \ | |
| 394 | + | deploys production and every pull request gets a preview.\n\ | |
| 380 | 395 | When they ask about work already under way, read the issue's `work` and the session tool's `get` for the \ | |
| 381 | 396 | session doing it (its subagents, what it touched, where it stands), and link the session.\n\n\ | |
| 382 | 397 | ## Answering\n\ |
| 33 | 33 | /// What the model is told once a session is proposed. | |
| 34 | 34 | const PROPOSED: &str = "Proposed. The person now sees a card under your answer to start this session on their workspace's Anthropic account or copy a Claude Code command for it; nothing has started. Don't ask them to confirm in words: say in a sentence what the session will do."; | |
| 35 | 35 | ||
| 36 | + | /// What the model is told once it proposes building a new repository. | |
| 37 | + | const PROPOSED_NEW: &str = "Proposed. The person now sees a card under your answer; nothing has happened yet. Starting it creates the repository, turns on its deployments if you asked, opens the issue and starts the session, which pushes the first version to the default branch; that push deploys it, and the card then shows the address where it will be live on g1t.page. Don't ask them to confirm in words: say in a sentence or two what will be built, that it goes live on its own once the session pushes it, and that they can ask you how it is going."; | |
| 38 | + | ||
| 36 | 39 | /// The most sessions one answer proposes. | |
| 37 | 40 | const MAX_PROPOSALS: usize = 5; | |
| 38 | 41 | ||
| ⋯ | |||
| 287 | 290 | match ask_tools::proposal(&id, &call.input) { | |
| 288 | 291 | Ok(proposal) => { | |
| 289 | 292 | self.proposals += 1; | |
| 293 | + | let told = match &proposal { | |
| 294 | + | AskEvent::Proposal { new_repository: true, .. } => PROPOSED_NEW, | |
| 295 | + | _ => PROPOSED, | |
| 296 | + | }; | |
| 290 | 297 | self.out.push_back(ask::line(&proposal)); | |
| 291 | − | Plan::Skip(PROPOSED.to_owned(), AskToolStatus::Done) | |
| 298 | + | Plan::Skip(told.to_owned(), AskToolStatus::Done) | |
| 292 | 299 | } | |
| 293 | 300 | Err(why) => Plan::Skip(why, AskToolStatus::Failed), | |
| 294 | 301 | } | |
| 188 | 188 | vec![Native { | |
| 189 | 189 | spec: ToolSpec { | |
| 190 | 190 | name: PROPOSE_SESSION.to_owned(), | |
| 191 | − | description: "Propose handing work to a coding session in the person's own harness (their Claude, with their skills and subagents), tied to an issue. Use it when they ask for work to be done: planning, implementing, fixing or reviewing. It starts nothing: the person sees a card to start the session on their workspace's Anthropic account, or to copy a Claude Code command to run it themselves. Give `issue` when the work has one (read it first), else `title` and `body` for a new issue. Propose one session per piece of work." | |
| 191 | + | description: "Propose handing work to a coding session in the person's own harness (their Claude, with their skills and subagents), tied to an issue. Use it when they ask for work to be done: planning, implementing, fixing or reviewing. It starts nothing: the person sees a card to start the session on their workspace's Anthropic account, or to copy a Claude Code command to run it themselves. Give `issue` when the work has one (read it first), else `title` and `body` for a new issue. Propose one session per piece of work. To build something new from nothing (an app, a site, a service), set `new_repository` with a `repo` in the workspace that does not exist yet: the card creates it, turns on its deployments (unless `deploy` is false), opens the issue and starts the session, which pushes the first version to the default branch, and that deploys it." | |
| 192 | 192 | .to_owned(), | |
| 193 | 193 | input_schema: json!({ | |
| 194 | 194 | "type": "object", | |
| 195 | 195 | "properties": { | |
| 196 | − | "repo": { "type": "string", "description": "The repository, owner/name." }, | |
| 196 | + | "repo": { "type": "string", "description": "The repository, owner/name. With new_repository, a new name in the workspace: short, lowercase, words joined by -." }, | |
| 197 | + | "new_repository": { "type": "boolean", "description": "The repository does not exist yet: the card creates it first. Needs title, body and mode implement." }, | |
| 198 | + | "private": { "type": "boolean", "description": "With new_repository: make it private. Public unless they ask." }, | |
| 199 | + | "deploy": { "type": "boolean", "description": "With new_repository: turn on its deployments, so the first push goes live on g1t.page. Default true." }, | |
| 197 | 200 | "issue": { "type": "integer", "description": "The issue the work belongs to." }, | |
| 198 | 201 | "title": { "type": "string", "description": "A new issue's title, when there is no issue yet." }, | |
| 199 | 202 | "body": { "type": "string", "description": "A new issue's description: what to do and what done means." }, | |
| ⋯ | |||
| 220 | 223 | Some(mode @ ("plan" | "implement" | "review")) => mode.to_owned(), | |
| 221 | 224 | _ => return Err("Give a mode: plan, implement or review.".to_owned()), | |
| 222 | 225 | }; | |
| 226 | + | let new_repository = arguments["new_repository"].as_bool() == Some(true); | |
| 227 | + | if new_repository { | |
| 228 | + | let name = repo.split('/').nth(1).unwrap_or_default(); | |
| 229 | + | if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) { | |
| 230 | + | return Err("A new repository's name is letters, digits, - _ and . only, such as hello-world.".to_owned()); | |
| 231 | + | } | |
| 232 | + | if issue.is_some() || mode != "implement" { | |
| 233 | + | return Err("A new repository has no issues yet: give a title and body for its first, and mode implement.".to_owned()); | |
| 234 | + | } | |
| 235 | + | } | |
| 223 | 236 | let clip = |value: Option<String>, max: usize| value.map(|value| value.chars().take(max).collect::<String>()); | |
| 224 | 237 | Ok(AskEvent::Proposal { | |
| 225 | 238 | id: id.to_owned(), | |
| ⋯ | |||
| 229 | 242 | body: if issue.is_some() { None } else { clip(text("body"), 8_000) }, | |
| 230 | 243 | mode, | |
| 231 | 244 | instruction: clip(text("instruction"), 4_000), | |
| 245 | + | new_repository, | |
| 246 | + | private: new_repository && arguments["private"].as_bool() == Some(true), | |
| 247 | + | deploy: new_repository && arguments["deploy"].as_bool() != Some(false), | |
| 232 | 248 | }) | |
| 233 | 249 | } | |
| 234 | 250 | ||
| ⋯ | |||
| 340 | 356 | return format!("Searched g1t for \u{201c}{query}\u{201d}"); | |
| 341 | 357 | } | |
| 342 | 358 | if tool == PROPOSE_SESSION { | |
| 359 | + | if arguments["new_repository"].as_bool() == Some(true) { | |
| 360 | + | let repo = arguments["repo"].as_str().unwrap_or("a new repository"); | |
| 361 | + | return format!("Proposed building {repo}, a new repository"); | |
| 362 | + | } | |
| 343 | 363 | let what = arguments["issue"].as_u64().map(|number| format!("issue #{number}")).unwrap_or_else(|| "a new issue".to_owned()); | |
| 344 | 364 | let repo = arguments["repo"].as_str().unwrap_or("the repository"); | |
| 345 | 365 | return format!("Proposed a session on {what} in {repo}"); | |
| ⋯ | |||
| 725 | 745 | assert!(toolbox.is_native(PROPOSE_SESSION)); | |
| 726 | 746 | let on_issue = json!({ "repo": "acme/web", "issue": 12, "mode": "implement", "title": "ignored", "instruction": " use the new name " }); | |
| 727 | 747 | assert_eq!(toolbox.decide(PROPOSE_SESSION, &on_issue), Decision::Confirm); | |
| 728 | − | let AskEvent::Proposal { id, repo, issue, title, body, mode, instruction } = proposal("proposal_0_0", &on_issue).unwrap() else { | |
| 748 | + | let AskEvent::Proposal { id, repo, issue, title, body, mode, instruction, new_repository, deploy, .. } = | |
| 749 | + | proposal("proposal_0_0", &on_issue).unwrap() | |
| 750 | + | else { | |
| 729 | 751 | panic!() | |
| 730 | 752 | }; | |
| 731 | 753 | assert_eq!((id.as_str(), repo.as_str(), issue, mode.as_str()), ("proposal_0_0", "acme/web", Some(12), "implement")); | |
| 732 | 754 | assert_eq!((title, body, instruction.as_deref()), (None, None, Some("use the new name"))); | |
| 755 | + | assert!(!new_repository && !deploy, "an existing repository is left as it is"); | |
| 733 | 756 | let new_issue = json!({ "repo": "acme/web", "title": "Add a collaborators route", "body": "List them.", "mode": "plan" }); | |
| 734 | 757 | let AskEvent::Proposal { issue, title, .. } = proposal("p", &new_issue).unwrap() else { panic!() }; | |
| 735 | 758 | assert_eq!((issue, title.as_deref()), (None, Some("Add a collaborators route"))); | |
| ⋯ | |||
| 740 | 763 | } | |
| 741 | 764 | ||
| 742 | 765 | #[test] | |
| 766 | + | fn a_new_repository_is_created_deployed_and_built_from_one_card() { | |
| 767 | + | let build = json!({ "repo": "acme/hello-world", "new_repository": true, "title": "Hello world", "body": "A page that says hello.", "mode": "implement" }); | |
| 768 | + | let AskEvent::Proposal { repo, issue, title, new_repository, private, deploy, .. } = proposal("p", &build).unwrap() else { panic!() }; | |
| 769 | + | assert_eq!((repo.as_str(), issue, title.as_deref()), ("acme/hello-world", None, Some("Hello world"))); | |
| 770 | + | assert!(new_repository && deploy && !private, "public and deployed unless asked otherwise"); | |
| 771 | + | let quiet = json!({ "repo": "acme/notes", "new_repository": true, "private": true, "deploy": false, "title": "Notes", "mode": "implement" }); | |
| 772 | + | let AskEvent::Proposal { private, deploy, .. } = proposal("p", &quiet).unwrap() else { panic!() }; | |
| 773 | + | assert!(private && !deploy); | |
| 774 | + | assert!(proposal("p", &json!({ "repo": "acme/hello world", "new_repository": true, "title": "x", "mode": "implement" })).is_err()); | |
| 775 | + | assert!(proposal("p", &json!({ "repo": "acme/hello", "new_repository": true, "title": "x", "mode": "plan" })).is_err()); | |
| 776 | + | assert!(proposal("p", &json!({ "repo": "acme/hello", "new_repository": true, "issue": 1, "mode": "implement" })).is_err()); | |
| 777 | + | assert_eq!(label(PROPOSE_SESSION, &build), "Proposed building acme/hello-world, a new repository"); | |
| 778 | + | } | |
| 779 | + | ||
| 780 | + | #[test] | |
| 743 | 781 | fn steps_say_what_they_do_in_a_line() { | |
| 744 | 782 | assert_eq!(label("pull_request", &json!({ "action": "list", "repo": "acme/web" })), "Listed pull requests in acme/web"); | |
| 745 | 783 | assert_eq!(label("pull_request", &json!({ "action": "get", "repo": "acme/web", "number": 7 })), "Read pull request #7 in acme/web"); | |
Comments
Sign in to comment.