@a-t-h-i/bot-lobby 0.6.7 → 0.6.8

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.
Files changed (49) hide show
  1. package/README.md +132 -15
  2. package/package.json +1 -1
  3. package/prompts/master.md +15 -0
  4. package/prompts/panel.md +4 -0
  5. package/prompts/planner.md +5 -0
  6. package/prompts/pr-review.md +62 -0
  7. package/prompts/splitter.md +58 -0
  8. package/src/ask/dialog.ts +19 -5
  9. package/src/ask/state.ts +8 -2
  10. package/src/ask/tool.ts +3 -1
  11. package/src/ask/view.ts +6 -3
  12. package/src/classifier/instance.ts +6 -0
  13. package/src/classifier/knowledge.ts +158 -0
  14. package/src/classifier/review.ts +146 -0
  15. package/src/execution/workspace.ts +185 -0
  16. package/src/knowledge/edit.ts +109 -0
  17. package/src/knowledge/notes.ts +143 -0
  18. package/src/knowledge/selector.ts +1 -1
  19. package/src/knowledge/store.ts +16 -2
  20. package/src/lobby/ask.ts +42 -21
  21. package/src/lobby/issues.ts +1 -1
  22. package/src/lobby/knowledge.ts +179 -0
  23. package/src/lobby/layout.ts +95 -18
  24. package/src/lobby/planner.ts +199 -7
  25. package/src/lobby/pr-review.ts +360 -0
  26. package/src/lobby/pulls.ts +250 -0
  27. package/src/lobby/runtime.ts +115 -13
  28. package/src/lobby/split.ts +230 -0
  29. package/src/lobby/tabs/git.ts +162 -0
  30. package/src/lobby/tabs/knowledge.ts +135 -0
  31. package/src/lobby/tabs/plan.ts +3 -1
  32. package/src/lobby/tabs/tasks.ts +11 -2
  33. package/src/lobby/view.ts +432 -32
  34. package/src/master/master.ts +39 -13
  35. package/src/pi/commands.ts +12 -7
  36. package/src/pi/model-support.ts +9 -0
  37. package/src/pi/plan-checklist.ts +42 -0
  38. package/src/pi/route.ts +2 -1
  39. package/src/pi/settings-ui.ts +41 -1
  40. package/src/pi/start-flags.ts +14 -0
  41. package/src/pi/start-task.ts +56 -5
  42. package/src/pi/tools.ts +4 -2
  43. package/src/schemas/configuration.ts +32 -3
  44. package/src/schemas/task.ts +15 -0
  45. package/src/state/backlog.ts +27 -4
  46. package/src/state/metrics.ts +2 -2
  47. package/src/state/persistence.ts +5 -5
  48. package/src/text.ts +38 -0
  49. package/src/workflow/workflow.ts +11 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Relevant knowledge. An agent's knowledge, standards and decisions are put in
