projectinator 0.3.1 → 0.4.0
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 +7 -6
- package/bin/projectinator.mjs +22 -15
- package/dist/bakeoff.js +163 -0
- package/dist/bakeoff.js.map +1 -0
- package/dist/build-state.js +26 -0
- package/dist/build-state.js.map +1 -0
- package/dist/burndown.js +19 -0
- package/dist/burndown.js.map +1 -0
- package/dist/calibration.js +86 -0
- package/dist/calibration.js.map +1 -0
- package/dist/cli.js +289 -0
- package/dist/cli.js.map +1 -0
- package/dist/cost.js +35 -0
- package/dist/cost.js.map +1 -0
- package/dist/council.js +147 -0
- package/dist/council.js.map +1 -0
- package/dist/demo.js +93 -0
- package/dist/demo.js.map +1 -0
- package/dist/estimate.js +98 -0
- package/dist/estimate.js.map +1 -0
- package/dist/executor.js +118 -0
- package/dist/executor.js.map +1 -0
- package/dist/git.js +82 -0
- package/dist/git.js.map +1 -0
- package/dist/intake.js +98 -0
- package/dist/intake.js.map +1 -0
- package/dist/models.js +171 -0
- package/dist/models.js.map +1 -0
- package/dist/narrate.js +76 -0
- package/dist/narrate.js.map +1 -0
- package/dist/openrouter.js +112 -0
- package/dist/openrouter.js.map +1 -0
- package/dist/orchestrator.js +273 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/pm.js +245 -0
- package/dist/pm.js.map +1 -0
- package/dist/preview.js +194 -0
- package/dist/preview.js.map +1 -0
- package/dist/registry-store.js +38 -0
- package/dist/registry-store.js.map +1 -0
- package/dist/registry.js +115 -0
- package/dist/registry.js.map +1 -0
- package/dist/research.js +98 -0
- package/dist/research.js.map +1 -0
- package/dist/retro.js +83 -0
- package/dist/retro.js.map +1 -0
- package/dist/roles.js +357 -0
- package/dist/roles.js.map +1 -0
- package/dist/router.js +95 -0
- package/dist/router.js.map +1 -0
- package/dist/run-bakeoff.js +68 -0
- package/dist/run-bakeoff.js.map +1 -0
- package/dist/run-build.js +193 -0
- package/dist/run-build.js.map +1 -0
- package/{src/run-dev.ts → dist/run-dev.js} +23 -39
- package/dist/run-dev.js.map +1 -0
- package/{src/run-pm.ts → dist/run-pm.js} +34 -46
- package/dist/run-pm.js.map +1 -0
- package/{src/run-research.ts → dist/run-research.js} +19 -30
- package/dist/run-research.js.map +1 -0
- package/{src/run-scout.ts → dist/run-scout.js} +17 -27
- package/dist/run-scout.js.map +1 -0
- package/dist/run-web.js +93 -0
- package/dist/run-web.js.map +1 -0
- package/dist/scout.js +81 -0
- package/dist/scout.js.map +1 -0
- package/dist/session-cost.js +15 -0
- package/dist/session-cost.js.map +1 -0
- package/dist/stack.js +34 -0
- package/dist/stack.js.map +1 -0
- package/dist/stuck.js +21 -0
- package/dist/stuck.js.map +1 -0
- package/dist/tui/App.js +1269 -0
- package/dist/tui/App.js.map +1 -0
- package/dist/tui/BakeOff.js +95 -0
- package/dist/tui/BakeOff.js.map +1 -0
- package/dist/tui/BoardEditor.js +183 -0
- package/dist/tui/BoardEditor.js.map +1 -0
- package/dist/tui/EditableBoard.js +140 -0
- package/dist/tui/EditableBoard.js.map +1 -0
- package/dist/tui/Frame.js +78 -0
- package/dist/tui/Frame.js.map +1 -0
- package/dist/tui/Intake.js +59 -0
- package/dist/tui/Intake.js.map +1 -0
- package/dist/tui/Kanban.js +75 -0
- package/dist/tui/Kanban.js.map +1 -0
- package/dist/tui/Settings.js +299 -0
- package/dist/tui/Settings.js.map +1 -0
- package/dist/tui/StackPick.js +44 -0
- package/dist/tui/StackPick.js.map +1 -0
- package/dist/tui/WebAccounts.js +131 -0
- package/dist/tui/WebAccounts.js.map +1 -0
- package/dist/tui/components.js +178 -0
- package/dist/tui/components.js.map +1 -0
- package/dist/tui/config.js +129 -0
- package/dist/tui/config.js.map +1 -0
- package/dist/tui/deploy.js +114 -0
- package/dist/tui/deploy.js.map +1 -0
- package/dist/tui/engine.js +710 -0
- package/dist/tui/engine.js.map +1 -0
- package/dist/tui/notify.js +47 -0
- package/dist/tui/notify.js.map +1 -0
- package/dist/tui/panels.js +42 -0
- package/dist/tui/panels.js.map +1 -0
- package/dist/tui/templates.js +106 -0
- package/dist/tui/templates.js.map +1 -0
- package/dist/tui/theme.js +42 -0
- package/dist/tui/theme.js.map +1 -0
- package/dist/tui/validate.js +51 -0
- package/dist/tui/validate.js.map +1 -0
- package/{src/tui.tsx → dist/tui.js} +23 -33
- package/dist/tui.js.map +1 -0
- package/dist/types.js +19 -0
- package/dist/types.js.map +1 -0
- package/dist/web/oauth-anthropic.js +179 -0
- package/dist/web/oauth-anthropic.js.map +1 -0
- package/dist/web/session.js +274 -0
- package/dist/web/session.js.map +1 -0
- package/package.json +5 -4
- package/src/bakeoff.ts +0 -214
- package/src/build-state.ts +0 -47
- package/src/burndown.ts +0 -35
- package/src/calibration.ts +0 -88
- package/src/cli.ts +0 -263
- package/src/cost.ts +0 -43
- package/src/council.ts +0 -175
- package/src/demo.ts +0 -106
- package/src/estimate.ts +0 -111
- package/src/executor.ts +0 -169
- package/src/git.ts +0 -72
- package/src/intake.ts +0 -125
- package/src/models.ts +0 -175
- package/src/narrate.ts +0 -87
- package/src/openrouter.ts +0 -119
- package/src/orchestrator.ts +0 -310
- package/src/pm.ts +0 -302
- package/src/preview.ts +0 -206
- package/src/registry-store.ts +0 -41
- package/src/registry.ts +0 -132
- package/src/research.ts +0 -123
- package/src/retro.ts +0 -99
- package/src/roles.ts +0 -401
- package/src/router.ts +0 -120
- package/src/run-bakeoff.ts +0 -77
- package/src/run-build.ts +0 -203
- package/src/run-web.ts +0 -87
- package/src/scout.ts +0 -121
- package/src/session-cost.ts +0 -17
- package/src/stack.ts +0 -46
- package/src/tui/App.tsx +0 -1760
- package/src/tui/BakeOff.tsx +0 -190
- package/src/tui/BoardEditor.tsx +0 -260
- package/src/tui/EditableBoard.tsx +0 -179
- package/src/tui/Frame.tsx +0 -142
- package/src/tui/Intake.tsx +0 -111
- package/src/tui/Kanban.tsx +0 -158
- package/src/tui/Settings.tsx +0 -513
- package/src/tui/StackPick.tsx +0 -79
- package/src/tui/WebAccounts.tsx +0 -197
- package/src/tui/components.tsx +0 -340
- package/src/tui/config.ts +0 -150
- package/src/tui/deploy.ts +0 -137
- package/src/tui/engine.ts +0 -749
- package/src/tui/notify.ts +0 -21
- package/src/tui/panels.tsx +0 -89
- package/src/tui/templates.ts +0 -119
- package/src/tui/theme.ts +0 -44
- package/src/tui/validate.ts +0 -53
- package/src/types.ts +0 -208
- package/src/web/oauth-anthropic.ts +0 -206
- package/src/web/session.ts +0 -299
package/src/orchestrator.ts
DELETED
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
// Phase 4 — the Orchestrator. Runs a whole backlog end to end.
|
|
2
|
-
//
|
|
3
|
-
// - Topologically orders tasks by dependsOn (respects design->code->test->deploy).
|
|
4
|
-
// - Threads handoff context: a task sees the final output of its dependencies.
|
|
5
|
-
// - Runs a Tester -> Developer feedback loop, bounded by maxFeedbackRounds.
|
|
6
|
-
// - Tracks cost and halts on the budget cap.
|
|
7
|
-
//
|
|
8
|
-
// The executor is INJECTED (RoleExecutor), so this entire control flow is testable
|
|
9
|
-
// offline with a fake — no model, no spend. The real Pi executor lives in roles.ts.
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
TaskLimitError,
|
|
13
|
-
type RegistryEntry,
|
|
14
|
-
type RoleExecutor,
|
|
15
|
-
type RoutingPolicy,
|
|
16
|
-
type Task,
|
|
17
|
-
type TaskOutcome,
|
|
18
|
-
} from "./types.js";
|
|
19
|
-
import { route } from "./router.js";
|
|
20
|
-
import { REGISTRY } from "./registry.js";
|
|
21
|
-
|
|
22
|
-
/** Order tasks so every task comes after its dependencies. Throws on a cycle. */
|
|
23
|
-
export function toposort(tasks: Task[]): Task[] {
|
|
24
|
-
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
25
|
-
const state = new Map<string, "visiting" | "done">();
|
|
26
|
-
const out: Task[] = [];
|
|
27
|
-
|
|
28
|
-
const visit = (t: Task, trail: string[]) => {
|
|
29
|
-
const s = state.get(t.id);
|
|
30
|
-
if (s === "done") return;
|
|
31
|
-
if (s === "visiting") {
|
|
32
|
-
throw new Error(`Dependency cycle: ${[...trail, t.id].join(" -> ")}`);
|
|
33
|
-
}
|
|
34
|
-
state.set(t.id, "visiting");
|
|
35
|
-
for (const dep of t.dependsOn ?? []) {
|
|
36
|
-
const d = byId.get(dep);
|
|
37
|
-
if (d) visit(d, [...trail, t.id]); // unknown deps already stripped by normalize
|
|
38
|
-
}
|
|
39
|
-
state.set(t.id, "done");
|
|
40
|
-
out.push(t);
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
for (const t of tasks) visit(t, []);
|
|
44
|
-
return out;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface RunOptions {
|
|
48
|
-
policy: RoutingPolicy;
|
|
49
|
-
execute: RoleExecutor;
|
|
50
|
-
/** Registry to route against. Swap this to lock every role to one provider. */
|
|
51
|
-
registry?: RegistryEntry[];
|
|
52
|
-
onProgress?: (event: OrchestratorEvent) => void;
|
|
53
|
-
/** Prior outcomes to resume from (append-only record). Their tasks are skipped. */
|
|
54
|
-
seedOutcomes?: TaskOutcome[];
|
|
55
|
-
/** Called after each task settles, with the full record + running total, for persistence. */
|
|
56
|
-
onCheckpoint?: (outcomes: TaskOutcome[], totalCost: number) => void;
|
|
57
|
-
/** Max tasks to run at once. 1 (default) = sequential. >1 runs independent tasks in parallel. */
|
|
58
|
-
concurrency?: number;
|
|
59
|
-
/** Optional human gate before development begins (design done → dev). Resolve "stop" to halt. */
|
|
60
|
-
onGate?: (info: { stage: string }) => Promise<"continue" | "stop">;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export type OrchestratorEvent =
|
|
64
|
-
| { type: "task_start"; task: Task; round: number; provider: string; modelId: string }
|
|
65
|
-
| { type: "task_done"; outcome: TaskOutcome; runningTotal: number }
|
|
66
|
-
| { type: "task_failed"; outcome: TaskOutcome; runningTotal: number }
|
|
67
|
-
| { type: "task_skipped"; taskId: string }
|
|
68
|
-
| { type: "test_failed"; taskId: string; bugs: number; round: number }
|
|
69
|
-
| { type: "retry_dev"; taskId: string; forTest: string; round: number }
|
|
70
|
-
| { type: "budget_halt"; runningTotal: number; cap: number }
|
|
71
|
-
| { type: "gate"; stage: string }
|
|
72
|
-
| { type: "cycle_or_error"; message: string };
|
|
73
|
-
|
|
74
|
-
export interface RunResult {
|
|
75
|
-
outcomes: TaskOutcome[];
|
|
76
|
-
totalCost: number;
|
|
77
|
-
halted: boolean;
|
|
78
|
-
haltReason?: string;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Build handoff text from a task's dependency outcomes. */
|
|
82
|
-
function gatherContext(task: Task, outcomes: Map<string, TaskOutcome>): string {
|
|
83
|
-
const deps = task.dependsOn ?? [];
|
|
84
|
-
if (deps.length === 0) return "";
|
|
85
|
-
const parts: string[] = [];
|
|
86
|
-
for (const depId of deps) {
|
|
87
|
-
const o = outcomes.get(depId);
|
|
88
|
-
if (!o) continue;
|
|
89
|
-
const snippet = o.finalText.trim();
|
|
90
|
-
if (snippet) parts.push(`### From ${depId} (${o.capability}):\n${snippet}`);
|
|
91
|
-
if (o.files.length) parts.push(`### Files from ${depId}: ${o.files.join(", ")}`);
|
|
92
|
-
}
|
|
93
|
-
return parts.length ? `Context from upstream work:\n\n${parts.join("\n\n")}` : "";
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function bugReport(bugs: { severity: string; description: string; file?: string }[]): string {
|
|
97
|
-
return [
|
|
98
|
-
"The tester found these issues. Fix them, then stop:",
|
|
99
|
-
...bugs.map((b) => `- [${b.severity}]${b.file ? ` (${b.file})` : ""} ${b.description}`),
|
|
100
|
-
].join("\n");
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export async function runBacklog(tasks: Task[], opts: RunOptions): Promise<RunResult> {
|
|
104
|
-
const { policy, execute } = opts;
|
|
105
|
-
const registry = opts.registry ?? REGISTRY;
|
|
106
|
-
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
107
|
-
const outcomes = new Map<string, TaskOutcome>();
|
|
108
|
-
const record: TaskOutcome[] = [];
|
|
109
|
-
let running = 0;
|
|
110
|
-
|
|
111
|
-
// Resume: replay prior outcomes so finished tasks are skipped and cost is restored.
|
|
112
|
-
// A failed attempt is billed but never "done" — it is rebuilt.
|
|
113
|
-
const seed = opts.seedOutcomes ?? [];
|
|
114
|
-
for (const o of seed) {
|
|
115
|
-
record.push(o);
|
|
116
|
-
if (o.error) outcomes.delete(o.taskId); // last wins: a later failure voids an earlier pass
|
|
117
|
-
else outcomes.set(o.taskId, o);
|
|
118
|
-
running += o.cost;
|
|
119
|
-
}
|
|
120
|
-
running = round2(running);
|
|
121
|
-
const wasDone = new Set(outcomes.keys());
|
|
122
|
-
|
|
123
|
-
let halted = false;
|
|
124
|
-
let haltReason: string | undefined;
|
|
125
|
-
|
|
126
|
-
const emit = opts.onProgress ?? (() => {});
|
|
127
|
-
const checkpoint = () => opts.onCheckpoint?.(record, round2(running));
|
|
128
|
-
|
|
129
|
-
let ordered: Task[];
|
|
130
|
-
try {
|
|
131
|
-
ordered = toposort(tasks);
|
|
132
|
-
} catch (e) {
|
|
133
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
134
|
-
emit({ type: "cycle_or_error", message });
|
|
135
|
-
return { outcomes: [], totalCost: 0, halted: true, haltReason: message };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const runOne = async (task: Task, round: number, contextOverride?: string): Promise<TaskOutcome> => {
|
|
139
|
-
const decision = route(task, { policy, registry, runningTotalBefore: running });
|
|
140
|
-
emit({ type: "task_start", task, round, provider: decision.provider, modelId: decision.model.id });
|
|
141
|
-
const contextText = contextOverride ?? gatherContext(task, outcomes);
|
|
142
|
-
const meta = { taskId: task.id, capability: task.capability, provider: decision.provider, modelId: decision.model.id, round };
|
|
143
|
-
let outcome: TaskOutcome;
|
|
144
|
-
try {
|
|
145
|
-
outcome = { ...(await execute({ task, decision, contextText, round, limits: policy.taskLimits })), ...meta };
|
|
146
|
-
} catch (e) {
|
|
147
|
-
if (!(e instanceof TaskLimitError)) throw e;
|
|
148
|
-
// Limit breach: bill what was spent, record the failure, halt the build.
|
|
149
|
-
outcome = { finalText: "", files: [], cost: e.costSoFar, error: e.message, ...meta };
|
|
150
|
-
running += outcome.cost;
|
|
151
|
-
record.push(outcome);
|
|
152
|
-
halted = true;
|
|
153
|
-
haltReason = `${task.id} aborted: ${e.message}`;
|
|
154
|
-
emit({ type: "task_failed", outcome, runningTotal: round2(running) });
|
|
155
|
-
return outcome;
|
|
156
|
-
}
|
|
157
|
-
running += outcome.cost;
|
|
158
|
-
outcomes.set(task.id, outcome);
|
|
159
|
-
record.push(outcome);
|
|
160
|
-
emit({ type: "task_done", outcome, runningTotal: round2(running) });
|
|
161
|
-
return outcome;
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
// One task's full lifecycle: run it, then its Reviewer/Tester -> Developer feedback loop.
|
|
165
|
-
const runTaskUnit = async (task: Task): Promise<void> => {
|
|
166
|
-
let outcome = await runOne(task, 0);
|
|
167
|
-
const judges = task.capability === "test" || task.capability === "review";
|
|
168
|
-
if (!outcome.error && judges && outcome.verdict && !outcome.verdict.passed) {
|
|
169
|
-
// The code to fix: direct code deps, plus code deps reached through a review
|
|
170
|
-
// (a test depends on the review, which depends on the code).
|
|
171
|
-
const codeDeps: Task[] = [];
|
|
172
|
-
for (const id of task.dependsOn ?? []) {
|
|
173
|
-
const dep = byId.get(id);
|
|
174
|
-
if (!dep) continue;
|
|
175
|
-
if (dep.capability === "code") codeDeps.push(dep);
|
|
176
|
-
else if (dep.capability === "review") {
|
|
177
|
-
for (const id2 of dep.dependsOn ?? []) {
|
|
178
|
-
const d2 = byId.get(id2);
|
|
179
|
-
if (d2?.capability === "code" && !codeDeps.includes(d2)) codeDeps.push(d2);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
let round = 1;
|
|
185
|
-
fix: while (outcome.verdict && !outcome.verdict.passed && round <= policy.maxFeedbackRounds) {
|
|
186
|
-
emit({ type: "test_failed", taskId: task.id, bugs: outcome.verdict.bugs.length, round });
|
|
187
|
-
const fixContext = bugReport(outcome.verdict.bugs);
|
|
188
|
-
for (const dep of codeDeps) {
|
|
189
|
-
emit({ type: "retry_dev", taskId: dep.id, forTest: task.id, round });
|
|
190
|
-
if ((await runOne(dep, round, fixContext)).error) break fix;
|
|
191
|
-
}
|
|
192
|
-
outcome = await runOne(task, round); // re-test
|
|
193
|
-
if (outcome.error) break;
|
|
194
|
-
round++;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
checkpoint();
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
// Human gate: pause once before the first development (code) task begins.
|
|
201
|
-
let gateDone = false;
|
|
202
|
-
const passGate = async (): Promise<boolean> => {
|
|
203
|
-
if (gateDone || !opts.onGate) return true;
|
|
204
|
-
gateDone = true;
|
|
205
|
-
emit({ type: "gate", stage: "design→dev" });
|
|
206
|
-
const decision = await opts.onGate({ stage: "design→dev" });
|
|
207
|
-
return decision !== "stop";
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
const concurrency = Math.max(1, Math.floor(opts.concurrency ?? 1));
|
|
211
|
-
|
|
212
|
-
// ---- sequential path (concurrency 1) — unchanged behavior ----
|
|
213
|
-
if (concurrency === 1) {
|
|
214
|
-
for (const task of ordered) {
|
|
215
|
-
if (wasDone.has(task.id)) {
|
|
216
|
-
emit({ type: "task_skipped", taskId: task.id });
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
if (task.capability === "code" && !(await passGate())) {
|
|
220
|
-
checkpoint();
|
|
221
|
-
return { outcomes: record, totalCost: round2(running), halted: true, haltReason: "stopped at review gate" };
|
|
222
|
-
}
|
|
223
|
-
const est = route(task, { policy, registry, runningTotalBefore: running });
|
|
224
|
-
if (est.overCap) {
|
|
225
|
-
emit({ type: "budget_halt", runningTotal: round2(running + est.cost), cap: policy.budgetCapUSD });
|
|
226
|
-
checkpoint();
|
|
227
|
-
return { outcomes: record, totalCost: round2(running), halted: true, haltReason: "budget cap" };
|
|
228
|
-
}
|
|
229
|
-
await runTaskUnit(task);
|
|
230
|
-
if (halted) return { outcomes: record, totalCost: round2(running), halted, haltReason };
|
|
231
|
-
}
|
|
232
|
-
return { outcomes: record, totalCost: round2(running), halted: false };
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// ---- parallel path (concurrency > 1) — ready-set scheduler ----
|
|
236
|
-
// JS is single-threaded, so mutations between awaits are atomic (no locks needed).
|
|
237
|
-
// Independent tasks (deps satisfied) run concurrently up to `concurrency`. A budget
|
|
238
|
-
// reservation on in-flight estimates prevents launches that could cross the cap.
|
|
239
|
-
const remaining = new Set(ordered.filter((t) => !wasDone.has(t.id)).map((t) => t.id));
|
|
240
|
-
for (const id of wasDone) emit({ type: "task_skipped", taskId: id });
|
|
241
|
-
|
|
242
|
-
const inFlight = new Map<string, Promise<void>>();
|
|
243
|
-
let reserved = 0;
|
|
244
|
-
let codeInFlight = 0; // code tasks are serialized (they share files) even in parallel mode
|
|
245
|
-
let failure: unknown; // first task error; rethrown after in-flight work drains
|
|
246
|
-
|
|
247
|
-
const depsSatisfied = (t: Task) => (t.dependsOn ?? []).every((d) => !remaining.has(d));
|
|
248
|
-
const readyTasks = () =>
|
|
249
|
-
ordered.filter((t) => remaining.has(t.id) && !inFlight.has(t.id) && depsSatisfied(t));
|
|
250
|
-
|
|
251
|
-
while (remaining.size > 0 && !halted) {
|
|
252
|
-
// Gate before any development task launches.
|
|
253
|
-
if (!gateDone && opts.onGate && readyTasks().some((t) => t.capability === "code")) {
|
|
254
|
-
if (!(await passGate())) {
|
|
255
|
-
halted = true;
|
|
256
|
-
haltReason = "stopped at review gate";
|
|
257
|
-
break;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
for (const task of readyTasks()) {
|
|
261
|
-
if (inFlight.size >= concurrency) break;
|
|
262
|
-
// Only one code task builds at a time — they write to the shared workspace.
|
|
263
|
-
if (task.capability === "code" && codeInFlight >= 1) continue;
|
|
264
|
-
// Reservations keep full precision: rounding each one to cents drops sub-cent
|
|
265
|
-
// estimates entirely, so a wide backlog of cheap tasks would under-reserve.
|
|
266
|
-
const est = route(task, { policy, registry, runningTotalBefore: running + reserved });
|
|
267
|
-
if (running + reserved + est.cost > policy.budgetCapUSD) {
|
|
268
|
-
if (inFlight.size === 0) {
|
|
269
|
-
emit({ type: "budget_halt", runningTotal: round2(running + est.cost), cap: policy.budgetCapUSD });
|
|
270
|
-
halted = true;
|
|
271
|
-
haltReason = "budget cap";
|
|
272
|
-
}
|
|
273
|
-
break; // wait for in-flight tasks to free budget/capacity
|
|
274
|
-
}
|
|
275
|
-
reserved += est.cost;
|
|
276
|
-
const cost = est.cost;
|
|
277
|
-
const isCode = task.capability === "code";
|
|
278
|
-
if (isCode) codeInFlight++;
|
|
279
|
-
const settle = () => {
|
|
280
|
-
if (isCode) codeInFlight--;
|
|
281
|
-
reserved -= cost;
|
|
282
|
-
remaining.delete(task.id);
|
|
283
|
-
inFlight.delete(task.id);
|
|
284
|
-
};
|
|
285
|
-
// A rejection must NOT escape through Promise.race below: that abandons the
|
|
286
|
-
// sibling promises, and their later rejections would have no handler attached
|
|
287
|
-
// (unhandled rejection -> the host process dies mid-build). Capture the first
|
|
288
|
-
// failure, stop launching, drain what's running, checkpoint, then rethrow.
|
|
289
|
-
const p = runTaskUnit(task).then(settle, (e: unknown) => {
|
|
290
|
-
settle();
|
|
291
|
-
halted = true;
|
|
292
|
-
haltReason ??= e instanceof Error ? e.message : String(e);
|
|
293
|
-
failure ??= e;
|
|
294
|
-
});
|
|
295
|
-
inFlight.set(task.id, p);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
if (inFlight.size === 0) break; // nothing running and nothing launchable -> done or halted
|
|
299
|
-
await Promise.race(inFlight.values());
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
await Promise.all(inFlight.values());
|
|
303
|
-
checkpoint();
|
|
304
|
-
if (failure) throw failure;
|
|
305
|
-
return { outcomes: record, totalCost: round2(running), halted, haltReason };
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function round2(n: number): number {
|
|
309
|
-
return Math.round(n * 100) / 100;
|
|
310
|
-
}
|
package/src/pm.ts
DELETED
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
// Phase 3 — the PM / Decomposer.
|
|
2
|
-
// Turns a one-line idea into a tagged backlog (epic -> story -> task).
|
|
3
|
-
//
|
|
4
|
-
// Structured output: Pi has none built in, so we force it. The PM model is given
|
|
5
|
-
// exactly ONE tool — submit_backlog, whose typebox schema IS the backlog shape —
|
|
6
|
-
// and must call it. That's far more reliable than parsing JSON out of prose.
|
|
7
|
-
//
|
|
8
|
-
// Token estimates are NOT asked of the model (it's bad at them); we fill them from
|
|
9
|
-
// code buckets after decomposition. The PM only decomposes + tags.
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
createAgentSession,
|
|
13
|
-
defineTool,
|
|
14
|
-
type AgentSession,
|
|
15
|
-
} from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import { Type, type Static } from "typebox";
|
|
17
|
-
import type { Backend, Capability, Difficulty, Provider, Task } from "./types.js";
|
|
18
|
-
import { estimateTokens } from "./estimate.js";
|
|
19
|
-
import { findEntry } from "./registry.js";
|
|
20
|
-
import { piRuntime, resolvePiModel } from "./executor.js";
|
|
21
|
-
import { addSessionCost } from "./session-cost.js";
|
|
22
|
-
|
|
23
|
-
// ---- typebox schema = the backlog contract ----
|
|
24
|
-
// DELIBERATELY PERMISSIVE. A forced-tool call fails INVISIBLY if the args don't
|
|
25
|
-
// validate (Pi rejects it, our capture never fires). So: flat list, only id/title/
|
|
26
|
-
// capability/difficulty required, capability/difficulty as free strings (coerced in
|
|
27
|
-
// code), extra fields allowed. We validate/clean afterwards instead of at the gate.
|
|
28
|
-
const TaskSchema = Type.Object(
|
|
29
|
-
{
|
|
30
|
-
id: Type.String({ description: "Unique task id, e.g. T-01" }),
|
|
31
|
-
title: Type.String({ description: "One concrete, buildable unit of work" }),
|
|
32
|
-
capability: Type.String({ description: "one of: plan | design | code | review | test | ops" }),
|
|
33
|
-
difficulty: Type.String({ description: "one of: trivial | low | medium | high" }),
|
|
34
|
-
dependsOn: Type.Optional(Type.Array(Type.String(), { description: "task ids that must finish first" })),
|
|
35
|
-
epic: Type.Optional(Type.String({ description: "optional grouping label" })),
|
|
36
|
-
story: Type.Optional(Type.String({ description: "optional grouping label" })),
|
|
37
|
-
},
|
|
38
|
-
{ additionalProperties: true },
|
|
39
|
-
);
|
|
40
|
-
const BacklogSchema = Type.Object({ tasks: Type.Array(TaskSchema) }, { additionalProperties: true });
|
|
41
|
-
|
|
42
|
-
const CAPS = new Set<Capability>(["plan", "design", "code", "review", "test", "ops"]);
|
|
43
|
-
const DIFFS = new Set<Difficulty>(["trivial", "low", "medium", "high"]);
|
|
44
|
-
function coerceCap(s: string): Capability {
|
|
45
|
-
const v = s?.toLowerCase().trim() as Capability;
|
|
46
|
-
return CAPS.has(v) ? v : "code";
|
|
47
|
-
}
|
|
48
|
-
function coerceDiff(s: string): Difficulty {
|
|
49
|
-
const v = s?.toLowerCase().trim() as Difficulty;
|
|
50
|
-
return DIFFS.has(v) ? v : "medium";
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export type Backlog = Static<typeof BacklogSchema>;
|
|
54
|
-
export type BacklogTask = Static<typeof TaskSchema>;
|
|
55
|
-
|
|
56
|
-
// ---- the capture tool ----
|
|
57
|
-
export function buildBacklogTool() {
|
|
58
|
-
let captured: Backlog | undefined;
|
|
59
|
-
const tool = defineTool({
|
|
60
|
-
name: "submit_backlog",
|
|
61
|
-
label: "Submit Backlog",
|
|
62
|
-
description: "Submit the finished backlog as a flat list of tasks. Call this exactly once.",
|
|
63
|
-
parameters: BacklogSchema,
|
|
64
|
-
execute: async (_id, params) => {
|
|
65
|
-
captured = params as Backlog;
|
|
66
|
-
return { content: [{ type: "text", text: `Backlog received: ${params.tasks.length} tasks.` }], details: {} };
|
|
67
|
-
},
|
|
68
|
-
});
|
|
69
|
-
return { tool, get: () => captured };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export type Scope = "full" | "change";
|
|
73
|
-
|
|
74
|
-
export function pmSystemPrompt(scope: Scope = "full"): string {
|
|
75
|
-
const sizing =
|
|
76
|
-
scope === "change"
|
|
77
|
-
? [
|
|
78
|
-
"This is a CHANGE to an EXISTING project whose files are already on disk.",
|
|
79
|
-
"Produce the FEWEST tasks that accomplish the change — usually 1 code task plus 1",
|
|
80
|
-
"`review` task that dependsOn it (cheap read-only wiring check), plus 1 test task",
|
|
81
|
-
"(dependsOn the review) only if the change is risky. Do NOT re-plan the whole project,",
|
|
82
|
-
"do NOT add design/setup/deploy tasks. One small tweak = code + review.",
|
|
83
|
-
]
|
|
84
|
-
: [
|
|
85
|
-
"Scale the number of tasks to the request. A tiny page = a few tasks; a full app = many.",
|
|
86
|
-
"Group tasks under EPICS (big features/areas of the product). Set every task's `epic`",
|
|
87
|
-
"field to its epic name, e.g. 'Hero section', 'Contact form', 'Deployment'. Aim for",
|
|
88
|
-
"2-5 tasks per epic. Do not pad: never split one obvious unit of work into multiple",
|
|
89
|
-
"tasks. Skip design/plan/ops tasks when the request clearly doesn't need them.",
|
|
90
|
-
"For a MULTI-FILE app: name the target file(s) in each code task's title (e.g. 'Build",
|
|
91
|
-
"src/components/Header.jsx') and keep file names CONSISTENT across tasks — decide one",
|
|
92
|
-
"structure and reuse it. When several files must agree, add ONE early design task that",
|
|
93
|
-
"defines the file tree, and have the code tasks depend on it.",
|
|
94
|
-
"After EVERY code task add one `review` task that dependsOn that code task (a cheap",
|
|
95
|
-
"read-only wiring check). The test task must dependsOn the review task(s), not the code",
|
|
96
|
-
"task(s) directly. Order: design -> code -> review -> test.",
|
|
97
|
-
];
|
|
98
|
-
return [
|
|
99
|
-
"You are the PROJECT MANAGER on an autonomous software team.",
|
|
100
|
-
"Break the user's request into a flat list of tasks.",
|
|
101
|
-
...sizing,
|
|
102
|
-
"",
|
|
103
|
-
"Each TASK must be:",
|
|
104
|
-
"- atomic: one model can complete it in one focused turn",
|
|
105
|
-
"- tagged with a capability: plan | design | code | review | test | ops",
|
|
106
|
-
"- tagged with a difficulty: trivial | low | medium | high (how hard the thinking is)",
|
|
107
|
-
"Optional per task: dependsOn (ids that must finish first, e.g. code depends on design),",
|
|
108
|
-
"and epic/story labels for grouping. Use ids like T-01, unique across the list.",
|
|
109
|
-
"Order matters: a design task should precede the code task that implements it; tests come after code.",
|
|
110
|
-
"",
|
|
111
|
-
"CRITICAL: You have exactly ONE tool — submit_backlog — and you MUST call it with a",
|
|
112
|
-
"`tasks` array. Never reply with prose. Never ask the user a question. If anything is",
|
|
113
|
-
"unclear, make a reasonable assumption (e.g. the main file is index.html) and submit.",
|
|
114
|
-
"Always produce at least one task. Call submit_backlog exactly once.",
|
|
115
|
-
].join("\n");
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ---- pure post-processing (testable, no model) ----
|
|
119
|
-
|
|
120
|
-
export interface NormalizeResult {
|
|
121
|
-
backlog: Backlog;
|
|
122
|
-
diagnostics: string[];
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Clean a raw backlog: drop duplicate task ids, strip dangling dependsOn refs. */
|
|
126
|
-
export function normalizeBacklog(raw: Backlog): NormalizeResult {
|
|
127
|
-
const diagnostics: string[] = [];
|
|
128
|
-
const seen = new Set<string>();
|
|
129
|
-
const allIds = new Set(raw.tasks.map((t) => t.id));
|
|
130
|
-
|
|
131
|
-
const tasks = raw.tasks
|
|
132
|
-
.filter((t) => {
|
|
133
|
-
if (seen.has(t.id)) {
|
|
134
|
-
diagnostics.push(`dropped duplicate task id ${t.id}`);
|
|
135
|
-
return false;
|
|
136
|
-
}
|
|
137
|
-
seen.add(t.id);
|
|
138
|
-
return true;
|
|
139
|
-
})
|
|
140
|
-
.map((t) => {
|
|
141
|
-
const deps = (t.dependsOn ?? []).filter((d) => {
|
|
142
|
-
if (!allIds.has(d)) {
|
|
143
|
-
diagnostics.push(`task ${t.id}: dropped dangling dependsOn ${d}`);
|
|
144
|
-
return false;
|
|
145
|
-
}
|
|
146
|
-
return true;
|
|
147
|
-
});
|
|
148
|
-
return { ...t, dependsOn: deps };
|
|
149
|
-
});
|
|
150
|
-
return { backlog: { tasks }, diagnostics };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** Flatten a backlog to routable Tasks, coercing loose tags + filling token estimates. */
|
|
154
|
-
export function flattenBacklog(backlog: Backlog): Task[] {
|
|
155
|
-
return backlog.tasks.map((t) => {
|
|
156
|
-
const capability = coerceCap(t.capability);
|
|
157
|
-
const difficulty = coerceDiff(t.difficulty);
|
|
158
|
-
return {
|
|
159
|
-
id: t.id,
|
|
160
|
-
title: t.title,
|
|
161
|
-
capability,
|
|
162
|
-
difficulty,
|
|
163
|
-
dependsOn: t.dependsOn ?? [],
|
|
164
|
-
epic: t.epic,
|
|
165
|
-
story: t.story,
|
|
166
|
-
estTokens: estimateTokens(capability, difficulty),
|
|
167
|
-
};
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ---- text fallback: parse a task list out of prose if the tool wasn't used ----
|
|
172
|
-
|
|
173
|
-
function lastAssistantText(session: AgentSession): string {
|
|
174
|
-
const msgs = session.messages as Array<{ role?: string; content?: unknown }>;
|
|
175
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
176
|
-
const m = msgs[i];
|
|
177
|
-
if (m?.role !== "assistant") continue;
|
|
178
|
-
const c = m.content;
|
|
179
|
-
if (typeof c === "string") return c;
|
|
180
|
-
if (Array.isArray(c)) {
|
|
181
|
-
return c
|
|
182
|
-
.map((p: unknown) => (typeof p === "string" ? p : p && typeof p === "object" && "text" in p ? String((p as { text: unknown }).text) : ""))
|
|
183
|
-
.join("");
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
return "";
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** Best-effort: pull a { tasks: [...] } (or a bare [...]) out of a model's text reply. */
|
|
190
|
-
export function extractBacklogFromText(text: string): Backlog | undefined {
|
|
191
|
-
const tryParse = (s: string): Backlog | undefined => {
|
|
192
|
-
try {
|
|
193
|
-
const p = JSON.parse(s) as unknown;
|
|
194
|
-
if (Array.isArray(p)) return { tasks: p as Backlog["tasks"] };
|
|
195
|
-
if (p && typeof p === "object" && Array.isArray((p as { tasks?: unknown }).tasks)) return p as Backlog;
|
|
196
|
-
} catch {
|
|
197
|
-
/* not json */
|
|
198
|
-
}
|
|
199
|
-
return undefined;
|
|
200
|
-
};
|
|
201
|
-
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
202
|
-
const objMatch = text.match(/\{[\s\S]*"tasks"[\s\S]*\}/);
|
|
203
|
-
const arrMatch = text.match(/\[[\s\S]*\]/);
|
|
204
|
-
for (const c of [fence?.[1], objMatch?.[0], arrMatch?.[0]]) {
|
|
205
|
-
if (!c) continue;
|
|
206
|
-
const r = tryParse(c.trim());
|
|
207
|
-
if (r?.tasks?.length) return r;
|
|
208
|
-
}
|
|
209
|
-
return undefined;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// ---- live decomposition (calls the PM model, spends money) ----
|
|
213
|
-
|
|
214
|
-
export interface DecomposeOptions {
|
|
215
|
-
backend: Backend;
|
|
216
|
-
thinkingLevel?: "off" | "low" | "medium" | "high";
|
|
217
|
-
onEvent?: Parameters<import("@earendil-works/pi-coding-agent").AgentSession["subscribe"]>[0];
|
|
218
|
-
/** Override the PM model (else resolved from registry plan/mid). */
|
|
219
|
-
modelOverride?: { provider: Provider; model: string };
|
|
220
|
-
/** "change" = minimal tasks against an existing project; "full" = a fresh build. */
|
|
221
|
-
scope?: Scope;
|
|
222
|
-
/** Summary of the existing project (files + contents) so the PM plans with real context. */
|
|
223
|
-
projectContext?: string;
|
|
224
|
-
/** Council-approved epics: organize ALL tasks under exactly these. */
|
|
225
|
-
epics?: { name: string; rationale: string }[];
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export interface DecomposeResult {
|
|
229
|
-
provider: Provider;
|
|
230
|
-
modelId: string;
|
|
231
|
-
backlog: Backlog;
|
|
232
|
-
tasks: Task[];
|
|
233
|
-
diagnostics: string[];
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
export async function decomposeIdea(idea: string, opts: DecomposeOptions): Promise<DecomposeResult> {
|
|
237
|
-
const runtime = await piRuntime();
|
|
238
|
-
|
|
239
|
-
// PM = plan capability, mid tier — unless the caller overrides the model.
|
|
240
|
-
const { entry } = findEntry("plan", "mid");
|
|
241
|
-
const pick = opts.modelOverride ?? entry.byBackend[opts.backend];
|
|
242
|
-
const model = resolvePiModel(runtime, pick.provider, pick.model);
|
|
243
|
-
|
|
244
|
-
const { tool, get } = buildBacklogTool();
|
|
245
|
-
const { session } = await createAgentSession({
|
|
246
|
-
model,
|
|
247
|
-
modelRuntime: runtime,
|
|
248
|
-
thinkingLevel: opts.thinkingLevel ?? "medium",
|
|
249
|
-
noTools: "all",
|
|
250
|
-
customTools: [tool],
|
|
251
|
-
tools: ["submit_backlog"],
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
const unsub = opts.onEvent ? session.subscribe(opts.onEvent) : undefined;
|
|
255
|
-
try {
|
|
256
|
-
const ctx = opts.projectContext ? `\n\n--- EXISTING PROJECT (plan the change against this) ---\n${opts.projectContext}` : "";
|
|
257
|
-
const epicsBlock = opts.epics?.length
|
|
258
|
-
? `\n\n--- APPROVED EPICS (organize ALL tasks under EXACTLY these; set each task's epic field to one of these names) ---\n${opts.epics.map((e) => `- ${e.name}: ${e.rationale}`).join("\n")}`
|
|
259
|
-
: "";
|
|
260
|
-
await session.prompt(`${pmSystemPrompt(opts.scope ?? "full")}${ctx}${epicsBlock}\n\n--- REQUEST ---\n${idea}`);
|
|
261
|
-
let raw = get();
|
|
262
|
-
// Nudge up to 3 times if the tool wasn't called (or its args failed validation).
|
|
263
|
-
for (let i = 0; i < 3 && !raw; i++) {
|
|
264
|
-
await session.prompt(
|
|
265
|
-
"You did not call submit_backlog successfully. Call submit_backlog NOW with a `tasks` " +
|
|
266
|
-
"array as the tool arguments. Do not write prose. Make reasonable assumptions if needed.",
|
|
267
|
-
);
|
|
268
|
-
raw = get();
|
|
269
|
-
}
|
|
270
|
-
// Last resort: the model may have printed the backlog as JSON text — parse it.
|
|
271
|
-
if (!raw) raw = extractBacklogFromText(lastAssistantText(session));
|
|
272
|
-
if (!raw || !raw.tasks?.length) {
|
|
273
|
-
const stats = session.getSessionStats();
|
|
274
|
-
if (stats.tokens.total === 0) {
|
|
275
|
-
// The provider call returned nothing — bad/inaccessible key, OR (very common)
|
|
276
|
-
// the account has no credit/balance so the API rejects the request.
|
|
277
|
-
throw new Error(
|
|
278
|
-
`The ${pick.provider} model returned nothing (0 tokens). Likely causes: the API key is invalid, ` +
|
|
279
|
-
`the account has no credit/balance, or the key lacks access to ${pick.model}. Add credit or set a ` +
|
|
280
|
-
`working key, or pick a different provider in Settings → Preferred provider.`,
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
const said = lastAssistantText(session).slice(0, 200).replace(/\s+/g, " ").trim();
|
|
284
|
-
throw new Error(
|
|
285
|
-
`The planner didn't return a task list.${said ? ` It said: "${said}…"` : ""} Try rephrasing the request.`,
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
const { backlog, diagnostics } = normalizeBacklog(raw);
|
|
290
|
-
return {
|
|
291
|
-
provider: pick.provider,
|
|
292
|
-
modelId: pick.model,
|
|
293
|
-
backlog,
|
|
294
|
-
tasks: flattenBacklog(backlog),
|
|
295
|
-
diagnostics,
|
|
296
|
-
};
|
|
297
|
-
} finally {
|
|
298
|
-
addSessionCost(session.getSessionStats().cost);
|
|
299
|
-
unsub?.();
|
|
300
|
-
session.dispose();
|
|
301
|
-
}
|
|
302
|
-
}
|