@a-t-h-i/bot-lobby 0.1.0

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 (61) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +406 -0
  3. package/package.json +46 -0
  4. package/prompts/backend.md +28 -0
  5. package/prompts/designer.md +33 -0
  6. package/prompts/global.md +42 -0
  7. package/prompts/master.md +107 -0
  8. package/prompts/qa.md +26 -0
  9. package/prompts/researcher.md +32 -0
  10. package/prompts/reviewer.md +43 -0
  11. package/prompts/scout.md +36 -0
  12. package/prompts/worker.md +49 -0
  13. package/src/agents/backend.ts +10 -0
  14. package/src/agents/designer.ts +10 -0
  15. package/src/agents/qa.ts +10 -0
  16. package/src/agents/registry.ts +14 -0
  17. package/src/execution/agent-runner.ts +150 -0
  18. package/src/execution/git.ts +42 -0
  19. package/src/execution/pi-runner.ts +292 -0
  20. package/src/index.ts +13 -0
  21. package/src/knowledge/compactor.ts +135 -0
  22. package/src/knowledge/paths.ts +43 -0
  23. package/src/knowledge/selector.ts +82 -0
  24. package/src/knowledge/store.ts +111 -0
  25. package/src/master/decisions.ts +53 -0
  26. package/src/master/master.ts +298 -0
  27. package/src/master/research.ts +98 -0
  28. package/src/master/synthesis.ts +57 -0
  29. package/src/pi/activity.ts +60 -0
  30. package/src/pi/commands.ts +268 -0
  31. package/src/pi/events.ts +76 -0
  32. package/src/pi/expressions.ts +101 -0
  33. package/src/pi/mascot-art.ts +252 -0
  34. package/src/pi/notify.ts +42 -0
  35. package/src/pi/quiet.ts +46 -0
  36. package/src/pi/settings-ui.ts +258 -0
  37. package/src/pi/tool-renderers.ts +121 -0
  38. package/src/pi/tools.ts +158 -0
  39. package/src/pi/ui.ts +227 -0
  40. package/src/pi/zen-large.ts +483 -0
  41. package/src/pi/zen-metrics.ts +80 -0
  42. package/src/pi/zen.ts +460 -0
  43. package/src/prompts/compiler.ts +50 -0
  44. package/src/prompts/loader.ts +20 -0
  45. package/src/roles/markdown.ts +64 -0
  46. package/src/roles/registry.ts +16 -0
  47. package/src/roles/researcher.ts +83 -0
  48. package/src/roles/reviewer.ts +65 -0
  49. package/src/roles/scout.ts +61 -0
  50. package/src/roles/worker.ts +94 -0
  51. package/src/schemas/agent.ts +39 -0
  52. package/src/schemas/configuration.ts +107 -0
  53. package/src/schemas/findings.ts +110 -0
  54. package/src/schemas/task.ts +113 -0
  55. package/src/state/persistence.ts +232 -0
  56. package/src/state/project.ts +99 -0
  57. package/src/state/task-state.ts +22 -0
  58. package/src/text.ts +51 -0
  59. package/src/workflow/approvals.ts +45 -0
  60. package/src/workflow/transitions.ts +41 -0
  61. package/src/workflow/workflow.ts +771 -0
