Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Plan first: an agent task can plan before it builds, then be approved, changed or split (phase 3c) (#104)

* Plan first: a task can plan in a read-only sandbox before it builds, then be approved, changed or split (WIP)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2

* Plan first: the settings form sets and checks plan_first in the project e2e

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2

* Plan first e2e: a custom command's transcript doesn't echo its prompt

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2

* Plan first e2e: follow-ups return 201; check an approved plan can't be split once a coordinator exists

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2

---------

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 227d9a0commit 6e0e611578fa21f83fbc6475c22704437329f677Browse files
13 files changed+503−58
Files (13)
modifiedapps/diffinery/docs/agents/projects.md+11−3View file
@@ -87,6 +87,14 @@
8787
8888 Text every agent task and [agent review](/docs/agents/review) in the repository gets in its prompt: for example, which tests to run before opening a pull request. Up to 10,000 characters. Reviews also follow `REVIEW.md` files.
8989
90+### Plan first
91+
92+Whether new tasks [plan before they build](/docs/agents/tasks#plan-first):
93+
94+- **Off** (the default): tasks build at once, unless someone ticks **Plan first**.
95+- **On by default**: **Plan first** is ticked on new tasks, and people can untick it.
96+- **Required**: every task plans first, ticked or not, including the tasks coordinators start.
97+
9098 ### How many at once
9199
92100 How many tasks run at the same time in the repository, from 1 to 10. The default is 3. A task started beyond it is **Queued**: its page says so, and it starts, oldest first, when a running task finishes or stops, or when an admin raises the number. Follow-ups and retries queue the same way. Agent reviews don't count toward the number and never wait.
@@ -100,7 +108,7 @@
100108 | `POST .../diffinery/project/posts` | Post `{ "body": "…" }`; add `"coordinator": false` to skip your coordinator | Write access; browser session only |
101109 | `GET .../diffinery/project/memory` | The memory with its versions | Write access; any token |
102110 | `PUT .../diffinery/project/memory` | Save `{ "body": "…" }`, or restore `{ "restore": 4 }` | Write access; browser session only |
103-| `PUT .../diffinery/project/settings` | Set `{ "max_running": 2, "instructions": "…" }` | Admin; browser session only |
111+| `PUT .../diffinery/project/settings` | Set `{ "max_running": 2, "instructions": "…", "plan_first": "default" }` (`plan_first` is `off`, `default` or `required`) | Admin; browser session only |
104112 | `GET .../diffinery/project/coordinator` | Your coordinator's settings | Write access; any token |
105113 | `PUT .../diffinery/project/coordinator` | Turn yours on or change it: `{ "agent": "my-bot", "runtime": "claude-code", "tokens_per_run": 1000000, "tokens_per_task": 2000000, "accept": true }` | Write access; browser session only |
106114 | `DELETE .../diffinery/project/coordinator` | Turn yours off | Write access; browser session only |
@@ -113,13 +121,13 @@
113121
114122 ```json
115123 {
116- "settings": { "max_running": 3, "instructions": "Run pnpm test before opening a pull request." },
124+ "settings": { "max_running": 3, "instructions": "Run pnpm test before opening a pull request.", "plan_first": "off" },
117125 "memory": { "body": "Docs live in docs/.", "version": 4, "updated_by": { "login": "ada", "type": "User" }, "updated_at": "2026-10-13T09:12:00Z" },
118126 "board": { "waiting": [], "working": [{ "number": 7, "state": "running", "…": "…" }], "queued": [], "review": [], "landing": [], "done": [], "idle": [] }
119127 }
120128 ```
121129
122-Saving memory and settings is recorded in the audit log as `project.memory` and `project.settings`, and coordinator changes as `project.coordinator`. A task a coordinator started has `started_via` (with the post), `token_budget` and `waits_for` in its JSON.
130+Saving memory and settings is recorded in the audit log as `project.memory` and `project.settings`, and coordinator changes as `project.coordinator`. A task a coordinator started has `started_via` (with the post), `token_budget` and `waits_for` in its JSON; a task split from a plan has `parent_task`.
123131
124132 ## MCP
125133
modifiedapps/diffinery/docs/agents/tasks.md+18−2View file
@@ -23,7 +23,8 @@
2323 2. Write the **goal**, as you would ask a colleague. Markdown works.
2424 3. Pick the **runtime**: Claude Code, Codex or **Custom command** (see [Runtimes](#runtimes)).
2525 4. Pick the **agent** and the **base branch**.
26-5. Click **Start task**.
26+5. Tick **Plan first** to have the agent plan before it builds (see [Plan first](#plan-first)).
27+6. Click **Start task**.
2728
2829 The task page shows the transcript live: what the agent says, the commands it runs and their output.
2930
@@ -50,6 +51,18 @@
5051 | **Failed** | The last run failed: the runtime exited with an error, the push was refused, or you lost access. The transcript says why. |
5152 | **Stopped** | Someone stopped it. |
5253
54+A [plan-first](#plan-first) task also has a plan state: `planning` while its plan runs, `waiting` when the plan waits for you (the board shows it under **Waiting on you**), then `approved` or `split`.
55+
56+### Plan first
57+
58+A plan-first task's first run writes a plan instead of code. The agent gets a **read-only** sandbox, as an [agent review](/docs/agents/review) does: it reads the code and runs read-only commands, its git credential can't push, and nothing it changes is kept. The plan is its final message (for a custom command, everything it printed), up to 20,000 characters, and shows on the task page. Then you choose:
59+
60+- **Approve plan**: a build run starts with the goal and the plan. It, and every later run of the task, gets the plan in its prompt as the one you approved.
61+- **Send a follow-up** to ask for changes: the agent plans again, with its previous plan and your message.
62+- **Split into tasks**: your [coordinator](/docs/agents/projects#coordinator) gets the goal and the plan as a post of yours and starts tasks for its parts. Each says it was split from this task, which is then done with. You need a coordinator in the project for this.
63+
64+Repository admins can make plans the default, or require them for every task, including ones coordinators start (see [Plan first](/docs/agents/projects#plan-first)).
65+
5366 ### Steer, retry and stop
5467
5568 - **Send a follow-up** from the task page to continue: a new run starts on the task's branch with the goal, how the previous run ended and your message, and its work goes to the same pull request. Sent while a run is active, the message waits and starts when that run ends.
@@ -136,9 +149,11 @@
136149 | `GET /repos/{owner}/{repo}/diffinery/tasks` | List tasks | Write access; any token |
137150 | `GET /repos/{owner}/{repo}/diffinery/tasks/{number}` | A task with its runs | Write access; any token |
138151 | `GET /repos/{owner}/{repo}/diffinery/tasks/{number}/transcript?after=0` | Transcript lines | Write access; any token |
139-| `POST /repos/{owner}/{repo}/diffinery/tasks` | Start a task | Browser session only |
152+| `POST /repos/{owner}/{repo}/diffinery/tasks` | Start a task; `"plan_first": true` or `false` overrides the project's default, unless it requires plans | Browser session only |
140153 | `POST .../tasks/{number}/messages` | Send a follow-up | Browser session only, the person who started it |
141154 | `POST .../tasks/{number}/retry` | Retry | Browser session only, the person who started it |
155+| `POST .../tasks/{number}/plan/approve` | Approve a waiting plan: the build run starts | Browser session only, the person who started it |
156+| `POST .../tasks/{number}/plan/split` | Hand a waiting plan to your coordinator to split into tasks | Browser session only, the person who started it |
142157 | `POST .../tasks/{number}/stop` | Stop | The person who started it, its agent or a repository admin |
143158 | `GET /diffinery/tasks` | Tasks you started, across repositories | Any token |
144159 | `GET /diffinery/model-keys` | Your saved keys (provider, last four characters, dates) | Any token |
@@ -158,6 +173,7 @@
158173 "runtime": "claude-code",
159174 "agent": { "login": "ayse-bot", "type": "Bot" },
160175 "started_by": { "login": "ayse", "type": "User" },
176+ "plan_first": false,
161177 "base": "main",
162178 "branch": "agent/7-fix-the-contact-sales-link-on-the",
163179 "pull_request": { "number": 42, "html_url": "https://diffinery.com/acme/website/pull/42" },
modifiedapps/diffinery/src/agentic/ProjectDO.ts+107−15View file
@@ -7,10 +7,10 @@
77 import { PROVIDERS, type Provider } from "./keys.js";
88 import { enqueueMail } from "../mail/send.js";
99 import { formatTokens, pinRefusal, weighted, type Usage } from "./usage.js";
10-import { lineDiff, MAX_RUNNING_DEFAULT, MEMORY_VERSIONS, projectContext, type MemoryRow, type PostRow, type ProjectSettings } from "./project.js";
10+import { isPlanFirst, lineDiff, MAX_PLAN, MAX_POST, MAX_RUNNING_DEFAULT, MEMORY_VERSIONS, projectContext, type MemoryRow, type PlanState, type PostRow, type ProjectSettings } from "./project.js";
1111 import { COORDINATOR_MAX_MODEL_CALLS, COORDINATOR_TIMEOUT_MS, COORDINATOR_TOKENS_DEFAULT, coordinatorPrompt, parseActions, TASKS_PER_DAY, TASKS_PER_RUN, type CoordinatorSettings } from "./coordinator.js";
1212 import { countText, dayAgo, findingComment, rulesFiles, findingId, MAX_AUTOFIXES, placeFindings, REPO_DAILY_DEFAULT, REVIEW_APP, REVIEW_CHECK, REVIEW_CREATOR, REVIEW_MAX_MODEL_CALLS, REVIEW_TIMEOUT_MS, REVIEW_TOKENS_DEFAULT, reviewBody, reviewLimit, reviewOutput, reviewPrompt, fixGoal, shouldMailLimit, type Finding, type PriorFinding, type ReviewLimit, type ReviewSettings } from "./review.js";
13-import { agentEnv, finalMessage, INSTALL_TIMEOUT_MS, RUN_TIMEOUT_MS, RUNTIMES, runPrompt, runtimeLabel, taskBranch, taskTitle, type ModelRoute, type RuntimeId, type TranscriptLine } from "./runtimes.js";
13+import { agentEnv, finalMessage, INSTALL_TIMEOUT_MS, planPrompt, RUN_TIMEOUT_MS, RUNTIMES, runPrompt, runtimeLabel, taskBranch, taskTitle, type ModelRoute, type RuntimeId, type TranscriptLine } from "./runtimes.js";
1414
1515 /**
1616 * One per repository: its agent tasks. A task is a goal a person gives one of
@@ -77,6 +77,13 @@
7777 token_budget: number | null;
7878 /** Tasks (a JSON list of numbers) that must be ready before this one's first run starts. */
7979 after_tasks: string | null;
80+ /** Plan first: its first runs write a plan in a read-only sandbox, for the person to approve, change or split. */
81+ plan_first: number;
82+ plan_state: PlanState | null;
83+ /** The latest plan; once approved, every later run's prompt carries it. */
84+ plan: string | null;
85+ /** A task a coordinator started while splitting another task's plan: that task. */
86+ parent_task: number | null;
8087 created_at: string;
8188 updated_at: string;
8289 };
@@ -85,7 +92,7 @@
8592 task: number;
8693 seq: number;
8794 /** For a review run, `message` is who asked for it (null when automatic); for a coordinator run, the posts it handles (a JSON list). */
88- kind: "start" | "message" | "retry" | "review" | "coordinate";
95+ kind: "start" | "message" | "retry" | "review" | "coordinate" | "plan" | "build";
8996 message: string | null;
9097 prompt: string;
9198 providers: string;
@@ -243,6 +250,11 @@
243250 ["posts", "handled_by", "handled_by INTEGER"],
244251 ["posts", "memory_version", "memory_version INTEGER"],
245252 ["runs", "applied_at", "applied_at TEXT"],
253+ ["tasks", "plan_first", "plan_first INTEGER NOT NULL DEFAULT 0"],
254+ ["tasks", "plan_state", "plan_state TEXT"],
255+ ["tasks", "plan", "plan TEXT"],
256+ ["tasks", "parent_task", "parent_task INTEGER"],
257+ ["posts", "split_of", "split_of INTEGER"],
246258 ];
247259
248260 /** A finding as stored. */
@@ -292,7 +304,9 @@
292304 /** A title other than the goal's first line. */
293305 title?: string | null;
294306 /** Started by a coordinator handling this post, with a token budget per run, after these tasks are ready. */
295- coordinator?: { post: number; tokenBudget: number; after: number[] };
307+ coordinator?: { post: number; tokenBudget: number; after: number[]; parent?: number | null };
308+ /** Plan first (agentic/project.ts): the first run writes a plan for the person to approve. */
309+ planFirst?: boolean;
296310 }
297311
298312 export interface ReviewRequest {
@@ -344,12 +358,14 @@
344358
345359 projectSettings(): ProjectSettings {
346360 const kv = new Map(this.sql.exec<{ k: string; v: string }>("SELECT k, v FROM project_settings").toArray().map((r) => [r.k, r.v]));
347- return { maxRunning: Number(kv.get("max_running") ?? MAX_RUNNING_DEFAULT) || MAX_RUNNING_DEFAULT, instructions: kv.get("instructions") ?? "" };
361+ const planFirst = kv.get("plan_first");
362+ return { maxRunning: Number(kv.get("max_running") ?? MAX_RUNNING_DEFAULT) || MAX_RUNNING_DEFAULT, instructions: kv.get("instructions") ?? "", planFirst: isPlanFirst(planFirst) ? planFirst : "off" };
348363 }
349364
350365 setProjectSettings(s: Partial<ProjectSettings>): ProjectSettings {
351366 if (s.maxRunning !== undefined) this.sql.exec("INSERT INTO project_settings (k, v) VALUES ('max_running', ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v", String(s.maxRunning));
352367 if (s.instructions !== undefined) this.sql.exec("INSERT INTO project_settings (k, v) VALUES ('instructions', ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v", s.instructions);
368+ if (s.planFirst !== undefined) this.sql.exec("INSERT INTO project_settings (k, v) VALUES ('plan_first', ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v", s.planFirst);
353369 // More room may start queued tasks.
354370 this.promote();
355371 return this.projectSettings();
@@ -468,7 +484,7 @@
468484 const branch = input.pull?.head ?? taskBranch(number, input.title || input.goal);
469485 const title = input.title ? taskTitle(input.title) : taskTitle(input.goal);
470486 this.sql.exec(
471- "INSERT INTO tasks (number, id, repo_id, goal, title, runtime, command, via, model, agent_id, agent_login, person_id, person_login, base, branch, state, pr_number, autofix_of, coordinator_post, token_budget, after_tasks, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?)",
487+ "INSERT INTO tasks (number, id, repo_id, goal, title, runtime, command, via, model, agent_id, agent_login, person_id, person_login, base, branch, state, pr_number, autofix_of, coordinator_post, token_budget, after_tasks, plan_first, plan_state, parent_task, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
472488 number,
473489 crypto.randomUUID(),
474490 input.repoId,
@@ -489,11 +505,14 @@
489505 input.coordinator?.post ?? null,
490506 input.coordinator?.tokenBudget ?? null,
491507 input.coordinator?.after.length ? JSON.stringify(input.coordinator.after) : null,
508+ input.planFirst ? 1 : 0,
509+ input.planFirst ? "planning" : null,
510+ input.coordinator?.parent ?? null,
492511 at,
493512 at,
494513 );
495514 this.event(number, `${input.autofixOf ? "Autofix" : input.coordinator ? `${input.person.login}'s coordinator` : input.person.login} started task #${number}: ${title}`);
496- this.startRun(number, "start", null, input.providers);
515+ this.startRun(number, input.planFirst ? "plan" : "start", null, input.providers);
497516 await this.index(number);
498517 return this.get(number)!;
499518 }
@@ -508,7 +527,8 @@
508527 this.sql.exec("UPDATE tasks SET queued = ?, updated_at = ? WHERE number = ?", t.queued ? `${t.queued}\n\n${text}` : text, now(), number);
509528 this.note(number, this.lastRun(number)?.seq ?? 0, `${from ?? t.person_login} sent a message; it starts a new run when this one ends.`);
510529 } else {
511- this.startRun(number, "message", text, providers);
530+ // While a plan waits, a message asks for changes to it: another plan run.
531+ this.startRun(number, t.plan_state === "planning" || t.plan_state === "waiting" ? "plan" : "message", text, providers);
512532 }
513533 await this.index(number);
514534 return this.get(number)!;
@@ -518,7 +538,7 @@
518538 this.mustTask(number);
519539 if (this.activeRun(number)) throw new HttpError(409, "the task is running; stop it before retrying");
520540 const last = this.lastRun(number);
521- this.startRun(number, "retry", last?.message ?? null, providers);
541+ this.startRun(number, last?.kind === "plan" ? "plan" : "retry", last?.message ?? null, providers);
522542 await this.index(number);
523543 return this.get(number)!;
524544 }
@@ -535,6 +555,40 @@
535555 return this.get(number)!;
536556 }
537557
558+ // ---------------------------------------------------------------- plan first
559+
560+ /** Approve a waiting plan: the build run starts with the plan in its prompt, as does every later run. */
561+ async approvePlan(number: number, providers: Provider[], by: string): Promise<TaskView> {
562+ const t = this.waitingPlan(number);
563+ this.sql.exec("UPDATE tasks SET plan_state = 'approved' WHERE number = ?", number);
564+ this.event(number, `${by} approved the plan of task #${number}.`);
565+ this.startRun(t.number, "build", null, providers);
566+ await this.index(number);
567+ return this.get(number)!;
568+ }
569+
570+ /**
571+ * Split a waiting plan into tasks: the person's coordinator gets it as a post of theirs, and the tasks it starts
572+ * link back to this one, which is then done with.
573+ */
574+ async splitPlan(number: number, settings: CoordinatorSettings, providers: Provider[], repo: { id: string; base: string }): Promise<{ post: number; task: number; status: "started" | "queued" }> {
575+ const t = this.waitingPlan(number);
576+ const head = `Split the plan of task #${number} (${t.title}) into tasks. Its goal was:\n\n${t.goal}\n\nThe plan:\n\n`;
577+ const p = this.addPost("person", settings.person, `${head}${t.plan!}`.slice(0, MAX_POST), number);
578+ this.sql.exec("UPDATE posts SET split_of = ? WHERE n = ?", number, p.n);
579+ this.sql.exec("UPDATE tasks SET plan_state = 'split', updated_at = ? WHERE number = ?", now(), number);
580+ const out = await this.handlePost(p.n, settings, providers, repo);
581+ await this.index(number);
582+ return { post: p.n, ...out };
583+ }
584+
585+ private waitingPlan(number: number): TaskRow {
586+ const t = this.mustTask(number);
587+ if (t.plan_state !== "waiting" || !t.plan) throw new HttpError(409, t.plan_state === "planning" ? "the plan isn't ready yet" : "this task has no plan waiting for approval");
588+ if (this.activeRun(number)) throw new HttpError(409, "the task is running");
589+ return t;
590+ }
591+
538592 // ---------------------------------------------------------------- agent review (agentic/review.ts)
539593
540594 reviewSettings(): ReviewSettings | null {
@@ -731,7 +785,12 @@
731785 // project memory (a pull request mustn't be reviewed against notes an agent wrote); they get the instructions.
732786 const own = t.kind !== "task";
733787 const context = own ? null : projectContext({ instructions: this.projectSettings().instructions, memory: this.memory()?.body ?? null });
734- const prompt = own ? "" : runPrompt({ goal: t.goal, number, branch: t.branch, message: message ?? undefined, previous, by: t.person_login, context });
788+ const prompt = own
789+ ? ""
790+ : kind === "plan"
791+ ? planPrompt({ goal: t.goal, number, by: t.person_login, context, message, plan: t.plan })
792+ : runPrompt({ goal: t.goal, number, branch: t.branch, message: message ?? undefined, previous, by: t.person_login, context, plan: t.plan_state === "approved" ? t.plan : null });
793+ if (kind === "plan") this.sql.exec("UPDATE tasks SET plan_state = 'planning' WHERE number = ?", number);
735794 const at = now();
736795 // A task's run waits its turn under the project's "how many at once"; reviews and coordinators have their own limits.
737796 const step: RunStep = own ? "boot" : "queued";
@@ -743,7 +802,21 @@
743802 this.note(number, seq, `Handling ${posts.map((p) => `post #${p}`).join(", ")} as ${t.agent_login} with ${runtimeLabel(t.runtime, t)} and ${t.person_login}'s model key.`);
744803 } else {
745804 if (message) this.line(number, seq, "note", `${t.person_login}: ${message}`);
746- this.note(number, seq, kind === "retry" ? "Retrying." : kind === "message" ? "Starting a new run with the message." : `${t.coordinator_post ? `${t.person_login}'s coordinator` : t.person_login} started the task with ${runtimeLabel(t.runtime, t)}.`);
805+ this.note(
806+ number,
807+ seq,
808+ kind === "retry"
809+ ? "Retrying."
810+ : kind === "message"
811+ ? "Starting a new run with the message."
812+ : kind === "build"
813+ ? "The plan is approved: building it."
814+ : kind === "plan" && seq > 1
815+ ? message
816+ ? "Planning again with the changes asked for."
817+ : "Planning again."
818+ : `${t.coordinator_post ? `${t.person_login}'s coordinator` : t.person_login} started the task with ${runtimeLabel(t.runtime, t)}${kind === "plan" ? ". It plans first" : ""}.`,
819+ );
747820 }
748821 if (step === "queued" && !this.promote().includes(number) && this.run(number, seq)?.step === "queued") {
749822 const waits = this.waitsFor(t);
@@ -864,6 +937,7 @@
864937 case "finish": {
865938 if (t.kind === "review") return this.collect(t, r);
866939 if (t.kind === "coordinator") return this.coordinate(t, r);
940+ if (r.kind === "plan") return this.keepPlan(t, r);
867941 if (!r.proc) {
868942 return this.startProc(r, "finish", FINISH_SCRIPT, { DIFFINERY_TASK_BRANCH: t.branch, DIFFINERY_TASK_START: r.start_sha ?? "", DIFFINERY_TASK_MESSAGE: commitMessage(t, r) }, {}, 10 * 60 * 1000, `Pushing to ${t.branch}.`);
869943 }
@@ -881,6 +955,18 @@
881955 }
882956 }
883957
958+ /** A plan run is done: keep the plan from its final message, and wait for the person. */
959+ private async keepPlan(t: TaskRow, r: RunRow): Promise<void> {
960+ if ((r.exit_code ?? 0) !== 0) return this.endRun(r, "failed", `${RUNTIMES[t.runtime].name} exited with code ${r.exit_code}.`);
961+ const lines = this.sql.exec<TranscriptLine>("SELECT seq, run, at, stream, text FROM lines WHERE task = ? AND run = ? AND stream = 'out' ORDER BY seq", t.number, r.seq).toArray();
962+ const text = (t.runtime === "custom" ? lines.map((l) => l.text).join("\n") : (finalMessage(lines, Infinity) ?? "")).trim();
963+ if (!text) return this.endRun(r, "failed", "The plan run ended without a plan in its final message.");
964+ const plan = text.length > MAX_PLAN ? `${text.slice(0, MAX_PLAN)}\n\n… (plan cut at ${MAX_PLAN} characters)` : text;
965+ this.sql.exec("UPDATE tasks SET plan = ?, plan_state = 'waiting' WHERE number = ?", plan, t.number);
966+ this.note(t.number, r.seq, `The plan is ready for ${t.person_login}: approve it, ask for changes, or split it into tasks.`);
967+ return this.endRun(r, "done", null);
968+ }
969+
884970 private async boot(t: TaskRow, r: RunRow): Promise<void> {
885971 const { caller, repo } = await this.checkAccess(t);
886972 const stub = this.env.REPO.get(this.env.REPO.idFromName(t.repo_id));
@@ -888,8 +974,10 @@
888974 const ref = branchSha ? t.branch : t.base;
889975 const startSha = branchSha ?? (await stub.getRef(`refs/heads/${t.base}`));
890976 if (!startSha) throw new HttpError(422, `the branch ${t.base} doesn't exist in ${repo.owner_login}/${repo.name}`);
891- this.note(t.number, r.seq, `Starting a sandbox on ${ref} as ${t.agent_login}.`);
892- const sb = await stub.createSandbox(caller, ref, { personId: t.person_id });
977+ // A plan run can't change code: its sandbox is read-only, as a review's.
978+ const plan = r.kind === "plan";
979+ this.note(t.number, r.seq, `Starting a ${plan ? "read-only sandbox to plan" : "sandbox"} on ${ref} as ${t.agent_login}.`);
980+ const sb = await stub.createSandbox(caller, ref, { personId: t.person_id, ...(plan ? { readOnly: true } : {}) });
893981 // Stopped while the sandbox started: close it again.
894982 const now_ = this.run(t.number, r.seq);
895983 if (!now_ || now_.step !== "boot") {
@@ -974,6 +1062,8 @@
9741062 await this.checkAccess(t);
9751063 const repo = await this.repoPath(t);
9761064 const post = handled[0] ?? null;
1065+ // Splitting a plan: the tasks link back to the task whose plan it was.
1066+ const splitOf = handled.map((n) => this.post1(n)?.split_of).find((n) => n) ?? null;
9771067 const providers = JSON.parse(r.providers) as Provider[];
9781068 // From here on its actions take effect; nothing awaits between the check above and this mark.
9791069 if (this.run(r.task, r.seq)?.applied_at) return;
@@ -992,7 +1082,8 @@
9921082 person: settings.person,
9931083 base: x.base ?? t.base,
9941084 providers,
995- coordinator: { post: post!, tokenBudget: settings.tokensPerTask, after: x.after.map((i) => started[i]!) },
1085+ coordinator: { post: post!, tokenBudget: settings.tokensPerTask, after: x.after.map((i) => started[i]!), parent: splitOf },
1086+ planFirst: this.projectSettings().planFirst === "required",
9961087 });
9971088 started.push(task.number);
9981089 this.ctx.waitUntil(new Directory(this.env).audit({ actorId: t.person_id, actorLogin: t.person_login, action: "task.start", target: `${repo}#${task.number}`, detail: { coordinator: true, post, agent: settings.agent.login, runtime: settings.runtime, ...(settings.via ? { provider: settings.via, model: settings.model } : {}), token_budget: settings.tokensPerTask } }));
@@ -1299,11 +1390,12 @@
12991390 if (t.queued && step !== "stopped") {
13001391 this.sql.exec("UPDATE tasks SET queued = NULL WHERE number = ?", r.task);
13011392 if (t.kind === "review") await this.restartReview(t, t.queued === "automatic" ? null : t.queued, JSON.parse(r.providers) as Provider[]);
1302- else this.startRun(r.task, t.kind === "coordinator" ? "coordinate" : "message", t.queued, JSON.parse(r.providers) as Provider[]);
1393+ else this.startRun(r.task, t.kind === "coordinator" ? "coordinate" : this.task(r.task)!.plan_state === "planning" || this.task(r.task)!.plan_state === "waiting" ? "plan" : "message", t.queued, JSON.parse(r.providers) as Provider[]);
13031394 } else {
13041395 this.sql.exec("UPDATE tasks SET state = ?, error = ?, updated_at = ? WHERE number = ?", step === "done" ? "ready" : step, error, at, r.task);
13051396 if (step === "failed") this.event(r.task, `Task #${r.task} failed: ${error ?? "the run failed"}`);
13061397 else if (step === "stopped") this.event(r.task, `Task #${r.task} stopped: ${error ?? "stopped"}`);
1398+ else if (r.kind === "plan") this.event(r.task, `Task #${r.task}'s plan needs approval.`);
13071399 else if (!this.task(r.task)!.pr_number) this.event(r.task, `Task #${r.task} finished with no changes to review.`);
13081400 }
13091401 // Its slot is free: the next queued task starts.
modifiedapps/diffinery/src/agentic/api.ts+13−3View file
@@ -4,10 +4,10 @@
44 import { loadRepo } from "../http/common.js";
55 import { HttpError } from "../lib/util.js";
66 import { isProvider, ModelKeys, type ModelKeyInfo } from "./keys.js";
7-import { getTask, lineJson, messageTask, myTasks, projectStub, requirePersonSession, requireTaskReader, retryTask, startTask, stopTask, taskJson, taskNumber, type StartInput } from "./tasks.js";
7+import { approvePlan, getTask, lineJson, messageTask, myTasks, projectStub, requirePersonSession, requireTaskReader, retryTask, startTask, stopTask, taskJson, taskNumber, type StartInput } from "./tasks.js";
88 import { askForReview, findingJson, reviewSettings, reviewSettingsJson, saveReviewSettings, type SettingsInput } from "./reviews.js";
99 import { gatherToday, todayJson } from "./today.js";
10-import { boardJson, coordinatorJson, handlePost, loadBoard, memoryJson, postJson, postToProject, saveCoordinator, saveMemory, saveProjectSettings, settingsJson, type CoordinatorInput } from "./projects.js";
10+import { boardJson, coordinatorJson, handlePost, splitPlan, loadBoard, memoryJson, postJson, postToProject, saveCoordinator, saveMemory, saveProjectSettings, settingsJson, type CoordinatorInput } from "./projects.js";
1111
1212 async function jsonBody<T>(c: AppContext): Promise<T> {
1313 try {
@@ -177,6 +177,16 @@
177177 return c.json({ ...postJson(base(c), full(l), post), coordinator: handled }, 201);
178178 });
179179
180+agenticApi.post("/repos/:owner/:repo/diffinery/tasks/:number/plan/approve", async (c) => {
181+ const l = await repoOf(c);
182+ return c.json(taskJson(base(c), full(l), await approvePlan(c, l, taskNumber(c.req.param("number")))), 202);
183+});
184+
185+agenticApi.post("/repos/:owner/:repo/diffinery/tasks/:number/plan/split", async (c) => {
186+ const l = await repoOf(c);
187+ return c.json(await splitPlan(c, l, taskNumber(c.req.param("number"))), 202);
188+});
189+
180190 agenticApi.post("/repos/:owner/:repo/diffinery/project/posts/:n/handle", async (c) => {
181191 const l = await repoOf(c);
182192 requireTaskReader(l);
@@ -226,5 +236,5 @@
226236 agenticApi.put("/repos/:owner/:repo/diffinery/project/settings", async (c) => {
227237 const l = await repoOf(c);
228238 requireTaskReader(l);
229- return c.json(settingsJson(await saveProjectSettings(c, l, await jsonBody<{ max_running?: unknown; instructions?: unknown }>(c))));
239+ return c.json(settingsJson(await saveProjectSettings(c, l, await jsonBody<{ max_running?: unknown; instructions?: unknown; plan_first?: unknown }>(c))));
230240 });
modifiedapps/diffinery/src/agentic/project-web.tsx+8−3View file
@@ -10,7 +10,7 @@
1010 import { Form, Markdown, Time, act, flash, post, prm, render, repoUrl, statusOf, type WebApp } from "../web/ui.js";
1111 import { COORDINATOR_TOKENS_DEFAULT, TASK_TOKENS_DEFAULT, TASKS_PER_DAY, TASKS_PER_RUN, TOKENS_MAX, TOKENS_MIN, COORDINATOR_TIMEOUT_MS, type CoordinatorSettings } from "./coordinator.js";
1212 import { ModelKeys, PROVIDERS, type Provider } from "./keys.js";
13-import { BOARD, BOARD_TEXT, lineDiff, MAX_INSTRUCTIONS, MAX_MEMORY, MAX_POST, MAX_RUNNING_MAX, MAX_RUNNING_MIN, type BoardState, type MemoryRow, type PostRow } from "./project.js";
13+import { BOARD, BOARD_TEXT, lineDiff, PLAN_FIRST, MAX_INSTRUCTIONS, MAX_MEMORY, MAX_POST, MAX_RUNNING_MAX, MAX_RUNNING_MIN, type BoardState, type MemoryRow, type PostRow } from "./project.js";
1414 import type { TaskRow } from "./ProjectDO.js";
1515 import { handlePost, loadBoard, postToProject, saveCoordinator, saveMemory, saveProjectSettings, type Handled } from "./projects.js";
1616 import { parseRuntimeChoice, runtimeChoices, runtimeLabel } from "./runtimes.js";
@@ -478,7 +478,7 @@
478478 );
479479 }
480480
481-async function settingsForm(c: AppContext, l: Loaded, error?: string, values?: { max_running?: string; instructions?: string }) {
481+async function settingsForm(c: AppContext, l: Loaded, error?: string, values?: { max_running?: string; instructions?: string; plan_first?: string }) {
482482 const base = repoUrl(l.repo);
483483 const s = await projectStub(c.env, l.repo.id).projectSettings();
484484 return settingsPage(
@@ -501,6 +501,11 @@
501501 </div>
502502 </div>
503503 <div class="field">
504+ <label>Plan first</label>
505+ <KRadio name="plan_first" label="Plan first" value={values?.plan_first ?? s.planFirst} items={Object.entries(PLAN_FIRST).map(([value, label]) => ({ value, label }))} />
506+ <div class="hint">A task that plans first writes a plan in a read-only sandbox, then waits for the person who started it to approve it, ask for changes, or split it into tasks with their coordinator. Autofix tasks never plan first.</div>
507+ </div>
508+ <div class="field">
504509 <label for="instructions">Project instructions</label>
505510 <textarea id="instructions" name="instructions" rows={10} maxlength={MAX_INSTRUCTIONS} placeholder="For example: run pnpm test before opening a pull request. Keep changes small.">
506511 {values?.instructions ?? s.instructions}
@@ -615,7 +620,7 @@
615620 post(web, "/:owner/:repo/settings/project", async (c, f) => {
616621 const l = await admin(c);
617622 requireAccount(c);
618- const values = { max_running: f.s("max_running"), instructions: f.s("instructions") };
623+ const values = { max_running: f.s("max_running"), instructions: f.s("instructions"), plan_first: f.s("plan_first") || "off" };
619624 try {
620625 await saveProjectSettings(c, l, values);
621626 } catch (e) {
modifiedapps/diffinery/src/agentic/project.ts+18−2View file
@@ -20,8 +20,19 @@
2020 maxRunning: number;
2121 /** Text every task's prompt carries, set by repository admins. */
2222 instructions: string;
23+ /** Whether new tasks plan first: never by default, by default (people can untick it), or always. */
24+ planFirst: PlanFirst;
2325 }
2426
27+export const PLAN_FIRST = { off: "Off: tasks start building at once, unless someone ticks Plan first", default: "On by default: Plan first is ticked on new tasks", required: "Required: every task plans first, including ones coordinators start" } as const;
28+export type PlanFirst = keyof typeof PLAN_FIRST;
29+export const isPlanFirst = (v: unknown): v is PlanFirst => typeof v === "string" && Object.hasOwn(PLAN_FIRST, v);
30+/** The longest plan kept from a plan run's final message. */
31+export const MAX_PLAN = 20_000;
32+
33+/** Where a plan-first task stands: its plan runs, the plan waits for the person, then it is built or split. */
34+export type PlanState = "planning" | "waiting" | "approved" | "split";
35+
2536 /** A line in the project conversation: a person's post, a coordinator's reply, or an event from a task. */
2637 export type PostRow = {
2738 n: number;
@@ -34,6 +45,8 @@
3445 handled_by: number | null;
3546 /** A memory change by a coordinator: the version it made. */
3647 memory_version: number | null;
48+ /** A post that asks the coordinator to split a task's plan: that task. */
49+ split_of: number | null;
3750 at: string;
3851 };
3952
@@ -51,7 +64,7 @@
5164 export type BoardState = (typeof BOARD)[number];
5265
5366 export const BOARD_TEXT: Record<BoardState, { label: string; tone: string; hint: string }> = {
54- waiting: { label: "Waiting on you", tone: "amber", hint: "Failed, or its pull request has failing checks." },
67+ waiting: { label: "Waiting on you", tone: "amber", hint: "Failed, a plan to approve, or its pull request has failing checks." },
5568 working: { label: "Working", tone: "blue", hint: "The agent is running, or its pull request's checks are." },
5669 queued: { label: "Queued", tone: "", hint: "Starts when a running task finishes." },
5770 review: { label: "Ready for review", tone: "green", hint: "Pull request open, checks green." },
@@ -64,8 +77,11 @@
6477 export type PullState = { open: true; pull: Pick<TodayPull, "checks" | "approvedHead" | "autoMerge" | "queued" | "draft"> } | { open: false; merged: boolean };
6578
6679 /** Where a task stands on the board: its own state, then its pull request's. */
67-export function boardState(task: { state: string; pr_number: number | null }, pull: PullState | null): BoardState {
80+export function boardState(task: { state: string; pr_number: number | null; plan_state?: PlanState | null }, pull: PullState | null): BoardState {
6881 if (task.state === "queued") return "queued";
82+ // A plan waiting for approval waits on you; a plan split into other tasks is done with.
83+ if (task.state === "ready" && task.plan_state === "waiting") return "waiting";
84+ if (task.state === "ready" && task.plan_state === "split") return "idle";
6985 if (task.state === "running") return "working";
7086 if (task.state === "failed") return "waiting";
7187 if (task.state === "stopped") return "idle";
modifiedapps/diffinery/src/agentic/projects.ts+27−10View file
@@ -5,10 +5,10 @@
55 import type { Account } from "../lib/directory.js";
66 import { taskCaller, TaskAccessError } from "./access.js";
77 import { COORDINATOR_TOKENS_DEFAULT, TASK_TOKENS_DEFAULT, TOKENS_MAX, TOKENS_MIN, type CoordinatorSettings } from "./coordinator.js";
8-import { BOARD, boardState, MAX_INSTRUCTIONS, MAX_MEMORY, MAX_POST, validMaxRunning, type BoardState, type MemoryRow, type PostRow, type ProjectSettings, type PullState } from "./project.js";
8+import { BOARD, boardState, isPlanFirst, MAX_INSTRUCTIONS, MAX_MEMORY, MAX_POST, validMaxRunning, type BoardState, type MemoryRow, type PostRow, type ProjectSettings, type PullState } from "./project.js";
99 import type { TaskRow } from "./ProjectDO.js";
1010 import { isRuntime, MAX_COMMAND } from "./runtimes.js";
11-import { modelRoute, projectStub, providersFor, requirePersonSession, taskJson } from "./tasks.js";
11+import { modelRoute, projectStub, providersFor, requirePersonSession, starter, taskJson } from "./tasks.js";
1212
1313 /**
1414 * The project layer over a repository (agentic/project.ts): the board, the
@@ -79,6 +79,15 @@
7979 }
8080 }
8181
82+/** Split a task's waiting plan into tasks: the person who started it hands it to their coordinator. */
83+export async function splitPlan(c: AppContext, l: Loaded, number: number): Promise<{ post: number; task: number; status: "started" | "queued" }> {
84+ const { person } = await starter(c, l, number);
85+ const { s, providers } = await coordinatorFor(c, l, person);
86+ const out = await projectStub(c.env, l.repo.id).splitPlan(number, s, providers, { id: l.repo.id, base: l.repo.default_branch });
87+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "task.plan_split", target: `${l.repo.owner_login}/${l.repo.name}#${number}`, detail: { post: out.post, coordinator_task: out.task } }));
88+ return out;
89+}
90+
8291 /** "Handle this": hand a post nobody's coordinator took to yours. */
8392 export async function handlePost(c: AppContext, l: Loaded, n: number): Promise<{ task: number; status: "started" | "queued" }> {
8493 const person = requirePersonSession(c);
@@ -86,9 +95,9 @@
8695 return handleAs(c, l, person, n);
8796 }
8897
89-async function handleAs(c: AppContext, l: Loaded, person: Account, n: number) {
90- const project = projectStub(c.env, l.repo.id);
91- const s = await project.coordinator(person.id);
98+/** A person's coordinator here, checked: they may still drive its agent, and have the keys its runtime needs. */
99+async function coordinatorFor(c: AppContext, l: Loaded, person: Account) {
100+ const s = await projectStub(c.env, l.repo.id).coordinator(person.id);
92101 if (!s) throw new HttpError(422, "set up your coordinator for this project first");
93102 const agent = await c.get("dir").account(s.agent.id);
94103 if (!agent || agent.kind !== "agent") throw new HttpError(422, "your coordinator's agent no longer exists: choose another");
@@ -98,8 +107,12 @@
98107 if (e instanceof TaskAccessError) throw new HttpError(403, e.message);
99108 throw e;
100109 }
101- const providers = await providersFor(c.env, person, s.runtime, s.via);
102- const out = await project.handlePost(n, s, providers, { id: l.repo.id, base: l.repo.default_branch });
110+ return { s, providers: await providersFor(c.env, person, s.runtime, s.via) };
111+}
112+
113+async function handleAs(c: AppContext, l: Loaded, person: Account, n: number) {
114+ const { s, providers } = await coordinatorFor(c, l, person);
115+ const out = await projectStub(c.env, l.repo.id).handlePost(n, s, providers, { id: l.repo.id, base: l.repo.default_branch });
103116 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "project.coordinate", target: `${l.repo.owner_login}/${l.repo.name}`, detail: { post: n, task: out.task, agent: s.agent.login, runtime: s.runtime } }));
104117 return out;
105118 }
@@ -177,7 +190,7 @@
177190 return m;
178191 }
179192
180-export async function saveProjectSettings(c: AppContext, l: Loaded, input: { max_running?: unknown; instructions?: unknown }): Promise<ProjectSettings> {
193+export async function saveProjectSettings(c: AppContext, l: Loaded, input: { max_running?: unknown; instructions?: unknown; plan_first?: unknown }): Promise<ProjectSettings> {
181194 const person = requirePersonSession(c);
182195 if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)");
183196 if (!atLeast(l.caller.role, "admin")) throw new HttpError(403, "admin access required");
@@ -195,8 +208,12 @@
195208 if (text.length > MAX_INSTRUCTIONS) throw new HttpError(422, `\`instructions\` is longer than ${MAX_INSTRUCTIONS} characters`);
196209 next.instructions = text;
197210 }
211+ if (input.plan_first !== undefined) {
212+ if (!isPlanFirst(input.plan_first)) throw new HttpError(422, "`plan_first` must be off, default or required");
213+ next.planFirst = input.plan_first;
214+ }
198215 const s = await projectStub(c.env, l.repo.id).setProjectSettings(next);
199- c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "project.settings", target: `${l.repo.owner_login}/${l.repo.name}`, detail: { max_running: s.maxRunning, instructions_length: s.instructions.length } }));
216+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "project.settings", target: `${l.repo.owner_login}/${l.repo.name}`, detail: { max_running: s.maxRunning, instructions_length: s.instructions.length, plan_first: s.planFirst } }));
200217 return s;
201218 }
202219
@@ -218,7 +235,7 @@
218235
219236 export const memoryJson = (m: MemoryRow | null) => (m ? { body: m.body, version: m.n, updated_by: { login: m.by_login, type: m.by_kind === "person" ? "User" : "Bot" }, updated_at: m.at } : { body: "", version: null, updated_by: null, updated_at: null });
220237
221-export const settingsJson = (s: ProjectSettings) => ({ max_running: s.maxRunning, instructions: s.instructions });
238+export const settingsJson = (s: ProjectSettings) => ({ max_running: s.maxRunning, instructions: s.instructions, plan_first: s.planFirst });
222239
223240 export function boardJson(base: string, repoFull: string, b: Board) {
224241 return {
modifiedapps/diffinery/src/agentic/runtimes.ts+22−4View file
@@ -163,18 +163,36 @@
163163 return { env, secret };
164164 }
165165
166-/** The prompt for a run: the goal, plus what came before for a follow-up or a retry. */
167-export function runPrompt(o: { goal: string; number: number; branch: string; message?: string; previous?: string | null; by: string; context?: string | null }): string {
166+/** The prompt for a run: the goal, plus what came before for a follow-up or a retry, and the plan a person approved. */
167+export function runPrompt(o: { goal: string; number: number; branch: string; message?: string; previous?: string | null; by: string; context?: string | null; plan?: string | null }): string {
168168 const rules = [
169169 `You are working on task #${o.number} in this Git repository, on behalf of ${o.by}.`,
170170 "Work in the current directory. When you are done, leave your changes in the working tree or commit them; Diffinery pushes them to the branch " + o.branch + " and opens a pull request for review.",
171171 "Don't push, change Git remotes or credentials, or open pull requests yourself.",
172172 ].join(" ");
173173 const head = o.context ? `${rules}\n\n${o.context}` : rules;
174- if (!o.message) return `${head}\n\nGoal:\n${o.goal}`;
175- return [head, "", "The original goal:", o.goal, ...(o.previous ? ["", "Where the previous run ended:", o.previous] : []), "", "The branch already has the previous runs' work checked out.", "", `New instructions from ${o.by}:`, o.message].join("\n");
174+ const plan = o.plan ? `\n\nThe plan for this goal, which ${o.by} approved. Follow it:\n--- plan ---\n${o.plan}\n--- end of plan ---` : "";
175+ if (!o.message) return `${head}\n\nGoal:\n${o.goal}${plan}`;
176+ return [head, "", "The original goal:", o.goal + plan, ...(o.previous ? ["", "Where the previous run ended:", o.previous] : []), "", "The branch already has the previous runs' work checked out.", "", `New instructions from ${o.by}:`, o.message].join("\n");
176177 }
177178
179+/**
180+ * The prompt for a plan run: the agent reads the code in a read-only sandbox and writes a plan, which waits for
181+ * the person to approve, change or split. A run asked to change the plan gets the plan it replaces.
182+ */
183+export function planPrompt(o: { goal: string; number: number; by: string; context?: string | null; message?: string | null; plan?: string | null }): string {
184+ const rules = [
185+ `You are planning task #${o.number} in this Git repository, on behalf of ${o.by}. Plan only: the checkout is read-only, so don't change files, commit or push; nothing you change is kept.`,
186+ "Read the code and run read-only commands to understand it. Then write a plan another agent can build from: what to change and where, in what order, how to test it, and the risks and open questions.",
187+ `End your final message with the whole plan, in Markdown. ${o.by} approves it, asks for changes or splits it into tasks.`,
188+ ].join(" ");
189+ const head = o.context ? `${rules}\n\n${o.context}` : rules;
190+ const parts = [head, "", "Goal:", o.goal];
191+ if (o.plan && o.message) parts.push("", "Your previous plan:", "--- plan ---", o.plan, "--- end of plan ---", "", `Changes ${o.by} asked for:`, o.message);
192+ else if (o.message) parts.push("", `Also from ${o.by}:`, o.message);
193+ return parts.join("\n");
194+}
195+
178196 /** A transcript line, as stored. */
179197 export type TranscriptLine = {
180198 seq: number;
modifiedapps/diffinery/src/agentic/tasks-web.tsx+80−8View file
@@ -11,7 +11,8 @@
1111 import type { TaskRow, TaskView } from "./ProjectDO.js";
1212 import { fixGoal } from "./review.js";
1313 import { OPENROUTER_DEFAULT_MODEL, parseRuntimeChoice, runtimeChoices, runtimeLabel, transcriptItem, type TranscriptItem } from "./runtimes.js";
14-import { drivableAgents, getTask, mayStop, messageTask, projectStub, requireTaskReader, retryTask, startTask, stopTask, taskNumber } from "./tasks.js";
14+import { approvePlan, drivableAgents, getTask, mayStop, messageTask, projectStub, requireTaskReader, retryTask, startTask, stopTask, taskNumber } from "./tasks.js";
15+import { splitPlan } from "./projects.js";
1516
1617 /**
1718 * Web pages for agent tasks: Settings → Model access, and a repository's
@@ -188,7 +189,7 @@
188189 return <pre class={`tx-pre${item.kind === "error" ? " tx-err" : ""}`}>{item.body}</pre>;
189190 };
190191
191-function taskPage(c: AppContext, l: Loaded, t: TaskView, items: Array<{ run: number; item: TranscriptItem }>, truncated: boolean) {
192+function taskPage(c: AppContext, l: Loaded, t: TaskView, items: Array<{ run: number; item: TranscriptItem }>, truncated: boolean, canSplit = false) {
192193 const base = repoUrl(l.repo);
193194 const tk = csrfToken(c);
194195 const me = requireAccount(c);
@@ -237,6 +238,51 @@
237238 <div class="body"><Markdown text={t.goal} /></div>
238239 </div>
239240 {t.error ? <div class="flash error">{t.error}</div> : null}
241+ {t.plan && t.plan_state === "waiting" ? (
242+ <div class="box" id="plan">
243+ <div class="head">
244+ <h2>Plan</h2>
245+ <span class="badge amber">Waiting for {t.person_login}</span>
246+ </div>
247+ <div class="body">
248+ <Markdown text={t.plan} />
249+ {starter ? (
250+ <div class="row mt" style="gap:8px;flex-wrap:wrap">
251+ <Form action={`${base}/tasks/${t.number}/plan/approve`} t={tk} class="inline">
252+ <button class="btn primary">Approve plan</button>
253+ </Form>
254+ {canSplit ? (
255+ <Form action={`${base}/tasks/${t.number}/plan/split`} t={tk} class="inline" confirm="Hand this plan to your coordinator to split into tasks? This task is then done with.">
256+ <button class="btn">Split into tasks</button>
257+ </Form>
258+ ) : (
259+ <span class="muted small">
260+ <a href={`${base}/project/coordinator`}>Set up your coordinator</a> to split plans into tasks.
261+ </span>
262+ )}
263+ <span class="muted small">Or ask for changes below.</span>
264+ </div>
265+ ) : null}
266+ </div>
267+ </div>
268+ ) : t.plan ? (
269+ <details class="box">
270+ <summary class="head">
271+ <h2>Plan</h2>
272+ <span class="badge">{t.plan_state === "split" ? "Split into tasks" : t.plan_state === "approved" ? "Approved" : "Being revised"}</span>
273+ </summary>
274+ <div class="body">
275+ <Markdown text={t.plan} />
276+ </div>
277+ </details>
278+ ) : t.plan_state === "planning" ? (
279+ <div class="flash info">This task plans first: its run reads the code in a read-only sandbox and writes a plan for {t.person_login} to approve, change or split into tasks.</div>
280+ ) : null}
281+ {t.parent_task ? (
282+ <div class="flash info">
283+ Split from the plan of <a href={`${base}/tasks/${t.parent_task}`}>task #{t.parent_task}</a>.
284+ </div>
285+ ) : null}
240286 {t.queued ? <div class="flash info">A message is waiting for the current run to end: {t.queued}</div> : null}
241287 <div class="box">
242288 <div class="head">
@@ -260,7 +306,7 @@
260306 {starter ? (
261307 <Form action={`${base}/tasks/${t.number}/messages`} t={tk} class="box">
262308 <div class="head">
263- <h2>{running ? "Send a follow-up" : "Continue the task"}</h2>
309+ <h2>{t.plan_state === "waiting" ? "Ask for changes to the plan" : running ? "Send a follow-up" : "Continue the task"}</h2>
264310 </div>
265311 <div class="body">
266312 <div class="field">
@@ -284,11 +330,11 @@
284330 );
285331 }
286332
287-async function newTaskPage(c: AppContext, l: Loaded, error?: string, v: { goal?: string; runtime?: string; agent?: string; base?: string; command?: string; model?: string; pull?: string } = {}) {
333+async function newTaskPage(c: AppContext, l: Loaded, error?: string, v: { goal?: string; runtime?: string; agent?: string; base?: string; command?: string; model?: string; pull?: string; plan_first?: string } = {}) {
288334 const me = requireAccount(c);
289335 const base = repoUrl(l.repo);
290336 const tk = csrfToken(c);
291- const [agents, keys, branches] = await Promise.all([drivableAgents(c, me, l), new ModelKeys(c.env).providers(me.id), l.stub.refs("refs/heads/")]);
337+ const [agents, keys, branches, settings] = await Promise.all([drivableAgents(c, me, l), new ModelKeys(c.env).providers(me.id), l.stub.refs("refs/heads/"), projectStub(c.env, l.repo.id).projectSettings()]);
292338 const runtimes = runtimeChoices(keys, providerNames);
293339 const firstUsable = runtimes.find((r) => !r.disabled)?.value ?? "custom";
294340 return render(
@@ -346,6 +392,14 @@
346392 </div>
347393 )}
348394 </div>
395+ <div class="field">
396+ <KCheckbox
397+ name="plan_first"
398+ checked={settings.planFirst === "required" || (v.plan_first ?? (settings.planFirst === "default" ? "1" : "")) === "1"}
399+ disabled={settings.planFirst === "required"}
400+ label={<>Plan first: the agent reads the code and writes a plan in a read-only sandbox; it builds once you approve the plan{settings.planFirst === "required" ? " (this project requires it)" : ""}</>}
401+ />
402+ </div>
349403 <button class="btn primary" disabled={!agents.length}>
350404 Start task
351405 </button>
@@ -428,10 +482,10 @@
428482
429483 post(web, "/:owner/:repo/tasks", async (c, f) => {
430484 const l = await forTasks(c);
431- const v = { goal: f.s("goal"), runtime: f.s("runtime") || "claude-code", agent: f.s("agent"), base: f.s("base"), command: f.s("command"), model: f.s("model"), pull: f.s("pull") || undefined };
485+ const v = { goal: f.s("goal"), runtime: f.s("runtime") || "claude-code", agent: f.s("agent"), base: f.s("base"), command: f.s("command"), model: f.s("model"), pull: f.s("pull") || undefined, plan_first: f.all("plan_first").length ? "1" : "" };
432486 try {
433487 const { runtime, provider } = parseRuntimeChoice(v.runtime);
434- const t = await startTask(c, l, { ...v, runtime, provider, model: provider ? v.model : undefined });
488+ const t = await startTask(c, l, { ...v, runtime, provider, model: provider ? v.model : undefined, plan_first: v.plan_first === "1" });
435489 return c.redirect(`${repoUrl(l.repo)}/tasks/${t.number}`, 303);
436490 } catch (e) {
437491 const st = statusOf(e);
@@ -449,7 +503,8 @@
449503 const lines = await stub.lines(n, 0, 5000);
450504 const tail = lines.slice(-1500);
451505 const items = tail.flatMap((line) => transcriptItem(line).map((item) => ({ run: line.run, item })));
452- return taskPage(c, l, t, items, lines.length > tail.length);
506+ const canSplit = t.plan_state === "waiting" && !!(await stub.coordinator(requireAccount(c).id));
507+ return taskPage(c, l, t, items, lines.length > tail.length, canSplit);
453508 });
454509
455510 post(web, "/:owner/:repo/tasks/:number/messages", async (c, f) => {
@@ -460,6 +515,23 @@
460515 });
461516 });
462517
518+ post(web, "/:owner/:repo/tasks/:number/plan/approve", async (c) => {
519+ const l = await forTasks(c);
520+ const n = taskNumber(c.req.param("number"));
521+ return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => {
522+ await approvePlan(c, l, n);
523+ }, "Plan approved: building it.");
524+ });
525+
526+ post(web, "/:owner/:repo/tasks/:number/plan/split", async (c) => {
527+ const l = await forTasks(c);
528+ const n = taskNumber(c.req.param("number"));
529+ return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => {
530+ const out = await splitPlan(c, l, n);
531+ return `${repoUrl(l.repo)}/project#post-${out.post}`;
532+ }, "Your coordinator is splitting the plan into tasks.");
533+ });
534+
463535 post(web, "/:owner/:repo/tasks/:number/retry", async (c) => {
464536 const l = await forTasks(c);
465537 const n = taskNumber(c.req.param("number"));
modifiedapps/diffinery/src/agentic/tasks.ts+20−3View file
@@ -100,6 +100,8 @@
100100 model?: unknown;
101101 /** Work on this pull request's branch (Fix it) instead of a new branch. */
102102 pull?: unknown;
103+ /** Plan first: true or false; left out, the project's default. A project can require it. */
104+ plan_first?: unknown;
103105 }
104106
105107 export async function startTask(c: AppContext, l: Loaded, input: StartInput): Promise<TaskView> {
@@ -131,14 +133,18 @@
131133 base = issue.pull.base;
132134 }
133135 if (!(await l.stub.getRef(`refs/heads/${base}`))) throw new HttpError(422, `branch ${base} doesn't exist`);
136+ if (input.plan_first !== undefined && typeof input.plan_first !== "boolean") throw new HttpError(422, "`plan_first` must be true or false");
134137 const providers = await providersFor(c.env, person, runtime, route.via);
135- const t = await projectStub(c.env, l.repo.id).create({ repoId: l.repo.id, goal, runtime, command, route, agent: { id: agent.id, login: agent.login }, person: { id: person.id, login: person.login }, base, providers, pull });
136- c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "task.start", target: `${l.repo.owner_login}/${l.repo.name}#${t.number}`, detail: { agent: agent.login, runtime, ...(route.via ? { provider: route.via, model: route.model } : {}) } }));
138+ const project = projectStub(c.env, l.repo.id);
139+ const { planFirst: policy } = await project.projectSettings();
140+ const planFirst = policy === "required" || (input.plan_first === undefined ? policy === "default" : input.plan_first === true);
141+ const t = await project.create({ repoId: l.repo.id, goal, runtime, command, route, agent: { id: agent.id, login: agent.login }, person: { id: person.id, login: person.login }, base, providers, pull, planFirst });
142+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "task.start", target: `${l.repo.owner_login}/${l.repo.name}#${t.number}`, detail: { agent: agent.login, runtime, ...(route.via ? { provider: route.via, model: route.model } : {}), ...(planFirst ? { plan_first: true } : {}) } }));
137143 return t;
138144 }
139145
140146 /** The person who started the task, in a browser session, still allowed to drive its agent here. */
141-async function starter(c: AppContext, l: Loaded, number: number): Promise<{ person: Account; task: TaskView }> {
147+export async function starter(c: AppContext, l: Loaded, number: number): Promise<{ person: Account; task: TaskView }> {
142148 const person = requirePersonSession(c);
143149 const task = await getTask(c, l, number);
144150 if (task.kind === "review") throw new HttpError(409, "an agent review takes no messages and isn't retried: ask for a new review on its pull request");
@@ -161,6 +167,14 @@
161167 return t;
162168 }
163169
170+/** Approve a task's waiting plan: its build run starts. */
171+export async function approvePlan(c: AppContext, l: Loaded, number: number): Promise<TaskView> {
172+ const { person, task } = await starter(c, l, number);
173+ const t = await projectStub(c.env, l.repo.id).approvePlan(number, await providersFor(c.env, person, task.runtime, task.via), person.login);
174+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "task.plan_approve", target: `${l.repo.owner_login}/${l.repo.name}#${number}` }));
175+ return t;
176+}
177+
164178 export async function retryTask(c: AppContext, l: Loaded, number: number): Promise<TaskView> {
165179 const { person, task } = await starter(c, l, number);
166180 const t = await projectStub(c.env, l.repo.id).retry(number, await providersFor(c.env, person, task.runtime, task.via));
@@ -207,6 +221,9 @@
207221 model: t.model ?? null,
208222 agent: { login: t.agent_login, type: "Bot" },
209223 started_by: { login: t.person_login, type: "User" },
224+ plan_first: !!t.plan_first,
225+ ...(t.plan_first ? { plan_state: t.plan_state, plan: t.plan } : {}),
226+ ...(t.parent_task ? { parent_task: t.parent_task } : {}),
210227 // Security 3b: a task a coordinator started says so, with the post it came from.
211228 ...(t.coordinator_post ? { started_via: { type: "coordinator", post: t.coordinator_post }, token_budget: t.token_budget, waits_for: t.after_tasks ? (JSON.parse(t.after_tasks) as number[]) : [] } : {}),
212229 base: t.base,
modifiedapps/diffinery/test/e2e/tasks.test.mjs+142−3View file
@@ -464,7 +464,7 @@
464464 // Settings: an admin in a browser; never a token.
465465 assert.equal((await api("PUT", `${P}/settings`, { max_running: 1 })).status, 403);
466466 for (const bad of [0, 11, 1.5, "x"]) assert.equal((await asPerson("PUT", `${P}/settings`, { max_running: bad })).status, 422, String(bad));
467- assert.deepEqual((await asPerson("PUT", `${P}/settings`, { max_running: 1, instructions: "Always say PROJECT-RULE-7." })).json, { max_running: 1, instructions: "Always say PROJECT-RULE-7." });
467+ assert.deepEqual((await asPerson("PUT", `${P}/settings`, { max_running: 1, instructions: "Always say PROJECT-RULE-7." })).json, { max_running: 1, instructions: "Always say PROJECT-RULE-7.", plan_first: "off" });
468468
469469 // Memory: every save is a version, and a restore is one more.
470470 assert.equal((await api("PUT", `${P}/memory`, { body: "x" })).status, 403);
@@ -561,8 +561,10 @@
561561 return fetch(`${base}/${owner}/tasks-demo/${path}`, { method: "POST", redirect: "manual", headers: { cookie: `dfy_csrf=${cookie}`, "sec-fetch-site": "same-origin" }, body: new URLSearchParams({ _csrf: csrf ?? "", ...fields }) });
562562 };
563563 assert.equal((await form("settings/project", { max_running: "0", instructions: "" })).status, 422);
564- assert.equal((await form("settings/project", { max_running: "3", instructions: "" })).status, 303);
565- assert.deepEqual((await ok("GET", P)).settings, { max_running: 3, instructions: "" });
564+ assert.equal((await form("settings/project", { max_running: "3", instructions: "", plan_first: "sometimes" })).status, 422);
565+ assert.equal((await form("settings/project", { max_running: "3", instructions: "", plan_first: "default" })).status, 303);
566+ assert.deepEqual((await ok("GET", P)).settings, { max_running: 3, instructions: "", plan_first: "default" });
567+ assert.equal((await form("settings/project", { max_running: "3", instructions: "", plan_first: "off" })).status, 303);
566568 assert.equal((await form("project/posts", { body: "Posted from the page" })).status, 303);
567569 assert.ok((await ok("GET", `${P}/posts`)).some((p) => p.body === "Posted from the page"));
568570 assert.equal((await form("project/memory/2/restore", {})).status, 303);
@@ -746,6 +748,143 @@
746748 assert.equal((await asPerson("POST", `${P}/posts/${after.json.id}/handle`)).status, 422);
747749 });
748750
751+test("plan first: a read-only plan run, then approve, change or split the plan", async () => {
752+ const me = await ok("GET", "/user");
753+ const owner = me.login;
754+ const P = `/repos/${owner}/tasks-demo/diffinery/project`;
755+ const R = `/repos/${owner}/tasks-demo/diffinery/tasks`;
756+ const src = join(work, "src");
757+ writeFileSync(
758+ join(src, "plan.sh"),
759+ `p="$DIFFINERY_TASK_PROMPT"
760+case "$p" in
761+ *"You are the project coordinator"*)
762+ post=$(printf '%s\\n' "$p" | sed -n '/^--- post #/,/^--- end of post ---/p')
763+ case "$post" in
764+ *"Split the plan of task #"*"PLAN-V1-STEP"*) printf '%s\\n' '\`\`\`json' '{"reply": "Split it.", "tasks": [{"title": "Plan part one", "goal": "PLAN-PART-ONE: build step one."}]}' '\`\`\`' ;;
765+ *) printf '%s\\n' '\`\`\`json' '{"reply": "Noted."}' '\`\`\`' ;;
766+ esac ;;
767+ *"You are planning task"*)
768+ echo planned > should-not-land.txt
769+ git add -A && git commit -qm "plan run commit" && git push -q origin HEAD:refs/heads/plan-run-push 2>/dev/null && echo "PUSHED" >&2
770+ case "$p" in
771+ *"Your previous plan:"*"Use the KV store"*) printf '%s\\n' '## Plan' '1. PLAN-V2-STEP: write planned.txt, with the KV store.' ;;
772+ *) printf '%s\\n' '## Plan' '1. PLAN-V1-STEP: write planned.txt.' ;;
773+ esac ;;
774+ *"approved. Follow it:"*)
775+ printf '%s\\n' "$p" | sed -n '/^--- plan ---$/,/^--- end of plan ---$/p'
776+ echo built > planned.txt ;;
777+ *PLAN-PART-ONE*) echo part > part.txt ;;
778+ *) echo other > other.txt ;;
779+esac
780+`,
781+ );
782+ git(src, ["pull", "-q", "--rebase", "origin", "main"]);
783+ git(src, ["add", "plan.sh"]);
784+ git(src, ["commit", "-q", "-m", "plan script"]);
785+ git(src, ["push", "-q", "origin", "main"]);
786+ const settled = (n, what, fn = () => true) =>
787+ until(what, async () => {
788+ const t = await ok("GET", `${R}/${n}`);
789+ const last = t.runs.at(-1);
790+ return last && !["queued", "boot", "install", "agent", "finish"].includes(last.state) && fn(t) ? t : null;
791+ });
792+ const transcript = async (n) => (await ok("GET", `${R}/${n}/transcript`)).map((l) => l.text).join("\n");
793+
794+ // A plan-first task's first run plans in a read-only sandbox: nothing it changes is pushed.
795+ assert.equal((await asPerson("POST", R, { goal: "x", runtime: "custom", command: "sh plan.sh", agent: "task-bot", plan_first: "yes" })).status, 422);
796+ const started = await asPerson("POST", R, { goal: "PLANNED-GOAL: write planned.txt", runtime: "custom", command: "sh plan.sh", agent: "task-bot", plan_first: true });
797+ assert.equal(started.status, 201, JSON.stringify(started.json));
798+ const n = started.json.number;
799+ assert.equal(started.json.plan_first, true);
800+ assert.equal(started.json.plan_state, "planning");
801+ const planned = await settled(n, "the plan");
802+ const text1 = await transcript(n);
803+ assert.equal(planned.runs[0].kind, "plan");
804+ assert.equal(planned.runs[0].state, "done", text1);
805+ assert.equal(planned.state, "ready");
806+ assert.equal(planned.plan_state, "waiting");
807+ assert.match(planned.plan, /^## Plan\n1\. PLAN-V1-STEP: write planned\.txt\.$/);
808+ assert.equal(planned.pull_request, null);
809+ assert.match(text1, /Starting a read-only sandbox to plan on main as task-bot\./);
810+ assert.match(text1, /It plans first\./);
811+ assert.ok(!text1.includes("PUSHED"), "the plan run's push was refused");
812+ assert.equal(git(src, ["ls-remote", "origin", "refs/heads/plan-run-push", `refs/heads/${planned.branch}`]).trim(), "", "nothing was pushed");
813+ const board = await ok("GET", P);
814+ assert.ok(board.board.waiting.some((t) => t.number === n), "a plan waiting for approval waits on you");
815+ assert.ok((await ok("GET", `${P}/posts`)).some((p) => p.body === `Task #${n}'s plan needs approval.`));
816+ const page = await (await fetch(`${base}/${owner}/tasks-demo/tasks/${n}`)).text();
817+ assert.match(page, /PLAN-V1-STEP/);
818+ assert.match(page, /Approve plan/);
819+
820+ // Asking for changes plans again, with the plan it replaces.
821+ assert.equal((await asPerson("POST", `${R}/${n}/messages`, { body: "Use the KV store" })).status, 201);
822+ const replanned = await settled(n, "the second plan", (t) => t.runs.length === 2);
823+ assert.equal(replanned.runs[1].kind, "plan");
824+ assert.equal(replanned.plan_state, "waiting");
825+ assert.match(replanned.plan, /PLAN-V2-STEP/);
826+ assert.match(await transcript(n), /Planning again with the changes asked for\./);
827+
828+ // Approving starts the build run, which carries the approved plan; only a person in a browser may approve.
829+ assert.equal((await api("POST", `${R}/${n}/plan/approve`)).status, 403);
830+ const approved = await asPerson("POST", `${R}/${n}/plan/approve`);
831+ assert.equal(approved.status, 202, JSON.stringify(approved.json));
832+ assert.equal(approved.json.plan_state, "approved");
833+ assert.equal((await asPerson("POST", `${R}/${n}/plan/approve`)).status, 409);
834+ const built = await settled(n, "the build", (t) => t.runs.length === 3);
835+ const text3 = await transcript(n);
836+ assert.equal(built.runs[2].kind, "build");
837+ assert.equal(built.runs[2].state, "done", text3);
838+ assert.match(text3, /The plan is approved: building it\./);
839+ assert.match(text3, /^--- plan ---\n## Plan\n1\. PLAN-V2-STEP: write planned\.txt, with the KV store\.\n--- end of plan ---$/m);
840+ assert.ok(built.pull_request, "the build opened a pull request");
841+ assert.ok((await ok("GET", `${P}/posts`)).some((p) => p.body === `${owner} approved the plan of task #${n}.`));
842+ // A follow-up after approval is an ordinary run, still with the plan.
843+ assert.equal((await asPerson("POST", `${R}/${n}/messages`, { body: "One more thing" })).status, 201);
844+ const after = await settled(n, "the follow-up", (t) => t.runs.length === 4);
845+ assert.equal(after.runs[3].kind, "message");
846+
847+ // A project can require plans: every task plans first, ticked or not, including ones coordinators start.
848+ assert.equal((await asPerson("PUT", `${P}/settings`, { plan_first: "always" })).status, 422);
849+ assert.equal((await asPerson("PUT", `${P}/settings`, { plan_first: "required" })).json.plan_first, "required");
850+ const forced = await asPerson("POST", R, { goal: "SPLIT-GOAL: a goal to split", runtime: "custom", command: "sh plan.sh", agent: "task-bot", plan_first: false });
851+ assert.equal(forced.status, 201, JSON.stringify(forced.json));
852+ const m = forced.json.number;
853+ assert.equal(forced.json.plan_first, true);
854+ await settled(m, "the plan to split", (t) => t.plan_state === "waiting");
855+
856+ // Splitting hands the plan to the person's coordinator; the tasks it starts link back, and the task is done with.
857+ assert.equal((await asPerson("POST", `${R}/${m}/plan/split`)).status, 422, "no coordinator yet");
858+ const coord = await asPerson("PUT", `${P}/coordinator`, { agent: "task-bot", runtime: "custom", command: "sh plan.sh", accept: true });
859+ assert.equal(coord.status, 200, JSON.stringify(coord.json));
860+ assert.equal((await asPerson("POST", `${R}/${n}/plan/split`)).status, 409, "an approved plan can't be split");
861+ assert.equal((await api("POST", `${R}/${m}/plan/split`)).status, 403);
862+ const split = await asPerson("POST", `${R}/${m}/plan/split`);
863+ assert.equal(split.status, 202, JSON.stringify(split.json));
864+ assert.equal((await asPerson("POST", `${R}/${m}/plan/split`)).status, 409);
865+ const splitPost = (await ok("GET", `${P}/posts?after=${split.json.post - 1}`)).find((p) => p.id === split.json.post);
866+ assert.match(splitPost.body, new RegExp(`^Split the plan of task #${m} \\(SPLIT-GOAL: a goal to split\\) into tasks\\.`));
867+ assert.equal(splitPost.author.login, owner);
868+ const child = await until("the coordinator to split the plan", async () => {
869+ const posts = await ok("GET", `${P}/posts?after=${split.json.post}`);
870+ const ev = posts.find((p) => p.kind === "event" && p.body.startsWith(`${owner}'s coordinator started task`));
871+ return ev ? ev.task.number : null;
872+ });
873+ const c = await ok("GET", `${R}/${child}`);
874+ assert.equal(c.parent_task, m);
875+ assert.equal(c.title, "Plan part one");
876+ assert.equal(c.plan_first, true, "a coordinator's task plans first when the project requires it");
877+ const parent = await ok("GET", `${R}/${m}`);
878+ assert.equal(parent.plan_state, "split");
879+ assert.ok((await ok("GET", P)).board.idle.some((t) => t.number === m));
880+ const childPage = await (await fetch(`${base}/${owner}/tasks-demo/tasks/${child}`)).text();
881+ assert.match(childPage, new RegExp(`Split from the plan of <a href="[^"]*/tasks/${m}">task #${m}</a>`));
882+ await settled(child, "the child's plan", (t) => t.plan_state === "waiting");
883+
884+ assert.equal((await asPerson("PUT", `${P}/settings`, { plan_first: "off" })).json.plan_first, "off");
885+ assert.equal((await asPerson("DELETE", `${P}/coordinator`)).status, 204);
886+});
887+
749888 test("losing access to the agent stops the task", async () => {
750889 const me = await ok("GET", "/user");
751890 const owner = me.login;
modifiedapps/diffinery/test/unit/project.test.ts+13−1View file
@@ -1,5 +1,5 @@
11 import { describe, expect, it } from "vitest";
2-import { boardState, lineDiff, projectContext, validMaxRunning, type PullState } from "../../src/agentic/project.js";
2+import { boardState, isPlanFirst, lineDiff, projectContext, validMaxRunning, type PullState } from "../../src/agentic/project.js";
33
44 const open = (p: Partial<{ checks: "success" | "failure" | "pending" | "none"; approvedHead: boolean; autoMerge: boolean; queued: boolean }> = {}): PullState => ({ open: true, pull: { checks: "success", approvedHead: false, autoMerge: false, queued: false, draft: false, ...p } });
55
@@ -11,6 +11,18 @@
1111 expect(boardState({ state: "stopped", pr_number: 4 }, open())).toBe("idle");
1212 expect(boardState({ state: "ready", pr_number: null }, null)).toBe("idle");
1313 });
14+ it("puts a plan waiting for approval on you, and a split plan aside", () => {
15+ expect(boardState({ state: "ready", pr_number: null, plan_state: "waiting" }, null)).toBe("waiting");
16+ expect(boardState({ state: "ready", pr_number: null, plan_state: "split" }, null)).toBe("idle");
17+ expect(boardState({ state: "running", pr_number: null, plan_state: "planning" }, null)).toBe("working");
18+ expect(boardState({ state: "queued", pr_number: null, plan_state: "planning" }, null)).toBe("queued");
19+ expect(boardState({ state: "ready", pr_number: 4, plan_state: "approved" }, open())).toBe("review");
20+ expect(boardState({ state: "failed", pr_number: null, plan_state: "waiting" }, null)).toBe("waiting");
21+ });
22+ it("knows the plan-first policies", () => {
23+ for (const v of ["off", "default", "required"]) expect(isPlanFirst(v)).toBe(true);
24+ for (const v of ["on", "", null, 1, "toString"]) expect(isPlanFirst(v)).toBe(false);
25+ });
1426 it("then by its pull request", () => {
1527 expect(boardState({ state: "ready", pr_number: 4 }, open())).toBe("review");
1628 expect(boardState({ state: "ready", pr_number: 4 }, open({ checks: "none" }))).toBe("review");
modifiedapps/diffinery/test/unit/tasks.test.ts+24−1View file
@@ -1,7 +1,7 @@
11 import { describe, expect, it } from "vitest";
22 import { PROVIDERS } from "../../src/agentic/keys.js";
33 import { proxyAllows, proxyToken } from "../../src/agentic/proxy.js";
4-import { agentEnv, finalMessage, OPENROUTER_MODEL, parseRuntimeChoice, runPrompt, RUNTIMES, runtimeChoices, runtimeLabel, runtimeNeeds, taskBranch, taskTitle, transcriptItem, type TranscriptLine } from "../../src/agentic/runtimes.js";
4+import { agentEnv, finalMessage, OPENROUTER_MODEL, parseRuntimeChoice, planPrompt, runPrompt, RUNTIMES, runtimeChoices, runtimeLabel, runtimeNeeds, taskBranch, taskTitle, transcriptItem, type TranscriptLine } from "../../src/agentic/runtimes.js";
55 import { modelRoute } from "../../src/agentic/tasks.js";
66 import { mayStop } from "../../src/agentic/tasks.js";
77 import type { TaskRow } from "../../src/agentic/ProjectDO.js";
@@ -19,6 +19,29 @@
1919 });
2020 });
2121
22+describe("plan first prompts", () => {
23+ it("asks a plan run for a plan only, read-only", () => {
24+ const p = planPrompt({ goal: "Add caching", number: 7, by: "ayse" });
25+ expect(p).toContain("planning task #7");
26+ expect(p).toContain("read-only");
27+ expect(p).toContain("Goal:\nAdd caching");
28+ expect(p).not.toContain("previous plan");
29+ });
30+ it("gives a re-plan the plan it replaces and the asked-for changes", () => {
31+ const p = planPrompt({ goal: "Add caching", number: 7, by: "ayse", plan: "1. Add a cache", message: "Use the KV store" });
32+ expect(p).toMatch(/Your previous plan:\n--- plan ---\n1\. Add a cache\n--- end of plan ---/);
33+ expect(p).toContain("Changes ayse asked for:\nUse the KV store");
34+ });
35+ it("hands the build run the approved plan, also on a follow-up", () => {
36+ const first = runPrompt({ goal: "Add caching", number: 7, branch: "agent/7", by: "ayse", plan: "1. Add a cache" });
37+ expect(first).toMatch(/Goal:\nAdd caching\n\nThe plan for this goal, which ayse approved\. Follow it:\n--- plan ---\n1\. Add a cache\n--- end of plan ---$/);
38+ const next = runPrompt({ goal: "Add caching", number: 7, branch: "agent/7", by: "ayse", plan: "1. Add a cache", message: "Fix the test" });
39+ expect(next).toContain("--- plan ---\n1. Add a cache");
40+ expect(next.indexOf("--- plan ---")).toBeLessThan(next.indexOf("New instructions from ayse"));
41+ expect(runPrompt({ goal: "Add caching", number: 7, branch: "agent/7", by: "ayse" })).not.toContain("plan");
42+ });
43+});
44+
2245 describe("runPrompt", () => {
2346 it("carries the goal, and for a follow-up the previous ending and the message", () => {
2447 const first = runPrompt({ goal: "Add a test", number: 4, branch: "agent/4-add-a-test", by: "ayse" });