@saadnvd1/agent-os 0.3.519 → 0.3.520
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/app/api/bus/spawn/route.ts +4 -0
- package/app/api/tasks/[id]/approve-plan/route.ts +22 -0
- package/app/api/tasks/route.ts +2 -0
- package/components/Tasks/TaskRow.tsx +46 -1
- package/components/Tasks/task-state-labels.ts +1 -0
- package/data/tasks/index.ts +12 -0
- package/lib/db/migrations.ts +15 -0
- package/lib/db/types.ts +3 -1
- package/lib/lumifyhub/lists.ts +1 -0
- package/lib/models/plan-step.ts +116 -32
- package/lib/models/roles.ts +3 -1
- package/lib/orchestrator/act.ts +24 -2
- package/lib/orchestrator/brief.ts +3 -1
- package/lib/orchestrator/conditions.ts +1 -0
- package/lib/orchestrator/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +11 -2
- package/lib/orchestrator/serve.ts +8 -0
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +18 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/tasks/index.ts +28 -7
- package/lib/tasks/move-guard.ts +2 -0
- package/lib/tasks/move.ts +5 -1
- package/lib/tasks/plan-view.ts +6 -1
- package/lib/tasks/queue.ts +6 -2
- package/lib/tasks/start.ts +1 -1
- package/lib/tasks/state.ts +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -539,6 +539,21 @@ and **Usage → Compare** splits cost into planned and not. When the plan step
|
|
|
539
539
|
finishes, the orchestrator gets one event with the plan file's path and a
|
|
540
540
|
3-5 line summary; the handoff to implementation stays automatic.
|
|
541
541
|
|
|
542
|
+
**Plan only.** A task started with `plan_only` (`start_task`'s `plan_only`,
|
|
543
|
+
`aos task --plan-only`, or `plan_only: true` on `POST /api/tasks`) writes its
|
|
544
|
+
plan on the planning model and then stops: no model switch, no implement
|
|
545
|
+
prompt, even when one model does every role. It sits in a **Plan ready**
|
|
546
|
+
state (`plan_phase` `awaiting`, stored on the task, so it survives a restart)
|
|
547
|
+
that isn't blocked, doesn't count as needing you, and holds no agent process
|
|
548
|
+
(its worker is released; the conversation resumes on approval). The
|
|
549
|
+
orchestrator gets one event with the plan's path, a summary and that the task
|
|
550
|
+
is waiting. **Approve** on the task row (plan viewable there), `POST
|
|
551
|
+
/api/tasks/<id>/approve-plan`, or the orchestrator's `approve_plan(task,
|
|
552
|
+
message?)` continues it on the implementation model, once; a message amends
|
|
553
|
+
the plan and is recorded on the task's brief as a scope change, so the review
|
|
554
|
+
judges against it. Reject with **Drop**. A chat task on this machine only; it
|
|
555
|
+
can't move until approved.
|
|
556
|
+
|
|
542
557
|
**Per task.** The task draft has a **Model** chip ("Routed by role", or one
|
|
543
558
|
model); `POST /api/tasks` and the orchestrator's `start_task` take `model`, as
|
|
544
559
|
`"provider/model"` for the task's own roles or a map such as
|
|
@@ -29,12 +29,15 @@ export async function POST(request: NextRequest) {
|
|
|
29
29
|
on,
|
|
30
30
|
after,
|
|
31
31
|
view,
|
|
32
|
+
planOnly,
|
|
32
33
|
from,
|
|
33
34
|
} = await request.json();
|
|
34
35
|
const startedBy = orchestratorFrom(from);
|
|
35
36
|
const given = typeof name === "string" ? name : undefined;
|
|
36
37
|
if (after !== undefined && mode !== "task")
|
|
37
38
|
throw new Error("--after works for tasks only");
|
|
39
|
+
if (planOnly !== undefined && mode !== "task")
|
|
40
|
+
throw new Error("--plan-only works for tasks only");
|
|
38
41
|
if (mode === "task") {
|
|
39
42
|
const task = {
|
|
40
43
|
projectId: findProject(String(project ?? "")).id,
|
|
@@ -43,6 +46,7 @@ export async function POST(request: NextRequest) {
|
|
|
43
46
|
model,
|
|
44
47
|
hostId: on ? hostIdNamed(String(on)) : undefined,
|
|
45
48
|
view: startView(view),
|
|
49
|
+
planOnly: planOnly === true ? true : undefined,
|
|
46
50
|
startedBy,
|
|
47
51
|
};
|
|
48
52
|
const out = await startOrQueue(
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { approvePlanNow } from "@/lib/models/plan-step";
|
|
3
|
+
|
|
4
|
+
// Approves a plan-only task's plan: it continues on the implementation
|
|
5
|
+
// model. An optional message amends the plan (recorded as a scope change).
|
|
6
|
+
export async function POST(
|
|
7
|
+
request: NextRequest,
|
|
8
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
9
|
+
) {
|
|
10
|
+
try {
|
|
11
|
+
const body = await request.json().catch(() => ({}));
|
|
12
|
+
const message =
|
|
13
|
+
typeof body?.message === "string" && body.message.trim()
|
|
14
|
+
? body.message.trim().slice(0, 8000)
|
|
15
|
+
: undefined;
|
|
16
|
+
const out = await approvePlanNow((await params).id, message);
|
|
17
|
+
return NextResponse.json({ success: true, model: out.ref });
|
|
18
|
+
} catch (error) {
|
|
19
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
20
|
+
return NextResponse.json({ error: message }, { status: 409 });
|
|
21
|
+
}
|
|
22
|
+
}
|
package/app/api/tasks/route.ts
CHANGED
|
@@ -36,6 +36,7 @@ export async function POST(request: NextRequest) {
|
|
|
36
36
|
view,
|
|
37
37
|
plan,
|
|
38
38
|
plan_reason,
|
|
39
|
+
plan_only,
|
|
39
40
|
} = await request.json();
|
|
40
41
|
if (id !== undefined && !isSessionId(id)) throw new Error("Bad task id");
|
|
41
42
|
const pid =
|
|
@@ -48,6 +49,7 @@ export async function POST(request: NextRequest) {
|
|
|
48
49
|
// Checked now, so a queued task can't fail on it later.
|
|
49
50
|
model: parseOverride(model) ?? undefined,
|
|
50
51
|
plan: typeof plan === "boolean" ? plan : undefined,
|
|
52
|
+
planOnly: plan_only === true ? true : undefined,
|
|
51
53
|
planReason:
|
|
52
54
|
typeof plan_reason === "string" && plan_reason.trim()
|
|
53
55
|
? plan_reason.trim().slice(0, 200)
|
|
@@ -10,7 +10,13 @@ import {
|
|
|
10
10
|
import { Button } from "@/components/ui/button";
|
|
11
11
|
import type { TaskView } from "@/lib/tasks";
|
|
12
12
|
import { isFinished } from "@/lib/tasks/state";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
useApprovePlan,
|
|
15
|
+
useArmTask,
|
|
16
|
+
useDropTask,
|
|
17
|
+
useSignOffTask,
|
|
18
|
+
} from "@/data/tasks";
|
|
19
|
+
import { Markdown } from "@/components/Chat/Markdown";
|
|
14
20
|
import { tmuxAttachActions } from "@/stores/tmuxAttach";
|
|
15
21
|
import { tasksUiActions } from "@/stores/tasksUi";
|
|
16
22
|
import { cn } from "@/lib/utils";
|
|
@@ -23,6 +29,8 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
23
29
|
const signOff = useSignOffTask();
|
|
24
30
|
const drop = useDropTask();
|
|
25
31
|
const arm = useArmTask();
|
|
32
|
+
const approve = useApprovePlan();
|
|
33
|
+
const [showPlan, setShowPlan] = useState(false);
|
|
26
34
|
const [confirmDrop, setConfirmDrop] = useState(false);
|
|
27
35
|
const [moveError, setMoveError] = useState<string | null>(null);
|
|
28
36
|
const live = !isFinished(task.state);
|
|
@@ -30,6 +38,7 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
30
38
|
signOff.error?.message ||
|
|
31
39
|
drop.error?.message ||
|
|
32
40
|
arm.error?.message ||
|
|
41
|
+
approve.error?.message ||
|
|
33
42
|
moveError ||
|
|
34
43
|
task.hostError;
|
|
35
44
|
const settingUp = live && task.setup?.status === "running";
|
|
@@ -148,6 +157,26 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
148
157
|
{task.armed ? "Armed" : "Arm merge"}
|
|
149
158
|
</Button>
|
|
150
159
|
)}
|
|
160
|
+
{task.state === "plan-ready" && (
|
|
161
|
+
<>
|
|
162
|
+
<Button
|
|
163
|
+
size="sm"
|
|
164
|
+
variant="outline"
|
|
165
|
+
aria-expanded={showPlan}
|
|
166
|
+
onClick={() => setShowPlan((v) => !v)}
|
|
167
|
+
>
|
|
168
|
+
{showPlan ? "Hide plan" : "View plan"}
|
|
169
|
+
</Button>
|
|
170
|
+
<Button
|
|
171
|
+
size="sm"
|
|
172
|
+
className="h-11 md:h-8"
|
|
173
|
+
disabled={approve.isPending}
|
|
174
|
+
onClick={() => approve.mutate({ id: task.id })}
|
|
175
|
+
>
|
|
176
|
+
{approve.isPending ? "Approving..." : "Approve"}
|
|
177
|
+
</Button>
|
|
178
|
+
</>
|
|
179
|
+
)}
|
|
151
180
|
{task.state === "review" && (
|
|
152
181
|
<Button
|
|
153
182
|
size="sm"
|
|
@@ -159,6 +188,22 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
159
188
|
)}
|
|
160
189
|
</div>
|
|
161
190
|
)}
|
|
191
|
+
{live && task.state === "plan-ready" && showPlan && (
|
|
192
|
+
<div className="bg-foreground/[0.04] max-h-80 overflow-y-auto rounded-lg p-3 text-sm">
|
|
193
|
+
{task.plan?.text ? (
|
|
194
|
+
<Markdown text={task.plan.text} />
|
|
195
|
+
) : (
|
|
196
|
+
<p className="text-muted-foreground text-xs">
|
|
197
|
+
No plan file was written.
|
|
198
|
+
</p>
|
|
199
|
+
)}
|
|
200
|
+
{task.plan?.truncated && (
|
|
201
|
+
<p className="text-muted-foreground mt-2 font-mono text-[11px]">
|
|
202
|
+
Shortened; the whole plan is {task.plan.path} in the worktree.
|
|
203
|
+
</p>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
162
207
|
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
163
208
|
</div>
|
|
164
209
|
);
|
|
@@ -8,6 +8,7 @@ export const TASK_STATE: Record<TaskState, { label: string; tone: string }> = {
|
|
|
8
8
|
tone: "text-amber-600 dark:text-amber-400",
|
|
9
9
|
},
|
|
10
10
|
blocked: { label: "Blocked", tone: "text-amber-600 dark:text-amber-400" },
|
|
11
|
+
"plan-ready": { label: "Plan ready", tone: "text-primary" },
|
|
11
12
|
review: { label: "Ready for review", tone: "text-primary" },
|
|
12
13
|
"checks-failing": { label: "Checks failing", tone: "text-destructive" },
|
|
13
14
|
exited: { label: "Agent exited", tone: "text-amber-600 dark:text-amber-400" },
|
package/data/tasks/index.ts
CHANGED
|
@@ -85,6 +85,18 @@ export function useDropTask() {
|
|
|
85
85
|
);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
export function useApprovePlan() {
|
|
89
|
+
return useTaskMutation(async (input: { id: string; message?: string }) =>
|
|
90
|
+
json(
|
|
91
|
+
await fetch(`/api/tasks/${input.id}/approve-plan`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "Content-Type": "application/json" },
|
|
94
|
+
body: JSON.stringify({ message: input.message }),
|
|
95
|
+
})
|
|
96
|
+
)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
export function useResumeTask() {
|
|
89
101
|
return useTaskMutation(async (input: { id: string; force?: boolean }) =>
|
|
90
102
|
json(
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1739,6 +1739,21 @@ const migrations: Migration[] = [
|
|
|
1739
1739
|
);
|
|
1740
1740
|
},
|
|
1741
1741
|
},
|
|
1742
|
+
{
|
|
1743
|
+
id: 75,
|
|
1744
|
+
name: "plan_only_tasks",
|
|
1745
|
+
up: (db) => {
|
|
1746
|
+
// plan_only: 1 for a task that stops after its plan and waits for
|
|
1747
|
+
// approval (plan_phase 'awaiting'); queued tasks keep it until they start.
|
|
1748
|
+
const has = (table: string) =>
|
|
1749
|
+
(
|
|
1750
|
+
db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
|
|
1751
|
+
).some((c) => c.name === "plan_only");
|
|
1752
|
+
for (const table of ["sessions", "task_queue"])
|
|
1753
|
+
if (!has(table))
|
|
1754
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN plan_only INTEGER`);
|
|
1755
|
+
},
|
|
1756
|
+
},
|
|
1742
1757
|
];
|
|
1743
1758
|
|
|
1744
1759
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/db/types.ts
CHANGED
|
@@ -19,7 +19,9 @@ export interface Session {
|
|
|
19
19
|
// A task's own models: "provider/model", or JSON of role to one.
|
|
20
20
|
model_overrides?: string | null;
|
|
21
21
|
// A task's plan step: planning on the planning model, then implementing.
|
|
22
|
-
|
|
22
|
+
// 'awaiting': a plan-only task's plan is written; it waits for approval.
|
|
23
|
+
plan_phase?: "planning" | "awaiting" | "implementing" | null;
|
|
24
|
+
plan_only?: number | null;
|
|
23
25
|
// The orchestrator session that started this one; null for a person.
|
|
24
26
|
started_by?: string | null;
|
|
25
27
|
// 1 planned, 0 no plan, null not stated; why, in the starter's words.
|
package/lib/lumifyhub/lists.ts
CHANGED
package/lib/models/plan-step.ts
CHANGED
|
@@ -19,20 +19,28 @@ import {
|
|
|
19
19
|
} from "./resolve";
|
|
20
20
|
import { ANTHROPIC } from "./roles";
|
|
21
21
|
import { queueEvent } from "../orchestrator/events";
|
|
22
|
+
import { addNote } from "../orchestrator/notes";
|
|
23
|
+
import { amendBrief, amendmentsOf } from "../orchestrator/amendments";
|
|
22
24
|
|
|
23
25
|
export const PLAN_FILE = ".agentos/plan.md";
|
|
24
26
|
|
|
25
|
-
export function planPrompt(task: string): string {
|
|
27
|
+
export function planPrompt(task: string, only = false): string {
|
|
28
|
+
const after = only
|
|
29
|
+
? "it waits for approval before anything is implemented, so don't ask for it"
|
|
30
|
+
: "the implementation model takes over this conversation next and follows it";
|
|
26
31
|
return `${task}
|
|
27
32
|
|
|
28
33
|
---
|
|
29
|
-
Plan first. This turn, only read and plan: write the implementation plan for the task above to \`${PLAN_FILE}\` in this worktree (what to change, file by file; the tests; how you'll verify it; anything ambiguous and the call you'd make). Change no other file, and don't commit, push or open a PR. End the turn once the plan is written:
|
|
34
|
+
Plan first. This turn, only read and plan: write the implementation plan for the task above to \`${PLAN_FILE}\` in this worktree (what to change, file by file; the tests; how you'll verify it; anything ambiguous and the call you'd make). Change no other file, and don't commit, push or open a PR. End the turn once the plan is written: ${after}.`;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
export function implementPrompt(planned: boolean): string {
|
|
33
|
-
|
|
37
|
+
export function implementPrompt(planned: boolean, amendment?: string): string {
|
|
38
|
+
const base = planned
|
|
34
39
|
? `The plan is in \`${PLAN_FILE}\`. Implement it now, finishing the task as its instructions above say. Where the plan turns out wrong, do what's right and say so.`
|
|
35
40
|
: `No plan was written to \`${PLAN_FILE}\`. Plan briefly, then implement the task, finishing it as its instructions above say.`;
|
|
41
|
+
return amendment
|
|
42
|
+
? `${base}\n\nThe plan was approved with this change, which is now part of the task's scope:\n${amendment}`
|
|
43
|
+
: base;
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
// The worktree's plan folder, kept out of git by its own .gitignore.
|
|
@@ -72,7 +80,7 @@ export function planSummary(text: string, max = 5): string[] {
|
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
// Tells the orchestrator that started a task its plan is written, once.
|
|
75
|
-
function announcePlan(s: Session, ref: string): void {
|
|
83
|
+
function announcePlan(s: Session, ref: string | null): void {
|
|
76
84
|
if (!s.started_by && !s.held_by_device) return;
|
|
77
85
|
const workspaceId = sessionWorkspace(s);
|
|
78
86
|
// A task held for another machine's orchestrator can't reach it: the line
|
|
@@ -80,10 +88,13 @@ function announcePlan(s: Session, ref: string): void {
|
|
|
80
88
|
if (!s.held_by_device && !workspaceId) return;
|
|
81
89
|
if (!s.worktree_path) return;
|
|
82
90
|
const file = path.join(s.worktree_path, PLAN_FILE);
|
|
83
|
-
|
|
91
|
+
const next = ref
|
|
92
|
+
? `implementing on ${ref}`
|
|
93
|
+
: "waiting for approval, not implementing (approve_plan to go ahead, drop to reject)";
|
|
94
|
+
let head = `Task "${s.name}": no plan file was written; ${next}${ref ? " anyway" : ""}.`;
|
|
84
95
|
try {
|
|
85
96
|
const summary = planSummary(fs.readFileSync(file, "utf8"));
|
|
86
|
-
head = `Task "${s.name}": plan written to ${file};
|
|
97
|
+
head = `Task "${s.name}": plan written to ${file}; ${next}.`;
|
|
87
98
|
if (summary.length) head += `\n${summary.map((l) => `- ${l}`).join("\n")}`;
|
|
88
99
|
} catch {
|
|
89
100
|
// No plan file: the line says so.
|
|
@@ -101,56 +112,129 @@ function announcePlan(s: Session, ref: string): void {
|
|
|
101
112
|
export const implementMessageId = (sessionId: string) =>
|
|
102
113
|
`implement-${sessionId}`;
|
|
103
114
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
export async function handoffAfterPlan(
|
|
109
|
-
sessionId: string,
|
|
110
|
-
send: (sessionId: string, id: string) => Promise<void>
|
|
111
|
-
): Promise<boolean> {
|
|
112
|
-
const planned = db
|
|
113
|
-
.prepare(
|
|
114
|
-
`SELECT 1 FROM chat_turns WHERE session_id = ? AND role = 'plan' LIMIT 1`
|
|
115
|
-
)
|
|
116
|
-
.get(sessionId);
|
|
117
|
-
if (!planned) return false;
|
|
118
|
-
const s = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(sessionId) as
|
|
115
|
+
type Send = (sessionId: string, id: string) => Promise<void>;
|
|
116
|
+
|
|
117
|
+
function getSession(sessionId: string): Session | undefined {
|
|
118
|
+
return db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(sessionId) as
|
|
119
119
|
| Session
|
|
120
120
|
| undefined;
|
|
121
|
-
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Planning (or, once approved, awaiting) becomes implementing, once: the
|
|
124
|
+
// phase, the model and the message in one go, so a restart part-way finds
|
|
125
|
+
// either none of them or all, and sends the message on the new model. The
|
|
126
|
+
// message goes under a fixed id, which a restart sends rather than repeats.
|
|
127
|
+
async function startImplementing(
|
|
128
|
+
s: Session,
|
|
129
|
+
from: "planning" | "awaiting",
|
|
130
|
+
send: Send,
|
|
131
|
+
amendment?: string
|
|
132
|
+
): Promise<string | null> {
|
|
133
|
+
const sessionId = s.id;
|
|
122
134
|
const routed = resolveRole("implement", {
|
|
123
135
|
workspaceId: sessionWorkspace(s),
|
|
124
136
|
override: taskOverride(s),
|
|
125
137
|
});
|
|
126
138
|
const id = implementMessageId(sessionId);
|
|
127
|
-
// The phase, the model and the message in one go: a restart part-way
|
|
128
|
-
// finds either none of them or all, and sends the message on the new model.
|
|
129
139
|
const claimed = db.transaction(() => {
|
|
130
140
|
const changed = db
|
|
131
141
|
.prepare(
|
|
132
142
|
`UPDATE sessions SET plan_phase = 'implementing'
|
|
133
|
-
WHERE id = ? AND plan_phase =
|
|
143
|
+
WHERE id = ? AND plan_phase = ? AND task_status = 'running'`
|
|
134
144
|
)
|
|
135
|
-
.run(sessionId).changes;
|
|
145
|
+
.run(sessionId, from).changes;
|
|
136
146
|
if (!changed) return false;
|
|
137
147
|
setSessionModel(sessionId, routed);
|
|
138
148
|
enqueue(sessionId, {
|
|
139
149
|
id,
|
|
140
150
|
text: implementPrompt(
|
|
141
|
-
fs.existsSync(path.join(s.worktree_path!, PLAN_FILE))
|
|
151
|
+
fs.existsSync(path.join(s.worktree_path!, PLAN_FILE)),
|
|
152
|
+
amendment
|
|
142
153
|
),
|
|
143
154
|
origin: { kind: "decision", label: "Plan step" },
|
|
144
155
|
});
|
|
145
156
|
return true;
|
|
146
157
|
})();
|
|
147
|
-
if (!claimed) return
|
|
158
|
+
if (!claimed) return null;
|
|
159
|
+
if (from === "planning") {
|
|
160
|
+
try {
|
|
161
|
+
announcePlan(s, routed.ref);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
console.error("plan-done event failed:", e);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
await applySessionModel(sessionId, s.model_provider ?? ANTHROPIC, routed);
|
|
167
|
+
await send(sessionId, id);
|
|
168
|
+
return routed.ref;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Called when a chat goes idle. A task whose plan turn has ended (it has a
|
|
172
|
+
// recorded plan turn) moves to implementing, once; a plan-only task instead
|
|
173
|
+
// waits for approval (approvePlan), holding no agent process.
|
|
174
|
+
export async function handoffAfterPlan(
|
|
175
|
+
sessionId: string,
|
|
176
|
+
send: Send
|
|
177
|
+
): Promise<boolean> {
|
|
178
|
+
const planned = db
|
|
179
|
+
.prepare(
|
|
180
|
+
`SELECT 1 FROM chat_turns WHERE session_id = ? AND role = 'plan' LIMIT 1`
|
|
181
|
+
)
|
|
182
|
+
.get(sessionId);
|
|
183
|
+
if (!planned) return false;
|
|
184
|
+
const s = getSession(sessionId);
|
|
185
|
+
if (s?.plan_phase !== "planning" || !s.worktree_path) return false;
|
|
186
|
+
if (!s.plan_only)
|
|
187
|
+
return (await startImplementing(s, "planning", send)) !== null;
|
|
188
|
+
const waiting = db
|
|
189
|
+
.prepare(
|
|
190
|
+
`UPDATE sessions SET plan_phase = 'awaiting'
|
|
191
|
+
WHERE id = ? AND plan_phase = 'planning' AND task_status = 'running'`
|
|
192
|
+
)
|
|
193
|
+
.run(sessionId).changes;
|
|
194
|
+
if (!waiting) return false;
|
|
148
195
|
try {
|
|
149
|
-
announcePlan(s,
|
|
196
|
+
announcePlan(s, null);
|
|
150
197
|
} catch (e) {
|
|
151
198
|
console.error("plan-done event failed:", e);
|
|
152
199
|
}
|
|
153
|
-
|
|
154
|
-
|
|
200
|
+
// The worker has nothing left to do: it ends at this turn boundary, and
|
|
201
|
+
// the next message (an approval, or a revision) starts one that resumes.
|
|
202
|
+
try {
|
|
203
|
+
await (await import("../chat/runner")).retireChat(sessionId);
|
|
204
|
+
} catch (e) {
|
|
205
|
+
console.error(`Releasing the worker of ${sessionId} failed:`, e);
|
|
206
|
+
}
|
|
155
207
|
return true;
|
|
156
208
|
}
|
|
209
|
+
|
|
210
|
+
// approvePlan, sending through the chat runner (imported when needed: it
|
|
211
|
+
// imports this file).
|
|
212
|
+
export async function approvePlanNow(sessionId: string, message?: string) {
|
|
213
|
+
const { sendQueuedNow } = await import("../chat/runner");
|
|
214
|
+
return approvePlan(sessionId, message, (id, m) => sendQueuedNow(id, m));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Approves a waiting plan-only task: implementation starts on the
|
|
218
|
+
// implementation model, once. A message amends the plan and is recorded on
|
|
219
|
+
// the task's brief as a scope change, before implementing, so its review
|
|
220
|
+
// judges against it.
|
|
221
|
+
export async function approvePlan(
|
|
222
|
+
sessionId: string,
|
|
223
|
+
message: string | undefined,
|
|
224
|
+
send: Send
|
|
225
|
+
): Promise<{ ref: string; amendments: number }> {
|
|
226
|
+
const s = getSession(sessionId);
|
|
227
|
+
if (!s || s.plan_phase !== "awaiting" || s.task_status !== "running")
|
|
228
|
+
throw new Error(
|
|
229
|
+
`${s?.name ?? "That task"} isn't waiting for plan approval`
|
|
230
|
+
);
|
|
231
|
+
const text = message?.trim() || undefined;
|
|
232
|
+
const workspaceId = sessionWorkspace(s);
|
|
233
|
+
if (text && workspaceId) {
|
|
234
|
+
amendBrief(workspaceId, s.id, text);
|
|
235
|
+
addNote(workspaceId, `Plan approved with a change for ${s.name}: ${text}`);
|
|
236
|
+
}
|
|
237
|
+
const ref = await startImplementing(s, "awaiting", send, text);
|
|
238
|
+
if (!ref) throw new Error(`${s.name} isn't waiting for plan approval`);
|
|
239
|
+
return { ref, amendments: amendmentsOf(s.id).length };
|
|
240
|
+
}
|
package/lib/models/roles.ts
CHANGED
|
@@ -97,5 +97,7 @@ export function sessionRole(session: {
|
|
|
97
97
|
// Work an orchestrator started is implementation, not a person's session.
|
|
98
98
|
if (!session.task_status)
|
|
99
99
|
return session.started_by ? "implement" : "interactive";
|
|
100
|
-
return session.plan_phase === "planning"
|
|
100
|
+
return session.plan_phase === "planning" || session.plan_phase === "awaiting"
|
|
101
|
+
? "plan"
|
|
102
|
+
: "implement";
|
|
101
103
|
}
|
package/lib/orchestrator/act.ts
CHANGED
|
@@ -33,6 +33,8 @@ import { treeOrder } from "../stacks/tree";
|
|
|
33
33
|
import type { StackItemView } from "../stacks/types";
|
|
34
34
|
import { doneSession } from "../done";
|
|
35
35
|
import { amendBrief, amendmentsOf } from "./amendments";
|
|
36
|
+
import type { Session } from "../db";
|
|
37
|
+
import { approvePlanNow } from "../models/plan-step";
|
|
36
38
|
import { braked } from "./brakes";
|
|
37
39
|
import { getOrchestrator } from "./home";
|
|
38
40
|
import { addNote } from "./notes";
|
|
@@ -99,9 +101,12 @@ export async function startTask(
|
|
|
99
101
|
after?: string;
|
|
100
102
|
model?: ModelOverride;
|
|
101
103
|
plan?: boolean;
|
|
104
|
+
planOnly?: boolean;
|
|
102
105
|
planReason?: string;
|
|
103
106
|
} = {}
|
|
104
107
|
): Promise<string> {
|
|
108
|
+
if (opts.planOnly && opts.plan === false)
|
|
109
|
+
throw new Error("plan_only implies plan; pass plan true");
|
|
105
110
|
const project = workspaceProject(workspaceId, projectRef);
|
|
106
111
|
const model = parseOverride(opts.model) ?? undefined;
|
|
107
112
|
const startedBy = orchestratorId(workspaceId);
|
|
@@ -121,6 +126,7 @@ export async function startTask(
|
|
|
121
126
|
view,
|
|
122
127
|
model,
|
|
123
128
|
plan: opts.plan,
|
|
129
|
+
planOnly: opts.planOnly,
|
|
124
130
|
planReason: opts.planReason,
|
|
125
131
|
startedBy,
|
|
126
132
|
baseBranch: opts.base || undefined,
|
|
@@ -140,6 +146,7 @@ export async function startTask(
|
|
|
140
146
|
view,
|
|
141
147
|
model,
|
|
142
148
|
plan: opts.plan,
|
|
149
|
+
planOnly: opts.planOnly,
|
|
143
150
|
planReason: opts.planReason,
|
|
144
151
|
startedBy,
|
|
145
152
|
}),
|
|
@@ -151,11 +158,26 @@ export async function startTask(
|
|
|
151
158
|
return `Queued (position ${queued.position}) task "${queued.name}" (id ${queued.id.slice(0, 8)}) in ${project.name}${queued.after ? `, after ${queued.after}` : ""}. It starts by itself; you'll get an event when it does.`;
|
|
152
159
|
}
|
|
153
160
|
const task = out.started;
|
|
161
|
+
const planOnlyNote = opts.planOnly
|
|
162
|
+
? " Plan only: it stops after the plan and waits; approve_plan continues it, drop rejects it."
|
|
163
|
+
: "";
|
|
154
164
|
const skipped =
|
|
155
|
-
opts.plan && task.plan_phase !== "planning"
|
|
165
|
+
opts.plan && !opts.planOnly && task.plan_phase !== "planning"
|
|
156
166
|
? ` Plan step skipped: ${view === "terminal" ? "a terminal task has none" : "planning and implementation use the same model"}.`
|
|
157
167
|
: "";
|
|
158
|
-
return `Started task "${task.name}" (id ${task.id.slice(0, 8)}, ${view}) in ${project.name} on ${task.branch_name} (from ${task.base_branch}). It ends in a PR; you'll get an event when it opens.${skipped}`;
|
|
168
|
+
return `Started task "${task.name}" (id ${task.id.slice(0, 8)}, ${view}) in ${project.name} on ${task.branch_name} (from ${task.base_branch}). It ends in a PR; you'll get an event when it opens.${skipped}${planOnlyNote}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The orchestrator approves a plan-only task's plan; a message amends it.
|
|
172
|
+
export async function approveTaskPlan(
|
|
173
|
+
task: Session,
|
|
174
|
+
message?: string
|
|
175
|
+
): Promise<string> {
|
|
176
|
+
const out = await approvePlanNow(task.id, message);
|
|
177
|
+
const amended = message
|
|
178
|
+
? ` Recorded as scope change ${out.amendments} on its brief: its review judges against it.`
|
|
179
|
+
: "";
|
|
180
|
+
return `Approved the plan of ${task.name}: implementing on ${out.ref}.${amended}`;
|
|
159
181
|
}
|
|
160
182
|
|
|
161
183
|
export async function startSession(
|
|
@@ -41,6 +41,7 @@ You may act without asking on anything inside this workspace that's additive or
|
|
|
41
41
|
Before you start work, decide what it needs, and hand it to the cheapest model that can do it.
|
|
42
42
|
- Small and clear (a known change, a few files, nothing to discover): start_task with plan false and a plan_reason saying why. Write the steps into the prompt yourself, from what Saad or the card said; do not read code in your own context to do it.
|
|
43
43
|
- Big, unclear or cross-cutting (many files, a design choice, unknown ground): start_task with plan true and a plan_reason. The planning model writes the plan, then the implementation model carries it out; you get one event with the plan file's path and a short summary when it is written.
|
|
44
|
+
- Saad (or the card) says plan only, or you want to see a plan before any code is written: start_task with plan_only true (it implies plan true). It writes the plan and then stops: no implementation model, no implement prompt. You get one event with the plan file's path and a short summary saying it is waiting for approval. While it waits it isn't blocked and isn't idle; read the plan, then \`approve_plan\` (a message amends the plan and is recorded as a scope change, so the review judges against it) or \`drop\` it.
|
|
44
45
|
- Not in a repository (research, a document, a machine task): start_session, which runs on the implementation model, with the steps in the prompt.
|
|
45
46
|
- Implementation on Opus only when the work needs deep reasoning (a subtle concurrency or security change, say). Pass model and say why. Sonnet is the default.
|
|
46
47
|
|
|
@@ -102,7 +103,8 @@ Reading:
|
|
|
102
103
|
Acting:
|
|
103
104
|
- \`${TOOL_NAMES.send}\` (session, message, scope_change?): message a session; it arrives as its next prompt. scope_change records it on the task's brief for its review.
|
|
104
105
|
- \`${TOOL_NAMES.message_orchestrator}\` (workspace, message): message another workspace's orchestrator, the only thing you reach outside this workspace. Use it to hand over a bug or ask for something in its projects; it decides what to do there.
|
|
105
|
-
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, model?, plan, plan_reason): a task in its own worktree that ends in a PR. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task (see "Triage every job"). Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
|
|
106
|
+
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task (see "Triage every job"). Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
|
|
107
|
+
- \`${TOOL_NAMES.approve_plan}\` (task, message?): continue a plan_only task whose plan is ready; it implements on the implementation model, once. message amends the plan and is recorded on the task's brief as a scope change. Reject a plan with drop.
|
|
106
108
|
- \`${TOOL_NAMES.set_task_model}\` (task, model, message?): move a paused task to another model and carry on in the same conversation and worktree; it keeps that model from then on. Only when Saad asks.
|
|
107
109
|
- \`${TOOL_NAMES.start_session}\` (project, prompt, name?, view?, model?): a session without a worktree or PR, a chat unless view is terminal. It runs on the implementation model (Sonnet) unless you pass model; use it for work outside a repository, with the steps in the prompt.
|
|
108
110
|
- \`${TOOL_NAMES.quick_change}\` (project, name): a worktree and branch cut from the project's default branch for a small, low-risk edit you make yourself: a typo, a config value, a one-file fix. It returns the worktree's path; edit there with Write and Edit, and run git there only as \`git -C <path> <status|diff|log|show|add|rm|mv|restore|reset>\`. Anything that needs real work, tests or a long investigation is a task: start_task. At most a few open at once.
|
|
@@ -39,7 +39,7 @@ function sessionLine(f: SessionFacts): string {
|
|
|
39
39
|
}
|
|
40
40
|
if (f.plan)
|
|
41
41
|
parts.push(
|
|
42
|
-
`${f.plan.planned ? "planned" : "no plan"}${f.plan.reason ? `: ${untrusted(f.name, f.plan.reason)}` : ""}`
|
|
42
|
+
`${f.plan.awaiting ? "plan ready, awaiting your approve_plan" : f.plan.planned ? "planned" : "no plan"}${f.plan.reason ? `: ${untrusted(f.name, f.plan.reason)}` : ""}`
|
|
43
43
|
);
|
|
44
44
|
if (f.stack) {
|
|
45
45
|
const card = f.stack.ticket ? ` ${f.stack.ticket}` : "";
|
|
@@ -50,7 +50,12 @@ export interface SessionFacts {
|
|
|
50
50
|
// A quick change the orchestrator makes itself (quick.ts): no agent.
|
|
51
51
|
quick?: boolean;
|
|
52
52
|
// The orchestrator's call on a plan step, with its reason, when stated.
|
|
53
|
-
plan?: {
|
|
53
|
+
plan?: {
|
|
54
|
+
planned: boolean;
|
|
55
|
+
reason: string | null;
|
|
56
|
+
// A plan-only task's plan is written; it waits for approve_plan.
|
|
57
|
+
awaiting?: boolean;
|
|
58
|
+
} | null;
|
|
54
59
|
task: {
|
|
55
60
|
state: TaskState;
|
|
56
61
|
pr: TaskPR | null;
|
|
@@ -344,7 +349,11 @@ export async function sessionFacts(
|
|
|
344
349
|
plan:
|
|
345
350
|
s.plan_decided == null
|
|
346
351
|
? null
|
|
347
|
-
: {
|
|
352
|
+
: {
|
|
353
|
+
planned: !!s.plan_decided,
|
|
354
|
+
reason: s.plan_reason ?? null,
|
|
355
|
+
awaiting: s.plan_phase === "awaiting",
|
|
356
|
+
},
|
|
348
357
|
task,
|
|
349
358
|
stack: stackOf(s.id),
|
|
350
359
|
};
|
|
@@ -2,6 +2,7 @@ import { resumeOnModel } from "../models/resume";
|
|
|
2
2
|
import { workspaceTask } from "./targets";
|
|
3
3
|
import { getWorkspace } from "../workspaces";
|
|
4
4
|
import {
|
|
5
|
+
approveTaskPlan,
|
|
5
6
|
done,
|
|
6
7
|
drop,
|
|
7
8
|
moveTaskTo,
|
|
@@ -48,6 +49,7 @@ const ACTING = new Set<ToolName>([
|
|
|
48
49
|
"land",
|
|
49
50
|
"drop",
|
|
50
51
|
"move",
|
|
52
|
+
"approve_plan",
|
|
51
53
|
"stop",
|
|
52
54
|
"done",
|
|
53
55
|
"review",
|
|
@@ -114,9 +116,15 @@ export async function runTool(
|
|
|
114
116
|
after: a.after,
|
|
115
117
|
model: a.model,
|
|
116
118
|
plan: a.plan,
|
|
119
|
+
planOnly: a.plan_only,
|
|
117
120
|
planReason: a.plan_reason,
|
|
118
121
|
});
|
|
119
122
|
}
|
|
123
|
+
case "approve_plan": {
|
|
124
|
+
const a = p(tool, raw);
|
|
125
|
+
const task = workspaceTask(w, a.task);
|
|
126
|
+
return approveTaskPlan(task, a.message);
|
|
127
|
+
}
|
|
120
128
|
case "set_task_model": {
|
|
121
129
|
const a = p(tool, raw);
|
|
122
130
|
const task = workspaceTask(w, a.task);
|
|
@@ -121,6 +121,12 @@ export const TOOL_SHAPES = {
|
|
|
121
121
|
.describe(
|
|
122
122
|
"Your decision for this job: true to plan first on the planning model, then implement on the implementation model; false to skip the plan and put the steps in the prompt yourself"
|
|
123
123
|
),
|
|
124
|
+
plan_only: z
|
|
125
|
+
.boolean()
|
|
126
|
+
.optional()
|
|
127
|
+
.describe(
|
|
128
|
+
"true: write the plan on the planning model, then stop and wait; implementation starts only when you approve_plan it (drop rejects it). Implies plan true"
|
|
129
|
+
),
|
|
124
130
|
plan_reason: z
|
|
125
131
|
.string()
|
|
126
132
|
.trim()
|
|
@@ -192,6 +198,18 @@ export const TOOL_SHAPES = {
|
|
|
192
198
|
},
|
|
193
199
|
stack_status: { id: stackId },
|
|
194
200
|
land: { id: stackId },
|
|
201
|
+
approve_plan: {
|
|
202
|
+
task,
|
|
203
|
+
message: z
|
|
204
|
+
.string()
|
|
205
|
+
.trim()
|
|
206
|
+
.min(1)
|
|
207
|
+
.max(8000)
|
|
208
|
+
.optional()
|
|
209
|
+
.describe(
|
|
210
|
+
"Amends the plan: recorded on the task's brief as a scope change, so its review judges the PR against it. Only for a change Saad made"
|
|
211
|
+
),
|
|
212
|
+
},
|
|
195
213
|
drop: { task, reason: z.string().trim().min(1).max(2000) },
|
|
196
214
|
move: {
|
|
197
215
|
task,
|
|
@@ -62,7 +62,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
62
62
|
message_orchestrator:
|
|
63
63
|
"Message another workspace's orchestrator, by workspace name; never any other session there. It arrives as its next prompt, marked as from you and fenced as untrusted.",
|
|
64
64
|
start_task:
|
|
65
|
-
"Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). With after (a task's id or name, or \"any\"), or over the workspace's running task limit, it's queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason: plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. Refused while a brake holds.",
|
|
65
|
+
"Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). With after (a task's id or name, or \"any\"), or over the workspace's running task limit, it's queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason (plan_only true plans, then stops and waits for approve_plan instead of implementing): plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. Refused while a brake holds.",
|
|
66
66
|
set_task_model:
|
|
67
67
|
'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
|
|
68
68
|
start_session:
|
|
@@ -75,6 +75,8 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
75
75
|
"Run a LumifyHub board's open cards as stacked tasks (each start braked), or with plan_only just show the plan.",
|
|
76
76
|
stack_status: "One stack's items, PRs and progress.",
|
|
77
77
|
land: "Merge a whole stack bottom-up, each PR through the sign-off gates at its current head.",
|
|
78
|
+
approve_plan:
|
|
79
|
+
'Approve the plan of a plan_only task that is waiting ("plan ready"): it continues on the implementation model, once. An optional message amends the plan and is recorded on the task\'s brief as a scope change. Reject a plan with drop.',
|
|
78
80
|
drop: "Reject a task: close its PR and remove its worktree. A task still waiting in the queue is removed by its id. Logged with the reason.",
|
|
79
81
|
move: 'Carry a running task to a linked machine, or back with "here": for heavy test or build work. It keeps its branch, conversation and plan; the gates, events, read, send, stop, done and drop work the same wherever it runs, and the machine it lands on can\'t merge it. Takes a few minutes and runs in the background; an event says when it lands. Refused while paused, for a task waiting on Saad (an escalation or open ask), and for card and stacked tasks.',
|
|
80
82
|
stop: "Stop a session's agent mid-turn, even while it's thinking, keeping its conversation, worktree and branch; your next message runs as its next turn.",
|
package/lib/tasks/index.ts
CHANGED
|
@@ -100,6 +100,8 @@ export interface TaskView {
|
|
|
100
100
|
// plan step (lib/models).
|
|
101
101
|
model?: string;
|
|
102
102
|
planning?: boolean;
|
|
103
|
+
// A plan-only task's plan is written and waits for approval.
|
|
104
|
+
planReady?: boolean;
|
|
103
105
|
// What its starter decided about a plan step, and why; null if unstated.
|
|
104
106
|
// Its plan: ready to read or approve, with a title and capped text.
|
|
105
107
|
plan?: TaskPlan | null;
|
|
@@ -128,6 +130,8 @@ export async function createTask(opts: {
|
|
|
128
130
|
plan?: boolean;
|
|
129
131
|
// Why the starter chose to plan or not: shown on the task's card.
|
|
130
132
|
planReason?: string | null;
|
|
133
|
+
// Stop after the plan and wait for approval (implies plan).
|
|
134
|
+
planOnly?: boolean;
|
|
131
135
|
// The orchestrator session that started it (null: a person).
|
|
132
136
|
startedBy?: string | null;
|
|
133
137
|
// Started from this LumifyHub card, which the task then moves.
|
|
@@ -198,6 +202,8 @@ async function startTask(
|
|
|
198
202
|
const hostId = opts.hostId ?? project.host_id;
|
|
199
203
|
const view = opts.view ?? DEFAULT_START_VIEW;
|
|
200
204
|
if (isRemoteHost(hostId)) {
|
|
205
|
+
if (opts.planOnly)
|
|
206
|
+
throw new Error("A plan-only task runs on this machine for now");
|
|
201
207
|
if (opts.base || opts.cardId)
|
|
202
208
|
throw new Error(
|
|
203
209
|
"Stacked and card tasks run on this machine only for now"
|
|
@@ -216,11 +222,18 @@ async function startTask(
|
|
|
216
222
|
|
|
217
223
|
if (opts.baseBranch !== undefined && !isBranchName(opts.baseBranch))
|
|
218
224
|
throw new Error(`"${opts.baseBranch}" isn't a branch name`);
|
|
225
|
+
if (opts.planOnly && opts.plan === false)
|
|
226
|
+
throw new Error(
|
|
227
|
+
"planOnly implies plan; it can't be combined with plan false"
|
|
228
|
+
);
|
|
229
|
+
if (opts.planOnly && view !== "chat")
|
|
230
|
+
throw new Error("A plan-only task needs a chat, not a terminal");
|
|
219
231
|
const routing = taskRouting(
|
|
220
232
|
project.workspace_id,
|
|
221
233
|
view,
|
|
222
234
|
opts.model,
|
|
223
|
-
opts.plan
|
|
235
|
+
opts.plan,
|
|
236
|
+
opts.planOnly
|
|
224
237
|
);
|
|
225
238
|
const projectPath = expandHome(project.working_directory);
|
|
226
239
|
const naming = await nameFor(prompt, projectPath, opts.name);
|
|
@@ -266,7 +279,7 @@ async function startTask(
|
|
|
266
279
|
// Everything the launch needs is on the row, so a restart can resume it.
|
|
267
280
|
db.prepare(
|
|
268
281
|
`UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ?,
|
|
269
|
-
model_provider = ?, model_overrides = ?, plan_phase = ?,
|
|
282
|
+
model_provider = ?, model_overrides = ?, plan_phase = ?, plan_only = ?,
|
|
270
283
|
plan_decided = ?, plan_reason = ?, started_by = ? WHERE id = ?`
|
|
271
284
|
).run(
|
|
272
285
|
prompt,
|
|
@@ -280,7 +293,8 @@ async function startTask(
|
|
|
280
293
|
routing.first.provider.id === ANTHROPIC ? null : routing.first.provider.id,
|
|
281
294
|
routing.override ? JSON.stringify(routing.override) : null,
|
|
282
295
|
routing.plan ? "planning" : null,
|
|
283
|
-
|
|
296
|
+
routing.planOnly ? 1 : null,
|
|
297
|
+
routing.planOnly ? 1 : opts.plan === undefined ? null : opts.plan ? 1 : 0,
|
|
284
298
|
opts.planReason?.trim() || null,
|
|
285
299
|
opts.startedBy ?? null,
|
|
286
300
|
id
|
|
@@ -356,8 +370,10 @@ export async function taskView(session: Session): Promise<TaskView> {
|
|
|
356
370
|
sessionStatus !== undefined
|
|
357
371
|
? await shellOnly(session.tmux_name)
|
|
358
372
|
: false;
|
|
373
|
+
const planReady = live && session.plan_phase === "awaiting";
|
|
374
|
+
// A plan waiting for approval isn't a question the agent asked.
|
|
359
375
|
const blocked =
|
|
360
|
-
live && sessionStatus === "waiting"
|
|
376
|
+
live && sessionStatus === "waiting" && !planReady
|
|
361
377
|
? blockedReason(
|
|
362
378
|
chat
|
|
363
379
|
? saidSinceLastMessage(session.id)
|
|
@@ -373,6 +389,7 @@ export async function taskView(session: Session): Promise<TaskView> {
|
|
|
373
389
|
sessionStatus: agentGone ? "dead" : sessionStatus,
|
|
374
390
|
pr,
|
|
375
391
|
blocked: blocked !== null,
|
|
392
|
+
planReady,
|
|
376
393
|
settingUp: setup?.status === "running" || setup?.status === "held",
|
|
377
394
|
});
|
|
378
395
|
syncTaskCardInBackground(session, state, pr);
|
|
@@ -399,6 +416,7 @@ export async function taskView(session: Session): Promise<TaskView> {
|
|
|
399
416
|
armed: !!session.merge_armed_at,
|
|
400
417
|
model: sessionModelRef(session),
|
|
401
418
|
planning: session.plan_phase === "planning",
|
|
419
|
+
planReady,
|
|
402
420
|
plan: taskPlan(session),
|
|
403
421
|
quickOf: session.quick_of ?? null,
|
|
404
422
|
lastActiveAt: session.held_by_device ? lastActiveOf(session) : undefined,
|
|
@@ -443,20 +461,23 @@ export function taskRouting(
|
|
|
443
461
|
workspaceId: string | null,
|
|
444
462
|
view: StartView,
|
|
445
463
|
model: unknown,
|
|
446
|
-
plan?: boolean
|
|
464
|
+
plan?: boolean,
|
|
465
|
+
planOnly?: boolean
|
|
447
466
|
) {
|
|
448
467
|
const override = parseOverride(model);
|
|
449
468
|
const models = effectiveModels();
|
|
450
469
|
const opts = { workspaceId, override };
|
|
451
470
|
const implement = resolveRole("implement", opts, models);
|
|
452
471
|
const planning = resolveRole("plan", opts, models);
|
|
472
|
+
// Plan only plans even on one model: the stop is the point, not a switch.
|
|
453
473
|
const planStep =
|
|
454
474
|
view === "chat" &&
|
|
455
|
-
(
|
|
456
|
-
|
|
475
|
+
(!!planOnly ||
|
|
476
|
+
((plan ?? models.planStep) && planning.ref !== implement.ref));
|
|
457
477
|
return {
|
|
458
478
|
override,
|
|
459
479
|
plan: planStep,
|
|
480
|
+
planOnly: planStep && !!planOnly,
|
|
460
481
|
first: planStep ? planning : implement,
|
|
461
482
|
};
|
|
462
483
|
}
|
package/lib/tasks/move-guard.ts
CHANGED
|
@@ -25,6 +25,8 @@ export function moveRefusal(session: Session): string | null {
|
|
|
25
25
|
return "It's a quick change its orchestrator makes on this machine, so it stays here";
|
|
26
26
|
if (escalations(session.id).length)
|
|
27
27
|
return "It's waiting on you (an escalated gate), so it stays here";
|
|
28
|
+
if (session.plan_only && session.plan_phase !== "implementing")
|
|
29
|
+
return "It's a plan-only task that hasn't been approved, so it stays here";
|
|
28
30
|
const ask = db
|
|
29
31
|
.prepare(
|
|
30
32
|
`SELECT 1 FROM orchestrator_asks WHERE subject IN (?, ?) AND status = 'open' LIMIT 1`
|
package/lib/tasks/move.ts
CHANGED
|
@@ -137,7 +137,11 @@ function orchestratedBundle(session: Session): OrchestratedBundle {
|
|
|
137
137
|
since: isoUTC(session.task_since ?? session.created_at),
|
|
138
138
|
modelProvider: session.model_provider ?? null,
|
|
139
139
|
modelOverrides: session.model_overrides ?? null,
|
|
140
|
-
|
|
140
|
+
// moveRefusal keeps an unapproved plan-only task here.
|
|
141
|
+
planPhase: (session.plan_phase ?? null) as
|
|
142
|
+
| "planning"
|
|
143
|
+
| "implementing"
|
|
144
|
+
| null,
|
|
141
145
|
planFile: readPlanFile(session.working_directory),
|
|
142
146
|
orchestrator: orchestratorOf(session),
|
|
143
147
|
};
|
package/lib/tasks/plan-view.ts
CHANGED
|
@@ -102,7 +102,12 @@ export function taskPlan(session: Session): TaskPlan | null {
|
|
|
102
102
|
return {
|
|
103
103
|
...base,
|
|
104
104
|
planned: true,
|
|
105
|
-
status:
|
|
105
|
+
status:
|
|
106
|
+
phase === "planning"
|
|
107
|
+
? "writing"
|
|
108
|
+
: phase === "awaiting"
|
|
109
|
+
? "ready"
|
|
110
|
+
: "approved",
|
|
106
111
|
source: "step",
|
|
107
112
|
title: planTitle(body),
|
|
108
113
|
path: text !== null ? PLAN_FILE : null,
|
package/lib/tasks/queue.ts
CHANGED
|
@@ -48,6 +48,7 @@ export interface QueueRow {
|
|
|
48
48
|
// orchestrator session that started it.
|
|
49
49
|
plan: number | null;
|
|
50
50
|
plan_reason: string | null;
|
|
51
|
+
plan_only: number | null;
|
|
51
52
|
started_by: string | null;
|
|
52
53
|
view: "chat" | "terminal" | null;
|
|
53
54
|
base_branch: string | null;
|
|
@@ -222,6 +223,7 @@ export interface QueueRequest {
|
|
|
222
223
|
model?: ModelOverride;
|
|
223
224
|
plan?: boolean;
|
|
224
225
|
planReason?: string | null;
|
|
226
|
+
planOnly?: boolean;
|
|
225
227
|
startedBy?: string | null;
|
|
226
228
|
view?: "chat" | "terminal";
|
|
227
229
|
baseBranch?: string;
|
|
@@ -277,9 +279,9 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
|
|
|
277
279
|
if (!req.prompt.trim()) throw new Error("Describe the task");
|
|
278
280
|
const id = randomUUID();
|
|
279
281
|
db.prepare(
|
|
280
|
-
`INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, plan, plan_reason, started_by, view, base_branch,
|
|
282
|
+
`INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, plan, plan_reason, plan_only, started_by, view, base_branch,
|
|
281
283
|
host_id, after_task, after_any, origin_workspace_id, position)
|
|
282
|
-
VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @plan, @plan_reason, @started_by, @view, @base_branch,
|
|
284
|
+
VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @plan, @plan_reason, @plan_only, @started_by, @view, @base_branch,
|
|
283
285
|
@host_id, @after_task, @after_any, @origin,
|
|
284
286
|
(SELECT COALESCE(MAX(position), 0) + 1 FROM task_queue))`
|
|
285
287
|
).run({
|
|
@@ -294,6 +296,7 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
|
|
|
294
296
|
: null,
|
|
295
297
|
plan: req.plan === undefined ? null : req.plan ? 1 : 0,
|
|
296
298
|
plan_reason: req.planReason?.trim() || null,
|
|
299
|
+
plan_only: req.planOnly ? 1 : null,
|
|
297
300
|
started_by: req.startedBy ?? null,
|
|
298
301
|
view: req.view ?? null,
|
|
299
302
|
base_branch: req.baseBranch || null,
|
|
@@ -472,6 +475,7 @@ async function startRow(
|
|
|
472
475
|
: (r.model ?? undefined),
|
|
473
476
|
plan: r.plan == null ? undefined : !!r.plan,
|
|
474
477
|
planReason: r.plan_reason,
|
|
478
|
+
planOnly: !!r.plan_only,
|
|
475
479
|
startedBy: r.started_by,
|
|
476
480
|
view: r.view ?? undefined,
|
|
477
481
|
baseBranch: r.base_branch ?? undefined,
|
package/lib/tasks/start.ts
CHANGED
|
@@ -141,7 +141,7 @@ export async function finishTaskStart(
|
|
|
141
141
|
const task = (session.task_prompt ?? "") + setupNote(setup);
|
|
142
142
|
const planning = session.plan_phase === "planning";
|
|
143
143
|
if (planning) preparePlanDir(session.worktree_path);
|
|
144
|
-
const prompt = planning ? planPrompt(task) : task;
|
|
144
|
+
const prompt = planning ? planPrompt(task, !!session.plan_only) : task;
|
|
145
145
|
try {
|
|
146
146
|
if (session.view === "chat") await launchChat(sessionId, prompt);
|
|
147
147
|
else {
|
package/lib/tasks/state.ts
CHANGED
|
@@ -41,6 +41,8 @@ export type TaskState =
|
|
|
41
41
|
| "blocked"
|
|
42
42
|
| "review"
|
|
43
43
|
| "checks-failing"
|
|
44
|
+
// A plan-only task's plan is written; it waits for approval.
|
|
45
|
+
| "plan-ready"
|
|
44
46
|
| "exited"
|
|
45
47
|
| "merged"
|
|
46
48
|
| "dropped"
|
|
@@ -56,11 +58,14 @@ export interface TaskStateInput {
|
|
|
56
58
|
blocked: boolean;
|
|
57
59
|
// Its worktree is still being set up; the agent hasn't started yet.
|
|
58
60
|
settingUp?: boolean;
|
|
61
|
+
// plan_phase "awaiting": no agent runs, and none is needed.
|
|
62
|
+
planReady?: boolean;
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
// Ordered by what needs the human first.
|
|
62
66
|
export function deriveTaskState(input: TaskStateInput): TaskState {
|
|
63
|
-
const { taskStatus, sessionStatus, pr, blocked, settingUp } =
|
|
67
|
+
const { taskStatus, sessionStatus, pr, blocked, settingUp, planReady } =
|
|
68
|
+
input;
|
|
64
69
|
if (taskStatus === "merged" || pr?.state === "MERGED") return "merged";
|
|
65
70
|
if (taskStatus === "dropped") return "dropped";
|
|
66
71
|
if (taskStatus === "done" || taskStatus === "moved") return "done";
|
|
@@ -70,6 +75,7 @@ export function deriveTaskState(input: TaskStateInput): TaskState {
|
|
|
70
75
|
if (pr?.state === "OPEN") {
|
|
71
76
|
return pr.checks === "fail" ? "checks-failing" : "review";
|
|
72
77
|
}
|
|
78
|
+
if (planReady && sessionStatus !== "running") return "plan-ready";
|
|
73
79
|
if (!sessionStatus || sessionStatus === "dead") return "exited";
|
|
74
80
|
if (sessionStatus === "running") return "working";
|
|
75
81
|
return "needs-input";
|