@a-t-h-i/bot-lobby 0.6.6 → 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 (58) hide show
  1. package/README.md +183 -11
  2. package/package.json +1 -1
  3. package/prompts/master.md +21 -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 +23 -7
  9. package/src/ask/state.ts +18 -3
  10. package/src/ask/tool.ts +9 -2
  11. package/src/ask/view.ts +8 -4
  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/agent-runner.ts +23 -1
  16. package/src/execution/fallback.ts +75 -0
  17. package/src/execution/workspace.ts +185 -0
  18. package/src/knowledge/edit.ts +109 -0
  19. package/src/knowledge/notes.ts +143 -0
  20. package/src/knowledge/selector.ts +1 -1
  21. package/src/knowledge/store.ts +16 -2
  22. package/src/lobby/ask.ts +42 -21
  23. package/src/lobby/feed.ts +7 -0
  24. package/src/lobby/issues.ts +1 -1
  25. package/src/lobby/knowledge.ts +179 -0
  26. package/src/lobby/layout.ts +99 -1
  27. package/src/lobby/mini.ts +159 -0
  28. package/src/lobby/planner.ts +209 -10
  29. package/src/lobby/pr-review.ts +360 -0
  30. package/src/lobby/pulls.ts +250 -0
  31. package/src/lobby/quickfix.ts +17 -3
  32. package/src/lobby/runtime.ts +142 -17
  33. package/src/lobby/split.ts +230 -0
  34. package/src/lobby/tabs/git.ts +162 -0
  35. package/src/lobby/tabs/knowledge.ts +135 -0
  36. package/src/lobby/tabs/plan.ts +3 -1
  37. package/src/lobby/tabs/tasks.ts +11 -2
  38. package/src/lobby/view.ts +450 -35
  39. package/src/master/master.ts +40 -14
  40. package/src/master/research.ts +1 -0
  41. package/src/pi/commands.ts +12 -7
  42. package/src/pi/events.ts +2 -0
  43. package/src/pi/master-fallback.ts +61 -0
  44. package/src/pi/model-support.ts +27 -3
  45. package/src/pi/plan-checklist.ts +42 -0
  46. package/src/pi/route.ts +2 -1
  47. package/src/pi/settings-ui.ts +104 -11
  48. package/src/pi/start-flags.ts +14 -0
  49. package/src/pi/start-task.ts +56 -5
  50. package/src/pi/tools.ts +4 -2
  51. package/src/schemas/configuration.ts +74 -11
  52. package/src/schemas/findings.ts +2 -0
  53. package/src/schemas/task.ts +15 -0
  54. package/src/state/backlog.ts +27 -4
  55. package/src/state/metrics.ts +2 -2
  56. package/src/state/persistence.ts +5 -5
  57. package/src/text.ts +38 -0
  58. package/src/workflow/workflow.ts +23 -2
