projectinator 0.1.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/LICENSE +21 -0
- package/README.md +139 -0
- package/bin/projectinator.mjs +21 -0
- package/package.json +73 -0
- package/src/bakeoff.ts +220 -0
- package/src/build-state.ts +46 -0
- package/src/burndown.ts +35 -0
- package/src/calibration.ts +88 -0
- package/src/cost.ts +45 -0
- package/src/council.ts +180 -0
- package/src/demo.ts +106 -0
- package/src/estimate.ts +104 -0
- package/src/executor.ts +168 -0
- package/src/git.ts +72 -0
- package/src/intake.ts +129 -0
- package/src/models.ts +92 -0
- package/src/narrate.ts +91 -0
- package/src/orchestrator.ts +271 -0
- package/src/pm.ts +301 -0
- package/src/preview.ts +192 -0
- package/src/registry-store.ts +41 -0
- package/src/registry.ts +118 -0
- package/src/research.ts +127 -0
- package/src/retro.ts +99 -0
- package/src/roles.ts +357 -0
- package/src/router.ts +123 -0
- package/src/run-bakeoff.ts +77 -0
- package/src/run-build.ts +190 -0
- package/src/run-dev.ts +83 -0
- package/src/run-pm.ts +92 -0
- package/src/run-research.ts +74 -0
- package/src/run-scout.ts +68 -0
- package/src/run-web.ts +87 -0
- package/src/scout.ts +121 -0
- package/src/session-cost.ts +17 -0
- package/src/stack.ts +46 -0
- package/src/tui/App.tsx +1739 -0
- package/src/tui/BakeOff.tsx +190 -0
- package/src/tui/BoardEditor.tsx +248 -0
- package/src/tui/EditableBoard.tsx +169 -0
- package/src/tui/Frame.tsx +142 -0
- package/src/tui/Intake.tsx +111 -0
- package/src/tui/Kanban.tsx +155 -0
- package/src/tui/Settings.tsx +419 -0
- package/src/tui/StackPick.tsx +79 -0
- package/src/tui/WebAccounts.tsx +197 -0
- package/src/tui/components.tsx +338 -0
- package/src/tui/config.ts +134 -0
- package/src/tui/deploy.ts +137 -0
- package/src/tui/engine.ts +742 -0
- package/src/tui/notify.ts +21 -0
- package/src/tui/panels.tsx +89 -0
- package/src/tui/templates.ts +119 -0
- package/src/tui/theme.ts +44 -0
- package/src/tui/validate.ts +48 -0
- package/src/tui.tsx +63 -0
- package/src/types.ts +175 -0
- package/src/web/oauth-anthropic.ts +206 -0
- package/src/web/session.ts +299 -0
package/src/intake.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Intake — the PM's clarifying-questions step. Before planning a vague request,
|
|
2
|
+
// the PM asks 2-4 short questions (each with suggested options) to pin down what
|
|
3
|
+
// to build. A clear request returns no questions and skips straight to planning.
|
|
4
|
+
//
|
|
5
|
+
// Forced structured output, same discipline as pm.ts: one tool, permissive
|
|
6
|
+
// schema, coerced/validated in code.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
AuthStorage,
|
|
10
|
+
ModelRegistry,
|
|
11
|
+
createAgentSession,
|
|
12
|
+
defineTool,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Type, type Static } from "typebox";
|
|
15
|
+
import type { Backend, Provider } from "./types.js";
|
|
16
|
+
import { findEntry } from "./registry.js";
|
|
17
|
+
import { resolvePiModel } from "./executor.js";
|
|
18
|
+
import { addSessionCost } from "./session-cost.js";
|
|
19
|
+
|
|
20
|
+
const IntakeSchema = Type.Object(
|
|
21
|
+
{
|
|
22
|
+
needsClarification: Type.Boolean({ description: "true only if the request is too vague to plan well" }),
|
|
23
|
+
questions: Type.Array(
|
|
24
|
+
Type.Object(
|
|
25
|
+
{
|
|
26
|
+
question: Type.String({ description: "one short clarifying question" }),
|
|
27
|
+
options: Type.Array(Type.String(), { description: "2-4 concrete pickable answers; may be empty for free text" }),
|
|
28
|
+
multi: Type.Boolean({ description: "true if several options can apply at once" }),
|
|
29
|
+
},
|
|
30
|
+
{ additionalProperties: true },
|
|
31
|
+
),
|
|
32
|
+
{ description: "empty when needsClarification is false; at most 4" },
|
|
33
|
+
),
|
|
34
|
+
},
|
|
35
|
+
{ additionalProperties: true },
|
|
36
|
+
);
|
|
37
|
+
type IntakeRaw = Static<typeof IntakeSchema>;
|
|
38
|
+
|
|
39
|
+
export interface IntakeQuestion {
|
|
40
|
+
question: string;
|
|
41
|
+
options: string[];
|
|
42
|
+
multi: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildIntakeTool() {
|
|
46
|
+
let captured: IntakeRaw | undefined;
|
|
47
|
+
const tool = defineTool({
|
|
48
|
+
name: "submit_intake",
|
|
49
|
+
label: "Submit Intake",
|
|
50
|
+
description: "Submit whether clarification is needed and, if so, the clarifying questions. Call exactly once.",
|
|
51
|
+
parameters: IntakeSchema,
|
|
52
|
+
execute: async (_id, params) => {
|
|
53
|
+
captured = params as IntakeRaw;
|
|
54
|
+
return { content: [{ type: "text", text: `Intake: ${params.needsClarification ? `${params.questions.length} questions` : "clear"}.` }], details: {} };
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
return { tool, get: () => captured };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const SYSTEM = [
|
|
61
|
+
"You are the PROJECT MANAGER doing intake for a build request.",
|
|
62
|
+
"If the request already has enough detail to plan and build — its purpose and the",
|
|
63
|
+
"must-have features are clear or reasonably inferable — set needsClarification=false",
|
|
64
|
+
"and questions=[].",
|
|
65
|
+
"If it is vague (a bare template like 'landing page', a one-liner missing the",
|
|
66
|
+
"essentials), set needsClarification=true and produce 2-4 SHORT questions that most",
|
|
67
|
+
"reduce ambiguity: what the product/business actually is, a name, must-have",
|
|
68
|
+
"sections/features, and style. For each question give 2-4 concrete pickable options;",
|
|
69
|
+
"set multi=true when several can apply (e.g. which sections). Never more than 4",
|
|
70
|
+
"questions. Do not ask what you can reasonably assume. Call submit_intake exactly once.",
|
|
71
|
+
].join("\n");
|
|
72
|
+
|
|
73
|
+
export interface AssessOptions {
|
|
74
|
+
backend: Backend;
|
|
75
|
+
modelOverride?: { provider: Provider; model: string };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Ask the PM whether the request needs clarification; returns up to 4 questions
|
|
79
|
+
* (empty = clear enough to plan directly). Never throws — returns [] on trouble. */
|
|
80
|
+
export async function assessIntake(idea: string, opts: AssessOptions): Promise<IntakeQuestion[]> {
|
|
81
|
+
const authStorage = AuthStorage.create();
|
|
82
|
+
const registry = ModelRegistry.create(authStorage);
|
|
83
|
+
const { entry } = findEntry("plan", "mid");
|
|
84
|
+
const pick = opts.modelOverride ?? entry.byBackend[opts.backend];
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const model = resolvePiModel(registry, pick.provider, pick.model);
|
|
88
|
+
const { tool, get } = buildIntakeTool();
|
|
89
|
+
const { session } = await createAgentSession({
|
|
90
|
+
model,
|
|
91
|
+
authStorage,
|
|
92
|
+
modelRegistry: registry,
|
|
93
|
+
thinkingLevel: "low",
|
|
94
|
+
noTools: "all",
|
|
95
|
+
customTools: [tool],
|
|
96
|
+
tools: ["submit_intake"],
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
await session.prompt(`${SYSTEM}\n\n--- REQUEST ---\n${idea}`);
|
|
100
|
+
let raw = get();
|
|
101
|
+
if (!raw) {
|
|
102
|
+
await session.prompt("Call submit_intake now.");
|
|
103
|
+
raw = get();
|
|
104
|
+
}
|
|
105
|
+
addSessionCost(session.getSessionStats().cost);
|
|
106
|
+
if (!raw || !raw.needsClarification) return [];
|
|
107
|
+
return (raw.questions ?? [])
|
|
108
|
+
.slice(0, 4)
|
|
109
|
+
.map((q) => ({
|
|
110
|
+
question: String(q.question ?? "").trim(),
|
|
111
|
+
options: (q.options ?? []).map((o) => String(o).trim()).filter(Boolean).slice(0, 6),
|
|
112
|
+
multi: !!q.multi,
|
|
113
|
+
}))
|
|
114
|
+
.filter((q) => q.question);
|
|
115
|
+
} finally {
|
|
116
|
+
session.dispose();
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
return []; // never block a build on intake — fall through to planning
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Fold the collected answers into the brief the PM will plan from. */
|
|
124
|
+
export function enrichBrief(idea: string, answers: { question: string; answer: string }[]): string {
|
|
125
|
+
const kept = answers.filter((a) => a.answer.trim());
|
|
126
|
+
if (!kept.length) return idea;
|
|
127
|
+
const lines = kept.map((a) => `- ${a.question} ${a.answer.trim()}`);
|
|
128
|
+
return `${idea}\n\nClarifications from the requester:\n${lines.join("\n")}`;
|
|
129
|
+
}
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Model pricing table. Rates USD per 1,000,000 tokens (verified July 2026).
|
|
2
|
+
// Shape mirrors Pi's models.json `cost` block so this ports to Pi later.
|
|
3
|
+
// cacheRead ~= 0.1x input, cacheWrite ~= 1.25x input (Anthropic-style; approximations
|
|
4
|
+
// for models whose exact cache rates we haven't pinned — refine against provider docs).
|
|
5
|
+
|
|
6
|
+
import type { Model } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export const MODELS: Record<string, Model> = {
|
|
9
|
+
// ---- OpenAI: GPT-5.6 family ----
|
|
10
|
+
"gpt-5.6-sol": {
|
|
11
|
+
id: "gpt-5.6-sol",
|
|
12
|
+
provider: "openai",
|
|
13
|
+
name: "GPT-5.6 Sol",
|
|
14
|
+
contextWindow: 272_000,
|
|
15
|
+
cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 },
|
|
16
|
+
},
|
|
17
|
+
"gpt-5.6-terra": {
|
|
18
|
+
id: "gpt-5.6-terra",
|
|
19
|
+
provider: "openai",
|
|
20
|
+
name: "GPT-5.6 Terra",
|
|
21
|
+
contextWindow: 272_000,
|
|
22
|
+
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 3.125 },
|
|
23
|
+
},
|
|
24
|
+
"gpt-5.6-luna": {
|
|
25
|
+
id: "gpt-5.6-luna",
|
|
26
|
+
provider: "openai",
|
|
27
|
+
name: "GPT-5.6 Luna",
|
|
28
|
+
contextWindow: 272_000,
|
|
29
|
+
cost: { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 1.25 },
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
// ---- Anthropic: Claude ----
|
|
33
|
+
"claude-fable-5": {
|
|
34
|
+
id: "claude-fable-5",
|
|
35
|
+
provider: "anthropic",
|
|
36
|
+
name: "Claude Fable 5",
|
|
37
|
+
contextWindow: 1_000_000,
|
|
38
|
+
cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
|
|
39
|
+
},
|
|
40
|
+
"claude-opus-4-8": {
|
|
41
|
+
id: "claude-opus-4-8",
|
|
42
|
+
provider: "anthropic",
|
|
43
|
+
name: "Claude Opus 4.8",
|
|
44
|
+
contextWindow: 1_000_000,
|
|
45
|
+
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
|
46
|
+
},
|
|
47
|
+
"claude-sonnet-4-6": {
|
|
48
|
+
id: "claude-sonnet-4-6",
|
|
49
|
+
provider: "anthropic",
|
|
50
|
+
name: "Claude Sonnet 4.6",
|
|
51
|
+
contextWindow: 1_000_000,
|
|
52
|
+
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
53
|
+
},
|
|
54
|
+
"claude-haiku-4-5": {
|
|
55
|
+
id: "claude-haiku-4-5",
|
|
56
|
+
provider: "anthropic",
|
|
57
|
+
name: "Claude Haiku 4.5",
|
|
58
|
+
contextWindow: 200_000,
|
|
59
|
+
cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
// ---- Google: Gemini ----
|
|
63
|
+
// NOTE: ids match Pi's built-in registry exactly (provider "google"), so they
|
|
64
|
+
// resolve directly via ModelRegistry.find() with no alias layer.
|
|
65
|
+
"gemini-3.1-pro-preview": {
|
|
66
|
+
id: "gemini-3.1-pro-preview",
|
|
67
|
+
provider: "google",
|
|
68
|
+
name: "Gemini 3.1 Pro",
|
|
69
|
+
contextWindow: 1_000_000,
|
|
70
|
+
cost: {
|
|
71
|
+
input: 2,
|
|
72
|
+
output: 12,
|
|
73
|
+
cacheRead: 0.2,
|
|
74
|
+
cacheWrite: 2.5,
|
|
75
|
+
// Google charges more past 200k input tokens.
|
|
76
|
+
tiers: [{ inputTokensAbove: 200_000, input: 4, output: 18, cacheRead: 0.4, cacheWrite: 5 }],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
"gemini-3-flash-preview": {
|
|
80
|
+
id: "gemini-3-flash-preview",
|
|
81
|
+
provider: "google",
|
|
82
|
+
name: "Gemini 3 Flash",
|
|
83
|
+
contextWindow: 1_000_000,
|
|
84
|
+
cost: { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0.625 },
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export function getModel(id: string): Model {
|
|
89
|
+
const m = MODELS[id];
|
|
90
|
+
if (!m) throw new Error(`Unknown model id: "${id}". Add it to src/models.ts.`);
|
|
91
|
+
return m;
|
|
92
|
+
}
|
package/src/narrate.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// AI narrative for a build retro. Feeds the data-driven RetroReport to a model
|
|
2
|
+
// and gets back a short "what went well / what to improve" write-up. On-demand
|
|
3
|
+
// (costs a small call) and cached on the build state by the caller.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
AuthStorage,
|
|
7
|
+
ModelRegistry,
|
|
8
|
+
createAgentSession,
|
|
9
|
+
type AgentSession,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { Backend, Provider } from "./types.js";
|
|
12
|
+
import type { RetroReport } from "./retro.js";
|
|
13
|
+
import { findEntry } from "./registry.js";
|
|
14
|
+
import { resolvePiModel } from "./executor.js";
|
|
15
|
+
import { addSessionCost } from "./session-cost.js";
|
|
16
|
+
|
|
17
|
+
function lastAssistantText(session: AgentSession): string {
|
|
18
|
+
const msgs = session.messages as Array<{ role?: string; content?: unknown }>;
|
|
19
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
20
|
+
const m = msgs[i];
|
|
21
|
+
if (m?.role !== "assistant") continue;
|
|
22
|
+
const c = m.content;
|
|
23
|
+
if (typeof c === "string") return c;
|
|
24
|
+
if (Array.isArray(c)) {
|
|
25
|
+
return c
|
|
26
|
+
.map((p: unknown) => (typeof p === "string" ? p : p && typeof p === "object" && "text" in p ? String((p as { text: unknown }).text) : ""))
|
|
27
|
+
.join("")
|
|
28
|
+
.trim();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Compact fact sheet the model reasons over — numbers, not prose. */
|
|
35
|
+
function facts(r: RetroReport): string {
|
|
36
|
+
const lines = [
|
|
37
|
+
`Idea: ${r.idea}`,
|
|
38
|
+
`Status: ${r.status}. Tasks done: ${r.doneCount}/${r.taskCount}.`,
|
|
39
|
+
`Cost: predicted $${r.estCost.toFixed(2)}, actual $${r.totalCost.toFixed(2)}.`,
|
|
40
|
+
`Tests: ${r.tests.passed} passed, ${r.tests.failed} failed.`,
|
|
41
|
+
r.retries.length ? `Rebuilds: ${r.retries.map((x) => `${x.taskId}×${x.rounds}`).join(", ")}.` : "Rebuilds: none.",
|
|
42
|
+
`Cost by epic: ${r.byEpic.map((e) => `${e.epic} $${e.cost}`).join(", ") || "n/a"}.`,
|
|
43
|
+
`Cost by model: ${r.byModel.map((m) => `${m.model} $${m.cost} (${m.tasks})`).join(", ") || "n/a"}.`,
|
|
44
|
+
`Priciest tasks: ${r.topCost.map((t) => `${t.taskId} $${t.cost}`).join(", ") || "n/a"}.`,
|
|
45
|
+
r.bugs.length
|
|
46
|
+
? `Tester flags: ${r.bugs.map((b) => `[${b.severity}] ${b.description}`).slice(0, 6).join("; ")}.`
|
|
47
|
+
: "Tester flags: none.",
|
|
48
|
+
];
|
|
49
|
+
return lines.join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const PROMPT = [
|
|
53
|
+
"You are a pragmatic engineering lead writing a SHORT retro for this build.",
|
|
54
|
+
"Use the facts below — reference the actual numbers. Do not invent anything.",
|
|
55
|
+
"Write exactly three sections, terse bullets, under 140 words total:",
|
|
56
|
+
"**What went well** (2-3 bullets)",
|
|
57
|
+
"**What to improve** (2-3 bullets)",
|
|
58
|
+
"**Next time** (one line)",
|
|
59
|
+
"No preamble, no closing remarks.",
|
|
60
|
+
].join("\n");
|
|
61
|
+
|
|
62
|
+
export interface NarrateOptions {
|
|
63
|
+
backend: Backend;
|
|
64
|
+
modelOverride?: { provider: Provider; model: string };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Generate the narrative. Throws on failure (caller shows the error). */
|
|
68
|
+
export async function narrateRetro(report: RetroReport, opts: NarrateOptions): Promise<string> {
|
|
69
|
+
const authStorage = AuthStorage.create();
|
|
70
|
+
const registry = ModelRegistry.create(authStorage);
|
|
71
|
+
const { entry } = findEntry("plan", "mid");
|
|
72
|
+
const pick = opts.modelOverride ?? entry.byBackend[opts.backend];
|
|
73
|
+
const model = resolvePiModel(registry, pick.provider, pick.model);
|
|
74
|
+
|
|
75
|
+
const { session } = await createAgentSession({
|
|
76
|
+
model,
|
|
77
|
+
authStorage,
|
|
78
|
+
modelRegistry: registry,
|
|
79
|
+
thinkingLevel: "low",
|
|
80
|
+
noTools: "all",
|
|
81
|
+
});
|
|
82
|
+
try {
|
|
83
|
+
await session.prompt(`${PROMPT}\n\n--- FACTS ---\n${facts(report)}`);
|
|
84
|
+
const text = lastAssistantText(session);
|
|
85
|
+
if (!text) throw new Error("The model returned no narrative.");
|
|
86
|
+
return text;
|
|
87
|
+
} finally {
|
|
88
|
+
addSessionCost(session.getSessionStats().cost);
|
|
89
|
+
session.dispose();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
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 type {
|
|
12
|
+
RegistryEntry,
|
|
13
|
+
RoleExecutor,
|
|
14
|
+
RoutingPolicy,
|
|
15
|
+
Task,
|
|
16
|
+
TaskOutcome,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
import { route } from "./router.js";
|
|
19
|
+
import { REGISTRY } from "./registry.js";
|
|
20
|
+
|
|
21
|
+
/** Order tasks so every task comes after its dependencies. Throws on a cycle. */
|
|
22
|
+
export function toposort(tasks: Task[]): Task[] {
|
|
23
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
24
|
+
const state = new Map<string, "visiting" | "done">();
|
|
25
|
+
const out: Task[] = [];
|
|
26
|
+
|
|
27
|
+
const visit = (t: Task, trail: string[]) => {
|
|
28
|
+
const s = state.get(t.id);
|
|
29
|
+
if (s === "done") return;
|
|
30
|
+
if (s === "visiting") {
|
|
31
|
+
throw new Error(`Dependency cycle: ${[...trail, t.id].join(" -> ")}`);
|
|
32
|
+
}
|
|
33
|
+
state.set(t.id, "visiting");
|
|
34
|
+
for (const dep of t.dependsOn ?? []) {
|
|
35
|
+
const d = byId.get(dep);
|
|
36
|
+
if (d) visit(d, [...trail, t.id]); // unknown deps already stripped by normalize
|
|
37
|
+
}
|
|
38
|
+
state.set(t.id, "done");
|
|
39
|
+
out.push(t);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
for (const t of tasks) visit(t, []);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RunOptions {
|
|
47
|
+
policy: RoutingPolicy;
|
|
48
|
+
execute: RoleExecutor;
|
|
49
|
+
/** Registry to route against. Swap this to lock every role to one provider. */
|
|
50
|
+
registry?: RegistryEntry[];
|
|
51
|
+
onProgress?: (event: OrchestratorEvent) => void;
|
|
52
|
+
/** Prior outcomes to resume from (append-only record). Their tasks are skipped. */
|
|
53
|
+
seedOutcomes?: TaskOutcome[];
|
|
54
|
+
/** Called after each task settles, with the full record + running total, for persistence. */
|
|
55
|
+
onCheckpoint?: (outcomes: TaskOutcome[], totalCost: number) => void;
|
|
56
|
+
/** Max tasks to run at once. 1 (default) = sequential. >1 runs independent tasks in parallel. */
|
|
57
|
+
concurrency?: number;
|
|
58
|
+
/** Optional human gate before development begins (design done → dev). Resolve "stop" to halt. */
|
|
59
|
+
onGate?: (info: { stage: string }) => Promise<"continue" | "stop">;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type OrchestratorEvent =
|
|
63
|
+
| { type: "task_start"; task: Task; round: number; provider: string; modelId: string }
|
|
64
|
+
| { type: "task_done"; outcome: TaskOutcome; runningTotal: number }
|
|
65
|
+
| { type: "task_skipped"; taskId: string }
|
|
66
|
+
| { type: "test_failed"; taskId: string; bugs: number; round: number }
|
|
67
|
+
| { type: "retry_dev"; taskId: string; forTest: string; round: number }
|
|
68
|
+
| { type: "budget_halt"; runningTotal: number; cap: number }
|
|
69
|
+
| { type: "gate"; stage: string }
|
|
70
|
+
| { type: "cycle_or_error"; message: string };
|
|
71
|
+
|
|
72
|
+
export interface RunResult {
|
|
73
|
+
outcomes: TaskOutcome[];
|
|
74
|
+
totalCost: number;
|
|
75
|
+
halted: boolean;
|
|
76
|
+
haltReason?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Build handoff text from a task's dependency outcomes. */
|
|
80
|
+
function gatherContext(task: Task, outcomes: Map<string, TaskOutcome>): string {
|
|
81
|
+
const deps = task.dependsOn ?? [];
|
|
82
|
+
if (deps.length === 0) return "";
|
|
83
|
+
const parts: string[] = [];
|
|
84
|
+
for (const depId of deps) {
|
|
85
|
+
const o = outcomes.get(depId);
|
|
86
|
+
if (!o) continue;
|
|
87
|
+
const snippet = o.finalText.trim();
|
|
88
|
+
if (snippet) parts.push(`### From ${depId} (${o.capability}):\n${snippet}`);
|
|
89
|
+
if (o.files.length) parts.push(`### Files from ${depId}: ${o.files.join(", ")}`);
|
|
90
|
+
}
|
|
91
|
+
return parts.length ? `Context from upstream work:\n\n${parts.join("\n\n")}` : "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function bugReport(bugs: { severity: string; description: string; file?: string }[]): string {
|
|
95
|
+
return [
|
|
96
|
+
"The tester found these issues. Fix them, then stop:",
|
|
97
|
+
...bugs.map((b) => `- [${b.severity}]${b.file ? ` (${b.file})` : ""} ${b.description}`),
|
|
98
|
+
].join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function runBacklog(tasks: Task[], opts: RunOptions): Promise<RunResult> {
|
|
102
|
+
const { policy, execute } = opts;
|
|
103
|
+
const registry = opts.registry ?? REGISTRY;
|
|
104
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
105
|
+
const outcomes = new Map<string, TaskOutcome>();
|
|
106
|
+
const record: TaskOutcome[] = [];
|
|
107
|
+
let running = 0;
|
|
108
|
+
|
|
109
|
+
// Resume: replay prior outcomes so finished tasks are skipped and cost is restored.
|
|
110
|
+
const seed = opts.seedOutcomes ?? [];
|
|
111
|
+
for (const o of seed) {
|
|
112
|
+
record.push(o);
|
|
113
|
+
outcomes.set(o.taskId, o); // last wins (retries overwrite)
|
|
114
|
+
running += o.cost;
|
|
115
|
+
}
|
|
116
|
+
running = round2(running);
|
|
117
|
+
const wasDone = new Set(seed.map((o) => o.taskId));
|
|
118
|
+
|
|
119
|
+
const emit = opts.onProgress ?? (() => {});
|
|
120
|
+
const checkpoint = () => opts.onCheckpoint?.(record, round2(running));
|
|
121
|
+
|
|
122
|
+
let ordered: Task[];
|
|
123
|
+
try {
|
|
124
|
+
ordered = toposort(tasks);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
127
|
+
emit({ type: "cycle_or_error", message });
|
|
128
|
+
return { outcomes: [], totalCost: 0, halted: true, haltReason: message };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const runOne = async (task: Task, round: number, contextOverride?: string): Promise<TaskOutcome> => {
|
|
132
|
+
const decision = route(task, { policy, registry, runningTotalBefore: running });
|
|
133
|
+
emit({ type: "task_start", task, round, provider: decision.provider, modelId: decision.model.id });
|
|
134
|
+
const contextText = contextOverride ?? gatherContext(task, outcomes);
|
|
135
|
+
const result = await execute({ task, decision, contextText, round });
|
|
136
|
+
const outcome: TaskOutcome = {
|
|
137
|
+
...result,
|
|
138
|
+
taskId: task.id,
|
|
139
|
+
capability: task.capability,
|
|
140
|
+
provider: decision.provider,
|
|
141
|
+
modelId: decision.model.id,
|
|
142
|
+
round,
|
|
143
|
+
};
|
|
144
|
+
running += result.cost;
|
|
145
|
+
outcomes.set(task.id, outcome);
|
|
146
|
+
record.push(outcome);
|
|
147
|
+
emit({ type: "task_done", outcome, runningTotal: round2(running) });
|
|
148
|
+
return outcome;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// One task's full lifecycle: run it, then its Tester->Developer feedback loop.
|
|
152
|
+
const runTaskUnit = async (task: Task): Promise<void> => {
|
|
153
|
+
let outcome = await runOne(task, 0);
|
|
154
|
+
if (task.capability === "test" && outcome.verdict && !outcome.verdict.passed) {
|
|
155
|
+
const codeDeps = (task.dependsOn ?? [])
|
|
156
|
+
.map((id) => byId.get(id))
|
|
157
|
+
.filter((t): t is Task => !!t && t.capability === "code");
|
|
158
|
+
|
|
159
|
+
let round = 1;
|
|
160
|
+
while (outcome.verdict && !outcome.verdict.passed && round <= policy.maxFeedbackRounds) {
|
|
161
|
+
emit({ type: "test_failed", taskId: task.id, bugs: outcome.verdict.bugs.length, round });
|
|
162
|
+
const fixContext = bugReport(outcome.verdict.bugs);
|
|
163
|
+
for (const dep of codeDeps) {
|
|
164
|
+
emit({ type: "retry_dev", taskId: dep.id, forTest: task.id, round });
|
|
165
|
+
await runOne(dep, round, fixContext);
|
|
166
|
+
}
|
|
167
|
+
outcome = await runOne(task, round); // re-test
|
|
168
|
+
round++;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
checkpoint();
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Human gate: pause once before the first development (code) task begins.
|
|
175
|
+
let gateDone = false;
|
|
176
|
+
const passGate = async (): Promise<boolean> => {
|
|
177
|
+
if (gateDone || !opts.onGate) return true;
|
|
178
|
+
gateDone = true;
|
|
179
|
+
emit({ type: "gate", stage: "design→dev" });
|
|
180
|
+
const decision = await opts.onGate({ stage: "design→dev" });
|
|
181
|
+
return decision !== "stop";
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const concurrency = Math.max(1, Math.floor(opts.concurrency ?? 1));
|
|
185
|
+
|
|
186
|
+
// ---- sequential path (concurrency 1) — unchanged behavior ----
|
|
187
|
+
if (concurrency === 1) {
|
|
188
|
+
for (const task of ordered) {
|
|
189
|
+
if (wasDone.has(task.id)) {
|
|
190
|
+
emit({ type: "task_skipped", taskId: task.id });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (task.capability === "code" && !(await passGate())) {
|
|
194
|
+
checkpoint();
|
|
195
|
+
return { outcomes: record, totalCost: round2(running), halted: true, haltReason: "stopped at review gate" };
|
|
196
|
+
}
|
|
197
|
+
const est = route(task, { policy, registry, runningTotalBefore: running });
|
|
198
|
+
if (est.overCap) {
|
|
199
|
+
emit({ type: "budget_halt", runningTotal: round2(running + est.cost), cap: policy.budgetCapUSD });
|
|
200
|
+
checkpoint();
|
|
201
|
+
return { outcomes: record, totalCost: round2(running), halted: true, haltReason: "budget cap" };
|
|
202
|
+
}
|
|
203
|
+
await runTaskUnit(task);
|
|
204
|
+
}
|
|
205
|
+
return { outcomes: record, totalCost: round2(running), halted: false };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---- parallel path (concurrency > 1) — ready-set scheduler ----
|
|
209
|
+
// JS is single-threaded, so mutations between awaits are atomic (no locks needed).
|
|
210
|
+
// Independent tasks (deps satisfied) run concurrently up to `concurrency`. A budget
|
|
211
|
+
// reservation on in-flight estimates prevents launches that could cross the cap.
|
|
212
|
+
const remaining = new Set(ordered.filter((t) => !wasDone.has(t.id)).map((t) => t.id));
|
|
213
|
+
for (const id of wasDone) emit({ type: "task_skipped", taskId: id });
|
|
214
|
+
|
|
215
|
+
const inFlight = new Map<string, Promise<void>>();
|
|
216
|
+
let reserved = 0;
|
|
217
|
+
let codeInFlight = 0; // code tasks are serialized (they share files) even in parallel mode
|
|
218
|
+
let halted = false;
|
|
219
|
+
let haltReason: string | undefined;
|
|
220
|
+
|
|
221
|
+
const depsSatisfied = (t: Task) => (t.dependsOn ?? []).every((d) => !remaining.has(d));
|
|
222
|
+
const readyTasks = () =>
|
|
223
|
+
ordered.filter((t) => remaining.has(t.id) && !inFlight.has(t.id) && depsSatisfied(t));
|
|
224
|
+
|
|
225
|
+
while (remaining.size > 0 && !halted) {
|
|
226
|
+
// Gate before any development task launches.
|
|
227
|
+
if (!gateDone && opts.onGate && readyTasks().some((t) => t.capability === "code")) {
|
|
228
|
+
if (!(await passGate())) {
|
|
229
|
+
halted = true;
|
|
230
|
+
haltReason = "stopped at review gate";
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
for (const task of readyTasks()) {
|
|
235
|
+
if (inFlight.size >= concurrency) break;
|
|
236
|
+
// Only one code task builds at a time — they write to the shared workspace.
|
|
237
|
+
if (task.capability === "code" && codeInFlight >= 1) continue;
|
|
238
|
+
const est = route(task, { policy, registry, runningTotalBefore: round2(running + reserved) });
|
|
239
|
+
if (round2(running + reserved + est.cost) > policy.budgetCapUSD) {
|
|
240
|
+
if (inFlight.size === 0) {
|
|
241
|
+
emit({ type: "budget_halt", runningTotal: round2(running + est.cost), cap: policy.budgetCapUSD });
|
|
242
|
+
halted = true;
|
|
243
|
+
haltReason = "budget cap";
|
|
244
|
+
}
|
|
245
|
+
break; // wait for in-flight tasks to free budget/capacity
|
|
246
|
+
}
|
|
247
|
+
reserved = round2(reserved + est.cost);
|
|
248
|
+
const cost = est.cost;
|
|
249
|
+
const isCode = task.capability === "code";
|
|
250
|
+
if (isCode) codeInFlight++;
|
|
251
|
+
const p = runTaskUnit(task).then(() => {
|
|
252
|
+
if (isCode) codeInFlight--;
|
|
253
|
+
reserved = round2(reserved - cost);
|
|
254
|
+
remaining.delete(task.id);
|
|
255
|
+
inFlight.delete(task.id);
|
|
256
|
+
});
|
|
257
|
+
inFlight.set(task.id, p);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (inFlight.size === 0) break; // nothing running and nothing launchable -> done or halted
|
|
261
|
+
await Promise.race(inFlight.values());
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await Promise.all(inFlight.values());
|
|
265
|
+
checkpoint();
|
|
266
|
+
return { outcomes: record, totalCost: round2(running), halted, haltReason };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function round2(n: number): number {
|
|
270
|
+
return Math.round(n * 100) / 100;
|
|
271
|
+
}
|