@a-t-h-i/bot-lobby 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 +201 -0
- package/README.md +406 -0
- package/package.json +46 -0
- package/prompts/backend.md +28 -0
- package/prompts/designer.md +33 -0
- package/prompts/global.md +42 -0
- package/prompts/master.md +107 -0
- package/prompts/qa.md +26 -0
- package/prompts/researcher.md +32 -0
- package/prompts/reviewer.md +43 -0
- package/prompts/scout.md +36 -0
- package/prompts/worker.md +49 -0
- package/src/agents/backend.ts +10 -0
- package/src/agents/designer.ts +10 -0
- package/src/agents/qa.ts +10 -0
- package/src/agents/registry.ts +14 -0
- package/src/execution/agent-runner.ts +150 -0
- package/src/execution/git.ts +42 -0
- package/src/execution/pi-runner.ts +292 -0
- package/src/index.ts +13 -0
- package/src/knowledge/compactor.ts +135 -0
- package/src/knowledge/paths.ts +43 -0
- package/src/knowledge/selector.ts +82 -0
- package/src/knowledge/store.ts +111 -0
- package/src/master/decisions.ts +53 -0
- package/src/master/master.ts +298 -0
- package/src/master/research.ts +98 -0
- package/src/master/synthesis.ts +57 -0
- package/src/pi/activity.ts +60 -0
- package/src/pi/commands.ts +268 -0
- package/src/pi/events.ts +76 -0
- package/src/pi/expressions.ts +101 -0
- package/src/pi/mascot-art.ts +252 -0
- package/src/pi/notify.ts +42 -0
- package/src/pi/quiet.ts +46 -0
- package/src/pi/settings-ui.ts +258 -0
- package/src/pi/tool-renderers.ts +121 -0
- package/src/pi/tools.ts +158 -0
- package/src/pi/ui.ts +227 -0
- package/src/pi/zen-large.ts +483 -0
- package/src/pi/zen-metrics.ts +80 -0
- package/src/pi/zen.ts +460 -0
- package/src/prompts/compiler.ts +50 -0
- package/src/prompts/loader.ts +20 -0
- package/src/roles/markdown.ts +64 -0
- package/src/roles/registry.ts +16 -0
- package/src/roles/researcher.ts +83 -0
- package/src/roles/reviewer.ts +65 -0
- package/src/roles/scout.ts +61 -0
- package/src/roles/worker.ts +94 -0
- package/src/schemas/agent.ts +39 -0
- package/src/schemas/configuration.ts +107 -0
- package/src/schemas/findings.ts +110 -0
- package/src/schemas/task.ts +113 -0
- package/src/state/persistence.ts +232 -0
- package/src/state/project.ts +99 -0
- package/src/state/task-state.ts +22 -0
- package/src/text.ts +51 -0
- package/src/workflow/approvals.ts +45 -0
- package/src/workflow/transitions.ts +41 -0
- package/src/workflow/workflow.ts +771 -0
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { BotLobbyConfig } from "../schemas/configuration.ts";
|
|
3
|
+
import type { AgentRun, Pushback, ResearchResult, ReviewResult } from "../schemas/findings.ts";
|
|
4
|
+
import { TASK_STATES, TERMINAL_STATES, taskRequest, type Approval, type ApprovalKind, type Task, type TaskState } from "../schemas/task.ts";
|
|
5
|
+
import { isDomain, type Domain } from "../schemas/agent.ts";
|
|
6
|
+
import { transition } from "../state/task-state.ts";
|
|
7
|
+
import { ownerlessTask, readTaskArtifact, removeTaskScratchpads, saveTask, selectTask, taskDirFor, taskReadDirs } from "../state/persistence.ts";
|
|
8
|
+
import { dataRoot, readDataRoots } from "../state/project.ts";
|
|
9
|
+
import { appendCompletedTask, appendDecision, applyKnowledge, readFileOr, writeFileEnsured, type KnowledgeKind } from "../knowledge/store.ts";
|
|
10
|
+
import { compactKnowledgeFile, overThreshold } from "../knowledge/compactor.ts";
|
|
11
|
+
import { knowledgeDir, type KnowledgeAgent } from "../knowledge/paths.ts";
|
|
12
|
+
import { writeScratchpad } from "../state/persistence.ts";
|
|
13
|
+
import { spawnPiProcess, type ProcessRunner } from "../execution/pi-runner.ts";
|
|
14
|
+
import { readRepositoryDiff } from "../execution/git.ts";
|
|
15
|
+
import {
|
|
16
|
+
loadScoutResults,
|
|
17
|
+
runReviewer,
|
|
18
|
+
runScouts,
|
|
19
|
+
runWorker,
|
|
20
|
+
type ReviewerOutcome,
|
|
21
|
+
type ReviewerRequest,
|
|
22
|
+
type ScoutOutcome,
|
|
23
|
+
type WorkerOutcome,
|
|
24
|
+
type WorkerRequest,
|
|
25
|
+
} from "../master/master.ts";
|
|
26
|
+
import { researchResultPath, runResearch, type ResearchOutcome, type ResearchRequest } from "../master/research.ts";
|
|
27
|
+
import { assessReconnaissance, completionBlockers, decideReviewLoop, recordDecision } from "../master/decisions.ts";
|
|
28
|
+
import { detectSharedFiles, summarizeOutcomes } from "../master/synthesis.ts";
|
|
29
|
+
import { truncate } from "../text.ts";
|
|
30
|
+
import { assertNoPendingApprovals, pendingApprovals, requestApproval, resolveApproval } from "./approvals.ts";
|
|
31
|
+
import { pingApproval } from "../pi/notify.ts";
|
|
32
|
+
import { nextStates } from "./transitions.ts";
|
|
33
|
+
|
|
34
|
+
export const ORCHESTRATE_ACTIONS = [
|
|
35
|
+
"clarify",
|
|
36
|
+
"scout",
|
|
37
|
+
"research",
|
|
38
|
+
"propose",
|
|
39
|
+
"plan",
|
|
40
|
+
"implement",
|
|
41
|
+
"resolve_approval",
|
|
42
|
+
"qa",
|
|
43
|
+
"knowledge",
|
|
44
|
+
"compact",
|
|
45
|
+
"complete",
|
|
46
|
+
"block",
|
|
47
|
+
"resume",
|
|
48
|
+
"decide",
|
|
49
|
+
"status",
|
|
50
|
+
"cancel",
|
|
51
|
+
] as const;
|
|
52
|
+
export type OrchestrateAction = (typeof ORCHESTRATE_ACTIONS)[number];
|
|
53
|
+
|
|
54
|
+
/** Actions that claim an ownerless task for the calling session; status/cancel/resolve_approval never do. */
|
|
55
|
+
export const CLAIM_ACTIONS: ReadonlySet<OrchestrateAction> = new Set(
|
|
56
|
+
ORCHESTRATE_ACTIONS.filter((action) => action !== "status" && action !== "cancel" && action !== "resolve_approval"),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
export interface OrchestrateParams {
|
|
60
|
+
action: OrchestrateAction;
|
|
61
|
+
taskId?: string;
|
|
62
|
+
question?: string;
|
|
63
|
+
options?: string[];
|
|
64
|
+
domains?: string[];
|
|
65
|
+
instruction?: string;
|
|
66
|
+
proposal?: string;
|
|
67
|
+
concerns?: string[];
|
|
68
|
+
plan?: string;
|
|
69
|
+
domain?: string;
|
|
70
|
+
/** implement: the concrete instruction for the worker. */
|
|
71
|
+
task?: string;
|
|
72
|
+
/** knowledge: which persistent file the text belongs to. */
|
|
73
|
+
kind?: KnowledgeKind;
|
|
74
|
+
/** compact: the knowledge file being rewritten. */
|
|
75
|
+
file?: string;
|
|
76
|
+
approvalId?: string;
|
|
77
|
+
decision?: "approved" | "rejected";
|
|
78
|
+
note?: string;
|
|
79
|
+
reason?: string;
|
|
80
|
+
text?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface WorkflowDeps {
|
|
84
|
+
root: string;
|
|
85
|
+
configDir: string;
|
|
86
|
+
cwd: string;
|
|
87
|
+
/** The pi session driving this workflow; task ownership is skipped when absent (tests, headless use). */
|
|
88
|
+
sessionId?: string;
|
|
89
|
+
config: BotLobbyConfig;
|
|
90
|
+
signal?: AbortSignal;
|
|
91
|
+
onUpdate?: (run: AgentRun) => void;
|
|
92
|
+
ask: (question: string) => Promise<string | undefined>;
|
|
93
|
+
choose: (title: string, options: string[]) => Promise<string | undefined>;
|
|
94
|
+
notify: (message: string, level?: "info" | "warning" | "error") => void;
|
|
95
|
+
runProcess?: ProcessRunner;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface WorkflowResult {
|
|
99
|
+
ok: boolean;
|
|
100
|
+
taskId: string;
|
|
101
|
+
state: TaskState;
|
|
102
|
+
message: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type ApprovalChoice = "approve" | "amend" | "decline";
|
|
106
|
+
export const APPROVAL_OPTIONS = ["Approve", "Amend", "Decline"];
|
|
107
|
+
|
|
108
|
+
const PLAN_REQUIREMENTS: Array<[string, RegExp]> = [
|
|
109
|
+
["objective", /objective|goal/i],
|
|
110
|
+
["affected domains", /domains?/i],
|
|
111
|
+
["files/areas", /files?|areas?|modules?/i],
|
|
112
|
+
["implementation sequence", /sequence|steps|order/i],
|
|
113
|
+
["dependencies", /dependenc/i],
|
|
114
|
+
["testing strategy", /test/i],
|
|
115
|
+
["acceptance criteria", /acceptance|criteria/i],
|
|
116
|
+
["rollback/failure", /rollback|failure|revert/i],
|
|
117
|
+
["review requirements", /review/i],
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
/** §12: the internal plan must cover every required area. */
|
|
121
|
+
export function validatePlan(plan: string): string[] {
|
|
122
|
+
return PLAN_REQUIREMENTS.filter(([, pattern]) => !pattern.test(plan)).map(([label]) => label);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A user-facing proposal must be a short `- ` bullet list so the user can scan it. */
|
|
126
|
+
export const MAX_PROPOSAL_CHARS = 1200;
|
|
127
|
+
export function validateProposal(proposal: string): string[] {
|
|
128
|
+
const lines = proposal.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
129
|
+
const issues: string[] = [];
|
|
130
|
+
if (lines.length === 0) issues.push("proposal is empty");
|
|
131
|
+
if (lines.some((line) => !line.startsWith("- "))) issues.push("every proposal line must be a \`- \` bullet");
|
|
132
|
+
if (proposal.length > MAX_PROPOSAL_CHARS) issues.push(`proposal is too long (> ${MAX_PROPOSAL_CHARS} chars)`);
|
|
133
|
+
return issues;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function requireState(task: Task, allowed: TaskState[]): void {
|
|
137
|
+
if (!allowed.includes(task.state)) {
|
|
138
|
+
throw new Error(`action not allowed in state "${task.state}" (expected: ${allowed.join(", ")})`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function deriveDomains(values: string[] | undefined): Domain[] {
|
|
143
|
+
const domains = (values ?? []).map((value) => value.trim().toLowerCase()).filter((value) => isDomain(value));
|
|
144
|
+
if (domains.length === 0) throw new Error("scout requires at least one of: designer, backend, qa");
|
|
145
|
+
return [...new Set(domains)];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseDomain(value: string | undefined, action: string): Domain {
|
|
149
|
+
const domain = value?.trim().toLowerCase();
|
|
150
|
+
if (!domain || !isDomain(domain)) throw new Error(`${action} requires domain: designer, backend, or qa`);
|
|
151
|
+
return domain;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function describeTask(task: Task): string {
|
|
155
|
+
const lines = [
|
|
156
|
+
`${task.id} — state: ${task.state}${task.paused ? " (paused)" : ""}`,
|
|
157
|
+
`Title: ${task.title}`,
|
|
158
|
+
`Request: ${truncate(taskRequest(task), 200)}`,
|
|
159
|
+
task.domains.length > 0 ? `Domains: ${task.domains.join(", ")}` : "",
|
|
160
|
+
`Review iterations: ${Object.entries(task.reviewIterations).map(([d, n]) => `${d}=${n}`).join(", ")}`,
|
|
161
|
+
pendingApprovals(task).length > 0
|
|
162
|
+
? `Pending approvals: ${pendingApprovals(task).map((a) => `${a.id} ${a.kind} for ${a.domain}: ${truncate(a.detail, 120)}`).join("; ")}`
|
|
163
|
+
: "",
|
|
164
|
+
task.blockers.length > 0 ? `Blockers: ${task.blockers.map((b) => b.reason).join("; ")}` : "",
|
|
165
|
+
`Next legal states: ${nextStates(task.state).join(", ")}`,
|
|
166
|
+
];
|
|
167
|
+
return lines.filter((line) => line.length > 0).join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Knowledge files past the compaction threshold, for status reporting. */
|
|
171
|
+
export function describeOversizedKnowledge(dataRoots: readonly string[], threshold: number): string[] {
|
|
172
|
+
return overThreshold(dataRoots, threshold).map((entry) => `${entry.agent}/${entry.file} (${entry.chars} chars)`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Apply the user's approve/amend/decline decision to an awaiting-approval task. */
|
|
176
|
+
export function applyApprovalChoice(task: Task, choice: ApprovalChoice, amendment?: string): string {
|
|
177
|
+
requireState(task, ["awaiting_approval"]);
|
|
178
|
+
if (choice === "approve") {
|
|
179
|
+
transition(task, "planning");
|
|
180
|
+
return "Proposal approved. Write the detailed internal plan and call action=plan.";
|
|
181
|
+
}
|
|
182
|
+
if (choice === "decline") {
|
|
183
|
+
transition(task, "abandoned");
|
|
184
|
+
return "Proposal declined; the task is abandoned.";
|
|
185
|
+
}
|
|
186
|
+
const text = amendment?.trim();
|
|
187
|
+
if (!text) throw new Error("amend requires the amendment text");
|
|
188
|
+
task.amendments.push(text);
|
|
189
|
+
return `Amendment recorded: ${text}\nRe-evaluate the affected findings, update the proposal, and call action=propose again.`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function scoutReport(outcomes: ScoutOutcome[], verifying: boolean): string {
|
|
193
|
+
const assessment = assessReconnaissance(outcomes);
|
|
194
|
+
const pushbacks = outcomes.map((outcome) => pushbackLine(outcome.result.pushback, outcome.result.domain)).filter(Boolean);
|
|
195
|
+
const shared = detectSharedFiles(outcomes);
|
|
196
|
+
return [
|
|
197
|
+
verifying ? "Targeted verification results:" : "Scout results:",
|
|
198
|
+
"",
|
|
199
|
+
summarizeOutcomes(outcomes),
|
|
200
|
+
"",
|
|
201
|
+
`Usable reconnaissance: ${assessment.usableDomains.join(", ") || "none"}`,
|
|
202
|
+
shared.length > 0
|
|
203
|
+
? `Files reported by multiple domains: ${shared.map((entry) => `${entry.path} (${entry.domains.join("/")})`).join(", ")}`
|
|
204
|
+
: "",
|
|
205
|
+
assessment.warnings.length > 0 ? `Gaps to verify: ${assessment.warnings.join("; ")}` : "",
|
|
206
|
+
pushbacks.length > 0 ? `Pushbacks recorded: ${pushbacks.join("; ")}` : "",
|
|
207
|
+
verifying
|
|
208
|
+
? "Use this to confirm or correct the claim, then continue with action=propose."
|
|
209
|
+
: "Next: synthesize these findings, target-verify anything important, then call action=propose.",
|
|
210
|
+
]
|
|
211
|
+
.filter((line) => line.length > 0)
|
|
212
|
+
.join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function handleClarify(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
216
|
+
requireState(task, ["created", "clarifying"]);
|
|
217
|
+
const question = params.question?.trim();
|
|
218
|
+
if (!question) throw new Error("clarify requires a question");
|
|
219
|
+
transition(task, "clarifying");
|
|
220
|
+
const answer = params.options?.length ? await deps.choose(question, params.options) : await deps.ask(question);
|
|
221
|
+
if (answer === undefined) {
|
|
222
|
+
return `No answer captured. Ask the user this in your reply, then continue.\n\nQuestion: ${question}`;
|
|
223
|
+
}
|
|
224
|
+
return `User answered: ${answer}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function handleScout(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
228
|
+
requireState(task, ["created", "clarifying", "scouting", "synthesizing"]);
|
|
229
|
+
const domains = deriveDomains(params.domains);
|
|
230
|
+
if (task.state === "created") transition(task, "clarifying");
|
|
231
|
+
if (task.state === "clarifying") transition(task, "scouting");
|
|
232
|
+
const verifying = task.state === "synthesizing";
|
|
233
|
+
const outcomes = await runScouts(
|
|
234
|
+
{
|
|
235
|
+
taskId: task.id,
|
|
236
|
+
taskText: taskRequest(task),
|
|
237
|
+
instruction: params.instruction?.trim() || "Investigate this request and report findings the Master needs.",
|
|
238
|
+
domains,
|
|
239
|
+
cwd: deps.cwd,
|
|
240
|
+
dataRoots: readDataRoots(deps.root, deps.configDir),
|
|
241
|
+
taskDir: taskDirFor(deps.root, deps.configDir, task.id),
|
|
242
|
+
config: deps.config,
|
|
243
|
+
signal: deps.signal,
|
|
244
|
+
onUpdate: deps.onUpdate,
|
|
245
|
+
},
|
|
246
|
+
deps.runProcess ?? spawnPiProcess,
|
|
247
|
+
);
|
|
248
|
+
if (!verifying) transition(task, "synthesizing");
|
|
249
|
+
const involved = outcomes.filter((outcome) => outcome.usable).map((outcome) => outcome.result.domain);
|
|
250
|
+
task.domains = [...new Set([...task.domains, ...involved])];
|
|
251
|
+
recordAdvisoryPushbacks(task, outcomes.map((outcome) => ({ pushback: outcome.result.pushback, who: outcome.result.domain })));
|
|
252
|
+
return scoutReport(outcomes, verifying);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Research is evidence gathering, so it is legal in every non-terminal state. */
|
|
256
|
+
const RESEARCH_STATES: TaskState[] = TASK_STATES.filter((state) => !TERMINAL_STATES.includes(state));
|
|
257
|
+
|
|
258
|
+
const RESEARCH_DEGRADED =
|
|
259
|
+
"The researcher is spawned with read-only repository tools plus web_search, fetch_content, source_check and get_search_content. If pi-web-access is not installed, the pi CLI silently ignores those tool names, so research degrades to repository-only and cannot cite the internet.";
|
|
260
|
+
|
|
261
|
+
function bulletSection(label: string, items: string[], limit: number): string {
|
|
262
|
+
if (items.length === 0) return "";
|
|
263
|
+
const bullets = items.slice(0, limit).map((item) => `- ${truncate(item, 300)}`);
|
|
264
|
+
return `${label}:\n${bullets.join("\n")}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function sourceSection(result: ResearchResult): string {
|
|
268
|
+
if (result.sources.length === 0) return "";
|
|
269
|
+
const lines = result.sources.slice(0, 12).map(
|
|
270
|
+
(source) =>
|
|
271
|
+
`- ${source.url}${source.title ? ` — ${truncate(source.title, 160)}` : ""}${source.date ? ` (${source.date})` : ""}`,
|
|
272
|
+
);
|
|
273
|
+
return `Sources:\n${lines.join("\n")}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function researchLogEntry(domain: Domain, outcome: ResearchOutcome): string {
|
|
277
|
+
const { result, run, issues, usable } = outcome;
|
|
278
|
+
return [
|
|
279
|
+
`## ${new Date().toISOString()} — ${domain} (${run.status}${usable ? "" : ", unusable"})`,
|
|
280
|
+
result.question ? `Question: ${result.question}` : "",
|
|
281
|
+
bulletSection("Findings", result.findings, 20),
|
|
282
|
+
sourceSection(result),
|
|
283
|
+
bulletSection("Unverified", result.unverified, 10),
|
|
284
|
+
`Confidence: ${result.confidence}`,
|
|
285
|
+
issues.length > 0 ? `Issues: ${issues.join("; ")}` : "",
|
|
286
|
+
]
|
|
287
|
+
.filter((line) => line.length > 0)
|
|
288
|
+
.join("\n");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function appendResearchLog(taskDir: string, domain: Domain, outcome: ResearchOutcome): void {
|
|
292
|
+
const path = join(taskDir, "research.md");
|
|
293
|
+
const existing = readFileOr(path).trimEnd();
|
|
294
|
+
const entry = [existing, researchLogEntry(domain, outcome)].filter(Boolean).join("\n\n");
|
|
295
|
+
writeFileEnsured(path, `${entry}\n`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function researchReport(outcome: ResearchOutcome, artifact: string): string {
|
|
299
|
+
const { result, run, issues, usable } = outcome;
|
|
300
|
+
if (run.status !== "success" || !usable) {
|
|
301
|
+
return [
|
|
302
|
+
`No usable cited research report (run ${run.status}).`,
|
|
303
|
+
RESEARCH_DEGRADED,
|
|
304
|
+
run.error ? `Error: ${run.error}` : "",
|
|
305
|
+
issues.length > 0 ? `Issues: ${issues.join("; ")}` : "",
|
|
306
|
+
`Artifact: ${artifact}`,
|
|
307
|
+
]
|
|
308
|
+
.filter((line) => line.length > 0)
|
|
309
|
+
.join("\n");
|
|
310
|
+
}
|
|
311
|
+
return [
|
|
312
|
+
`Research for ${result.domain} (confidence: ${result.confidence})`,
|
|
313
|
+
`Question: ${truncate(result.question, 400)}`,
|
|
314
|
+
bulletSection("Findings", result.findings, 12),
|
|
315
|
+
sourceSection(result),
|
|
316
|
+
bulletSection("Unverified", result.unverified, 8),
|
|
317
|
+
`Artifact: ${artifact}`,
|
|
318
|
+
pushbackLine(result.pushback, result.domain),
|
|
319
|
+
"Research is evidence only: it is not injected into worker, reviewer, or QA prompts, and nothing enters persistent knowledge until you record it with action=knowledge.",
|
|
320
|
+
]
|
|
321
|
+
.filter((line) => line.length > 0)
|
|
322
|
+
.join("\n");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function researchRequestFor(
|
|
326
|
+
deps: WorkflowDeps,
|
|
327
|
+
task: Task,
|
|
328
|
+
domain: Domain,
|
|
329
|
+
instruction: string,
|
|
330
|
+
taskDir: string,
|
|
331
|
+
): ResearchRequest {
|
|
332
|
+
return {
|
|
333
|
+
taskId: task.id,
|
|
334
|
+
domain,
|
|
335
|
+
instruction,
|
|
336
|
+
config: deps.config,
|
|
337
|
+
cwd: deps.cwd,
|
|
338
|
+
taskDir,
|
|
339
|
+
signal: deps.signal,
|
|
340
|
+
onUpdate: deps.onUpdate,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function handleResearch(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
345
|
+
requireState(task, RESEARCH_STATES);
|
|
346
|
+
const domain = parseDomain(params.domain, "research");
|
|
347
|
+
const instruction = params.instruction?.trim();
|
|
348
|
+
if (!instruction) throw new Error("research requires instruction (the question to investigate)");
|
|
349
|
+
const taskDir = taskDirFor(deps.root, deps.configDir, task.id);
|
|
350
|
+
const outcome = await runResearch(researchRequestFor(deps, task, domain, instruction, taskDir), deps.runProcess ?? spawnPiProcess);
|
|
351
|
+
appendResearchLog(taskDir, domain, outcome);
|
|
352
|
+
recordAdvisoryPushbacks(task, [{ pushback: outcome.result.pushback, who: domain }]);
|
|
353
|
+
return researchReport(outcome, researchResultPath(taskDir, domain));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function handlePropose(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
357
|
+
requireState(task, ["created", "clarifying", "synthesizing", "awaiting_approval"]);
|
|
358
|
+
const proposal = params.proposal?.trim();
|
|
359
|
+
if (!proposal) throw new Error("propose requires a proposal");
|
|
360
|
+
const proposalIssues = validateProposal(proposal);
|
|
361
|
+
if (proposalIssues.length > 0) throw new Error(`proposal must be a concise bullet list: ${proposalIssues.join("; ")}`);
|
|
362
|
+
if (task.state === "created") transition(task, "clarifying");
|
|
363
|
+
if (task.state === "clarifying") transition(task, "awaiting_approval");
|
|
364
|
+
task.proposal = proposal;
|
|
365
|
+
writeFileEnsured(join(taskDirFor(deps.root, deps.configDir, task.id), "proposal.md"), proposal);
|
|
366
|
+
for (const concern of params.concerns ?? []) recordDecision(task, `Concern: ${concern}`);
|
|
367
|
+
transition(task, "awaiting_approval");
|
|
368
|
+
if (!deps.config.workflow.requireApprovalForFeatures) return applyApprovalChoice(task, "approve");
|
|
369
|
+
const choice = await deps.choose(`Approve this proposal?\n\n${truncate(proposal, 2000)}`, APPROVAL_OPTIONS);
|
|
370
|
+
if (!choice) return `Awaiting approval. Present the proposal to the user and continue after they respond.\n\n${proposal}`;
|
|
371
|
+
const lower = choice.toLowerCase();
|
|
372
|
+
if (lower.startsWith("approve")) return applyApprovalChoice(task, "approve");
|
|
373
|
+
if (lower.startsWith("decline")) return applyApprovalChoice(task, "decline");
|
|
374
|
+
return applyApprovalChoice(task, "amend", await deps.ask("What should change?"));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function handlePlan(task: Task, params: OrchestrateParams, deps: WorkflowDeps): string {
|
|
378
|
+
requireState(task, ["planning"]);
|
|
379
|
+
const plan = params.plan?.trim();
|
|
380
|
+
if (!plan) throw new Error("plan requires the plan text");
|
|
381
|
+
const missing = validatePlan(plan);
|
|
382
|
+
if (missing.length > 0) throw new Error(`plan is missing: ${missing.join(", ")}`);
|
|
383
|
+
task.plan = plan;
|
|
384
|
+
writeFileEnsured(join(taskDirFor(deps.root, deps.configDir, task.id), "plan.md"), plan);
|
|
385
|
+
return "Plan recorded. Next: call action=implement with domain and task for the first step.";
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Record approvals a worker asked for; auto-approve when config allows it. */
|
|
389
|
+
function recordWorkerApprovals(task: Task, outcome: WorkerOutcome, config: BotLobbyConfig): Approval[] {
|
|
390
|
+
const created: Approval[] = [];
|
|
391
|
+
const kinds: Array<[ApprovalKind, string, string[], boolean]> = [
|
|
392
|
+
["dependency", "dependencies", outcome.result.dependencyNeeds, config.workflow.requireApprovalForDependencies],
|
|
393
|
+
["architecture", "architecture changes", outcome.result.architectureChanges, config.workflow.requireApprovalForArchitectureChanges],
|
|
394
|
+
];
|
|
395
|
+
for (const [kind, label, items, required] of kinds) {
|
|
396
|
+
for (const detail of items) {
|
|
397
|
+
if (required) {
|
|
398
|
+
created.push(requestApproval(task, kind, outcome.result.domain, detail));
|
|
399
|
+
pingApproval(outcome.result.domain, detail);
|
|
400
|
+
} else recordDecision(task, `Auto-approved ${label}: ${detail}`, outcome.result.domain);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return created;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** One line naming a pushback for a report, or "" when there is none. */
|
|
407
|
+
function pushbackLine(pushback: Pushback | undefined, who: string): string {
|
|
408
|
+
if (!pushback) return "";
|
|
409
|
+
const alternative = pushback.alternative ? ` (alternative: ${truncate(pushback.alternative, 160)})` : "";
|
|
410
|
+
return `${who} pushed back on ${truncate(pushback.request, 160)}: ${truncate(pushback.reason, 240)}${alternative}`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Record a worker pushback as a pending approval that blocks its domain until the oracle resolves it. */
|
|
414
|
+
function recordPushback(task: Task, outcome: WorkerOutcome): Approval | undefined {
|
|
415
|
+
const pushback = outcome.result.pushback;
|
|
416
|
+
if (!pushback) return undefined;
|
|
417
|
+
const detail = `${truncate(pushback.request, 160)} — ${truncate(pushback.reason, 240)}`;
|
|
418
|
+
const approval = requestApproval(task, "pushback", outcome.result.domain, detail);
|
|
419
|
+
pingApproval(outcome.result.domain, detail);
|
|
420
|
+
recordDecision(task, pushbackLine(pushback, outcome.result.domain), outcome.result.domain);
|
|
421
|
+
return approval;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Record advisory pushbacks from read-only roles; they inform the oracle but never gate work. */
|
|
425
|
+
function recordAdvisoryPushbacks(task: Task, entries: Array<{ pushback?: Pushback; who: string }>): void {
|
|
426
|
+
for (const { pushback, who } of entries) {
|
|
427
|
+
if (pushback) recordDecision(task, pushbackLine(pushback, who));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function workerReport(outcome: WorkerOutcome, approvals: Approval[], pushback?: Approval): string {
|
|
432
|
+
const { result, run, issues } = outcome;
|
|
433
|
+
const objection = result.pushback;
|
|
434
|
+
return [
|
|
435
|
+
`Worker ${result.domain}: ${run.status}${run.error ? ` (${run.error})` : ""}`,
|
|
436
|
+
result.completed ? `Completed: ${truncate(result.completed, 1200)}` : "",
|
|
437
|
+
result.filesChanged.length > 0
|
|
438
|
+
? `Files changed:\n${result.filesChanged.map((file) => `- ${file.path} — ${file.change}`).join("\n")}`
|
|
439
|
+
: "",
|
|
440
|
+
result.verification ? `Verification: ${truncate(result.verification, 600)}` : "",
|
|
441
|
+
result.blockers.length > 0
|
|
442
|
+
? `Blocked: ${result.blockers.map((blocker) => `${blocker.reason} (need: ${blocker.need})`).join("; ")}`
|
|
443
|
+
: "",
|
|
444
|
+
approvals.length > 0
|
|
445
|
+
? `Approvals required before more ${result.domain} work: ${approvals.map((a) => `${a.id} ${a.kind}: ${a.detail}`).join("; ")}. Resolve with action=resolve_approval.`
|
|
446
|
+
: "",
|
|
447
|
+
result.knowledgeProposals.length > 0
|
|
448
|
+
? `Knowledge proposals (accept with action=knowledge, or ignore): ${result.knowledgeProposals.map((proposal) => `${proposal.kind}: ${truncate(proposal.content, 160)}`).join("; ")}`
|
|
449
|
+
: "",
|
|
450
|
+
issues.length > 0 ? `Issues: ${issues.join("; ")}` : "",
|
|
451
|
+
pushback && objection
|
|
452
|
+
? `Pushback recorded (${pushback.id}): ${truncate(objection.reason, 240)}. Resolve with action=resolve_approval before re-delegating ${result.domain}.`
|
|
453
|
+
: "",
|
|
454
|
+
"Next: inspect the diff, then run action=qa once this domain's work is complete.",
|
|
455
|
+
]
|
|
456
|
+
.filter((line) => line.length > 0)
|
|
457
|
+
.join("\n");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function updateScratchpad(deps: WorkflowDeps, task: Task, outcome: WorkerOutcome): void {
|
|
461
|
+
const dir = taskDirFor(deps.root, deps.configDir, task.id);
|
|
462
|
+
const domain = outcome.result.domain;
|
|
463
|
+
const existing = (readTaskArtifact(deps.root, deps.configDir, task.id, `${domain}.md`) ?? "").replace(/^#\s.*\n/, "").trim();
|
|
464
|
+
const entry = [
|
|
465
|
+
`### ${outcome.run.finishedAt ?? outcome.run.startedAt}`,
|
|
466
|
+
outcome.result.completed || outcome.run.error || "no summary",
|
|
467
|
+
...outcome.result.filesChanged.map((file) => `- ${file.path}: ${file.change}`),
|
|
468
|
+
].join("\n");
|
|
469
|
+
writeScratchpad(dir, domain, [existing, entry].filter(Boolean).join("\n\n"), deps.config.knowledge);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function workerTaskText(task: Task): string {
|
|
473
|
+
return [
|
|
474
|
+
`Requirements: ${taskRequest(task)}`,
|
|
475
|
+
task.proposal ? `Approved objective: ${task.proposal}` : "",
|
|
476
|
+
task.plan ? `Approved plan:\n${truncate(task.plan, 6000)}` : "",
|
|
477
|
+
task.amendments.length > 0 ? `User amendments:\n${task.amendments.map((entry) => `- ${entry}`).join("\n")}` : "",
|
|
478
|
+
]
|
|
479
|
+
.filter((line) => line.length > 0)
|
|
480
|
+
.join("\n\n");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function workerRequest(deps: WorkflowDeps, task: Task, domain: Domain, instruction: string): WorkerRequest {
|
|
484
|
+
return {
|
|
485
|
+
taskId: task.id,
|
|
486
|
+
domain,
|
|
487
|
+
instruction,
|
|
488
|
+
taskText: workerTaskText(task),
|
|
489
|
+
scoutOutcomes: loadScoutResults(taskReadDirs(deps.root, deps.configDir, task.id), [domain]),
|
|
490
|
+
cwd: deps.cwd,
|
|
491
|
+
dataRoots: readDataRoots(deps.root, deps.configDir),
|
|
492
|
+
config: deps.config,
|
|
493
|
+
signal: deps.signal,
|
|
494
|
+
onUpdate: deps.onUpdate,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function handleImplement(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
499
|
+
requireState(task, ["planning", "implementing", "reviewing"]);
|
|
500
|
+
const domain = parseDomain(params.domain, "implement");
|
|
501
|
+
const instruction = params.task?.trim();
|
|
502
|
+
if (!instruction) throw new Error("implement requires task (what to implement)");
|
|
503
|
+
assertNoPendingApprovals(task, domain);
|
|
504
|
+
if (!task.domains.includes(domain)) task.domains.push(domain);
|
|
505
|
+
if (task.state !== "implementing") transition(task, "implementing");
|
|
506
|
+
const outcome = await runWorker(workerRequest(deps, task, domain, instruction), deps.runProcess ?? spawnPiProcess);
|
|
507
|
+
const approvals = recordWorkerApprovals(task, outcome, deps.config);
|
|
508
|
+
const pushback = recordPushback(task, outcome);
|
|
509
|
+
task.blockers = [...task.blockers.filter((blocker) => blocker.domain !== domain), ...outcome.result.blockers];
|
|
510
|
+
updateScratchpad(deps, task, outcome);
|
|
511
|
+
return workerReport(outcome, approvals, pushback);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function handleResolveApproval(task: Task, params: OrchestrateParams): string {
|
|
515
|
+
const id = params.approvalId?.trim();
|
|
516
|
+
const decision = params.decision;
|
|
517
|
+
if (!id || (decision !== "approved" && decision !== "rejected")) {
|
|
518
|
+
throw new Error("resolve_approval requires approvalId and decision (approved|rejected)");
|
|
519
|
+
}
|
|
520
|
+
const pending = pendingApprovals(task).find((entry) => entry.id === id);
|
|
521
|
+
if (!pending) throw new Error(`no pending approval "${id}"`);
|
|
522
|
+
const note = params.note?.trim();
|
|
523
|
+
if (pending.kind === "pushback" && decision === "rejected" && !note) {
|
|
524
|
+
throw new Error("overruling a pushback requires note (the counter-argument)");
|
|
525
|
+
}
|
|
526
|
+
const approval = resolveApproval(task, id, decision, note)!;
|
|
527
|
+
recordDecision(task, `${decision} ${approval.kind} for ${approval.domain}: ${approval.detail}${note ? ` — ${note}` : ""}`);
|
|
528
|
+
if (approval.kind === "pushback") {
|
|
529
|
+
return decision === "approved"
|
|
530
|
+
? `${id} pushback accepted (${approval.detail}). Re-delegate without that change.`
|
|
531
|
+
: `${id} pushback overruled. Counter-argument: ${note}. Re-delegate the original change to ${approval.domain}.`;
|
|
532
|
+
}
|
|
533
|
+
return decision === "approved"
|
|
534
|
+
? `${id} approved. The ${approval.domain} worker may now proceed with: ${approval.detail}`
|
|
535
|
+
: `${id} rejected. Instruct the ${approval.domain} worker to achieve the goal without that change.`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function scratchpadSummary(deps: WorkflowDeps, task: Task, domain: Domain): string {
|
|
539
|
+
return readTaskArtifact(deps.root, deps.configDir, task.id, `${domain}.md`) ?? "";
|
|
540
|
+
}
|
|
541
|
+
function recordReview(task: Task, domain: Domain, result: ReviewResult): void {
|
|
542
|
+
task.reviewRecords.push({
|
|
543
|
+
domain,
|
|
544
|
+
verdict: result.verdict,
|
|
545
|
+
findings: result.findings.map((finding) => ({ severity: finding.severity, text: finding.text })),
|
|
546
|
+
requiredChanges: result.requiredChanges,
|
|
547
|
+
createdAt: new Date().toISOString(),
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
function allScratchpads(deps: WorkflowDeps, task: Task): string {
|
|
551
|
+
return (["designer", "backend", "qa"] as Domain[])
|
|
552
|
+
.map((domain) => scratchpadSummary(deps, task, domain).trim())
|
|
553
|
+
.filter((text) => text.length > 0)
|
|
554
|
+
.join("\n\n---\n\n");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const QA_INSTRUCTION = [
|
|
558
|
+
"Run the QA quality gate for the completed feature.",
|
|
559
|
+
"Verify requirements, acceptance criteria, regression risk, edge cases, security, accessibility,",
|
|
560
|
+
"UX, reliability, and tests. Passing automated tests alone is not acceptance.",
|
|
561
|
+
].join(" ");
|
|
562
|
+
|
|
563
|
+
/** The QA gate looks at every domain's work, not just one worker's diff. */
|
|
564
|
+
function qaRequest(deps: WorkflowDeps, task: Task, diff: string, instruction?: string): ReviewerRequest {
|
|
565
|
+
return {
|
|
566
|
+
taskId: task.id,
|
|
567
|
+
domain: "qa",
|
|
568
|
+
taskText: `${workerTaskText(task)}\n\nAcceptance criteria and plan:\n${truncate(task.plan ?? "", 5000)}`,
|
|
569
|
+
workerSummary: allScratchpads(deps, task),
|
|
570
|
+
scoutOutcomes: loadScoutResults(taskReadDirs(deps.root, deps.configDir, task.id), [...new Set<Domain>(["qa", ...task.domains])]),
|
|
571
|
+
diff,
|
|
572
|
+
instruction: instruction?.trim() || QA_INSTRUCTION,
|
|
573
|
+
cwd: deps.cwd,
|
|
574
|
+
dataRoots: readDataRoots(deps.root, deps.configDir),
|
|
575
|
+
config: deps.config,
|
|
576
|
+
signal: deps.signal,
|
|
577
|
+
onUpdate: deps.onUpdate,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function qaReport(outcome: ReviewerOutcome, decision: "accept" | "iterate" | "blocked"): string {
|
|
582
|
+
const { result, run, issues } = outcome;
|
|
583
|
+
return [
|
|
584
|
+
`QA gate: ${result.verdict.toUpperCase()} (run ${run.status}${run.error ? `: ${run.error}` : ""})`,
|
|
585
|
+
result.findings.length > 0
|
|
586
|
+
? `Findings:\n${result.findings.map((finding) => `- [${finding.severity}] ${truncate(finding.text, 300)}`).join("\n")}`
|
|
587
|
+
: "",
|
|
588
|
+
result.requiredChanges.length > 0
|
|
589
|
+
? `Required changes:\n${result.requiredChanges.map((change) => `- ${truncate(change, 300)}`).join("\n")}`
|
|
590
|
+
: "",
|
|
591
|
+
decision === "accept"
|
|
592
|
+
? "The QA gate passed. Record any distilled knowledge, then call action=complete."
|
|
593
|
+
: "The QA gate did not pass: delegate the required changes to the owning domain, then re-run action=qa.",
|
|
594
|
+
decision === "blocked" ? "The review limit is reached: mark the task blocked and tell the user." : "",
|
|
595
|
+
result.pushback ? pushbackLine(result.pushback, "qa") : "",
|
|
596
|
+
issues.length > 0 ? `Issues: ${issues.join("; ")}` : "",
|
|
597
|
+
]
|
|
598
|
+
.filter((line) => line.length > 0)
|
|
599
|
+
.join("\n");
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function handleQa(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
603
|
+
requireState(task, ["implementing", "reviewing"]);
|
|
604
|
+
if (task.state !== "reviewing") transition(task, "reviewing");
|
|
605
|
+
const iterations = (task.reviewIterations?.qa ?? 0) + 1;
|
|
606
|
+
task.reviewIterations = { qa: iterations };
|
|
607
|
+
const diff = await readRepositoryDiff(deps.cwd);
|
|
608
|
+
const outcome = await runReviewer(qaRequest(deps, task, diff, params.task), deps.runProcess ?? spawnPiProcess);
|
|
609
|
+
task.qaVerdict = outcome.result.verdict;
|
|
610
|
+
recordReview(task, "qa", outcome.result);
|
|
611
|
+
recordAdvisoryPushbacks(task, [{ pushback: outcome.result.pushback, who: "qa" }]);
|
|
612
|
+
const decision = decideReviewLoop(outcome.result.verdict, iterations, deps.config.workflow.maxReviewIterations);
|
|
613
|
+
if (decision === "accept") task.blockers = task.blockers.filter((blocker) => blocker.domain !== "qa");
|
|
614
|
+
return qaReport(outcome, decision);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* §22: the only path into persistent knowledge, and it is Master-only because
|
|
619
|
+
* domain agents never receive this tool. Proposals are accepted by calling this
|
|
620
|
+
* with rewritten text, or silently dropped by not calling it.
|
|
621
|
+
*/
|
|
622
|
+
function handleKnowledge(task: Task, params: OrchestrateParams, deps: WorkflowDeps): string {
|
|
623
|
+
const text = params.text?.trim();
|
|
624
|
+
if (!text) throw new Error("knowledge requires text");
|
|
625
|
+
const kind: KnowledgeKind = params.kind ?? "knowledge";
|
|
626
|
+
const agent: KnowledgeAgent = params.domain && isDomain(params.domain) ? params.domain : "master";
|
|
627
|
+
const outcome = applyKnowledge({ dataRoot: dataRoot(deps.root, deps.configDir), agent, kind, text });
|
|
628
|
+
if (outcome.result === "empty") throw new Error("knowledge text is empty");
|
|
629
|
+
if (outcome.result === "duplicate") return `Already recorded in ${outcome.path}; nothing changed.`;
|
|
630
|
+
recordDecision(task, `Recorded ${kind} for ${agent}: ${truncate(text, 200)}`);
|
|
631
|
+
return `Recorded ${kind} for ${agent} in ${outcome.path}.`;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* §24: the Master rewrites the file (asking the user about ambiguity), the
|
|
636
|
+
* engine archives the previous version and writes the compacted text.
|
|
637
|
+
*/
|
|
638
|
+
function handleCompact(task: Task, params: OrchestrateParams, deps: WorkflowDeps): string {
|
|
639
|
+
const agent: KnowledgeAgent = params.domain && isDomain(params.domain) ? params.domain : "master";
|
|
640
|
+
const file = params.file?.trim();
|
|
641
|
+
const content = params.text?.trim();
|
|
642
|
+
if (!file) throw new Error("compact requires file");
|
|
643
|
+
if (!content) throw new Error("compact requires text (the compacted content)");
|
|
644
|
+
const outcome = compactKnowledgeFile({
|
|
645
|
+
dataRoot: dataRoot(deps.root, deps.configDir),
|
|
646
|
+
agent,
|
|
647
|
+
file,
|
|
648
|
+
content,
|
|
649
|
+
backupCount: deps.config.knowledge.backupCount,
|
|
650
|
+
});
|
|
651
|
+
recordDecision(task, `Compacted ${agent}/${file} (${outcome.before} -> ${outcome.after} chars)`);
|
|
652
|
+
return `Compacted ${agent}/${file}: ${outcome.before} -> ${outcome.after} chars. Previous version archived at ${outcome.archive}.`;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** §63: record history, drop scratchpads, then mark the task completed. */
|
|
656
|
+
function handleComplete(task: Task, params: OrchestrateParams, deps: WorkflowDeps): string {
|
|
657
|
+
requireState(task, ["reviewing"]);
|
|
658
|
+
const blockers = completionBlockers(task, pendingApprovals(task).length);
|
|
659
|
+
if (blockers.length > 0) throw new Error(`cannot complete: ${blockers.join("; ")}`);
|
|
660
|
+
const summary = params.text?.trim() || task.proposal || task.title;
|
|
661
|
+
flushDecisions(deps, task);
|
|
662
|
+
recordCompletion(deps, task, summary);
|
|
663
|
+
removeTaskScratchpads(deps.root, deps.configDir, task.id);
|
|
664
|
+
task.blockers = [];
|
|
665
|
+
transition(task, "completed");
|
|
666
|
+
const oversized = overThreshold(readDataRoots(deps.root, deps.configDir), deps.config.knowledge.compactionThreshold);
|
|
667
|
+
const advice =
|
|
668
|
+
oversized.length > 0
|
|
669
|
+
? `\nKnowledge files over the compaction threshold: ${oversized.map((entry) => `${entry.agent}/${entry.file} (${entry.chars})`).join(", ")}. Compact them with action=compact when convenient.`
|
|
670
|
+
: "";
|
|
671
|
+
return `Task ${task.id} completed. History recorded and temporary scratchpads removed.${advice}`;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function flushDecisions(deps: WorkflowDeps, task: Task): void {
|
|
675
|
+
const root = dataRoot(deps.root, deps.configDir);
|
|
676
|
+
for (const decision of task.decisions) {
|
|
677
|
+
appendDecision(knowledgeDir(root, decision.domain), `${task.id} ${decision.text}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function recordCompletion(deps: WorkflowDeps, task: Task, summary: string): void {
|
|
682
|
+
const line = `${task.id}: ${truncate(summary.replace(/\s+/g, " "), 200)}`;
|
|
683
|
+
const agents: KnowledgeAgent[] = ["master", ...new Set(task.domains)];
|
|
684
|
+
for (const agent of agents) {
|
|
685
|
+
appendCompletedTask(knowledgeDir(dataRoot(deps.root, deps.configDir), agent), line);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function handleBlock(task: Task, params: OrchestrateParams): string {
|
|
690
|
+
const reason = params.reason?.trim() ?? params.text?.trim();
|
|
691
|
+
if (!reason) throw new Error("block requires reason");
|
|
692
|
+
const domain = params.domain && isDomain(params.domain) ? params.domain : task.domains[0] ?? "qa";
|
|
693
|
+
task.blockers.push({ domain, reason, tried: [], need: "a decision or input from the user", createdAt: new Date().toISOString() });
|
|
694
|
+
transition(task, "blocked");
|
|
695
|
+
return `Task blocked: ${reason}. Tell the user what is needed, then call action=resume once resolved.`;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function handleResume(task: Task, params: OrchestrateParams): string {
|
|
699
|
+
requireState(task, ["blocked"]);
|
|
700
|
+
if (params.domain && isDomain(params.domain)) {
|
|
701
|
+
task.blockers = task.blockers.filter((blocker) => blocker.domain !== params.domain);
|
|
702
|
+
}
|
|
703
|
+
transition(task, "implementing");
|
|
704
|
+
return "Task resumed. Continue with action=implement.";
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function handleDecide(task: Task, params: OrchestrateParams): string {
|
|
708
|
+
const text = params.text?.trim();
|
|
709
|
+
if (!text) throw new Error("decide requires text");
|
|
710
|
+
const domain = params.domain && isDomain(params.domain) ? params.domain : "master";
|
|
711
|
+
recordDecision(task, text, domain);
|
|
712
|
+
return `Decision recorded (${domain}).`;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function handleCancel(task: Task): string {
|
|
716
|
+
transition(task, "abandoned");
|
|
717
|
+
return `Task ${task.id} abandoned. Its scratchpad is kept until the task is formally resolved.`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams, deps: WorkflowDeps) => Promise<string> | string> = {
|
|
721
|
+
clarify: handleClarify,
|
|
722
|
+
scout: handleScout,
|
|
723
|
+
research: handleResearch,
|
|
724
|
+
propose: handlePropose,
|
|
725
|
+
plan: handlePlan,
|
|
726
|
+
implement: handleImplement,
|
|
727
|
+
resolve_approval: handleResolveApproval,
|
|
728
|
+
qa: handleQa,
|
|
729
|
+
knowledge: handleKnowledge,
|
|
730
|
+
compact: handleCompact,
|
|
731
|
+
complete: handleComplete,
|
|
732
|
+
block: handleBlock,
|
|
733
|
+
resume: handleResume,
|
|
734
|
+
decide: handleDecide,
|
|
735
|
+
status: (task) => describeTask(task),
|
|
736
|
+
cancel: handleCancel,
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Single entry point for every orchestration step. The engine — not the
|
|
741
|
+
* calling agent — decides whether an action is legal in the current state.
|
|
742
|
+
*/
|
|
743
|
+
export async function runWorkflowAction(params: OrchestrateParams, deps: WorkflowDeps): Promise<WorkflowResult> {
|
|
744
|
+
const selected = selectTask(deps.root, deps.configDir, params.taskId, deps.sessionId);
|
|
745
|
+
const task = selected ?? (params.taskId ? undefined : ownerlessTask(deps.root, deps.configDir));
|
|
746
|
+
if (params.action !== "status" && task?.ownerSessionId && deps.sessionId && task.ownerSessionId !== deps.sessionId) {
|
|
747
|
+
return { ok: false, taskId: task.id, state: task.state, message: `Task ${task.id} is owned by another pi session. Take it over with /bot-lobby claim ${task.id}.` };
|
|
748
|
+
}
|
|
749
|
+
if (task && !task.ownerSessionId && deps.sessionId && CLAIM_ACTIONS.has(params.action)) task.ownerSessionId = deps.sessionId;
|
|
750
|
+
if (!task) {
|
|
751
|
+
return { ok: false, taskId: params.taskId ?? "", state: "created", message: "No bot-lobby task found. Start one with /bot-lobby <request>." };
|
|
752
|
+
}
|
|
753
|
+
if (TERMINAL_STATES.includes(task.state) && params.action !== "status") {
|
|
754
|
+
return { ok: false, taskId: task.id, state: task.state, message: `Task ${task.id} is already ${task.state}; no further actions are possible.` };
|
|
755
|
+
}
|
|
756
|
+
if (task.paused && params.action !== "status" && params.action !== "decide") {
|
|
757
|
+
return { ok: false, taskId: task.id, state: task.state, message: `Task ${task.id} is paused. Resume it before continuing.` };
|
|
758
|
+
}
|
|
759
|
+
const handler = HANDLERS[params.action];
|
|
760
|
+
if (!handler) {
|
|
761
|
+
return { ok: false, taskId: task.id, state: task.state, message: `Unknown action "${params.action}".` };
|
|
762
|
+
}
|
|
763
|
+
try {
|
|
764
|
+
const message = await handler(task, params, deps);
|
|
765
|
+
saveTask(deps.root, deps.configDir, task);
|
|
766
|
+
return { ok: true, taskId: task.id, state: task.state, message };
|
|
767
|
+
} catch (error) {
|
|
768
|
+
saveTask(deps.root, deps.configDir, task);
|
|
769
|
+
return { ok: false, taskId: task.id, state: task.state, message: `Rejected: ${(error as Error).message}` };
|
|
770
|
+
}
|
|
771
|
+
}
|