@@ -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
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The user's comments on knowledge: notes about one entry of an agent's
3
+ * knowledge file ("outdated, we moved to Redis"). They live in one
4
+ * append-only JSON-lines log per project, never inside the knowledge files
5
+ * (agents rewrite those when they compact them), and reach every agent that
6
+ * reads the knowledge: `annotate` puts each note right under its entry, so it
7
+ * travels with the entry into the prompt and the agent weighs it there.
8
+ *
9
+ * A note is tied to its entry by the entry's exact text. Editing the entry
10
+ * from the Knowledge tab moves its notes to the new text; a note whose entry
11
+ * changed some other way is kept, and shown under the file as detached.
12
+ */
13
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { randomUUID } from "node:crypto";
16
+ import type { KnowledgeAgent } from "./paths.ts";
17
+ import { parseEntries } from "./edit.ts";
18
+
19
+ export interface KnowledgeNote {
20
+ id: string;
21
+ agent: KnowledgeAgent;
22
+ /** The knowledge file, e.g. `decisions.md`. */
23
+ file: string;
24
+ /** The exact text of the entry it is about. */
25
+ entry: string;
26
+ text: string;
27
+ createdAt: string;
28
+ /** The pi session that wrote it, when known. */
29
+ by?: string;
30
+ }
31
+
32
+ type NoteEvent =
33
+ | { kind: "note"; id: string; agent: KnowledgeAgent; file: string; entry: string; text: string; at: string; by?: string }
34
+ | { kind: "move"; id: string; entry: string; at: string }
35
+ | { kind: "remove"; id: string; at: string };
36
+
37
+ /** Longest note kept; agents read it verbatim. */
38
+ export const MAX_NOTE_CHARS = 2000;
39
+
40
+ export function notesPath(dataRoot: string): string {
41
+ return join(dataRoot, "knowledge-comments.jsonl");
42
+ }
43
+
44
+ function append(dataRoot: string, event: NoteEvent): void {
45
+ const path = notesPath(dataRoot);
46
+ mkdirSync(dirname(path), { recursive: true });
47
+ appendFileSync(path, `${JSON.stringify(event)}\n`, "utf8");
48
+ }
49
+
50
+ /** The notes as they stand, oldest first. A torn line from a crashed writer is skipped. */
51
+ export function readNotes(dataRoot: string | undefined): KnowledgeNote[] {
52
+ if (!dataRoot) return [];
53
+ const path = notesPath(dataRoot);
54
+ if (!existsSync(path)) return [];
55
+ let text: string;
56
+ try {
57
+ text = readFileSync(path, "utf8");
58
+ } catch {
59
+ return [];
60
+ }
61
+ const notes = new Map<string, KnowledgeNote>();
62
+ for (const line of text.split("\n")) {
63
+ if (!line.trim()) continue;
64
+ let event: NoteEvent;
65
+ try {
66
+ event = JSON.parse(line) as NoteEvent;
67
+ } catch {
68
+ continue;
69
+ }
70
+ if (!event || typeof event.id !== "string") continue;
71
+ if (event.kind === "note" && !notes.has(event.id) && typeof event.text === "string" && typeof event.entry === "string") {
72
+ notes.set(event.id, { id: event.id, agent: event.agent, file: event.file, entry: event.entry, text: event.text, createdAt: event.at, ...(event.by ? { by: event.by } : {}) });
73
+ } else if (event.kind === "move" && typeof event.entry === "string") {
74
+ const note = notes.get(event.id);
75
+ if (note) note.entry = event.entry;
76
+ } else if (event.kind === "remove") notes.delete(event.id);
77
+ }
78
+ return [...notes.values()];
79
+ }
80
+
81
+ /** Leave a note on an entry. */
82
+ export function addNote(dataRoot: string, note: { agent: KnowledgeAgent; file: string; entry: string; text: string; by?: string }, now = new Date()): KnowledgeNote {
83
+ const text = note.text.trim().slice(0, MAX_NOTE_CHARS);
84
+ if (!text) throw new Error("a note needs some words");
85
+ if (!note.entry.trim()) throw new Error("a note needs an entry to be about");
86
+ const id = randomUUID().slice(0, 8);
87
+ const at = now.toISOString();
88
+ append(dataRoot, { kind: "note", id, agent: note.agent, file: note.file, entry: note.entry, text, at, ...(note.by ? { by: note.by } : {}) });
89
+ return { id, agent: note.agent, file: note.file, entry: note.entry, text, createdAt: at, ...(note.by ? { by: note.by } : {}) };
90
+ }
91
+
92
+ export function removeNote(dataRoot: string, id: string, now = new Date()): void {
93
+ append(dataRoot, { kind: "remove", id, at: now.toISOString() });
94
+ }
95
+
96
+ /** Move every note on `from` to `to`: the entry was edited, its notes stay with it. Returns how many moved. */
97
+ export function moveNotes(dataRoot: string, agent: KnowledgeAgent, file: string, from: string, to: string, now = new Date()): number {
98
+ if (from === to) return 0;
99
+ const moving = readNotes(dataRoot).filter((note) => note.agent === agent && note.file === file && note.entry === from);
100
+ for (const note of moving) append(dataRoot, { kind: "move", id: note.id, entry: to, at: now.toISOString() });
101
+ return moving.length;
102
+ }
103
+
104
+ /** Drop every note on `entry`: the entry was deleted. Returns how many went. */
105
+ export function removeNotesOn(dataRoot: string, agent: KnowledgeAgent, file: string, entry: string, now = new Date()): number {
106
+ const going = readNotes(dataRoot).filter((note) => note.agent === agent && note.file === file && note.entry === entry);
107
+ for (const note of going) removeNote(dataRoot, note.id, now);
108
+ return going.length;
109
+ }
110
+
111
+ /** `> Note from the user: …` with every line of a longer note quoted. */
112
+ function noteLines(note: KnowledgeNote, about?: string): string[] {
113
+ const [first = "", ...rest] = note.text.split("\n");
114
+ const lead = about ? `Note from the user about "${about}": ` : "Note from the user: ";
115
+ return [`> ${lead}${first}`, ...rest.map((line) => `> ${line}`)];
116
+ }
117
+
118
+ /**
119
+ * A file's text with each note under the entry it is about, as the agents
120
+ * read it. A note whose entry is gone is added at the end, naming the entry it
121
+ * was about, so what the user said is never lost. Notes the text already
122
+ * carries (an agent copied them in when it compacted the file) are not added twice.
123
+ */
124
+ export function annotate(content: string, notes: readonly KnowledgeNote[]): string {
125
+ if (notes.length === 0) return content;
126
+ const lines = content.replace(/\r\n/g, "\n").split("\n");
127
+ const entries = parseEntries(content);
128
+ const carried = (note: KnowledgeNote) => content.includes(noteLines(note)[0]!);
129
+ const pending = notes.filter((note) => !carried(note));
130
+ const detached: KnowledgeNote[] = [];
131
+ const under = new Map<number, KnowledgeNote[]>();
132
+ for (const note of pending) {
133
+ const entry = entries.find((candidate) => candidate.text === note.entry);
134
+ if (!entry) detached.push(note);
135
+ else under.set(entry.end, [...(under.get(entry.end) ?? []), note]);
136
+ }
137
+ // Bottom-up, so earlier positions stay where they are.
138
+ for (const at of [...under.keys()].sort((a, b) => b - a)) lines.splice(at, 0, ...under.get(at)!.flatMap((note) => noteLines(note)));
139
+ const body = lines.join("\n").replace(/\n+$/, "");
140
+ if (detached.length === 0) return body;
141
+ const about = (note: KnowledgeNote) => note.entry.replace(/\s+/g, " ").trim().slice(0, 60);
142
+ return `${body}\n\n${detached.flatMap((note) => noteLines(note, about(note))).join("\n")}`;
143
+ }
@@ -36,7 +36,7 @@ export function splitSections(markdown: string): string[] {
36
36
  .filter((part) => part.length > 0);
37
37
  }
