@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.
package/README.md CHANGED
@@ -23,8 +23,8 @@ The questionnaire and web tools are built in, so don't also install extensions w
23
23
  A plan opens with a short list of steps; the detail of each follows for whoever wants it.
24
24
  Plans you leave without saving them as a task wait under **Plan → Previous**. You can read, carry on,
25
25
  archive or delete them there. A plan saved as a task leaves that list.
26
- 3. Follow the agents in **Lobby**: the step being worked on reads `active · 1m 12s`, and each task shows
27
- how long its agents have worked on it (idle time left out). Models, effort and time limits are in **Settings**.
26
+ 3. Follow the agents in **Lobby** and **Tasks**: in a task's steps, the one being worked on reads
27
+ `active · 1m 12s`, and each task shows how long its agents have worked on it (idle time left out). Models, effort and time limits are in **Settings**.
28
28
 
29
29
  **Issues** lists the repository's open issues (with `lobby.issues` on), and **Plan it** starts a
30
30
  planning session from one. **Git** lists open pull requests; an agent reviews one on request,
@@ -36,6 +36,12 @@ you to confirm merging and pushing main, then removes its worktree after verifyi
36
36
  Declining or failed verification keeps the worktree. Delivery can also be reviewed in **Tasks**,
37
37
  where you can create a PR instead. The task branch is kept.
38
38
 
39
+ Workers never commit; the oracle does. When a task completes, the oracle writes a short commit
40
+ message (a first line of 72 characters or fewer, at most three lines below it). The engine then
41
+ commits only the files that task's workers changed. A quick fix commits its own files when it
42
+ finishes, under the one-line message its agent gives. Changes that were already there before, another
43
+ agent's and anything no agent made are left uncommitted.
44
+
39
45
  New tasks get a short title summarizing the full request, which also names their branch and
40
46
  session. Naming uses low reasoning on `classifier.effort.cheapModel` when configured, otherwise
41
47
  the session model. If naming fails or takes more than eight seconds, the request's first content
@@ -106,6 +112,18 @@ first, searches the codebase for missing information, and records verified findi
106
112
  agent's knowledge. Answers appear in the side pane; the **Chat / Docs** toggle inside the composer
107
113
  switches back to the selected document. Questions do not create tasks.
108
114
 
115
+ ## Scripts
116
+
117
+ Jobs that come back (seeding data, regenerating fixtures, a set of checks before review) are kept
118
+ as scripts in `.pi/bot-lobby/scripts/`, shared by every agent, so a step done once is run the next
119
+ time instead of being worked out again, which saves tokens and time. Each is named for what it
120
+ does, in snake_case with its language's extension (`seed_public_holidays.py`,
121
+ `run_changed_tests.sh`), and its first lines say what it is for and how to run it. The oracle
122
+ decides when a step is worth a script and saves it (`action=script`); workers only propose one, and
123
+ a replaced script is kept under `archive/`. For each step, Jev picks the scripts that do part of it
124
+ and the agent is told to run them (**Settings → Classifier → Shared scripts**; with Jev off, scripts
125
+ whose names share words with the step are suggested).
126
+
109
127
  ## Keys
110
128
 
111
129
  The lobby is keyboard-first, and every action button shows its key (`Archive E`, `Delete Del`).
@@ -147,9 +165,10 @@ including startup, tools, retries, children, compaction and reporting. Per-step
147
165
  allowances persist across reassignments; only the user can extend them, including
148
166
  in auto mode. Waiting for user answers pauses clocks.
149
167
 
150
- The conversation pane groups running agents beneath their current step, for example
151
- **Step 6 – Implement large feature (6 devs working)**. Queued and finished agents
152
- are excluded. A child finishing does not complete a checklist step.
168
+ In **Tasks**, a step being worked on shows how many agents are on it, for example
169
+ **Implement large feature (6 agents working)**. Click the count to see which agent is
170
+ doing what, with children under their parent. Queued and finished agents are left out,
171
+ and a child finishing does not complete a checklist step.
153
172
 
154
173
  The oracle, agents and children compact at settled turn boundaries before context
155
174
  reaches 64,000 estimated tokens, or earlier at 60% of the model window or Pi's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.37",
3
+ "version": "0.6.39",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/prompts/master.md CHANGED
@@ -319,8 +319,8 @@ the task), or a worktree and branch of its own, and every agent works there.
319
319
  Look at the task's files under the worktree path the line names, run git
320
320
  there with `git -C "<path>"`, and never edit files outside it: the agents
321
321
  already do. Uncommitted changes in the main checkout are not in it.
322
- - Before `action=complete`, commit the verified task changes on its branch.
323
- Stage only this task's files; leave unrelated changes alone. The engine asks
322
+ - `action=complete` commits the task's changes on its branch (see
323
+ Completion); leave unrelated changes alone. The engine asks
324
324
  the user to confirm merging and pushing main and removing the task worktree.
325
325
  Let it handle delivery; never merge or remove a worktree yourself.
326
326
  - Without a `Git:` line, work as before.
@@ -355,6 +355,31 @@ the relevant knowledge files: omit `taskId` to update project knowledge without
355
355
  creating or advancing a task. Answer from saved knowledge first, cite code
356
356
  evidence for newly learned facts, and never record a guess.