@@ -0,0 +1,292 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, join } from "node:path";
5
+ import { activityWord } from "../pi/activity.ts";
6
+
7
+ export interface PiRunOptions {
8
+ cwd: string;
9
+ task: string;
10
+ systemPrompt?: string;
11
+ tools?: readonly string[];
12
+ model?: string;
13
+ thinking?: string;
14
+ timeoutMs: number;
15
+ signal?: AbortSignal;
16
+ /** Reports the current one-word tool activity while the agent works. */
17
+ onActivity?: (activity: string) => void;
18
+ }
19
+
20
+ export interface ProcessOutcome {
21
+ exitCode: number;
22
+ stdout: string;
23
+ stderr: string;
24
+ killed: boolean;
25
+ timedOut: boolean;
26
+ }
27
+
28
+ /** Streaming agent events forwarded as they arrive on stdout. */
29
+ export interface PiStreamEvent {
30
+ type: "tool_execution_start";
31
+ toolName: string;
32
+ }
33
+
34
+ export interface ProcessRunOptions {
35
+ cwd: string;
36
+ signal?: AbortSignal;
37
+ timeoutMs: number;
38
+ /** Optional sink for streaming agent events (tool starts). */
39
+ onEvent?: (event: PiStreamEvent) => void;
40
+ }
41
+
42
+ export type ProcessRunner = (
43
+ args: string[],
44
+ options: ProcessRunOptions,
45
+ ) => Promise<ProcessOutcome>;
46
+
47
+ export interface PiRunResult {
48
+ status: "success" | "failed" | "cancelled" | "timeout";
49
+ output: string;
50
+ error?: string;
51
+ usage: { input: number; output: number; cost: number; turns: number };
52
+ model?: string;
53
+ }
54
+
55
+ export interface ParsedStream {
56
+ text: string;
57
+ usage: { input: number; output: number; cost: number; turns: number };
58
+ model?: string;
59
+ stopReason?: string;
60
+ errorMessage?: string;
61
+ }
62
+
63
+ const MAX_STREAM_CHARS = 5_000_000;
64
+
65
+ /** Buffers one JSON-mode stream, keeping only lines parsePiStream consumes. */
66
+ export interface StreamCollector {
67
+ push(chunk: string): void;
68
+ finish(): string;
69
+ }
70
+
71
+ /**
72
+ * Buffers one JSON-mode stream, keeping only lines parsePiStream consumes and
73
+ * forwarding tool-start events to an optional sink. Keeping only assistant
74
+ * `message_end` events means a tool-heavy run cannot exhaust memory or
75
+ * truncate the final report. Events split across chunk boundaries are
76
+ * reassembled; malformed lines are ignored exactly as parsePiStream ignores
77
+ * them.
78
+ */
79
+ export function createStreamCollector(onEvent?: (event: PiStreamEvent) => void): StreamCollector {
80
+ let pending = "";
81
+ const kept: string[] = [];
82
+ const keep = (line: string) => {
83
+ let raw: unknown;
84
+ try {
85
+ raw = JSON.parse(line);
86
+ } catch {
87
+ return; // Malformed lines never parse; drop them.
88
+ }
89
+ if (raw === null || typeof raw !== "object") return;
90
+ const event = raw as { type?: string; toolName?: string; message?: { role?: string } };
91
+ if (event.type === "tool_execution_start" && typeof event.toolName === "string") {
92
+ onEvent?.({ type: "tool_execution_start", toolName: event.toolName });
93
+ }
94
+ if (event.type === "message_end" && event.message?.role === "assistant") kept.push(line);
95
+ };
96
+ return {
97
+ push(chunk) {
98
+ pending += chunk;
99
+ const lines = pending.split("\n");
100
+ pending = lines.pop() ?? "";
101
+ for (const line of lines) keep(line);
102
+ },
103
+ finish() {
104
+ if (pending) keep(pending);
105
+ pending = "";
106
+ return kept.join("\n");
107
+ },
108
+ };
109
+ }
110
+
111
+ /** Build the `pi` argv for one isolated, non-interactive agent run. */
112
+ export function buildPiArgs(options: PiRunOptions & { systemPromptFile?: string }): string[] {
113
+ const args = ["--mode", "json", "-p", "--no-session"];
114
+ if (options.model && options.model !== "inherit") args.push("--model", options.model);
115
+ if (options.thinking) args.push("--thinking", options.thinking);
116
+ if (options.tools && options.tools.length > 0) args.push("--tools", options.tools.join(","));
117
+ if (options.systemPromptFile) args.push("--append-system-prompt", options.systemPromptFile);
118
+ args.push(`Task: ${options.task}`);
119
+ return args;
120
+ }
121
+
122
+ interface MessageLike {
123
+ role?: string;
124
+ content?: unknown;
125
+ usage?: { input?: number; output?: number; cost?: { total?: number } };
126
+ model?: string;
127
+ stopReason?: string;
128
+ errorMessage?: string;
129
+ }
130
+
131
+ function messageText(content: unknown): string {
132
+ if (typeof content === "string") return content;
133
+ if (!Array.isArray(content)) return "";
134
+ return content
135
+ .filter((part): part is { type: string; text: string } =>
136
+ typeof part === "object" && part !== null && (part as { type?: string }).type === "text",
137
+ )
138
+ .map((part) => part.text)
139
+ .join("");
140
+ }
141
+
142
+ /** Parse the JSON-mode stream defensively; malformed lines are ignored. */
143
+ export function parsePiStream(stdout: string): ParsedStream {
144
+ const parsed: ParsedStream = { text: "", usage: { input: 0, output: 0, cost: 0, turns: 0 } };
145
+ for (const line of stdout.split("\n")) {
146
+ let event: { type?: string; message?: MessageLike };
147
+ try {
148
+ event = JSON.parse(line) as typeof event;
149
+ } catch {
150
+ continue;
151
+ }
152
+ if (event?.type !== "message_end" || event.message?.role !== "assistant") continue;
153
+ const message = event.message;
154
+ parsed.usage.turns += 1;
155
+ parsed.usage.input += message.usage?.input ?? 0;
156
+ parsed.usage.output += message.usage?.output ?? 0;
157
+ parsed.usage.cost += message.usage?.cost?.total ?? 0;
158
+ if (message.model) parsed.model = message.model;
159
+ if (message.stopReason) parsed.stopReason = message.stopReason;
160
+ if (message.errorMessage) parsed.errorMessage = message.errorMessage;
161
+ const text = messageText(message.content);
162
+ if (text.trim()) parsed.text = text;
163
+ }
164
+ return parsed;
165
+ }
166
+
167
+ /**
168
+ * Prefer the pi launcher that started this session so subagents use the same
169
+ * build; otherwise fall back to `pi` on PATH (or BOT_LOBBY_PI_BIN). We match
170
+ * on the launcher filename, not PI_SESSION_ID: that variable is inherited by
171
+ * every child process, including test runners and scripts, where argv[1] is
172
+ * not pi at all.
173
+ */
174
+ function resolvePiInvocation(args: string[]): { command: string; args: string[] } {
175
+ const launcher = process.argv[1];
176
+ if (launcher && existsSync(launcher) && isPiLauncher(launcher)) {
177
+ return { command: launcher, args };
178
+ }
179
+ return { command: process.env.BOT_LOBBY_PI_BIN ?? "pi", args };
180
+ }
181
+
182
+ /** Exported for tests: mis-detecting a non-pi script re-runs it as an agent. */
183
+ export function isPiLauncher(script: string): boolean {
184
+ const base = basename(script);
185
+ return base === "pi" || base === "pi.js" || script.includes("pi-coding-agent");
186
+ }
187
+
188
+ function terminate(proc: ChildProcess): void {
189
+ proc.kill("SIGTERM");
190
+ const escalation = setTimeout(() => {
191
+ if (proc.exitCode === null) proc.kill("SIGKILL");
192
+ }, 5000);
193
+ escalation.unref();
194
+ }
195
+
196
+ function wireOutput(
197
+ proc: ChildProcess,
198
+ buffers: { stdout: string; stderr: string },
199
+ onEvent?: (event: PiStreamEvent) => void,
200
+ ): () => void {
201
+ const collector = createStreamCollector(onEvent);
202
+ proc.stdout?.on("data", (chunk: Buffer) => collector.push(chunk.toString()));
203
+ proc.stderr?.on("data", (chunk: Buffer) => {
204
+ // Keep stderr head-bounded: it only feeds exit-code error messages.
205
+ if (buffers.stderr.length < MAX_STREAM_CHARS) buffers.stderr += chunk.toString();
206
+ });
207
+ return () => {
208
+ buffers.stdout = collector.finish();
209
+ };
210
+ }
211
+
212
+ /** Default runner: spawn the same pi binary in JSON mode and collect output. */
213
+ export function spawnPiProcess(args: string[], options: ProcessRunOptions): Promise<ProcessOutcome> {
214
+ return new Promise((resolve) => {
215
+ const invocation = resolvePiInvocation(args);
216
+ const proc = spawn(invocation.command, invocation.args, {
217
+ cwd: options.cwd,
218
+ // Marks subagent processes so bot-lobby skips master-only quiet wiring.
219
+ env: { ...process.env, BOT_LOBBY_SUBAGENT: "1" },
220
+ shell: false,
221
+ stdio: ["ignore", "pipe", "pipe"],
222
+ });
223
+ const buffers = { stdout: "", stderr: "" };
224
+ const state = { killed: false, timedOut: false };
225
+ const flush = wireOutput(proc, buffers, options.onEvent);
226
+
227
+ const finish = (exitCode: number) => {
228
+ clearTimeout(timer);
229
+ options.signal?.removeEventListener("abort", onAbort);
230
+ flush();
231
+ resolve({ exitCode, ...buffers, ...state });
232
+ };
233
+ const onAbort = () => {
234
+ state.killed = true;
235
+ terminate(proc);
236
+ };
237
+ const timer = setTimeout(() => {
238
+ state.killed = true;
239
+ state.timedOut = true;
240
+ terminate(proc);
241
+ }, options.timeoutMs);
242
+
243
+ proc.on("close", (code) => finish(code ?? 0));
244
+ proc.on("error", (error: Error) => {
245
+ buffers.stderr += error.message;
246
+ finish(1);
247
+ });
248
+ if (options.signal?.aborted) onAbort();
249
+ else options.signal?.addEventListener("abort", onAbort, { once: true });
250
+ });
251
+ }
252
+
253
+ function toResult(parsed: ParsedStream, outcome: ProcessOutcome, aborted: boolean): PiRunResult {
254
+ const base = { output: parsed.text, usage: parsed.usage, model: parsed.model };
255
+ if (outcome.timedOut) return { ...base, status: "timeout", error: "agent timed out" };
256
+ if (outcome.killed || aborted || parsed.stopReason === "aborted") {
257
+ return { ...base, status: "cancelled", error: "agent was cancelled" };
258
+ }
259
+ if (outcome.exitCode !== 0) {
260
+ const detail = parsed.errorMessage ?? outcome.stderr.trim();
261
+ return { ...base, status: "failed", error: detail || `pi exited with code ${outcome.exitCode}` };
262
+ }
263
+ if (parsed.stopReason === "error") {
264
+ return { ...base, status: "failed", error: parsed.errorMessage ?? "agent reported an error" };
265
+ }
266
+ if (!parsed.text.trim()) return { ...base, status: "failed", error: "agent produced no usable output" };
267
+ return { ...base, status: "success" };
268
+ }
269
+
270
+ function writePromptFile(prompt: string): { file: string; cleanup: () => void } {
271
+ const dir = mkdtempSync(join(tmpdir(), "bot-lobby-prompt-"));
272
+ const file = join(dir, "system.md");
273
+ writeFileSync(file, prompt, { encoding: "utf8", mode: 0o600 });
274
+ return { file, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
275
+ }
276
+
277
+ /** Run one isolated subagent and normalise its outcome. */
278
+ export async function runPiAgent(options: PiRunOptions, run: ProcessRunner = spawnPiProcess): Promise<PiRunResult> {
279
+ const prompt = options.systemPrompt ? writePromptFile(options.systemPrompt) : undefined;
280
+ try {
281
+ const args = buildPiArgs({ ...options, systemPromptFile: prompt?.file });
282
+ const outcome = await run(args, {
283
+ cwd: options.cwd,
284
+ signal: options.signal,
285
+ timeoutMs: options.timeoutMs,
286
+ onEvent: (event) => options.onActivity?.(activityWord(event.toolName)),
287
+ });
288
+ return toResult(parsePiStream(outcome.stdout), outcome, options.signal?.aborted === true);
289
+ } finally {
290
+ prompt?.cleanup();
291
+ }
292
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerCommands } from "./pi/commands.ts";
3
+ import { registerLifecycle } from "./pi/events.ts";
4
+ import { registerOrchestrateTool } from "./pi/tools.ts";
5
+ import { onTransition } from "./state/task-state.ts";
6
+ import { ping } from "./pi/notify.ts";
7
+
8
+ export default function (pi: ExtensionAPI): void {
9
+ registerLifecycle(pi, CONFIG_DIR_NAME);
10
+ onTransition((task) => ping(task.state, task.title));
11
+ registerCommands(pi, CONFIG_DIR_NAME);
12
+ registerOrchestrateTool(pi, CONFIG_DIR_NAME);
13
+ }
@@ -0,0 +1,135 @@
1
+ import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import type { KnowledgeAgent } from "./paths.ts";
4
+ import { AGENT_DIR_NAMES, KNOWLEDGE_FILES, knowledgeDir } from "./paths.ts";
5
+ import { readFirstExisting, readFileOr, writeFileEnsured } from "./store.ts";
6
+
7
+ export interface KnowledgeAnalysis {
8
+ /** Lines that say the same thing twice. */
9
+ duplicates: string[];
10
+ /** Lines with hedging or unresolved wording. */
11
+ ambiguity: string[];
12
+ /** Different statements sharing a subject: candidates for conflict review. */
13
+ conflictCandidates: { subject: string; statements: string[] }[];
14
+ }
15
+
16
+ const AMBIGUITY = /\bmaybe|possibly|probably|tbd|todo|unclear|not sure|some kind|etc\.|approximately\b/i;
17
+ const HEDGE_STOPWORDS = new Set(["this", "that", "with", "from", "into", "when", "they", "have", "will", "should", "must"]);
18
+
19
+ function significantLines(content: string, minLength = 12): string[] {
20
+ return content
21
+ .split("\n")
22
+ .map((line) => line.replace(/^[-*#>\s]+/, "").trim())
23
+ .filter((line) => line.length >= minLength);
24
+ }
25
+
26
+ function normalize(line: string): string {
27
+ return line
28
+ .toLowerCase()
29
+ .replace(/[^\w\s]/g, "")
30
+ .replace(/\s+/g, " ")
31
+ .trim();
32
+ }
33
+
34
+ function subjectKey(line: string): string {
35
+ return normalize(line)
36
+ .split(" ")
37
+ .filter((word) => word.length > 3 && !HEDGE_STOPWORDS.has(word))
38
+ .slice(0, 2)
39
+ .join(" ");
40
+ }
41
+
42
+ function findDuplicates(lines: string[]): string[] {
43
+ const seen = new Map<string, string>();
44
+ const duplicates: string[] = [];
45
+ for (const line of lines) {
46
+ const key = normalize(line);
47
+ if (seen.has(key)) duplicates.push(line);
48
+ else seen.set(key, line);
49
+ }
50
+ return duplicates;
51
+ }
52
+
53
+ function findConflicts(lines: string[]): { subject: string; statements: string[] }[] {
54
+ const groups = new Map<string, Set<string>>();
55
+ for (const line of lines) {
56
+ const key = subjectKey(line);
57
+ if (key.split(" ").length < 2) continue;
58
+ const statements = groups.get(key) ?? new Set<string>();
59
+ statements.add(line);
60
+ groups.set(key, statements);
61
+ }
62
+ return [...groups.entries()]
63
+ .filter(([, statements]) => statements.size > 1)
64
+ .map(([subject, statements]) => ({ subject, statements: [...statements] }));
65
+ }
66
+
67
+ /** Mechanical candidates that help the Master decide what to compact (§24). */
68
+ export function analyzeKnowledge(content: string): KnowledgeAnalysis {
69
+ const lines = significantLines(content);
70
+ return {
71
+ duplicates: findDuplicates(lines),
72
+ ambiguity: lines.filter((line) => AMBIGUITY.test(line)),
73
+ conflictCandidates: findConflicts(lines),
74
+ };
75
+ }
76
+
77
+ export interface OversizedFile {
78
+ agent: KnowledgeAgent;
79
+ file: string;
80
+ chars: number;
81
+ }
82
+
83
+ /** Knowledge files past the configured compaction threshold, sized from the merged read roots. */
84
+ export function overThreshold(roots: readonly string[], threshold: number): OversizedFile[] {
85
+ const oversized: OversizedFile[] = [];
86
+ for (const agent of Object.keys(AGENT_DIR_NAMES) as KnowledgeAgent[]) {
87
+ for (const file of KNOWLEDGE_FILES[agent]) {
88
+ const paths = roots.map((root) => join(knowledgeDir(root, agent), file));
89
+ const chars = readFirstExisting(paths).length;
90
+ if (chars > threshold) oversized.push({ agent, file, chars });
91
+ }
92
+ }
93
+ return oversized.sort((a, b) => b.chars - a.chars);
94
+ }
95
+
96
+ function archiveDir(dataRoot: string, agent: KnowledgeAgent): string {
97
+ return join(dataRoot, "archive", AGENT_DIR_NAMES[agent]);
98
+ }
99
+
100
+ function pruneArchives(dir: string, file: string, keep: number): void {
101
+ const prefix = `${file}.`;
102
+ const backups = readdirSync(dir)
103
+ .filter((entry) => entry.startsWith(prefix) && entry.endsWith(".bak"))
104
+ .sort();
105
+ for (const stale of backups.slice(0, Math.max(0, backups.length - keep))) {
106
+ rmSync(join(dir, stale), { force: true });
107
+ }
108
+ }
109
+
110
+ /**
111
+ * §24: archive the current file outside the retrieval paths, then replace it
112
+ * with the compacted version the Master approved.
113
+ */
114
+ export function compactKnowledgeFile(entry: {
115
+ dataRoot: string;
116
+ agent: KnowledgeAgent;
117
+ file: string;
118
+ content: string;
119
+ backupCount: number;
120
+ now?: Date;
121
+ }): { archive: string; before: number; after: number } {
122
+ if (!KNOWLEDGE_FILES[entry.agent].includes(entry.file)) {
123
+ throw new Error(`"${entry.file}" is not a knowledge file for ${entry.agent}`);
124
+ }
125
+ const path = join(knowledgeDir(entry.dataRoot, entry.agent), entry.file);
126
+ const previous = readFileOr(path);
127
+ const dir = archiveDir(entry.dataRoot, entry.agent);
128
+ mkdirSync(dir, { recursive: true });
129
+ const stamp = (entry.now ?? new Date()).toISOString().replace(/[:.]/g, "-");
130
+ const archive = join(dir, `${basename(entry.file)}.${stamp}.bak`);
131
+ writeFileSync(archive, previous, "utf8");
132
+ writeFileEnsured(path, `${entry.content.trim()}\n`);
133
+ pruneArchives(dir, entry.file, Math.max(1, entry.backupCount));
134
+ return { archive, before: previous.length, after: entry.content.trim().length };
135
+ }
@@ -0,0 +1,43 @@
1
+ import { join } from "node:path";
2
+ import type { Domain } from "../schemas/agent.ts";
3
+
4
+ export type KnowledgeAgent = Domain | "master";
5
+
6
+ /** Title-cased directory names per the build plan §21. */
7
+ export const AGENT_DIR_NAMES: Record<KnowledgeAgent, string> = {
8
+ master: "Master",
9
+ designer: "Designer",
10
+ backend: "Backend",
11
+ qa: "QA",
12
+ };
13
+
14
+ /** Domain-specific standards file per agent (§21). */
15
+ export const STANDARDS_FILE: Record<KnowledgeAgent, string> = {
16
+ master: "standards.md",
17
+ designer: "design-language.md",
18
+ backend: "engineering-standards.md",
19
+ qa: "testing-standards.md",
20
+ };
21
+
22
+ export const KNOWLEDGE_FILES: Record<KnowledgeAgent, readonly string[]> = {
23
+ master: ["knowledge.md", STANDARDS_FILE.master, "decisions.md", "completed-tasks.md"],
24
+ designer: ["knowledge.md", STANDARDS_FILE.designer, "decisions.md", "completed-tasks.md"],
25
+ backend: ["knowledge.md", STANDARDS_FILE.backend, "decisions.md", "completed-tasks.md"],
26
+ qa: ["knowledge.md", STANDARDS_FILE.qa, "decisions.md", "completed-tasks.md"],
27
+ };
28
+
29
+ export function knowledgeDir(dataRoot: string, agent: KnowledgeAgent): string {
30
+ return join(dataRoot, AGENT_DIR_NAMES[agent], "knowledge");
31
+ }
32
+
33
+ export function tasksRoot(dataRoot: string): string {
34
+ return join(dataRoot, "tasks");
35
+ }
36
+
37
+ export function taskDir(dataRoot: string, taskId: string): string {
38
+ return join(tasksRoot(dataRoot), taskId);
39
+ }
40
+
41
+ export function scratchpadPath(taskDir: string, domain: Domain): string {
42
+ return join(taskDir, `${domain}.md`);
43
+ }
@@ -0,0 +1,82 @@
1
+ const STOPWORDS = new Set([
2
+ "the", "and", "for", "with", "that", "this", "from", "into", "when", "then",
3
+ "than", "over", "under", "should", "must", "will", "not", "are", "was",
4
+ "has", "have", "its", "a", "an", "of", "to", "in", "on", "or", "is", "be",
5
+ "as", "by", "at", "it", "we", "you", "our", "use",
6
+ ]);
7
+
8
+ export interface KnowledgeSelection {
9
+ knowledge: string;
10
+ standards: string;
11
+ decisions: string;
12
+ }
13
+
14
+ export interface KnowledgeInput {
15
+ knowledge?: string;
16
+ standards?: string;
17
+ decisions?: string;
18
+ }
19
+
20
+ export function tokenize(text: string): string[] {
21
+ return [
22
+ ...new Set(
23
+ text
24
+ .toLowerCase()
25
+ .split(/[^a-z0-9_.-]+/)
26
+ .filter((token) => token.length >= 4 && !STOPWORDS.has(token)),
27
+ ),
28
+ ];
29
+ }
30
+
31
+ /** Split markdown into heading-anchored sections, keeping the preamble. */
32
+ export function splitSections(markdown: string): string[] {
33
+ return markdown
34
+ .split(/\n(?=#{1,3}\s)/)
35
+ .map((part) => part.trim())
36
+ .filter((part) => part.length > 0);
37
+ }
38
+
39
+ function score(sectionText: string, tokens: string[]): number {
40
+ const lower = sectionText.toLowerCase();
41
+ return tokens.reduce((total, token) => total + (lower.includes(token) ? 1 : 0), 0);
42
+ }
43
+
44
+ function takeWithin(parts: string[], budgetChars: number): string {
45
+ const kept: string[] = [];
46
+ let used = 0;
47
+ for (const part of parts) {
48
+ if (used + part.length > budgetChars) break;
49
+ kept.push(part);
50
+ used += part.length + 2;
51
+ }
52
+ return kept.join("\n\n").trim();
53
+ }
54
+
55
+ /**
56
+ * Keep the whole file when it fits; otherwise return the sections most
57
+ * relevant to the task, bounded by the budget.
58
+ */
59
+ export function selectRelevant(query: string, content: string, budgetChars: number): string {
60
+ const trimmed = content.trim();
61
+ if (trimmed.length <= budgetChars) return trimmed;
62
+ const tokens = tokenize(query);
63
+ const ranked = splitSections(trimmed)
64
+ .map((text) => ({ text, score: score(text, tokens) }))
65
+ .sort((a, b) => b.score - a.score);
66
+ const relevant = ranked.filter((entry) => entry.score > 0);
67
+ const chosen = relevant.length > 0 ? relevant : ranked;
68
+ return takeWithin(chosen.map((entry) => entry.text), budgetChars);
69
+ }
70
+
71
+ /** Select the relevant slice of each knowledge file for this task. */
72
+ export function selectKnowledge(
73
+ task: string,
74
+ files: KnowledgeInput,
75
+ budgetChars = 4000,
76
+ ): KnowledgeSelection {
77
+ return {
78
+ knowledge: selectRelevant(task, files.knowledge ?? "", budgetChars),
79
+ standards: selectRelevant(task, files.standards ?? "", budgetChars),
80
+ decisions: selectRelevant(task, files.decisions ?? "", budgetChars),
81
+ };
82
+ }
@@ -0,0 +1,111 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { knowledgeDir, STANDARDS_FILE, type KnowledgeAgent } from "./paths.ts";
4
+
5
+ export function readFileOr(path: string, fallback = ""): string {
6
+ try {
7
+ return readFileSync(path, "utf8");
8
+ } catch {
9
+ return fallback;
10
+ }
11
+ }
12
+
13
+ /** First existing file among `paths`, in order; merged legacy reads use it per file. */
14
+ export function readFirstExisting(paths: readonly string[], fallback = ""): string {
15
+ for (const path of paths) if (existsSync(path)) return readFileOr(path, fallback);
16
+ return fallback;
17
+ }
18
+
19
+ export function writeFileEnsured(path: string, content: string): void {
20
+ mkdirSync(dirname(path), { recursive: true });
21
+ writeFileSync(path, content, "utf8");
22
+ }
23
+
24
+ /** Seed content for freshly created knowledge files (§21). */
25
+ export const DEFAULT_KNOWLEDGE_CONTENT: Record<string, string> = {
26
+ "knowledge.md": "# Knowledge\n\nStable facts and useful patterns for this project.\n",
27
+ "standards.md": "# Standards\n\nUniversal project rules.\n",
28
+ "decisions.md": "# Decisions\n\nSignificant decisions and their rationale.\n",
29
+ "completed-tasks.md": "# Completed Tasks\n\nShort operational history.\n",
30
+ "design-language.md": "# Design Language\n\nVisual language, accessibility, and frontend conventions.\n",
31
+ "engineering-standards.md": "# Engineering Standards\n\nBackend, API, and security standards.\n",
32
+ "testing-standards.md": "# Testing Standards\n\nTesting conventions and definition of done.\n",
33
+ };
34
+
35
+ export function ensureFile(path: string, content: string): void {
36
+ if (!existsSync(path)) writeFileEnsured(path, content);
37
+ }
38
+
39
+ /** Append a line under a date heading, creating the heading when needed. */
40
+ function appendDatedEntry(path: string, line: string, now: Date): void {
41
+ const date = now.toISOString().slice(0, 10);
42
+ const header = `# ${date}`;
43
+ const body = readFileOr(path, "").trimEnd();
44
+ const updated = body.includes(header) ? `${body}\n${line}` : `${body}\n\n${header}\n\n${line}`;
45
+ writeFileEnsured(path, `${updated.trimStart()}\n`);
46
+ }
47
+
48
+ /** Append one operational-history line to an agent's completed-tasks log. */
49
+ export function appendCompletedTask(dir: string, text: string, now = new Date()): void {
50
+ appendDatedEntry(join(dir, "completed-tasks.md"), `- [x] ${text}`, now);
51
+ }
52
+
53
+ /** Append one decision to an agent's decisions log. */
54
+ export function appendDecision(dir: string, text: string, now = new Date()): void {
55
+ appendDatedEntry(join(dir, "decisions.md"), `- ${text}`, now);
56
+ }
57
+
58
+ export type KnowledgeKind = "knowledge" | "standard" | "decision" | "completed";
59
+
60
+ /** The persistent file a knowledge kind belongs to for one agent (§21). */
61
+ export function knowledgeFilePath(dataRoot: string, agent: KnowledgeAgent, kind: KnowledgeKind): string {
62
+ const dir = knowledgeDir(dataRoot, agent);
63
+ if (kind === "knowledge") return join(dir, "knowledge.md");
64
+ if (kind === "standard") return join(dir, STANDARDS_FILE[agent]);
65
+ if (kind === "decision") return join(dir, "decisions.md");
66
+ return join(dir, "completed-tasks.md");
67
+ }
68
+
69
+ export interface KnowledgeEntry {
70
+ dataRoot: string;
71
+ agent: KnowledgeAgent;
72
+ kind: KnowledgeKind;
73
+ text: string;
74
+ now?: Date;
75
+ }
76
+
77
+ /**
78
+ * The only write path into persistent knowledge. Deduplicates before writing,
79
+ * so re-accepting the same insight cannot bloat the file.
80
+ */
81
+ export function applyKnowledge(entry: KnowledgeEntry): { path: string; result: "added" | "duplicate" | "empty" } {
82
+ const text = entry.text.trim();
83
+ if (!text) return { path: "", result: "empty" };
84
+ const path = knowledgeFilePath(entry.dataRoot, entry.agent, entry.kind);
85
+ const existing = readFileOr(path);
86
+ if (existing.toLowerCase().includes(text.toLowerCase())) return { path, result: "duplicate" };
87
+ const now = entry.now ?? new Date();
88
+ if (entry.kind === "decision") appendDecision(dirname(path), text, now);
89
+ else if (entry.kind === "completed") appendCompletedTask(dirname(path), text, now);
90
+ else writeFileEnsured(path, `${existing.trimEnd()}\n\n${text}\n`);
91
+ return { path, result: "added" };
92
+ }
93
+
94
+ /** Read an agent's knowledge slices for prompt context selection (§23). */
95
+ export function readKnowledgeSlices(
96
+ dirs: readonly string[],
97
+ standardsFile: string,
98
+ ): { knowledge: string; standards: string; decisions: string; completed: string } {
99
+ const file = (name: string) => readFirstExisting(dirs.map((dir) => join(dir, name)));
100
+ return {
101
+ knowledge: file("knowledge.md"),
102
+ standards: file(standardsFile),
103
+ decisions: file("decisions.md"),
104
+ completed: file("completed-tasks.md"),
105
+ };
106
+ }
107
+
108
+ /** Read an agent's knowledge by agent key; bot-lobby dirs win per file over the pre-rename ones. */
109
+ export function readAgentKnowledge(roots: readonly string[], agent: KnowledgeAgent) {
110
+ return readKnowledgeSlices(roots.map((root) => knowledgeDir(root, agent)), STANDARDS_FILE[agent]);
111
+ }