@a-t-h-i/bot-lobby 0.6.37 → 0.6.39

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.
@@ -1,5 +1,5 @@
1
- import { existsSync, mkdirSync, realpathSync } from "node:fs";
2
- import { isAbsolute, join, relative } from "node:path";
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
  import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schemas/configuration.ts";
4
4
  import type { AgentRun, Pushback, ResearchResult, ReviewResult, WorkerResult } from "../schemas/findings.ts";
5
5
  import {
@@ -24,6 +24,7 @@ import { ExcalidrawBook } from "../excalidraw/sessions.ts";
24
24
  import { knowledgeDir, type KnowledgeAgent } from "../knowledge/paths.ts";
25
25
  import { lobbyTopics } from "../lobby/topics.ts";
26
26
  import { writeScratchpad } from "../state/persistence.ts";
27
+ import { saveScript } from "../scripts/library.ts";
27
28
  import { spawnPiProcess, type ProcessRunner, type RelayAsk } from "../execution/pi-runner.ts";
28
29
  import { previewDir } from "../ask/relay.ts";
29
30
  import type { AskQuestion, AskResult } from "../ask/types.ts";
@@ -31,7 +32,7 @@ import { mapConcurrent } from "../execution/agent-runner.ts";
31
32
  import { parseWorkerResult } from "../roles/worker.ts";
32
33
  import { autoNote, DESK_TOOLS, DeskSession } from "../desk/session.ts";
33
34
  import type { Handover } from "../desk/desk.ts";
34
- import { changedFiles, commitBefore, headCommit, readRepositoryDiff } from "../execution/git.ts";
35
+ import { changedFiles, commitBefore, commitFiles, commitMessage, headCommit, readRepositoryDiff, uncommitted } from "../execution/git.ts";
35
36
  import { appendChange, explainChanges, provenanceLines, provenanceSummary, readChanges, type FileProvenance } from "../state/changes.ts";
36
37
  import { budgetLine, budgetState, formatMinutes, MIN_READ_MS, parseMinutes, qaAllotment, readBudget, readerAllotment, updateBudget, workerAllotment, type Allotment, type BudgetState, type TaskBudget } from "../state/budget.ts";
37
38
  import type { AgentTime } from "../execution/agent-runner.ts";
@@ -90,6 +91,7 @@ export const ORCHESTRATE_ACTIONS = [
90
91
  "qa",
91
92
  "knowledge",
92
93
  "compact",
94
+ "script",
93
95
  "whiteboard",
94
96
  "complete",
95
97
  "block",
@@ -126,15 +128,21 @@ export interface OrchestrateParams {
126
128
  minutes?: number;
127
129
  /** knowledge: which persistent file the text belongs to. */
128
130
  kind?: KnowledgeKind;
129
- /** compact: the knowledge file being rewritten. */
131
+ /** compact: the knowledge file being rewritten; script: a drafted script to take instead of `text`. */
130
132
  file?: string;
131
- /** whiteboard: name for the new Excalidraw session. */
133
+ /** whiteboard: name for the new Excalidraw session; script: the script's file name (`seed_public_holidays.py`). */
132
134
  name?: string;
135
+ /** script: one line on what the script does. */
136
+ purpose?: string;
137
+ /** script: how to run it, with its arguments. */
138
+ usage?: string;
133
139
  approvalId?: string;
134
140
  decision?: "approved" | "rejected";
135
141
  note?: string;
136
142
  reason?: string;
137
143
  text?: string;
144
+ /** complete: the commit message for the agents' changes. */
145
+ message?: string;
138
146
  /** track: the path the task takes. */
139
147
  track?: "fast" | "full";
140
148
  /** track: who takes part (designer, backend, qa, researcher). */
@@ -635,6 +643,9 @@ function workerReport(outcome: WorkerOutcome, approvals: Approval[], pushback?:
635
643
  result.knowledgeProposals.length > 0
636
644
  ? `Knowledge proposals (accept with action=knowledge, or ignore): ${result.knowledgeProposals.map((proposal) => `${proposal.kind}: ${truncate(proposal.content, 160)}`).join("; ")}`
637
645
  : "",
646
+ result.scriptProposals?.length
647
+ ? `Script proposals (save with action=script name purpose usage file=<draft>, when the job will come back; or ignore): ${result.scriptProposals.map((proposal) => `${proposal.name}${proposal.purpose ? ` — ${truncate(proposal.purpose, 120)}` : ""}${proposal.draft ? ` (draft: ${proposal.draft})` : ""}`).join("; ")}`
648
+ : "",
638
649
  issues.length > 0 ? `Issues: ${issues.join("; ")}` : "",
639
650
  pushback && objection
640
651
  ? `Pushback recorded (${pushback.id}): ${truncate(objection.reason, 240)}. Resolve with action=resolve_approval before re-delegating ${result.domain}.`
@@ -1481,6 +1492,34 @@ function handleKnowledge(task: Task | undefined, params: OrchestrateParams, deps
1481
1492
  return `Recorded ${kind} for ${agent} in ${outcome.path}.`;
1482
1493
  }
1483
1494
 
1495
+ /**
1496
+ * The oracle adds a script to the shared library, or replaces one, so every
1497
+ * agent can run it the next time the job comes up. Its body is `text`, or a
1498
+ * file a worker drafted (`file`, inside the project or the task's directory).
1499
+ */
1500
+ function handleScript(task: Task | undefined, params: OrchestrateParams, deps: WorkflowDeps): string {
1501
+ const name = params.name?.trim();
1502
+ if (!name) throw new Error("script requires name: what it does in snake_case, with its extension (seed_public_holidays.py)");
1503
+ const purpose = params.purpose?.trim();
1504
+ if (!purpose) throw new Error("script requires purpose: one line on what it does");
1505
+ const body = params.file?.trim() ? readDraft(params.file.trim(), task, deps) : params.text;
1506
+ if (!body?.trim()) throw new Error("script requires text (the script) or file (a drafted script)");
1507
+ const saved = saveScript({ dataRoot: dataRoot(deps.root, deps.configDir), name, purpose, body, ...(params.usage?.trim() ? { usage: params.usage.trim() } : {}) });
1508
+ const { entry } = saved;
1509
+ if (task) recordDecision(task, `${saved.archived ? "Replaced" : "Saved"} the shared script ${entry.name}: ${truncate(entry.purpose, 160)}`);
1510
+ lobbyTopics.bump("knowledge");
1511
+ return [`${saved.archived ? "Replaced" : "Saved"} ${entry.name} in the shared scripts library (${entry.path}); run: ${entry.usage}.`, saved.archived ? `The previous version is kept at ${saved.archived}.` : "", "Agents whose steps it fits are told to run it."].filter(Boolean).join(" ");
1512
+ }
1513
+
1514
+ /** A drafted script, read only from inside the project, its worktree or the task's directory. */
1515
+ function readDraft(file: string, task: Task | undefined, deps: WorkflowDeps): string {
1516
+ const roots = [deps.cwd, deps.root, ...(task ? [taskDirFor(deps.root, deps.configDir, task.id)] : [])].map((dir) => resolve(dir));
1517
+ const path = isAbsolute(file) ? resolve(file) : resolve(deps.cwd, file);
1518
+ if (!roots.some((root) => path === root || path.startsWith(`${root}${sep}`))) throw new Error(`the drafted script must be inside the project: ${file}`);
1519
+ if (!existsSync(path)) throw new Error(`no drafted script at ${file}`);
1520
+ return readFileSync(path, "utf8");
1521
+ }
1522
+
1484
1523
  /**
1485
1524
  * §24: the Master rewrites the file (asking the user about ambiguity), the
1486
1525
  * engine archives the previous version and writes the compacted text.
@@ -1533,6 +1572,7 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
1533
1572
  // The gate refuses before any side effect, so a refusal leaves the task untouched.
1534
1573
  const oversized = overThreshold(readDataRoots(deps.root, deps.configDir), deps.config.knowledge.compactionThreshold);
1535
1574
  if (oversized.length > 0) throw new Error(`cannot complete: knowledge files over the compaction threshold: ${oversized.map((entry) => `${entry.agent}/${entry.file} (${entry.chars})`).join(", ")}. First record domain-relevant facts where they belong with action=knowledge (domain=designer|backend|qa), then rewrite each oversized file with action=compact, then call complete again.`);
1575
+ const committed = await commitTaskWork(task, params, deps);
1536
1576
  const summary = params.text?.trim() || task.proposal || task.title;
1537
1577
  recordCompletion(deps, task, summary);
1538
1578
  removeTaskScratchpads(deps.root, deps.configDir, task.id);
@@ -1544,7 +1584,43 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
1544
1584
  flushDecisions(deps, task);
1545
1585
  const delivery = await offerDelivery(task, deps);
1546
1586
  const left = lint?.state === "failing" ? ` ${task.lintWaiver ? "Accepted by the user with lint errors" : "Lint (advisory)"}: ${lintSummary(lint)} Tell the user.` : "";
1547
- return `Task ${task.id} completed. History recorded and temporary scratchpads removed. ${delivery}${left}`;
1587
+ return `Task ${task.id} completed.${committed} History recorded and temporary scratchpads removed. ${delivery}${left}`;
1588
+ }
1589
+
1590
+ /** The files a task's own workers changed: not one that held changes before they started, nor another task's. */
1591
+ function agentsWork(files: readonly FileProvenance[]): string[] {
1592
+ return files.filter((file) => file.kinds.includes("planned") && !file.kinds.includes("pre-existing") && !file.kinds.includes("other-task")).map((file) => file.path);
1593
+ }
1594
+
1595
+ /**
1596
+ * The oracle commits what its agents changed before the task is called done,
1597
+ * with its own short message (`message`); nothing else in the tree is
1598
+ * staged or committed. "" when git is not in use or nothing is left to commit.
1599
+ */
1600
+ async function commitTaskWork(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
1601
+ const state = await changeState(deps, task, await reviewBase(task, deps));
1602
+ if (!state) return "";
1603
+ const ours = agentsWork(state.files);
1604
+ const dirty = await uncommitted(state.top, ours).catch(() => []);
1605
+ if (dirty.length === 0) return "";
1606
+ const listed = dirty.slice(0, 20).join(", ") + (dirty.length > 20 ? ` and ${dirty.length - 20} more` : "");
1607
+ if (!params.message?.trim()) throw new Error(`cannot complete: commit the agents' changes first. Call complete again with message: a concise commit message for them (a first line of 72 characters or fewer saying what changed, at most three short lines below it). The engine commits only these files: ${listed}`);
1608
+ let message: string;
1609
+ try {
1610
+ message = commitMessage(params.message);
1611
+ } catch (error) {
1612
+ throw new Error(`cannot complete: ${(error as Error).message}`);
1613
+ }
1614
+ let result: Awaited<ReturnType<typeof commitFiles>>;
1615
+ try {
1616
+ result = await commitFiles(state.top, dirty, message);
1617
+ } catch (error) {
1618
+ throw new Error(`cannot complete: git refused the commit: ${(error as Error).message}`);
1619
+ }
1620
+ if (!result) return "";
1621
+ const others = await uncommitted(state.top, state.files.map((file) => file.path).filter((path) => !ours.includes(path))).catch(() => []);
1622
+ const left = others.length > 0 ? ` Left uncommitted, not this task's agents' work: ${others.slice(0, 10).join(", ")}${others.length > 10 ? ` and ${others.length - 10} more` : ""}.` : "";
1623
+ return ` Committed ${result.files.length} file${result.files.length === 1 ? "" : "s"} as ${result.commit.slice(0, 7)}: "${message.split("\n")[0]}".${left}`;
1548
1624
  }
1549
1625
 
1550
1626
  /** Completion always offers delivery to the user, including in auto mode. */
@@ -1990,6 +2066,7 @@ const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams
1990
2066
  resolve_approval: handleResolveApproval,
1991
2067
  qa: handleQa,
1992
2068
  knowledge: handleKnowledge,
2069
+ script: handleScript,
1993
2070
  compact: handleCompact,
1994
2071
  whiteboard: handleWhiteboard,
1995
2072
  complete: handleComplete,
@@ -2008,9 +2085,9 @@ const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams
2008
2085
  */
2009
2086
  export async function runWorkflowAction(params: OrchestrateParams, deps: WorkflowDeps): Promise<WorkflowResult> {
2010
2087
  // Project knowledge can be learned without starting or changing a task.
2011
- if ((params.action === "knowledge" || params.action === "compact") && !params.taskId) {
2088
+ if ((params.action === "knowledge" || params.action === "compact" || params.action === "script") && !params.taskId) {
2012
2089
  try {
2013
- const message = params.action === "knowledge" ? handleKnowledge(undefined, params, deps) : handleCompact(undefined, params, deps);
2090
+ const message = params.action === "knowledge" ? handleKnowledge(undefined, params, deps) : params.action === "script" ? handleScript(undefined, params, deps) : handleCompact(undefined, params, deps);
2014
2091
  return { ok: true, taskId: "", state: "created", message };
2015
2092
  } catch (error) {
2016
2093
  return { ok: false, taskId: "", state: "created", message: `Rejected: ${(error as Error).message}` };
@@ -2050,6 +2127,11 @@ export async function runWorkflowAction(params: OrchestrateParams, deps: Workflo
2050
2127
  ...(deps.askQuestions ? { askQuestions: (...args: Parameters<NonNullable<WorkflowDeps["askQuestions"]>>) => wait(() => deps.askQuestions!(...args)) } : {}),
2051
2128
  // Every agent, diff and desk of a task with its own worktree works there.
2052
2129
  cwd: taskCwd(task, deps.cwd),
2130
+ // An agent can work for an hour: what the action has done so far (implementing, reviewing) is saved before it starts, so every window reads it.
2131
+ runProcess: (args, options) => {
2132
+ saveTask(deps.root, deps.configDir, task);
2133
+ return (deps.runProcess ?? spawnPiProcess)(args, options);
2134
+ },
2053
2135
  onUpdate: (run) => {
2054
2136
  if (run.status !== "running") {
2055
2137
  finished.set(run.runId, run);