357
357
 
358
+ ## Scripts
359
+
360
+ The project keeps a shared scripts library that every agent can run; your
361
+ prompt lists it under Scripts, and each agent is shown the scripts that fit its
362
+ step. A script saves the tokens and time of working the same job out again, so
363
+ you decide when work becomes one.
364
+
365
+ - Make a script of a job that will come back in much the same form: seeding or
366
+ resetting data, regenerating fixtures or generated files, a migration run per
367
+ environment, a set of checks run before every review, a scaffold. Not of a
368
+ one-off change, and not of a single command anyone would type.
369
+ - When a step is such a job, say so in its brief: have the worker write the
370
+ step as a script, drafted in the task's directory under `scripts/`, and run
371
+ it. Then save it with `action=script`, `name`, `purpose`, `usage` and
372
+ `file` (the draft), or `text` (the script itself). Read it first: it runs
373
+ for every agent from then on.
374
+ - Name it for what it does, in snake_case, with its language's extension:
375
+ `seed_public_holidays.py`, `run_changed_tests.sh`. Prefer the project's own
376
+ language and runtime. Arguments over hard-coded values, so it serves the
377
+ next case too; and it says what it did, and fails loudly.
378
+ - Before you brief a step, look for a script that does it, and name it in the
379
+ brief. To improve one, save it again under the same name; the old version is
380
+ archived.
381
+ - Workers may propose scripts; take one only when it meets the bar above.
382
+
358
383
  ## Review
359
384
 
360
385
  The repository state is the source of truth; do not blindly trust Scout or
@@ -408,6 +433,17 @@ fast track: QA has taken part when it is on the roster), critical blockers are
408
433
  resolved, and relevant knowledge and decisions are recorded — never just
409
434
  because a Worker says it is done.
410
435
 