3
+ * its prompt, not looked up, so a gate ("does this step need the knowledge
4
+ * base?") would have to guess before the agent knows what it needs, and a wrong
5
+ * "no" is silent. What Jev does safely instead is the ranking the keyword
6
+ * selector does badly: when a file is longer than the prompt has room for, Jev
7
+ * judges each of its sections against the step (one yes/no per section) and the
8
+ * ones that bear on it stay. A file that fits goes in whole and costs nothing.
9
+ *
10
+ * Whatever is left out is said so, with the file's path, so an agent that does
11
+ * need it reads the rest itself. Any failure, timeout or switch-off keeps the
12
+ * keyword selection, which is what agents got before.
13
+ */
14
+ import { basename } from "node:path";
15
+ import { score, selectRelevant, splitSections, tokenize, type KnowledgeInput, type KnowledgeSelection } from "../knowledge/selector.ts";
16
+ import type { Classifier } from "./classifier.ts";
17
+ import { noul, yesOf, type SystemOneRequest } from "./client.ts";
18
+ import { clip } from "./limits.ts";
19
+
20
+ /** How many characters of each knowledge file a prompt has room for. */
21
+ export const KNOWLEDGE_BUDGET_CHARS = 4000;
22
+ /** A section is judged on its first characters; the whole section is kept when it stays. */
23
+ export const SECTION_EXCERPT_CHARS = 700;
24
+ /** Sections judged per file; a longer file is narrowed by keywords first. */
25
+ export const MAX_SECTIONS = 60;
26
+ /** The step waits this long for Jev, then goes with keywords. */
27
+ export const KNOWLEDGE_BUDGET_MS = 1500;
28
+
29
+ /** The three files a prompt carries: `knowledge`, `standards` and `decisions`. */
30
+ export type KnowledgeSlice = keyof KnowledgeSelection;
31
+ export type KnowledgePaths = Partial<Record<KnowledgeSlice, string>>;
32
+
33
+ export interface SectionCall {
34
+ request: SystemOneRequest;
35
+ /** The section each question key stands for, by index into the file's sections. */
36
+ keys: Map<string, number>;
37
+ }
38
+
39
+ /** One request that asks about each candidate section of a file. */
40
+ export function sectionRequest(task: string, sections: readonly string[], candidates: readonly number[]): SectionCall {
41
+ const entries: Record<string, string> = {};
42
+ const questions: SystemOneRequest["questions"] = {};
43
+ const keys = new Map<string, number>();
44
+ candidates.forEach((index, position) => {
45
+ const key = `s${position + 1}`;
46
+ entries[key] = clip(sections[index]!, SECTION_EXCERPT_CHARS);
47
+ questions[key] = noul(`Does \`sections.${key}\` hold a fact, rule or decision that an agent doing \`task\` should have in front of it? Yes when it bears on the code, files, conventions or choices the task touches; no when it is about something else or only shares words with it.`);
48
+ keys.set(key, index);
49
+ });
50
+ return { request: { state: { task: clip(task, 3000), sections: entries }, questions }, keys };
51
+ }
52
+
53
+ /** The sections to judge: all of them, or the `MAX_SECTIONS` that share the most words with the task. */
54
+ export function candidateSections(task: string, sections: readonly string[]): number[] {
55
+ const all = sections.map((_, index) => index);
56
+ if (sections.length <= MAX_SECTIONS) return all;
57
+ const tokens = tokenize(task);
58
+ const scored = all.map((index) => ({ index, hits: score(sections[index]!, tokens) }));
59
+ scored.sort((a, b) => b.hits - a.hits || b.index - a.index);
60
+ return scored.slice(0, MAX_SECTIONS).map((entry) => entry.index).sort((a, b) => a - b);
61
+ }
62
+
63
+ /** What was kept of a file: its text and how many of its sections that was. */
64
+ export interface Kept {
65
+ text: string;
66
+ kept: number;
67
+ total: number;
68
+ }
69
+
70
+ /**
71
+ * The most relevant sections that fit `budget`, in the order of the file. A
72
+ * section too long to fit on its own is cut to fit when nothing else is kept.
73
+ */
74
+ export function keepSections(sections: readonly string[], relevance: ReadonlyMap<number, number>, floor: number, budget: number): Kept {
75
+ const wanted = [...relevance].filter(([, value]) => value >= floor).sort((a, b) => b[1] - a[1] || a[0] - b[0]);
76
+ const chosen: number[] = [];
77
+ const texts = new Map<number, string>();
78
+ let used = 0;
79
+ for (const [index] of wanted) {
80
+ const text = sections[index]!;
81
+ if (used + text.length <= budget) {
82
+ chosen.push(index);
83
+ texts.set(index, text);
84
+ used += text.length + 2;
85
+ } else if (chosen.length === 0) {
86
+ chosen.push(index);
87
+ texts.set(index, clip(text, budget));
88
+ used = budget;
89
+ }
90
+ }
91
+ chosen.sort((a, b) => a - b);
92
+ return { text: chosen.map((index) => texts.get(index)!).join("\n\n").trim(), kept: chosen.length, total: sections.length };
93
+ }
94
+
95
+ /** The line that tells an agent what it was not given, and where all of it is. */
96
+ export function leftOutNote(kept: Kept, name: string, path?: string): string {
97
+ const where = path ? ` The whole file is ${path}.` : "";
98
+ return kept.kept === 0
99
+ ? `_No section of ${name} bears on this step (${kept.total} sections).${where}_`
100
+ : `_Kept ${kept.kept} of ${kept.total} sections of ${name} for this step.${where}_`;
101
+ }
102
+
103
+ /** Picks what an agent's prompt carries of its knowledge files. */
104
+ export interface KnowledgePicker {
105
+ select(task: string, files: KnowledgeInput, options?: { signal?: AbortSignal; paths?: KnowledgePaths }): Promise<KnowledgeSelection>;
106
+ }
107
+
108
+ const SLICES: readonly KnowledgeSlice[] = ["knowledge", "standards", "decisions"];
109
+
110
+ /**
111
+ * Jev's picks for one file over the budget, or undefined when it cannot judge
112
+ * (off, failed, out of time, no key): the caller then uses keywords.
113
+ */
114
+ async function rank(classifier: Classifier, task: string, content: string, budget: number, options: { signal?: AbortSignal }): Promise<{ kept: Kept; sections: string[]; ms: number } | undefined> {
115
+ const sections = splitSections(content.trim());
116
+ if (sections.length < 2) return undefined;
117
+ const call = sectionRequest(task, sections, candidateSections(task, sections));
118
+ const result = await classifier.ask("knowledge", call.request, { ...(options.signal ? { signal: options.signal } : {}), timeoutMs: Math.min(classifier.config.timeoutMs, KNOWLEDGE_BUDGET_MS) });
119
+ if (!result) return undefined;
120
+ const relevance = new Map<number, number>();
121
+ for (const [key, index] of call.keys) {
122
+ const value = yesOf(result.answers, key);
123
+ if (value !== undefined) relevance.set(index, value);
124
+ }
125
+ // An answer to none of the questions says nothing: keywords decide.
126
+ if (relevance.size === 0) return undefined;
127
+ return { kept: keepSections(sections, relevance, classifier.config.thresholds.knowledgeRelevantAt, budget), sections, ms: result.ms };
128
+ }
129
+
130
+ /**
131
+ * A picker on Jev. Files that fit the prompt are untouched; a longer one keeps
132
+ * the sections Jev judges to bear on the step, with a note of what was left
133
+ * out. Standards are the exception to an empty result: they are rules for all
134
+ * work, so when Jev sees none that applies the keyword selection stands.
135
+ */
136
+ export function knowledgePicker(classifier: Classifier, log?: (text: string) => void, budget = KNOWLEDGE_BUDGET_CHARS): KnowledgePicker {
137
+ return {
138
+ async select(task, files, options = {}) {
139
+ const plain = (kind: KnowledgeSlice) => selectRelevant(task, files[kind] ?? "", budget);
140
+ const picked = await Promise.all(SLICES.map(async (kind): Promise<string> => {
141
+ const content = files[kind] ?? "";
142
+ if (content.trim().length <= budget || !task.trim() || !classifier.enabled("knowledge")) return plain(kind);
143
+ try {
144
+ const ranked = await rank(classifier, task, content, budget, options.signal ? { signal: options.signal } : {});
145
+ if (!ranked) return plain(kind);
146
+ const path = options.paths?.[kind];
147
+ const name = path ? basename(path) : kind;
148
+ if (ranked.kept.kept === 0 && kind === "standards") return plain(kind);
149
+ log?.(`knowledge: kept ${ranked.kept.kept} of ${ranked.kept.total} sections of ${name} · ${ranked.ms} ms`);
150
+ return [ranked.kept.text, leftOutNote(ranked.kept, name, path)].filter(Boolean).join("\n\n");
151
+ } catch {
152
+ return plain(kind);
153
+ }
154
+ }));
155
+ return { knowledge: picked[0]!, standards: picked[1]!, decisions: picked[2]! };
156
+ },
157
+ };
158
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Jev's quick read of a pull request, for the Git tab: how big the change is,
3
+ * how likely it is risky, to break callers, to touch security-sensitive code
4
+ * or to change behaviour without tests, and what kind of change it is — in
5
+ * one call, in a moment, without writing a word. It is a triage, not a review:
6
+ * it says whether a full review by an agent is worth its tokens.
7
+ */
8
+ import type { Classifier } from "./classifier.ts";
9
+ import { choice, choiceOf, noul, score, scoreOf, yesOf, type SystemOneRequest } from "./client.ts";
10
+ import { clip } from "./limits.ts";
11
+ import { TRIAGE_SIZES } from "./triage.ts";
12
+ import type { TriageSize } from "../schemas/task.ts";
13
+
14
+ const SIZE_LEVELS = [
15
+ "trivial: a one-line or mechanical change (a rename, a typo, a version bump)",
16
+ "small: a few files in one area, following an existing pattern",
17
+ "medium: several files or two areas, with some design decisions",
18
+ "large: a cross-cutting change, a new subsystem, a migration or an architecture change",
19
+ ];
20
+
21
+ const KINDS: Record<string, string> = {
22
+ feature: "New capability or behaviour",
23
+ bugfix: "Existing behaviour was wrong and is fixed",
24
+ refactor: "Restructures code without changing behaviour",
25
+ tests: "Adds or fixes tests only",
26
+ docs: "Documentation only",
27
+ dependency: "Upgrades or changes dependencies",
28
+ chore: "Configuration, build, tooling or cleanup",
29
+ };
30
+
31
+ /** The pull request as Jev reads it. */
32
+ export interface PullInput {
33
+ title: string;
34
+ body: string;
35
+ /** Changed files with their line counts, e.g. `src/a.ts (+12 −3)`. */
36
+ files: readonly string[];
37
+ diff: string;
38
+ }
39
+
40
+ /** What Jev made of a pull request. */
41
+ export interface PullRead {
42
+ size: TriageSize;
43
+ sizeConfidence: number;
44
+ /** Probabilities of yes, 0 to 1. */
45
+ risky: number;
46
+ breaking: number;
47
+ security: number;
48
+ testsMissing: number;
49
+ kind?: string;
50
+ kindProbability?: number;
51
+ model: string;
52
+ ms: number;
53
+ }
54
+
55
+ /** Characters of diff sent: the rest is what the agent's review is for. */
56
+ export const READ_DIFF_CHARS = 80_000;
57
+
58
+ export function pullRequest(input: PullInput): SystemOneRequest {
59
+ return {
60
+ state: {
61
+ title: clip(input.title, 300),
62
+ description: clip(input.body, 3000),
63
+ changed_files: clip(input.files.join("\n"), 4000),
64
+ diff: clip(input.diff, READ_DIFF_CHARS),
65
+ },
66
+ questions: {
67
+ size: score("How big is the change in `diff`, judged by what it would take to review and verify?", SIZE_LEVELS),
68
+ risky: noul(
69
+ "Could merging `diff` break existing behaviour or cause harm: data loss, corrupted state, wrong results, concurrency bugs, performance cliffs, or an outage?",
70
+ "Yes: there is a plausible way this breaks something that works today.",
71
+ "No: it is low-risk, well contained, or purely additive.",
72
+ ),
73
+ breaking: noul(
74
+ "Does `diff` change a public API, a config or file format, a database schema or a command-line interface in a way that breaks existing callers or users?",
75
+ "Yes: something outside this change has to adapt.",
76
+ "No: existing callers and data keep working as they are.",
77
+ ),
78
+ security: noul(
79
+ "Does `diff` touch authentication, authorisation, secrets, input validation, cryptography, or other security-sensitive code?",
80
+ "Yes: a mistake here could be a vulnerability.",
81
+ "No: nothing in it is security-relevant.",
82
+ ),
83
+ tests_missing: noul(
84
+ "Does `diff` change behaviour without adding or updating tests that cover the change?",
85
+ "Yes: the behaviour changes and no test in the diff covers it.",
86
+ "No: it adds or updates tests for what it changes, or it changes no behaviour.",
87
+ ),
88
+ kind: choice("What kind of change is `diff`?", KINDS),
89
+ },
90
+ };
91
+ }
92
+
93
+ /** Jev's read of a pull request, or undefined when the classifier is off or fails. */
94
+ export async function readPull(classifier: Classifier, input: PullInput, signal?: AbortSignal): Promise<PullRead | undefined> {
95
+ if (!classifier.enabled("review")) return undefined;
96
+ const result = await classifier.ask("review", pullRequest(input), signal ? { signal } : {});
97
+ if (!result) return undefined;
98
+ const size = scoreOf(result.answers, "size");
99
+ if (!size) return undefined;
100
+ const kind = choiceOf(result.answers, "kind");
101
+ return {
102
+ size: TRIAGE_SIZES[Math.max(0, Math.min(TRIAGE_SIZES.length - 1, size.level))]!,
103
+ sizeConfidence: size.confidence,
104
+ risky: yesOf(result.answers, "risky") ?? 0,
105
+ breaking: yesOf(result.answers, "breaking") ?? 0,
106
+ security: yesOf(result.answers, "security") ?? 0,
107
+ testsMissing: yesOf(result.answers, "tests_missing") ?? 0,
108
+ ...(kind ? { kind: kind.choice, kindProbability: kind.probability } : {}),
109
+ model: result.model,
110
+ ms: result.ms,
111
+ };
112
+ }
113
+
114
+ const CONCERN = 0.5;
115
+
116
+ /** The concerns worth a look, most likely first, as words. */
117
+ export function concerns(read: PullRead): string[] {
118
+ const found: Array<[number, string]> = [
119
+ [read.risky, "risky"],
120
+ [read.security, "touches security"],
121
+ [read.breaking, "may break callers"],
122
+ [read.testsMissing, "no tests for the change"],
123
+ ];
124
+ return found.filter(([probability]) => probability >= CONCERN).sort((a, b) => b[0] - a[0]).map(([, words]) => words);
125
+ }
126
+
127
+ /** Whether a full review by an agent is worth its tokens: a concern, or a change too big to skim. */
128
+ export function worthReview(read: PullRead): boolean {
129
+ return concerns(read).length > 0 || read.size === "large";
130
+ }
131
+
132
+ /** One line: `medium · bugfix · risky 0.71, no tests for the change 0.64 → worth a full review`. */
133
+ export function readLine(read: PullRead): string {
134
+ const kind = read.kind ? ` · ${read.kind}` : "";
135
+ const flagged = concerns(read);
136
+ const figures = [
137
+ ["risky", read.risky],
138
+ ["security", read.security],
139
+ ["breaking", read.breaking],
140
+ ["tests missing", read.testsMissing],
141
+ ] as const;
142
+ const detail = flagged.length > 0
143
+ ? figures.filter(([, probability]) => probability >= CONCERN).sort((a, b) => b[1] - a[1]).map(([name, probability]) => `${name} ${probability.toFixed(2)}`).join(", ")
144
+ : "nothing stands out";
145
+ return `${read.size}${kind} · ${detail} → ${worthReview(read) ? "worth a full review" : "looks routine"}`;
146
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Where a task's work lives in git. A task can get a branch of its own
3
+ * (`branch`: created and checked out in the working folder) or a worktree of
4
+ * its own (`worktree`: a second checkout on that branch, which every agent of
5
+ * the task runs in, so tasks never trample each other's files). Both are named
6
+ * after the task. Every failure comes back as one readable line and the task
7
+ * simply runs without isolation: git being unusable must never stop a task.
8
+ */
9
+ import { execFile } from "node:child_process";
10
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
11
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
12
+ import { promisify } from "node:util";
13
+ import type { TaskGit } from "../schemas/task.ts";
14
+ import { dataRoot } from "../state/project.ts";
15
+
16
+ const run = promisify(execFile);
17
+ const TIMEOUT_MS = 30_000;
18
+
19
+ /** Runs git in a folder; resolves with trimmed stdout, rejects with git's own message. */
20
+ export type GitRunner = (cwd: string, args: readonly string[]) => Promise<string>;
21
+
22
+ export const runGit: GitRunner = async (cwd, args) => {
23
+ try {
24
+ const { stdout } = await run("git", [...args], { cwd, timeout: TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024, windowsHide: true });
25
+ return stdout.trim();
26
+ } catch (error) {
27
+ const failure = error as { stderr?: string; message: string; code?: unknown };
28
+ // A missing git binary reads as that, not as a spawn error.
29
+ if (failure.code === "ENOENT") throw new Error("git is not installed");
30
+ throw new Error(firstLine(failure.stderr) || firstLine(failure.message) || "git failed");
31
+ }
32
+ };
33
+
34
+ function firstLine(text: string | undefined): string {
35
+ return text?.split("\n").find((line) => line.trim())?.trim().replace(/^(fatal|error):\s*/i, "") ?? "";
36
+ }
37
+
38
+ /** Where a task's worktrees live: `<project>/<configDir>/bot-lobby/worktrees`. */
39
+ export function worktreesRoot(root: string, configDir: string): string {
40
+ return join(dataRoot(root, configDir), "worktrees");
41
+ }
42
+
43
+ /** A branch name git accepts, made from a task's friendly name (which already is one, bar odd characters). */
44
+ export function branchName(name: string): string {
45
+ const cleaned = name
46
+ .trim()
47
+ .replace(/[\s~^:?*[\\]+/g, "-")
48
+ .replace(/@\{/g, "-")
49
+ .replace(/\.{2,}/g, ".")
50
+ .replace(/\/{2,}/g, "/")
51
+ .replace(/[\u0000-\u001f\u007f]/g, "")
52
+ .replace(/^[-./]+|[-./]+$/g, "")
53
+ .replace(/\.lock$/i, "");
54
+ return cleaned || "task";
55
+ }
56
+
57
+ /** The first of `name`, `name-2`, `name-3`… that no branch (local or remote) already uses. */
58
+ async function freeBranch(git: GitRunner, top: string, name: string): Promise<string> {
59
+ const base = branchName(name);
60
+ for (let suffix = 1; suffix < 100; suffix += 1) {
61
+ const candidate = suffix === 1 ? base : `${base}-${suffix}`;
62
+ const taken = await git(top, ["for-each-ref", "--count=1", "--format=%(refname)", `refs/heads/${candidate}`, `refs/remotes/*/${candidate}`]).catch(() => "");
63
+ if (!taken) return candidate;
64
+ }
65
+ throw new Error(`every branch name like ${base} is taken`);
66
+ }
67
+
68
+ /** Keep the worktrees out of `git add -A` in the main checkout (a local exclude; nothing is committed). */
69
+ async function excludeWorktrees(git: GitRunner, top: string, root: string, configDir: string): Promise<void> {
70
+ try {
71
+ const inside = relative(top, worktreesRoot(root, configDir));
72
+ if (!inside || inside.startsWith("..") || isAbsolute(inside)) return;
73
+ const file = resolve(top, await git(top, ["rev-parse", "--git-path", "info/exclude"]));
74
+ const line = `/${inside.split(sep).join("/")}/`;
75
+ const current = existsSync(file) ? readFileSync(file, "utf8") : "";
76
+ if (current.split("\n").some((entry) => entry.trim() === line)) return;
77
+ mkdirSync(dirname(file), { recursive: true });
78
+ appendFileSync(file, `${current && !current.endsWith("\n") ? "\n" : ""}# bot-lobby: one worktree per task\n${line}\n`);
79
+ } catch {
80
+ // Only a convenience: a read-only .git leaves the worktrees showing as untracked.
81
+ }
82
+ }
83
+
84
+ export interface WorkspaceRequest {
85
+ /** The folder the task starts from (the session's working folder). */
86
+ cwd: string;
87
+ root: string;
88
+ configDir: string;
89
+ /** The task's friendly name: the branch takes it. */
90
+ name: string;
91
+ mode: "branch" | "worktree";
92
+ git?: GitRunner;
93
+ }
94
+
95
+ /**
96
+ * Give a task its branch (or worktree and branch). Returns what was made, or
97
+ * one line saying why not (not a repository, no commits for a worktree, a
98
+ * failed checkout); the task then runs without.
99
+ */
100
+ export async function createWorkspace(request: WorkspaceRequest): Promise<TaskGit | string> {
101
+ const git = request.git ?? runGit;
102
+ let top: string;
103
+ try {
104
+ top = await git(request.cwd, ["rev-parse", "--show-toplevel"]);
105
+ } catch {
106
+ return "this folder is not a git repository";
107
+ }
108
+ const [current, head] = await Promise.all([
109
+ git(top, ["branch", "--show-current"]).catch(() => ""),
110
+ git(top, ["rev-parse", "--verify", "HEAD"]).catch(() => ""),
111
+ ]);
112
+ let branch: string;
113
+ try {
114
+ branch = await freeBranch(git, top, request.name);
115
+ await git(top, ["check-ref-format", "--branch", branch]);
116
+ } catch (error) {
117
+ return `no valid branch name for ${request.name} — ${(error as Error).message}`;
118
+ }
119
+ const from = current || (head ? `detached at ${head.slice(0, 7)}` : undefined);
120
+ const base = { branch, ...(from ? { from } : {}), ...(head ? { base: head } : {}) };
121
+ if (request.mode === "branch") {
122
+ try {
123
+ await git(top, ["checkout", "-b", branch]);
124
+ } catch (error) {
125
+ return `could not create branch ${branch} — ${(error as Error).message}`;
126
+ }
127
+ return { mode: "branch", ...base };
128
+ }
129
+ if (!head) return "a worktree needs at least one commit to start from; commit first, or use a branch";
130
+ const path = join(worktreesRoot(request.root, request.configDir), branch);
131
+ try {
132
+ mkdirSync(dirname(path), { recursive: true });
133
+ await git(top, ["worktree", "add", "-b", branch, path, head]);
134
+ } catch (error) {
135
+ return `could not create a worktree for ${branch} — ${(error as Error).message}`;
136
+ }
137
+ await excludeWorktrees(git, top, request.root, request.configDir);
138
+ return { mode: "worktree", ...base, path };
139
+ }
140
+
141
+ /** The folder a task's agents run in: its worktree when it has one, otherwise where the session works. */
142
+ export function taskCwd(task: { git?: TaskGit }, cwd: string): string {
143
+ return task.git?.mode === "worktree" && task.git.path ? task.git.path : cwd;
144
+ }
145
+
146
+ /** Why a task's worktree cannot be used (it was removed), or undefined when it is there or the task has none. */
147
+ export function missingWorktree(task: { git?: TaskGit }): string | undefined {
148
+ const git = task.git;
149
+ if (git?.mode !== "worktree" || !git.path || existsSync(git.path)) return undefined;
150
+ return `the worktree ${git.path} of branch ${git.branch} is gone. Restore it with: git worktree add ${JSON.stringify(git.path)} ${git.branch}`;
151
+ }
152
+
153
+ /** The line that tells the oracle where the task's work lives; empty for a task without isolation. */
154
+ export function gitLine(git: TaskGit | undefined): string {
155
+ if (!git) return "";
156
+ const from = git.from ? ` (from ${git.from})` : "";
157
+ if (git.mode === "branch") return `Git: this task works on its own branch ${git.branch}${from}, checked out in the working folder. Commit there; never switch branches.`;
158
+ return [
159
+ `Git: this task works in its own worktree ${git.path} on branch ${git.branch}${from}. Every agent runs there.`,
160
+ `Your own tools run in the main checkout, so look at the task's files under ${git.path} (git -C ${JSON.stringify(git.path)} diff --stat), and never edit outside it.`,
161
+ "Uncommitted changes in the main checkout are not in the worktree.",
162
+ ].join(" ");
163
+ }
164
+
165
+ /** What the lobby's title shows: the repository (or folder) and the branch checked out in it. */
166
+ export interface WorkspaceInfo {
167
+ name: string;
168
+ branch?: string;
169
+ }
170
+
171
+ /** Repository (or folder) name and branch of a working folder; a folder outside git has only its name. */
172
+ export async function describeWorkspace(cwd: string, git: GitRunner = runGit): Promise<WorkspaceInfo> {
173
+ let top: string;
174
+ try {
175
+ top = await git(cwd, ["rev-parse", "--show-toplevel"]);
176
+ } catch {
177
+ return { name: basename(cwd) || cwd };
178
+ }
179
+ const name = basename(top) || top;
180
+ const branch = await git(top, ["branch", "--show-current"]).catch(() => "");
181
+ if (branch) return { name, branch };
182
+ // Detached (or an unborn branch): the commit, when there is one.
183
+ const head = await git(top, ["rev-parse", "--short", "HEAD"]).catch(() => "");
184
+ return head ? { name, branch: `detached ${head}` } : { name };
185
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Knowledge files as entries the user can pick, edit, add and delete from the
3
+ * Knowledge tab. An entry is what reads as one item: a heading, a bullet with
4
+ * its wrapped and nested lines, or a paragraph. Everything here is pure text
5
+ * in, text out; the tab finds an entry again by its exact text (and which of
6
+ * several identical ones it is), so an edit made after the file changed on
7
+ * disk is refused rather than landing on the wrong line.
8
+ */
9
+
10
+ export type EntryKind = "heading" | "bullet" | "text";
11
+
12
+ export interface KnowledgeEntry {
13
+ /** The entry's lines exactly as the file has them. */
14
+ text: string;
15
+ /** First line, and the line after the last (indexes into the file's lines). */
16
+ start: number;
17
+ end: number;
18
+ kind: EntryKind;
19
+ /** Which of the entries with this same text it is (0 for the first). */
20
+ occurrence: number;
21
+ }
22
+
23
+ const HEADING = /^#{1,6}\s/;
24
+ const BULLET = /^(?:[-*+]|\d+[.)])\s+\S/;
25
+
26
+ /** The entries of a knowledge file, in order; blank lines separate them and belong to none. */
27
+ export function parseEntries(content: string): KnowledgeEntry[] {
28
+ const lines = content.replace(/\r\n/g, "\n").split("\n");
29
+ const found: Array<{ start: number; end: number; kind: EntryKind }> = [];
30
+ let current: { start: number; end: number; kind: EntryKind } | undefined;
31
+ const close = () => {
32
+ if (current) found.push(current);
33
+ current = undefined;
34
+ };
35
+ lines.forEach((line, index) => {
36
+ if (!line.trim()) return close();
37
+ if (HEADING.test(line)) {
38
+ close();
39
+ found.push({ start: index, end: index + 1, kind: "heading" });
40
+ return;
41
+ }
42
+ if (BULLET.test(line)) {
43
+ close();
44
+ current = { start: index, end: index + 1, kind: "bullet" };
45
+ return;
46
+ }
47
+ // A wrapped or indented line joins the item above it; anything else starts a paragraph.
48
+ if (current) current.end = index + 1;
49
+ else current = { start: index, end: index + 1, kind: "text" };
50
+ });
51
+ close();
52
+ const seen = new Map<string, number>();
53
+ return found.map(({ start, end, kind }) => {
54
+ const text = lines.slice(start, end).join("\n");
55
+ const occurrence = seen.get(text) ?? 0;
56
+ seen.set(text, occurrence + 1);
57
+ return { text, start, end, kind, occurrence };
58
+ });
59
+ }
60
+
61
+ /** The entry with this exact text (the `occurrence`-th one), or undefined when the file no longer has it. */
62
+ export function findEntry(content: string, text: string, occurrence = 0): KnowledgeEntry | undefined {
63
+ return parseEntries(content).find((entry) => entry.text === text && entry.occurrence === occurrence);
64
+ }
65
+
66
+ function linesOf(content: string): string[] {
67
+ const text = content.replace(/\r\n/g, "\n").replace(/\n+$/, "");
68
+ return text ? text.split("\n") : [];
69
+ }
70
+
71
+ /** `text` as a bullet: its first line after `- `, the rest indented under it; text that already is a bullet or heading stays as written. */
72
+ export function bulletOf(text: string): string {
73
+ const trimmed = text.replace(/^\s*\n+|\s+$/g, "");
74
+ if (!trimmed.trim()) return "";
75
+ if (BULLET.test(trimmed) || HEADING.test(trimmed)) return trimmed;
76
+ const [first = "", ...rest] = trimmed.split("\n");
77
+ return [`- ${first}`, ...rest.map((line) => (line.trim() ? ` ${line.trimStart()}` : ""))].join("\n");
78
+ }
79
+
80
+ /** The file with `entry` replaced by `text`, exactly as written (a blank text removes it). */
81
+ export function replaceEntry(content: string, entry: KnowledgeEntry, text: string): string {
82
+ const body = text.replace(/\s+$/, "");
83
+ if (!body.trim()) return removeEntry(content, entry);
84
+ const lines = linesOf(content);
85
+ lines.splice(entry.start, entry.end - entry.start, ...body.split("\n"));
86
+ return `${lines.join("\n")}\n`;
87
+ }
88
+
89
+ /** The file without `entry`; the blank line it leaves doubled up is dropped with it. */
90
+ export function removeEntry(content: string, entry: KnowledgeEntry): string {
91
+ const lines = linesOf(content);
92
+ lines.splice(entry.start, entry.end - entry.start);
93
+ const before = lines[entry.start - 1];
94
+ const after = lines[entry.start];
95
+ if (entry.start < lines.length && after !== undefined && !after.trim() && (before === undefined || !before.trim())) lines.splice(entry.start, 1);
96
+ else if (entry.start >= lines.length && lines.length > 0 && !lines[lines.length - 1]!.trim()) lines.pop();
97
+ return lines.length > 0 ? `${lines.join("\n")}\n` : "";
98
+ }
99
+
100
+ /** The file with a new bullet after `entry` (at the end without one); after a paragraph it gets a blank line first. */
101
+ export function insertAfter(content: string, entry: KnowledgeEntry | undefined, text: string): string {
102
+ const added = bulletOf(text);
103
+ if (!added.trim()) return content;
104
+ const lines = linesOf(content);
105
+ const at = entry ? entry.end : lines.length;
106
+ const addition = entry?.kind === "text" || (!entry && lines.length > 0 && lines[lines.length - 1]!.trim() && !BULLET.test(lines[lines.length - 1]!)) ? ["", ...added.split("\n")] : added.split("\n");
107
+ lines.splice(at, 0, ...addition);
108
+ return `${lines.join("\n")}\n`;
109
+ }