38
38
 
39
- function score(sectionText: string, tokens: string[]): number {
39
+ export function score(sectionText: string, tokens: string[]): number {
40
40
  const lower = sectionText.toLowerCase();
41
41
  return tokens.reduce((total, token) => total + (lower.includes(token) ? 1 : 0), 0);
42
42
  }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { knowledgeDir, STANDARDS_FILE, type KnowledgeAgent } from "./paths.ts";
4
+ import { annotate, readNotes } from "./notes.ts";
4
5
 
5
6
  export function readFileOr(path: string, fallback = ""): string {
6
7
  try {
@@ -105,7 +106,20 @@ export function readKnowledgeSlices(
105
106
  };
106
107
  }
107
108
 
108
- /** Read an agent's knowledge by agent key; bot-lobby dirs win per file over the pre-rename ones. */
109
+ /**
110
+ * Read an agent's knowledge by agent key; bot-lobby dirs win per file over the
111
+ * pre-rename ones. The user's notes on entries (Knowledge tab) sit right under
112
+ * the entries they are about, so every agent reads them with the knowledge.
113
+ */
109
114
  export function readAgentKnowledge(roots: readonly string[], agent: KnowledgeAgent) {
110
- return readKnowledgeSlices(roots.map((root) => knowledgeDir(root, agent)), STANDARDS_FILE[agent]);
115
+ const slices = readKnowledgeSlices(roots.map((root) => knowledgeDir(root, agent)), STANDARDS_FILE[agent]);
116
+ const notes = readNotes(roots[0]).filter((note) => note.agent === agent);
117
+ if (notes.length === 0) return slices;
118
+ const about = (file: string) => notes.filter((note) => note.file === file);
119
+ return {
120
+ knowledge: annotate(slices.knowledge, about("knowledge.md")),
121
+ standards: annotate(slices.standards, about(STANDARDS_FILE[agent])),
122
+ decisions: annotate(slices.decisions, about("decisions.md")),
123
+ completed: annotate(slices.completed, about("completed-tasks.md")),
124
+ };
111
125
  }
package/src/lobby/ask.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * answers become the user's turn for the next round.
7
7
  */
8
8
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
- import type { PanelQuestion } from "./planner.ts";
9
+ import type { PanelQuestion, SettledQuestion } from "./planner.ts";
10
10
  import { MAX_HEADER, MAX_LABEL, MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS, RESERVED, type AskAnswer, type AskOption, type AskQuestion, type AskResult } from "../ask/types.ts";
11
11
  import type { Asker } from "../ask/dialog.ts";
12
12
 
@@ -64,6 +64,35 @@ function answerText(answer: AskAnswer | undefined): string | undefined {
64
64
  return answer.answer?.trim() || undefined;
65
65
  }
66
66
 
67
+ /** A multi-line answer keeps its lines under the `→` that opens it. */
68
+ function indented(text: string, by: string): string {
69
+ return text.replace(/\n/g, `\n${by}`);
70
+ }
71
+
72
+ /** Every question of the round with the answer the user gave it, in the order asked. */
73
+ function answered(questions: readonly PanelQuestion[], results: readonly AskResult[]): Array<{ number: number; question: PanelQuestion; answer: AskAnswer | undefined; text: string | undefined }> {
74
+ const rows: Array<{ number: number; question: PanelQuestion; answer: AskAnswer | undefined; text: string | undefined }> = [];
75
+ results.forEach((result, chunk) => {
76
+ const offset = chunk * MAX_QUESTIONS;
77
+ for (let i = 0; i < MAX_QUESTIONS; i++) {
78
+ const question = questions[offset + i];
79
+ if (!question) break;
80
+ const answer = result.answers.find((entry) => entry.questionIndex === i);
81
+ rows.push({ number: offset + i + 1, question, answer, text: answerText(answer) });
82
+ }
83
+ });
84
+ return rows;
85
+ }
86
+
87
+ /**
88
+ * What the user's reply settled: each question of the round with its answer,
89
+ * or without one when they left it. The panel is never allowed to ask a
90
+ * settled question again.
91
+ */
92
+ export function settledQuestions(questions: readonly PanelQuestion[], results: readonly AskResult[]): SettledQuestion[] {
93
+ return answered(questions, results).map(({ question, text }) => ({ from: question.from, question: question.text.replace(/\s+/g, " ").trim(), ...(text ? { answer: text } : {}) }));
94
+ }
95
+
67
96
  /**
68
97
  * The user's turn for the next round: every question with who asked it and
69
98
  * the answer (`→ …`), their notes, and what they left unanswered. Undefined
@@ -72,27 +101,19 @@ function answerText(answer: AskAnswer | undefined): string | undefined {
72
101
  export function answerMessage(questions: readonly PanelQuestion[], results: readonly AskResult[]): string | undefined {
73
102
  const lines: string[] = [];
74
103
  const skipped: string[] = [];
75
- const notes: string[] = [];
76
- let answered = 0;
77
- results.forEach((result, chunk) => {
78
- if (result.globalNote?.trim()) notes.push(result.globalNote.trim());
79
- const offset = chunk * MAX_QUESTIONS;
80
- for (let i = 0; i < MAX_QUESTIONS; i++) {
81
- const question = questions[offset + i];
82
- if (!question) break;
83
- const answer = result.answers.find((entry) => entry.questionIndex === i);
84
- const text = answerText(answer);
85
- const label = `${offset + i + 1}. [${question.from}] ${question.text.replace(/\s+/g, " ").trim()}`;
86
- if (!text) {
87
- skipped.push(label);
88
- continue;
89
- }
90
- answered += 1;
91
- lines.push(`${label}\n → ${text}${answer?.kind === "custom" ? " (in my words)" : ""}`);
92
- if (answer?.notes?.trim()) lines.push(` note: ${answer.notes.trim()}`);
104
+ const notes = results.map((result) => result.globalNote?.trim()).filter((note): note is string => Boolean(note));
105
+ let count = 0;
106
+ for (const { number, question, answer, text } of answered(questions, results)) {
107
+ const label = `${number}. [${question.from}] ${question.text.replace(/\s+/g, " ").trim()}`;
108
+ if (!text) {
109
+ skipped.push(label);
110
+ continue;
93
111
  }
94
- });
95
- if (answered === 0 && notes.length === 0) return undefined;
112
+ count += 1;
113
+ lines.push(`${label}\n → ${indented(text, " ")}${answer?.kind === "custom" ? " (in my words)" : ""}`);
114
+ if (answer?.notes?.trim()) lines.push(` note: ${indented(answer.notes.trim(), " ")}`);
115
+ }
116
+ if (count === 0 && notes.length === 0) return undefined;
96
117
  return [
97
118
  "Answers to the panel's questions:",
98
119
  ...lines,
package/src/lobby/feed.ts CHANGED
@@ -70,6 +70,8 @@ export class LobbyFeed {
70
70
  private readonly runSteps = new Map<string, string>();
71
71
  private readonly runThoughts = new Map<string, string>();
72
72
  private readonly runStatus = new Map<string, AgentRun["status"]>();
73
+ /** Runs already reported as switched to their fallback model. */
74
+ private readonly fellBack = new Set<string>();
73
75
 
74
76
  onChange(listener: () => void): () => void {
75
77
  this.listeners.add(listener);
@@ -200,6 +202,10 @@ export class LobbyFeed {
200
202
  const before = this.runStatus.get(key);
201
203
  this.runStatus.set(key, run.status);
202
204
  if (before === undefined) this.log(source, `started as ${run.role}`, "info", at);
205
+ if (run.fellBackFrom && !this.fellBack.has(key)) {
206
+ this.fellBack.add(key);
207
+ this.log(source, `${run.fellBackFrom} is out of usage or unavailable: running on the fallback model`, "warning", at);
208
+ }
203
209
  if (run.step && this.runSteps.get(key) !== run.step) {
204
210
  this.runSteps.set(key, run.step);
205
211
  this.step(source, run.step, key, at);
@@ -225,6 +231,7 @@ export class LobbyFeed {
225
231
  this.runSteps.clear();
226
232
  this.runThoughts.clear();
227
233
  this.runStatus.clear();
234
+ this.fellBack.clear();
228
235
  this.touch();
229
236
  }
230
237
  }
@@ -63,7 +63,7 @@ export function ghError(result: Partial<ExecResult> & { error?: string }): strin
63
63
  return first.length > 160 ? `${first.slice(0, 159)}…` : first;
64
64
  }
65
65
 
66
- async function gh(exec: Exec, cwd: string, args: string[]): Promise<string> {
66
+ export async function gh(exec: Exec, cwd: string, args: string[]): Promise<string> {
67
67
  let result: ExecResult;
68
68
  try {
69
69
  result = await exec("gh", args, { cwd, timeout: TIMEOUT_MS });