@a-t-h-i/bot-lobby 0.6.37 → 0.6.38
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 +12 -0
- package/package.json +1 -1
- package/prompts/master.md +25 -0
- package/prompts/worker.md +11 -0
- package/src/classifier/instance.ts +10 -2
- package/src/classifier/knowledge.ts +5 -2
- package/src/classifier/scripts.ts +76 -0
- package/src/execution/agent-runner.ts +2 -0
- package/src/knowledge/selector.ts +2 -0
- package/src/lobby/quickfix.ts +10 -2
- package/src/lobby/runtime.ts +2 -1
- package/src/master/master.ts +9 -1
- package/src/pi/activity.ts +2 -0
- package/src/pi/events.ts +4 -1
- package/src/pi/tools.ts +6 -4
- package/src/prompts/compiler.ts +4 -1
- package/src/roles/worker.ts +15 -1
- package/src/schemas/configuration.ts +5 -2
- package/src/schemas/findings.ts +12 -0
- package/src/scripts/library.ts +168 -0
- package/src/scripts/pick.ts +79 -0
- package/src/workflow/workflow.ts +49 -6
- package/webui/dist/assets/{index-BwyOrEcV.js → index-BFsMFIr5.js} +1 -1
- package/webui/dist/assets/index-Ccc6rsPD.css +2 -0
- package/webui/dist/build.json +3 -3
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BTk14vBb.css +0 -2
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scripts library: scripts every agent shares for jobs that come back
|
|
3
|
+
* (seeding data, regenerating fixtures, a set of checks, a migration), so a
|
|
4
|
+
* step done once is run the next time, not worked out again, which saves
|
|
5
|
+
* tokens and time. They live in `<data>/scripts/`, one file each, named for
|
|
6
|
+
* what they do in snake_case with the language's extension
|
|
7
|
+
* (`seed_public_holidays.py`, `run_changed_tests.sh`). The first lines of each
|
|
8
|
+
* file say what it is for and how to run it, and the library's index is read
|
|
9
|
+
* from them, so a script edited by hand stays listed. Only the oracle adds or
|
|
10
|
+
* replaces a script; workers propose one. A replaced script is kept under
|
|
11
|
+
* `archive/`.
|
|
12
|
+
*/
|
|
13
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
/** The languages a script may be written in: how it is run and how its header lines are commented. */
|
|
17
|
+
export const SCRIPT_LANGUAGES = {
|
|
18
|
+
py: { runner: "python3", comment: "#" },
|
|
19
|
+
sh: { runner: "bash", comment: "#" },
|
|
20
|
+
bash: { runner: "bash", comment: "#" },
|
|
21
|
+
js: { runner: "node", comment: "//" },
|
|
22
|
+
mjs: { runner: "node", comment: "//" },
|
|
23
|
+
cjs: { runner: "node", comment: "//" },
|
|
24
|
+
ts: { runner: "node", comment: "//" },
|
|
25
|
+
rb: { runner: "ruby", comment: "#" },
|
|
26
|
+
pl: { runner: "perl", comment: "#" },
|
|
27
|
+
ps1: { runner: "pwsh -File", comment: "#" },
|
|
28
|
+
} as const;
|
|
29
|
+
export type ScriptLanguage = keyof typeof SCRIPT_LANGUAGES;
|
|
30
|
+
|
|
31
|
+
/** Longest name, extension included. */
|
|
32
|
+
export const MAX_SCRIPT_NAME = 64;
|
|
33
|
+
/** Largest script the library takes. */
|
|
34
|
+
export const MAX_SCRIPT_BYTES = 64 * 1024;
|
|
35
|
+
|
|
36
|
+
const NAME = new RegExp(`^[a-z][a-z0-9]*(?:_[a-z0-9]+)+\\.(${Object.keys(SCRIPT_LANGUAGES).join("|")})$`);
|
|
37
|
+
/** How many lines from the top the header is looked for in. */
|
|
38
|
+
const HEADER_LINES = 12;
|
|
39
|
+
|
|
40
|
+
export interface ScriptEntry {
|
|
41
|
+
/** The file name: `seed_public_holidays.py`. */
|
|
42
|
+
name: string;
|
|
43
|
+
path: string;
|
|
44
|
+
language: ScriptLanguage;
|
|
45
|
+
/** What it is for, one line. */
|
|
46
|
+
purpose: string;
|
|
47
|
+
/** How to run it, with its arguments; the runner and path when the header gives none. */
|
|
48
|
+
usage: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function scriptsDir(dataRoot: string): string {
|
|
52
|
+
return join(dataRoot, "scripts");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The words of a name (`seed_public_holidays.py` → `seed public holidays`), for matching it against a step. */
|
|
56
|
+
export function nameWords(name: string): string {
|
|
57
|
+
return name.replace(/\.[a-z0-9]+$/, "").replace(/_/g, " ");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The library name for what was asked: lower-cased, spaces and hyphens as
|
|
62
|
+
* underscores (`Seed public-holidays.py` → `seed_public_holidays.py`). Throws
|
|
63
|
+
* with what is wrong when it still does not read `<what_it_does>.<ext>`.
|
|
64
|
+
*/
|
|
65
|
+
export function scriptName(asked: string): string {
|
|
66
|
+
const name = asked.trim().toLowerCase().replace(/[\s-]+/g, "_").replace(/_+/g, "_");
|
|
67
|
+
const languages = Object.keys(SCRIPT_LANGUAGES).map((ext) => `.${ext}`).join(", ");
|
|
68
|
+
if (!/\.[a-z0-9]+$/.test(name) || !(name.split(".").pop()! in SCRIPT_LANGUAGES)) {
|
|
69
|
+
throw new Error(`a script name ends in its language's extension (${languages}): "${asked}"`);
|
|
70
|
+
}
|
|
71
|
+
if (name.length > MAX_SCRIPT_NAME) throw new Error(`a script name is at most ${MAX_SCRIPT_NAME} characters: "${asked}"`);
|
|
72
|
+
if (!NAME.test(name)) {
|
|
73
|
+
throw new Error(`a script is named for what it does, in snake_case words (seed_public_holidays.py): "${asked}"`);
|
|
74
|
+
}
|
|
75
|
+
return name;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function language(name: string): ScriptLanguage {
|
|
79
|
+
return name.split(".").pop() as ScriptLanguage;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function headerValue(lines: readonly string[], key: string): string | undefined {
|
|
83
|
+
const line = lines.find((entry) => new RegExp(`^\\s*(?:#|//)\\s*${key}\\s*:`, "i").test(entry));
|
|
84
|
+
return line?.replace(new RegExp(`^\\s*(?:#|//)\\s*${key}\\s*:\\s*`, "i"), "").trim() || undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The command that runs a script when its header gives none. */
|
|
88
|
+
export function runCommand(entry: Pick<ScriptEntry, "path" | "language">): string {
|
|
89
|
+
return `${SCRIPT_LANGUAGES[entry.language].runner} ${entry.path}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readEntry(dir: string, name: string): ScriptEntry | undefined {
|
|
93
|
+
const path = join(dir, name);
|
|
94
|
+
try {
|
|
95
|
+
if (!statSync(path).isFile()) return undefined;
|
|
96
|
+
const lines = readFileSync(path, "utf8").split("\n").slice(0, HEADER_LINES);
|
|
97
|
+
const entry = { name, path, language: language(name) };
|
|
98
|
+
return { ...entry, purpose: headerValue(lines, "purpose") ?? nameWords(name), usage: headerValue(lines, "usage") ?? runCommand(entry) };
|
|
99
|
+
} catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Every script in the library, by name; files not named like a script are left out. */
|
|
105
|
+
export function listScripts(dataRoot: string): ScriptEntry[] {
|
|
106
|
+
const dir = scriptsDir(dataRoot);
|
|
107
|
+
if (!existsSync(dir)) return [];
|
|
108
|
+
return readdirSync(dir)
|
|
109
|
+
.filter((name) => NAME.test(name))
|
|
110
|
+
.sort()
|
|
111
|
+
.map((name) => readEntry(dir, name))
|
|
112
|
+
.filter((entry): entry is ScriptEntry => entry !== undefined);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The body with any header lines of its own taken out, and a shebang it starts with kept apart. */
|
|
116
|
+
function splitBody(body: string): { shebang?: string; rest: string } {
|
|
117
|
+
const lines = body.replace(/\r\n/g, "\n").split("\n");
|
|
118
|
+
const shebang = lines[0]?.startsWith("#!") ? lines.shift() : undefined;
|
|
119
|
+
const rest = lines.filter((line, index) => index >= HEADER_LINES || !/^\s*(?:#|\/\/)\s*(?:purpose|usage)\s*:/i.test(line));
|
|
120
|
+
while (rest.length > 0 && rest[0]!.trim() === "") rest.shift();
|
|
121
|
+
return { ...(shebang ? { shebang } : {}), rest: rest.join("\n") };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface SaveScript {
|
|
125
|
+
dataRoot: string;
|
|
126
|
+
name: string;
|
|
127
|
+
purpose: string;
|
|
128
|
+
/** How to run it, with its arguments; the runner and path when absent. */
|
|
129
|
+
usage?: string;
|
|
130
|
+
body: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface SavedScript {
|
|
134
|
+
entry: ScriptEntry;
|
|
135
|
+
/** Where the version it replaced was kept, when there was one. */
|
|
136
|
+
archived?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Add a script to the library, or replace the one of that name (the old one
|
|
141
|
+
* is kept under `archive/`). The engine writes its header: purpose and usage
|
|
142
|
+
* as comments after any shebang, so the index never depends on the agent
|
|
143
|
+
* having written one.
|
|
144
|
+
*/
|
|
145
|
+
export function saveScript(input: SaveScript, now = new Date()): SavedScript {
|
|
146
|
+
const name = scriptName(input.name);
|
|
147
|
+
const purpose = input.purpose.replace(/\s+/g, " ").trim();
|
|
148
|
+
if (!purpose) throw new Error("a script needs its purpose: one line on what it does");
|
|
149
|
+
const { shebang, rest } = splitBody(input.body);
|
|
150
|
+
if (!rest.trim()) throw new Error("the script is empty");
|
|
151
|
+
const dir = scriptsDir(input.dataRoot);
|
|
152
|
+
const path = join(dir, name);
|
|
153
|
+
const lang = language(name);
|
|
154
|
+
const usage = input.usage?.replace(/\s+/g, " ").trim() || runCommand({ path, language: lang });
|
|
155
|
+
const { comment } = SCRIPT_LANGUAGES[lang];
|
|
156
|
+
const text = [...(shebang ? [shebang] : []), `${comment} purpose: ${purpose}`, `${comment} usage: ${usage}`, "", rest.replace(/\s+$/, ""), ""].join("\n");
|
|
157
|
+
if (Buffer.byteLength(text) > MAX_SCRIPT_BYTES) throw new Error(`a script is at most ${MAX_SCRIPT_BYTES / 1024} KB`);
|
|
158
|
+
mkdirSync(dir, { recursive: true });
|
|
159
|
+
let archived: string | undefined;
|
|
160
|
+
if (existsSync(path)) {
|
|
161
|
+
if (readFileSync(path, "utf8") === text) return { entry: readEntry(dir, name)! };
|
|
162
|
+
mkdirSync(join(dir, "archive"), { recursive: true });
|
|
163
|
+
archived = join(dir, "archive", `${name}.${now.toISOString().replace(/[:.]/g, "-")}`);
|
|
164
|
+
copyFileSync(path, archived);
|
|
165
|
+
}
|
|
166
|
+
writeFileSync(path, text, { mode: 0o755 });
|
|
167
|
+
return { entry: readEntry(dir, name)!, ...(archived ? { archived } : {}) };
|
|
168
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of the library's scripts fit a step, and how an agent's prompt shows
|
|
3
|
+
* them. Jev judges each script against the step when it is on (see
|
|
4
|
+
* `classifier/scripts.ts`); without it, or when it fails, a script fits when
|
|
5
|
+
* its name or purpose shares words with the step. Either way the prompt names
|
|
6
|
+
* the library, so an agent can look for itself.
|
|
7
|
+
*/
|
|
8
|
+
import { score, tokenize } from "../knowledge/selector.ts";
|
|
9
|
+
import { listScripts, nameWords, scriptsDir, type ScriptEntry } from "./library.ts";
|
|
10
|
+
|
|
11
|
+
/** Most scripts one prompt suggests. */
|
|
12
|
+
export const MAX_SUGGESTED = 6;
|
|
13
|
+
/** Most scripts the oracle's prompt lists. */
|
|
14
|
+
export const MAX_LISTED = 40;
|
|
15
|
+
|
|
16
|
+
/** The scripts whose name or purpose shares the most words with the step, best first. */
|
|
17
|
+
export function keywordScripts(step: string, entries: readonly ScriptEntry[], limit = MAX_SUGGESTED): ScriptEntry[] {
|
|
18
|
+
const tokens = tokenize(step);
|
|
19
|
+
return entries
|
|
20
|
+
.map((entry, index) => ({ entry, index, hits: score(`${nameWords(entry.name)} ${entry.purpose}`, tokens) }))
|
|
21
|
+
.filter((scored) => scored.hits > 0)
|
|
22
|
+
.sort((a, b) => b.hits - a.hits || a.index - b.index)
|
|
23
|
+
.slice(0, limit)
|
|
24
|
+
.map((scored) => scored.entry);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Picks the scripts that fit a step. */
|
|
28
|
+
export interface ScriptPicker {
|
|
29
|
+
pick(step: string, entries: readonly ScriptEntry[], options?: { signal?: AbortSignal }): Promise<ScriptEntry[]>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The picker that goes by shared words alone. */
|
|
33
|
+
export const keywordPicker: ScriptPicker = { pick: async (step, entries) => keywordScripts(step, entries) };
|
|
34
|
+
|
|
35
|
+
function line(entry: ScriptEntry): string {
|
|
36
|
+
return `- \`${entry.name}\` — ${entry.purpose}\n run: \`${entry.usage}\``;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The Scripts layer of a worker's, scout's or QA agent's prompt: the scripts
|
|
41
|
+
* that fit this step and how to run each, then where the rest are. "" while
|
|
42
|
+
* the library is empty.
|
|
43
|
+
*/
|
|
44
|
+
export function scriptsBlock(dataRoot: string, entries: readonly ScriptEntry[], picked: readonly ScriptEntry[]): string {
|
|
45
|
+
if (entries.length === 0) return "";
|
|
46
|
+
const where = `The shared scripts library (${entries.length} script${entries.length === 1 ? "" : "s"}) is \`${scriptsDir(dataRoot)}\`; each file's first lines say what it does and how to run it.`;
|
|
47
|
+
if (picked.length === 0) return `None of the shared scripts fits this step. ${where}`;
|
|
48
|
+
return [
|
|
49
|
+
"These shared scripts do part of this step: run one instead of doing its work by hand, and check its output as you would your own.",
|
|
50
|
+
...picked.map(line),
|
|
51
|
+
where,
|
|
52
|
+
].join("\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The oracle's view of the library: every script by name and purpose, so it can point a brief at one. */
|
|
56
|
+
export function libraryBlock(dataRoot: string, entries: readonly ScriptEntry[] = listScripts(dataRoot)): string {
|
|
57
|
+
const dir = scriptsDir(dataRoot);
|
|
58
|
+
if (entries.length === 0) return `The shared scripts library (\`${dir}\`) is empty.`;
|
|
59
|
+
const shown = entries.slice(0, MAX_LISTED).map((entry) => `- \`${entry.name}\` — ${entry.purpose}`);
|
|
60
|
+
const more = entries.length > MAX_LISTED ? [`…and ${entries.length - MAX_LISTED} more in \`${dir}\`.`] : [];
|
|
61
|
+
return [`Shared scripts (\`${dir}\`):`, ...shown, ...more].join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The Scripts layer for one agent's step: the library read, the picker's
|
|
66
|
+
* choice (shared words when it fails), and the block. "" with no scripts.
|
|
67
|
+
*/
|
|
68
|
+
export async function scriptsFor(dataRoot: string | undefined, step: string, picker: ScriptPicker = keywordPicker, signal?: AbortSignal): Promise<string> {
|
|
69
|
+
if (!dataRoot) return "";
|
|
70
|
+
const entries = listScripts(dataRoot);
|
|
71
|
+
if (entries.length === 0) return "";
|
|
72
|
+
let picked: ScriptEntry[];
|
|
73
|
+
try {
|
|
74
|
+
picked = await picker.pick(step, entries, signal ? { signal } : {});
|
|
75
|
+
} catch {
|
|
76
|
+
picked = keywordScripts(step, entries);
|
|
77
|
+
}
|
|
78
|
+
return scriptsBlock(dataRoot, entries, picked.slice(0, MAX_SUGGESTED));
|
|
79
|
+
}
|
package/src/workflow/workflow.ts
CHANGED
|
@@ -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";
|
|
@@ -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,10 +128,14 @@ 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;
|
|
@@ -635,6 +641,9 @@ function workerReport(outcome: WorkerOutcome, approvals: Approval[], pushback?:
|
|
|
635
641
|
result.knowledgeProposals.length > 0
|
|
636
642
|
? `Knowledge proposals (accept with action=knowledge, or ignore): ${result.knowledgeProposals.map((proposal) => `${proposal.kind}: ${truncate(proposal.content, 160)}`).join("; ")}`
|
|
637
643
|
: "",
|
|
644
|
+
result.scriptProposals?.length
|
|
645
|
+
? `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("; ")}`
|
|
646
|
+
: "",
|
|
638
647
|
issues.length > 0 ? `Issues: ${issues.join("; ")}` : "",
|
|
639
648
|
pushback && objection
|
|
640
649
|
? `Pushback recorded (${pushback.id}): ${truncate(objection.reason, 240)}. Resolve with action=resolve_approval before re-delegating ${result.domain}.`
|
|
@@ -1481,6 +1490,34 @@ function handleKnowledge(task: Task | undefined, params: OrchestrateParams, deps
|
|
|
1481
1490
|
return `Recorded ${kind} for ${agent} in ${outcome.path}.`;
|
|
1482
1491
|
}
|
|
1483
1492
|
|
|
1493
|
+
/**
|
|
1494
|
+
* The oracle adds a script to the shared library, or replaces one, so every
|
|
1495
|
+
* agent can run it the next time the job comes up. Its body is `text`, or a
|
|
1496
|
+
* file a worker drafted (`file`, inside the project or the task's directory).
|
|
1497
|
+
*/
|
|
1498
|
+
function handleScript(task: Task | undefined, params: OrchestrateParams, deps: WorkflowDeps): string {
|
|
1499
|
+
const name = params.name?.trim();
|
|
1500
|
+
if (!name) throw new Error("script requires name: what it does in snake_case, with its extension (seed_public_holidays.py)");
|
|
1501
|
+
const purpose = params.purpose?.trim();
|
|
1502
|
+
if (!purpose) throw new Error("script requires purpose: one line on what it does");
|
|
1503
|
+
const body = params.file?.trim() ? readDraft(params.file.trim(), task, deps) : params.text;
|
|
1504
|
+
if (!body?.trim()) throw new Error("script requires text (the script) or file (a drafted script)");
|
|
1505
|
+
const saved = saveScript({ dataRoot: dataRoot(deps.root, deps.configDir), name, purpose, body, ...(params.usage?.trim() ? { usage: params.usage.trim() } : {}) });
|
|
1506
|
+
const { entry } = saved;
|
|
1507
|
+
if (task) recordDecision(task, `${saved.archived ? "Replaced" : "Saved"} the shared script ${entry.name}: ${truncate(entry.purpose, 160)}`);
|
|
1508
|
+
lobbyTopics.bump("knowledge");
|
|
1509
|
+
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(" ");
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/** A drafted script, read only from inside the project, its worktree or the task's directory. */
|
|
1513
|
+
function readDraft(file: string, task: Task | undefined, deps: WorkflowDeps): string {
|
|
1514
|
+
const roots = [deps.cwd, deps.root, ...(task ? [taskDirFor(deps.root, deps.configDir, task.id)] : [])].map((dir) => resolve(dir));
|
|
1515
|
+
const path = isAbsolute(file) ? resolve(file) : resolve(deps.cwd, file);
|
|
1516
|
+
if (!roots.some((root) => path === root || path.startsWith(`${root}${sep}`))) throw new Error(`the drafted script must be inside the project: ${file}`);
|
|
1517
|
+
if (!existsSync(path)) throw new Error(`no drafted script at ${file}`);
|
|
1518
|
+
return readFileSync(path, "utf8");
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1484
1521
|
/**
|
|
1485
1522
|
* §24: the Master rewrites the file (asking the user about ambiguity), the
|
|
1486
1523
|
* engine archives the previous version and writes the compacted text.
|
|
@@ -1990,6 +2027,7 @@ const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams
|
|
|
1990
2027
|
resolve_approval: handleResolveApproval,
|
|
1991
2028
|
qa: handleQa,
|
|
1992
2029
|
knowledge: handleKnowledge,
|
|
2030
|
+
script: handleScript,
|
|
1993
2031
|
compact: handleCompact,
|
|
1994
2032
|
whiteboard: handleWhiteboard,
|
|
1995
2033
|
complete: handleComplete,
|
|
@@ -2008,9 +2046,9 @@ const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams
|
|
|
2008
2046
|
*/
|
|
2009
2047
|
export async function runWorkflowAction(params: OrchestrateParams, deps: WorkflowDeps): Promise<WorkflowResult> {
|
|
2010
2048
|
// Project knowledge can be learned without starting or changing a task.
|
|
2011
|
-
if ((params.action === "knowledge" || params.action === "compact") && !params.taskId) {
|
|
2049
|
+
if ((params.action === "knowledge" || params.action === "compact" || params.action === "script") && !params.taskId) {
|
|
2012
2050
|
try {
|
|
2013
|
-
const message = params.action === "knowledge" ? handleKnowledge(undefined, params, deps) : handleCompact(undefined, params, deps);
|
|
2051
|
+
const message = params.action === "knowledge" ? handleKnowledge(undefined, params, deps) : params.action === "script" ? handleScript(undefined, params, deps) : handleCompact(undefined, params, deps);
|
|
2014
2052
|
return { ok: true, taskId: "", state: "created", message };
|
|
2015
2053
|
} catch (error) {
|
|
2016
2054
|
return { ok: false, taskId: "", state: "created", message: `Rejected: ${(error as Error).message}` };
|
|
@@ -2050,6 +2088,11 @@ export async function runWorkflowAction(params: OrchestrateParams, deps: Workflo
|
|
|
2050
2088
|
...(deps.askQuestions ? { askQuestions: (...args: Parameters<NonNullable<WorkflowDeps["askQuestions"]>>) => wait(() => deps.askQuestions!(...args)) } : {}),
|
|
2051
2089
|
// Every agent, diff and desk of a task with its own worktree works there.
|
|
2052
2090
|
cwd: taskCwd(task, deps.cwd),
|
|
2091
|
+
// 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.
|
|
2092
|
+
runProcess: (args, options) => {
|
|
2093
|
+
saveTask(deps.root, deps.configDir, task);
|
|
2094
|
+
return (deps.runProcess ?? spawnPiProcess)(args, options);
|
|
2095
|
+
},
|
|
2053
2096
|
onUpdate: (run) => {
|
|
2054
2097
|
if (run.status !== "running") {
|
|
2055
2098
|
finished.set(run.runId, run);
|