436
+ You commit your agents' work, not they: workers never commit. Call
437
+ `action=complete` with `message`, a commit message you write from what was
438
+ built, and the engine commits exactly the files this task's workers changed
439
+ (never ones that held changes before they started, a quick fix's alone, or
440
+ another task's) before calling the task done. Keep it concise: a first line of
441
+ 72 characters or fewer saying what changed, in the imperative ("Add login
442
+ rate limit"), and at most three short lines below it when the why is not
443
+ obvious. No file lists, step-by-step narration, test counts or sign-offs. A
444
+ refused commit (a hook failing, say) leaves the task open: fix the cause and
445
+ complete again.
446
+
411
447
  For an isolated task, `action=complete` automatically reviews delivery and
412
448
  asks the user before merging to main and removing its worktree, even in auto
413
449
  mode. A decline, missing answer or failed verification preserves the worktree
@@ -58,3 +58,8 @@ One or two sentences on what changed.
58
58
 
59
59
  ## Checked
60
60
  What you ran and the result, or "not checked" with the reason.
61
+
62
+ ## Commit
63
+ The commit message for your change: one line of 72 characters or fewer
64
+ saying what changed, in the imperative ("Fix the login button's focus ring").
65
+ The engine commits your files with it; do not commit, stage or stash yourself.
package/prompts/worker.md CHANGED
@@ -99,6 +99,7 @@ repository at the same time, and files are checked out like physical documents:
99
99
  nothing else.
100
100
  - Verify tests.
101
101
  - Update the temporary task scratchpad.
102
+ - Do not commit, stage or stash: the oracle commits the task's work.
102
103
  - Report concise results.
103
104
 
104
105
  ## Pushback
@@ -113,3 +114,14 @@ then add a `## Pushback` block to your output:
113
114
 
114
115
  The engine records the pushback and blocks that domain until the Master
115
116
  resolves it, so be specific and keep working on the rest of the task.
117
+
118
+ ## Shared scripts
119
+
120
+ Your prompt's Scripts section lists the shared scripts that fit your step. Run
121
+ one instead of doing its work by hand, and check its output as you would your
122
+ own; a script that does not do what it says is a finding for `## Notes`. Do not
123
+ edit the library yourself. When your brief asks you to write a step as a
124
+ script, or you wrote one for a job that will clearly come back, draft it in the
125
+ folder your Workflow Context names for script drafts, named for what it does in snake_case
126
+ (`seed_public_holidays.py`), run it, and propose it under `## Script
127
+ Proposals`.
@@ -9,6 +9,8 @@ import { appendMetrics } from "../state/metrics.ts";
9
9
  import { detectProjectRoot, loadConfig } from "../state/project.ts";
10
10
  import { isSubagentProcess } from "../pi/quiet.ts";
11
11
  import { Classifier } from "./classifier.ts";
12
+ import { scriptPicker } from "./scripts.ts";
13
+ import type { ScriptPicker } from "../scripts/pick.ts";
12
14
  import { registerJevProvider, type KeySource, type KeyStatus } from "./hosts.ts";
13
15
  import { fileHinter, type FileHinter, type FileScope } from "./files.ts";
14
16
  import { knowledgePicker, type KnowledgePicker } from "./knowledge.ts";
@@ -46,9 +48,15 @@ export function hintsFor(scope: FileScope): FileHinter {
46
48
  return fileHinter(classifier(), scope, (text) => lobbyFeed.log("CLASSIFIER", text, "info"));
47
49
  }
48
50
 
49
- /** Relevant knowledge for agents, with what Jev kept of each long file logged to the lobby's activity feed. */
51
+ /** Relevant knowledge and shared scripts for agents, with what Jev kept and matched logged to the lobby's activity feed. */
50
52
  export function knowledgeFor(): KnowledgePicker {
51
- return knowledgePicker(classifier(), (text) => lobbyFeed.log("CLASSIFIER", text, "info"));
53
+ const log = (text: string) => lobbyFeed.log("CLASSIFIER", text, "info");
54
+ return { ...knowledgePicker(classifier(), log), scripts: scriptPicker(classifier(), log) };
55
+ }
56
+
57
+ /** Shared scripts for a quick fix, with what Jev matched logged to the lobby's activity feed. */
58
+ export function scriptsPickerFor(): ScriptPicker {
59
+ return scriptPicker(classifier(), (text) => lobbyFeed.log("CLASSIFIER", text, "info"));
52
60
  }
53
61
 
54
62
  /** Triage a request for a task in a tree, logged to the lobby's activity feed; undefined when triage is off or fails. */
@@ -16,6 +16,7 @@ import { score, selectRelevant, splitSections, tokenize, type KnowledgeInput, ty
16
16
  import type { Classifier } from "./classifier.ts";
17
17
  import { noul, yesOf, type SystemOneRequest } from "./client.ts";
18
18
  import { clip } from "./limits.ts";
19
+ import type { ScriptPicker } from "../scripts/pick.ts";
19
20
 
20
21
  /** How many characters of each knowledge file a prompt has room for. */
21
22
  export const KNOWLEDGE_BUDGET_CHARS = 4000;
@@ -27,7 +28,7 @@ export const MAX_SECTIONS = 60;
27
28
  export const KNOWLEDGE_BUDGET_MS = 1500;
28
29
 
29
30
  /** The three files a prompt carries: `knowledge`, `standards` and `decisions`. */
30
- export type KnowledgeSlice = keyof KnowledgeSelection;
31
+ export type KnowledgeSlice = Exclude<keyof KnowledgeSelection, "scripts">;
31
32
  export type KnowledgePaths = Partial<Record<KnowledgeSlice, string>>;
32
33
 
33
34
  export interface SectionCall {
@@ -100,9 +101,11 @@ export function leftOutNote(kept: Kept, name: string, path?: string): string {
100
101
  : `_Kept ${kept.kept} of ${kept.total} sections of ${name} for this step.${where}_`;
101
102
  }
102
103
 
103
- /** Picks what an agent's prompt carries of its knowledge files. */
104
+ /** Picks what an agent's prompt carries of its knowledge files, and of the shared scripts. */
104
105
  export interface KnowledgePicker {
105
106
  select(task: string, files: KnowledgeInput, options?: { signal?: AbortSignal; paths?: KnowledgePaths }): Promise<KnowledgeSelection>;
107
+ /** Which shared scripts fit a step; shared words when absent. */
108
+ scripts?: ScriptPicker;
106
109
  }
107
110
 
108
111
  const SLICES: readonly KnowledgeSlice[] = ["knowledge", "standards", "decisions"];
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared scripts for a step. Jev reads each script's name and purpose against
3
+ * the step (one yes/no per script) and the ones that do part of it are put in
4
+ * the agent's prompt with how to run them, so the agent runs the script rather
5
+ * than working the job out again. A library longer than Jev is asked about is
6
+ * narrowed by shared words first. Off, failed or out of time, shared words
7
+ * decide (`scripts/pick.ts`).
8
+ */
9
+ import type { Classifier } from "./classifier.ts";
10
+ import { noul, yesOf, type SystemOneRequest } from "./client.ts";
11
+ import { clip } from "./limits.ts";
12
+ import { score, tokenize } from "../knowledge/selector.ts";
13
+ import { keywordScripts, MAX_SUGGESTED, type ScriptPicker } from "../scripts/pick.ts";
14
+ import { nameWords, type ScriptEntry } from "../scripts/library.ts";
15
+
16
+ /** Scripts Jev is asked about for one step. */
17
+ export const MAX_CANDIDATES = 40;
18
+ /** The step waits this long for Jev, then goes with shared words. */
19
+ export const SCRIPTS_BUDGET_MS = 1500;
20
+
21
+ export interface ScriptCall {
22
+ request: SystemOneRequest;
23
+ /** The script each question key stands for. */
24
+ keys: Map<string, ScriptEntry>;
25
+ }
26
+
27
+ /** The scripts to ask about: all of them, or the `MAX_CANDIDATES` that share the most words with the step. */
28
+ export function candidateScripts(step: string, entries: readonly ScriptEntry[]): ScriptEntry[] {
29
+ if (entries.length <= MAX_CANDIDATES) return [...entries];
30
+ const tokens = tokenize(step);
31
+ return entries
32
+ .map((entry, index) => ({ entry, index, hits: score(`${nameWords(entry.name)} ${entry.purpose}`, tokens) }))
33
+ .sort((a, b) => b.hits - a.hits || a.index - b.index)
34
+ .slice(0, MAX_CANDIDATES)
35
+ .map((scored) => scored.entry);
36
+ }
37
+
38
+ /** One request that asks, for each script, whether it does part of the step. */
39
+ export function scriptRequest(step: string, entries: readonly ScriptEntry[]): ScriptCall {
40
+ const scripts: Record<string, string> = {};
41
+ const questions: SystemOneRequest["questions"] = {};
42
+ const keys = new Map<string, ScriptEntry>();
43
+ entries.forEach((entry, position) => {
44
+ const key = `k${position + 1}`;
45
+ scripts[key] = clip(`${entry.name}: ${entry.purpose}`, 300);
46
+ questions[key] = noul(`Does the script \`scripts.${key}\` (its file name and purpose) do all or part of \`task\`, so an agent doing the task should run it instead of doing that work by hand? Yes when running it is a step the task needs; no when it is about something else or only shares words with it.`);
47
+ keys.set(key, entry);
48
+ });
49
+ return { request: { state: { task: clip(step, 3000), scripts }, questions }, keys };
50
+ }
51
+
52
+ /**
53
+ * A picker on Jev: the scripts it judges to do part of the step, most likely
54
+ * first. Shared words decide when it is off or cannot judge.
55
+ */
56
+ export function scriptPicker(classifier: Classifier, log?: (text: string) => void): ScriptPicker {
57
+ return {
58
+ async pick(step, entries, options = {}) {
59
+ if (entries.length === 0) return [];
60
+ if (!step.trim() || !classifier.enabled("scripts")) return keywordScripts(step, entries);
61
+ const call = scriptRequest(step, candidateScripts(step, entries));
62
+ const result = await classifier.ask("scripts", call.request, { ...(options.signal ? { signal: options.signal } : {}), timeoutMs: Math.min(classifier.config.timeoutMs, SCRIPTS_BUDGET_MS) });
63
+ if (!result) return keywordScripts(step, entries);
64
+ const judged = [...call.keys].map(([key, entry]) => ({ entry, yes: yesOf(result.answers, key) }));
65
+ // An answer to none of the questions says nothing: shared words decide.
66
+ if (judged.every((entry) => entry.yes === undefined)) return keywordScripts(step, entries);
67
+ const picked = judged
68
+ .filter((entry) => (entry.yes ?? 0) >= classifier.config.thresholds.scriptRelevantAt)
69
+ .sort((a, b) => b.yes! - a.yes!)
70
+ .slice(0, MAX_SUGGESTED)
71
+ .map((entry) => entry.entry);
72
+ log?.(`scripts: ${picked.length > 0 ? picked.map((entry) => entry.name).join(", ") : "none"} fit the step (of ${entries.length}) · ${result.ms} ms`);
73
+ return picked;
74
+ },
75
+ };
76
+ }
@@ -18,6 +18,8 @@ export interface AgentContext {
18
18
  standards?: string;
19
19
  knowledge?: string;
20
20
  decisions?: string;
21
+ /** The shared scripts that fit the step. */
22
+ scripts?: string;
21
23
  /** Per-agent custom instructions from the global config. */
22
24
  instructions?: string;
23
25
  workflowContext?: string;
@@ -214,3 +214,66 @@ export async function addedLines(top: string, base: string | undefined, files: r
214
214
  return undefined;
215
215
  }
216
216
  }
217
+
218
+ /** Longest first line a commit message may have. */
219
+ export const MAX_COMMIT_SUBJECT = 72;
220
+ /** Most lines a commit message may add below its first. */
221
+ export const MAX_COMMIT_BODY_LINES = 3;
222
+
223
+ /**
224
+ * A commit message kept short: one first line of at most
225
+ * `MAX_COMMIT_SUBJECT` characters, then, after a blank line, at most
226
+ * `MAX_COMMIT_BODY_LINES` short lines. Throws saying what is too long.
227
+ */
228
+ export function commitMessage(text: string): string {
229
+ const lines = text.replace(/\r\n?/g, "\n").trim().split("\n").map((line) => line.trimEnd());
230
+ const subject = lines[0]?.trim() ?? "";
231
+ if (!subject) throw new Error("the commit message is empty");
232
+ if (subject.length > MAX_COMMIT_SUBJECT) throw new Error(`the commit message's first line is ${subject.length} characters; keep it to ${MAX_COMMIT_SUBJECT} or fewer, saying what changed`);
233
+ const body = lines.slice(1).filter((line) => line.trim().length > 0);
234
+ if (body.length > MAX_COMMIT_BODY_LINES) throw new Error(`the commit message has ${body.length} lines below its first; keep it to ${MAX_COMMIT_BODY_LINES} or fewer`);
235
+ if (body.some((line) => line.trim().length > MAX_COMMIT_SUBJECT)) throw new Error(`keep each line of the commit message to ${MAX_COMMIT_SUBJECT} characters or fewer`);
236
+ return body.length > 0 ? `${subject}\n\n${body.map((line) => line.trim()).join("\n")}` : subject;
237
+ }
238
+
239
+ /** `git status` of `files` (relative to `top`): each changed path with its two-letter code, a rename's old path beside its new one. */
240
+ async function statusOf(top: string, files: readonly string[]): Promise<Array<{ path: string; code: string }>> {
241
+ if (files.length === 0) return [];
242
+ const { stdout } = await run("git", ["status", "--porcelain", "-z", "--untracked-files=all", "--", ...files], { cwd: top, maxBuffer: MAX_BUFFER });
243
+ const entries = stdout.split("\0");
244
+ const out: Array<{ path: string; code: string }> = [];
245
+ for (let index = 0; index < entries.length; index += 1) {
246
+ const entry = entries[index]!;
247
+ if (entry.length < 4) continue;
248
+ const code = entry.slice(0, 2);
249
+ out.push({ path: entry.slice(3), code });
250
+ if (/[RC]/.test(code) && entries[index + 1]) out.push({ path: entries[++index]!, code: "D " });
251
+ }
252
+ return out;
253
+ }
254
+
255
+ /** Which of `files` (relative to `top`) have changes git has not committed, with a rename's old path beside its new one. */
256
+ export async function uncommitted(top: string, files: readonly string[]): Promise<string[]> {
257
+ return [...new Set((await statusOf(top, files)).map((entry) => entry.path))];
258
+ }
259
+
260
+ /**
261
+ * Commit `files` (relative to `top`) as they stand, and nothing else: what
262
+ * else is staged stays staged. The commit, or undefined when none of them
263
+ * has anything to commit. Throws with git's reason when it refuses (a hook).
264
+ */
265
+ export async function commitFiles(top: string, files: readonly string[], message: string): Promise<{ commit: string; files: string[] } | undefined> {
266
+ const status = await statusOf(top, files);
267
+ const dirty = [...new Set(status.map((entry) => entry.path))];
268
+ if (dirty.length === 0) return undefined;
269
+ // Only what the working tree holds beyond the index needs adding; a staged deletion is no longer there to add.
270
+ const unstaged = [...new Set(status.filter((entry) => entry.code[1] !== " ").map((entry) => entry.path))];
271
+ try {
272
+ if (unstaged.length > 0) await run("git", ["add", "-A", "--", ...unstaged], { cwd: top, maxBuffer: MAX_BUFFER });
273
+ await run("git", ["commit", "-q", "-m", message, "--", ...dirty], { cwd: top, maxBuffer: MAX_BUFFER });
274
+ } catch (error) {
275
+ const failure = error as { stderr?: string; stdout?: string; message: string };
276
+ throw new Error(truncate((failure.stderr || failure.stdout || failure.message).trim(), 600));
277
+ }
278
+ return { commit: await git(top, ["rev-parse", "HEAD"]), files: dirty };
279
+ }
@@ -9,6 +9,8 @@ export interface KnowledgeSelection {
9
9
  knowledge: string;
10
10
  standards: string;
11
11
  decisions: string;
12
+ /** The shared scripts that fit the step and how to run them (see `scripts/pick.ts`). */
13
+ scripts?: string;
12
14
  }
13
15
 
14
16
  export interface KnowledgeInput {
@@ -21,8 +21,10 @@ import { fellShort, profileLabel, routeLabel, type EffortRoute, type EffortRoute
21
21
  import { existsSync } from "node:fs";
22
22
  import { isAbsolute, join, relative } from "node:path";
23
23
  import type { LintConfig } from "../schemas/configuration.ts";
24
- import { repositoryTop } from "../execution/git.ts";
24
+ import { changedFiles, commitFiles, commitMessage, MAX_COMMIT_SUBJECT, repositoryTop } from "../execution/git.ts";
25
25
  import type { LintExec } from "../execution/lint.ts";
26
+ import { scriptsFor, type ScriptPicker } from "../scripts/pick.ts";
27
+ import { dataRoot } from "../state/project.ts";
26
28
  import { lintFeed, runLintGate } from "../workflow/lint.ts";
27
29
 
28
30
  /** A quick fix edits code, so it gets the full coding tool set. */
@@ -70,6 +72,10 @@ export interface QuickFixJob {
70
72
  routed?: boolean;
71
73
  /** An earlier run was cut off when pi stopped: some of its edits may already be in the tree. */
72
74
  interrupted?: boolean;
75
+ /** Files (relative to the repository) that had uncommitted changes before its first run: never its to commit. */
76
+ before?: string[];
77
+ /** The commit its changes went in, and its message's first line. */
78
+ commit?: { sha: string; subject: string };
73
79
  }
74
80
 
75
81
  /** How a job enters the queue besides its prompt. */
@@ -105,6 +111,8 @@ export interface QuickFixDeps {
105
111
  notify?: (message: string, level: "info" | "warning" | "error") => void;
106
112
  /** Likely files for the prompt, and the lookup tool, while the classifier's file hints are on. */
107
113
  hints?: FileHinter;
114
+ /** Which shared scripts fit the request; shared words when absent. */
115
+ scripts?: ScriptPicker;
108
116
  /** Holds a request it judges large (a task, not a quick fix) instead of spending a run on it. */
109
117
  classifier?: Classifier;
110
118
  /** Lowers thinking or the model for a request the classifier judges simple or trivial. */
@@ -132,6 +140,24 @@ export function quickFixPrompt(instructions?: string): string {
132
140
  return custom ? `${loadPrompt("quickfix.md")}\n\n## Custom Instructions\n\n${custom}` : loadPrompt("quickfix.md");
133
141
  }
134
142
 
143
+ /**
144
+ * The commit message a quick fix's report gives under `## Commit`, kept short
145
+ * (see `commitMessage`); the request's first line, clipped, when it gives none
146
+ * or one too long.
147
+ */
148
+ export function quickFixCommitMessage(job: Pick<QuickFixJob, "prompt" | "report">): string {
149
+ const section = /^## Commit[^\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m.exec(job.report ?? "")?.[1]?.replace(/^\s*```[^\n]*\n?|```\s*$/g, "").trim();
150
+ if (section) {
151
+ try {
152
+ return commitMessage(section);
153
+ } catch {
154
+ // Too long or empty: the request names it instead.
155
+ }
156
+ }
157
+ const title = jobTitle(job).replace(/\s+/g, " ");
158
+ return title.length > MAX_COMMIT_SUBJECT ? `${title.slice(0, MAX_COMMIT_SUBJECT - 1).trimEnd()}…` : title;
159
+ }
160
+
135
161
  /** First line of a prompt, for lists. */
136
162
  export function jobTitle(job: Pick<QuickFixJob, "prompt">): string {
137
163
  return job.prompt.split("\n").find((line) => line.trim())?.trim() ?? "(empty)";
@@ -297,13 +323,19 @@ export class QuickFixQueue {
297
323
  }
298
324
  this.deps.feed?.log(QUICK_FIX_SOURCE, `started: ${jobTitle(job)}`, "info", job.startedAt);
299
325
  this.edits.set(job.id, new EditLog(this.deps.cwd));
326
+ // What was uncommitted before it ran is someone else's; a run cut off and resumed keeps its first look.
327
+ job.before ??= (await changedFiles(this.deps.cwd))?.files ?? [];
300
328
  try {
301
- const [likely, route] = await Promise.all([this.likely(job.prompt, controller.signal), this.route(job.prompt, profile, controller.signal)]);
329
+ const [likely, route, scripts] = await Promise.all([
330
+ this.likely(job.prompt, controller.signal),
331
+ this.route(job.prompt, profile, controller.signal),
332
+ scriptsFor(dataRoot(this.deps.root, this.deps.configDir), job.prompt, this.deps.scripts, controller.signal),
333
+ ]);
302
334
  const quota = { used: MAX_CHILDREN };
303
335
  const attempt = (model: string | undefined, thinking: string) => runPiAgent(
304
336
  {
305
337
  cwd: this.deps.cwd,
306
- task: [job.prompt, job.interrupted ? INTERRUPTED_NOTE : "", likely].filter(Boolean).join("\n\n"),
338
+ task: [job.prompt, job.interrupted ? INTERRUPTED_NOTE : "", likely, scripts ? `## Scripts\n\n${scripts}` : ""].filter(Boolean).join("\n\n"),
307
339
  systemPrompt: quickFixPrompt(profile.instructions),
308
340
  tools: [...QUICK_FIX_TOOLS, ...(this.deps.hints?.tools() ?? [])],
309
341
  ...grantOption("quickfix"),
@@ -355,6 +387,12 @@ export class QuickFixQueue {
355
387
  job.error = result.error;
356
388
  job.usage = result.usage;
357
389
  if (result.model) job.model = result.model;
390
+ const edited = this.edits.get(job.id)?.list() ?? [];
391
+ if (job.status === "success" && edited.length > 0) {
392
+ // Lint reads what changed since HEAD, so it goes before the commit.
393
+ await this.lintAfter(job, edited);
394
+ await this.commitAfter(job, edited);
395
+ }
358
396
  } catch (error) {
359
397
  job.status = "failed";
360
398
  job.error = (error as Error).message;
@@ -432,11 +470,34 @@ export class QuickFixQueue {
432
470
  this.deps.feed?.log(QUICK_FIX_SOURCE, `${outcome}: ${jobTitle(job)}`, ok ? "success" : job.status === "cancelled" ? "warning" : "error", job.finishedAt);
433
471
  appendMetrics(this.deps.root, this.deps.configDir, [quickFixMetric(job)]);
434
472
  this.deps.notify?.(`bot-lobby quick fix ${outcome}: ${jobTitle(job)}`, ok ? "info" : "warning");
435
- if (ok && edited.length > 0) void this.lintAfter(job, edited);
436
473
  this.changed();
437
474
  this.pump();
438
475
  }
439
476
 
477
+ /**
478
+ * Commit what a finished quick fix edited, with the message its report
479
+ * gives: only its own files, never one that held uncommitted changes before
480
+ * it ran. A refused commit is a line in the activity log; the fix stands.
481
+ */
482
+ private async commitAfter(job: QuickFixJob, edited: readonly string[]): Promise<void> {
483
+ const top = await repositoryTop(this.deps.cwd);
484
+ if (!top) return;
485
+ const before = new Set(job.before ?? []);
486
+ const files = edited.map((file) => relative(top, file).split("\\").join("/")).filter((file) => file && !file.startsWith("..") && !isAbsolute(file) && !before.has(file));
487
+ const own = relative(top, dataRoot(this.deps.root, this.deps.configDir)).split("\\").join("/");
488
+ const ours = own && !own.startsWith("..") ? files.filter((file) => !file.startsWith(`${own}/`)) : files;
489
+ if (ours.length === 0) return;
490
+ const message = quickFixCommitMessage(job);
491
+ try {
492
+ const commit = await commitFiles(top, ours, message);
493
+ if (!commit) return;
494
+ job.commit = { sha: commit.commit, subject: message.split("\n")[0]! };
495
+ this.deps.feed?.log(QUICK_FIX_SOURCE, `committed ${commit.commit.slice(0, 7)}: ${job.commit.subject}`, "info");
496
+ } catch (error) {
497
+ this.deps.feed?.log(QUICK_FIX_SOURCE, `not committed: ${(error as Error).message.split("\n")[0]}`, "warning");
498
+ }
499
+ }
500
+
440
501
  /**
441
502
  * The lint gate's pass over what a quick fix touched (only problems on the
442
503
  * lines it changed are its own): a line in the activity log, never a hold,
@@ -18,7 +18,7 @@ import { modelLookup } from "../pi/tools.ts";
18
18
  import { setQuickFixHandoff } from "../pi/route.ts";
19
19
  import type { Domain } from "../schemas/agent.ts";
20
20
  import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
21
- import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
21
+ import { classifier, effortFor, hintsFor, scriptsPickerFor } from "../classifier/instance.ts";
22
22
  import { checkThinking } from "../pi/model-support.ts";
23
23
  import { QuickFixQueue } from "./quickfix.ts";
24
24
  import type { Asker } from "../ask/types.ts";
@@ -192,6 +192,7 @@ export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, confi
192
192
  },
193
193
  notify: (message, level) => lobbyNotify(state, message, level),
194
194
  hints: hintsFor({ cwd: ctx.cwd, root, configDir }),
195
+ scripts: scriptsPickerFor(),
195
196
  classifier: classifier(),
196
197
  effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
197
198
  lint: () => loadConfig().lint,
@@ -7,7 +7,9 @@ import { domainSpec } from "../agents/registry.ts";
7
7
  import { boundedAgentTime, runAgent, runParallel, watchdogOptions, type AgentRequest, type AgentTime } from "../execution/agent-runner.ts";
8
8
  import { spawnPiProcess, type ProcessRunner, type RelayAsk } from "../execution/pi-runner.ts";
9
9
  import { knowledgeFilePath, readAgentKnowledge, writeFileEnsured } from "../knowledge/store.ts";
10
+ import { taskDir } from "../knowledge/paths.ts";
10
11
  import { selectKnowledge, type KnowledgeSelection } from "../knowledge/selector.ts";
12
+ import { scriptsFor } from "../scripts/pick.ts";
11
13
  import { summarizeOutcomes } from "./synthesis.ts";
12
14
  import { parseWorkerResult, validateWorkerResult } from "../roles/worker.ts";
13
15
  import { parseReviewResult, validateReviewResult } from "../roles/reviewer.ts";
@@ -73,9 +75,14 @@ function knowledgePaths(dataRoots: readonly string[], agent: Domain): KnowledgeP
73
75
  /**
74
76
  * What an agent's prompt carries of its knowledge for a step: Jev's picks from
75
77
  * the files that are too long when a picker is given, the keyword selection
76
- * otherwise and whenever the picker fails.
78
+ * otherwise and whenever the picker fails; and the shared scripts that fit it.
77
79
  */
78
80
  async function pickKnowledge(request: { knowledge?: KnowledgePicker; dataRoots: readonly string[]; signal?: AbortSignal }, agent: Domain, query: string): Promise<KnowledgeSelection> {
81
+ const [selected, scripts] = await Promise.all([pickFiles(request, agent, query), scriptsFor(request.dataRoots[0], query, request.knowledge?.scripts, request.signal)]);
82
+ return scripts ? { ...selected, scripts } : selected;
83
+ }
84
+
85
+ async function pickFiles(request: { knowledge?: KnowledgePicker; dataRoots: readonly string[]; signal?: AbortSignal }, agent: Domain, query: string): Promise<KnowledgeSelection> {
79
86
  const slices = readAgentKnowledge(request.dataRoots, agent);
80
87
  if (!request.knowledge) return selectKnowledge(query, slices);
81
88
  try {
@@ -271,6 +278,7 @@ function workerWorkflowContext(request: WorkerRequest, likely = ""): string {
271
278
  request.risk ?? "",
272
279
  request.time?.note ?? "",
273
280
  request.ask ? askGuidance(request.ask.previews) : "",
281
+ request.dataRoots[0] ? `Scripts you draft for the shared library go in \`${join(taskDir(request.dataRoots[0], request.taskId), "scripts")}\`.` : "",
274
282
  ].filter(Boolean).join("\n\n");
275
283
  }
276
284
 
@@ -120,6 +120,8 @@ function orchestrateStep(args: unknown): string {
120
120
  return "recording knowledge";
121
121
  case "compact":
122
122
  return `compacting ${text("file") ?? "knowledge"}`;
123
+ case "script":
124
+ return `saving the shared script ${text("name") ?? ""}`.trimEnd();
123
125
  case "resolve_approval":
124
126
  return `${text("decision") === "rejected" ? "rejecting" : "approving"} ${text("approvalId") ?? "a request"}`;
125
127
  case "complete":
package/src/pi/events.ts CHANGED
@@ -4,6 +4,7 @@ import { detectProjectRoot, loadConfig, readDataRoots } from "../state/project.t
4
4
  import { compilePrompt } from "../prompts/compiler.ts";
5
5
  import { readAgentKnowledge } from "../knowledge/store.ts";
6
6
  import { selectKnowledge } from "../knowledge/selector.ts";
7
+ import { libraryBlock } from "../scripts/pick.ts";
7
8
  import { cancelAllRuns } from "../execution/agent-runner.ts";
8
9
  import { describeTask } from "../workflow/workflow.ts";
9
10
  import { truncate } from "../text.ts";
@@ -137,7 +138,8 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
137
138
  else delete event.systemPromptOptions.sections["bot-lobby"];
138
139
  return;
139
140
  }
140
- const slices = readAgentKnowledge(readDataRoots(root, configDir), "master");
141
+ const dataRoots = readDataRoots(root, configDir);
142
+ const slices = readAgentKnowledge(dataRoots, "master");
141
143
  const selected = selectKnowledge(`${taskRequest(task)} ${task.proposal ?? ""}`, slices);
142
144
  event.systemPromptOptions.sections["bot-lobby"] = compilePrompt({
143
145
  domain: "master",
@@ -145,6 +147,7 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
145
147
  standards: selected.standards,
146
148
  knowledge: selected.knowledge,
147
149
  decisions: selected.decisions,
150
+ scripts: dataRoots[0] ? libraryBlock(dataRoots[0]) : "",
148
151
  workflowContext: masterWorkflowContext(task, budgetContext(root, configDir, task)),
149
152
  instructions: loadConfig().master.instructions,
150
153
  });
@@ -438,7 +438,8 @@ export function stepWork(steps: readonly string[], runs: readonly AgentRun[], no
438
438
  const ms = Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : 0;
439
439
  for (const index of covered) {
440
440
  if (!run.parentRunId) work[index]!.ms += ms;
441
- if (run.status === "running") {
441
+ // A run that has finished is off the step, whatever its last status said.
442
+ if (run.status === "running" && !run.finishedAt) {
442
443
  work[index]!.active = true;
443
444
  work[index]!.activeAgentCount += 1;
444
445
  work[index]!.runIds.push(run.runId);
package/src/pi/tools.ts CHANGED
@@ -59,9 +59,12 @@ const OrchestrateSchema = Type.Object({
59
59
  StringEnum(["fast", "full"] as const, { description: "track: fast (straight to the roster's agents, no scouts, proposal or plan; QA only for tests) or full (the whole workflow)" }),
60
60
  ),
61
61
  roster: Type.Optional(Type.Array(Type.String(), { description: "track: who takes part — any of designer, backend, qa, researcher" })),
62
- text: Type.Optional(Type.String({ description: "decide/complete/knowledge: the decision, completion summary, or knowledge text" })),
63
- file: Type.Optional(Type.String({ description: "compact: the knowledge file to rewrite, e.g. knowledge.md" })),
64
- name: Type.Optional(Type.String({ description: "whiteboard: name for the new Excalidraw session" })),
62
+ text: Type.Optional(Type.String({ description: "decide/complete/knowledge/script: the decision, completion summary, knowledge text, or the script itself" })),
63
+ message: Type.Optional(Type.String({ description: "complete: the commit message for the agents' changes, which the engine commits (only their files): a first line of 72 characters or fewer saying what changed, at most three short lines below it" })),
64
+ file: Type.Optional(Type.String({ description: "compact: the knowledge file to rewrite, e.g. knowledge.md; script: a script a worker drafted, to save instead of text" })),
65
+ name: Type.Optional(Type.String({ description: "whiteboard: name for the new Excalidraw session; script: the file name, what it does in snake_case with its language's extension (seed_public_holidays.py, run_changed_tests.sh)" })),
66
+ purpose: Type.Optional(Type.String({ description: "script: one line on what the script does" })),
67
+ usage: Type.Optional(Type.String({ description: "script: the command that runs it, with its arguments" })),
65
68
  kind: Type.Optional(
66
69
  StringEnum(["knowledge", "standard", "decision", "completed"] as const, {
67
70
  description: "knowledge: which persistent file the text belongs to",
@@ -76,7 +79,7 @@ const DESCRIPTION = [
76
79
  "dependency), propose (record the proposal and request approval), plan (record the internal",
77
80
  "plan, or amend it later with the full revised plan), implement (delegate a step to a domain worker, or several domains in parallel with assignments), qa (final quality gate and the only",
78
81
  "review), knowledge (record approved project knowledge or a decision; works without a task),",
79
- "compact (replace a knowledge file with a rewritten version, archiving the old one; works without a task; disperse domain-relevant facts to action=knowledge (domain=designer|backend|qa) first), whiteboard (create your own Excalidraw session and assign it to yourself),",
82
+ "compact (replace a knowledge file with a rewritten version, archiving the old one; works without a task; disperse domain-relevant facts to action=knowledge (domain=designer|backend|qa) first), script (save a script to the shared scripts library, or replace one, for a job that comes back; works without a task), whiteboard (create your own Excalidraw session and assign it to yourself),",
80
83
  "resolve_approval (approve or reject a request), complete (declare the task done after the gates",
81
84
  "pass, then ask the user to confirm merging and pushing main and removing its worktree), block/resume (escalate or continue), budget (under a time budget: where it stands, or ask the",
82
85
  "user for more minutes with a reason), track (the task's path and who takes part: show it, or correct it with",