@a-t-h-i/bot-lobby 0.6.59 → 0.6.60

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.
@@ -8,7 +8,12 @@ import { runAgent, runParallel, watchdogOptions, type AgentRequest } from "../ex
8
8
  import { spawnPiProcess, type ProcessRunner, type RelayAsk } from "../execution/pi-runner.ts";
9
9
  import { knowledgeFilePath, readAgentKnowledge, writeFileEnsured } from "../knowledge/store.ts";
10
10
  import { taskDir } from "../knowledge/paths.ts";
11
- import { selectKnowledge, type KnowledgeSelection } from "../knowledge/selector.ts";
11
+ import { selectKnowledge, selectRelevant, type KnowledgeSelection } from "../knowledge/selector.ts";
12
+ import { appendPost, assignmentId } from "../desk/board.ts";
13
+ import { refreshCatalogue } from "../knowledge/catalogue.ts";
14
+ import { notesFor } from "../knowledge/retrieval.ts";
15
+ import { assignmentKey, applySelection, bookmarkBlock, BOOKMARK_DEADLINE_MS, BOOKMARKS_VERSION, deliverBookmarks, fitHints, lexicalBookmarks, type BookmarkState, type Selection } from "../knowledge/bookmarks.ts";
16
+ import { KNOWLEDGE_BUDGET_CHARS, type BookmarkPick, type BookmarkSelectorInput, type KnowledgePaths, type KnowledgePicker } from "../classifier/knowledge.ts";
12
17
  import { scriptsFor } from "../scripts/pick.ts";
13
18
  import { summarizeOutcomes } from "./synthesis.ts";
14
19
  import { parseWorkerResult, validateWorkerResult } from "../roles/worker.ts";
@@ -20,8 +25,8 @@ import { toolBudgetEnv, toolBudgetNote } from "../pi/tool-budget.ts";
20
25
  import { recordFileNotes } from "../knowledge/file-notes.ts";
21
26
  import { readCachedScout, scoutKey, writeCachedScout } from "./scout-cache.ts";
22
27
  import { BOARD_ENV, BOARD_TOOLS, boardContext } from "../desk/board.ts";
28
+ import { seedDelivery } from "../desk/delivery.ts";
23
29
  import type { FileHinter } from "../classifier/files.ts";
24
- import type { KnowledgePaths, KnowledgePicker } from "../classifier/knowledge.ts";
25
30
  import { fellShort, profileLabel, routeLabel, type EffortRoute, type EffortRouter } from "../classifier/effort.ts";
26
31
 
27
32
  export interface ScoutOutcome {
@@ -82,13 +87,21 @@ function knowledgePaths(dataRoots: readonly string[], agent: Domain): KnowledgeP
82
87
  * the files that are too long when a picker is given, the keyword selection
83
88
  * otherwise and whenever the picker fails; and the shared scripts that fit it.
84
89
  */
85
- async function pickKnowledge(request: { knowledge?: KnowledgePicker; dataRoots: readonly string[]; signal?: AbortSignal }, agent: Domain, query: string): Promise<KnowledgeSelection> {
90
+ /** Retrieval mode replaces the historical knowledge and decision dumps with assignment bookmarks; standards stay direct. */
91
+ function retrievalMode(request: { config?: BotLobbyConfig }): boolean {
92
+ return request.config?.workflow.knowledgeRetrieval !== false && request.config !== undefined;
93
+ }
94
+
95
+ async function pickKnowledge(request: { config?: BotLobbyConfig; knowledge?: KnowledgePicker; dataRoots: readonly string[]; signal?: AbortSignal }, agent: Domain, query: string): Promise<KnowledgeSelection> {
86
96
  const [selected, scripts] = await Promise.all([pickFiles(request, agent, query), scriptsFor(request.dataRoots[0], query, request.knowledge?.scripts, request.signal)]);
87
97
  return scripts ? { ...selected, scripts } : selected;
88
98
  }
89
99
 
90
- async function pickFiles(request: { knowledge?: KnowledgePicker; dataRoots: readonly string[]; signal?: AbortSignal }, agent: Domain, query: string): Promise<KnowledgeSelection> {
100
+ async function pickFiles(request: { config?: BotLobbyConfig; knowledge?: KnowledgePicker; dataRoots: readonly string[]; signal?: AbortSignal }, agent: Domain, query: string): Promise<KnowledgeSelection> {
91
101
  const slices = readAgentKnowledge(request.dataRoots, agent);
102
+ if (retrievalMode(request)) {
103
+ return { knowledge: "", decisions: "", standards: selectRelevant(query, slices.standards ?? "", KNOWLEDGE_BUDGET_CHARS) };
104
+ }
92
105
  if (!request.knowledge) return selectKnowledge(query, slices);
93
106
  try {
94
107
  return await request.knowledge.select(query, slices, { paths: knowledgePaths(request.dataRoots, agent), ...(request.signal ? { signal: request.signal } : {}) });
@@ -97,10 +110,91 @@ async function pickFiles(request: { knowledge?: KnowledgePicker; dataRoots: read
97
110
  }
98
111
  }
99
112
 
100
- function scoutContext(request: ScoutRequest, domain: Domain, selected: KnowledgeSelection, likely = ""): AgentRequest["context"] {
113
+ /** Resolve with the value, or `fallback` once `ms` pass: bookmark preparation never blocks the step. */
114
+ async function within<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
115
+ let timer: ReturnType<typeof setTimeout> | undefined;
116
+ const late = new Promise<T>((resolve) => {
117
+ timer = setTimeout(() => resolve(fallback), ms);
118
+ });
119
+ return Promise.race([promise, late]).finally(() => clearTimeout(timer));
120
+ }
121
+
122
+ /** The lexical picks as the bookmark list records them. */
123
+ function lexicalPicks(input: BookmarkSelectorInput): BookmarkPick[] {
124
+ return lexicalBookmarks(input.assignment, input.entries).map((pick) => ({ ...pick, source: "lexical" as const }));
125
+ }
126
+
127
+ /** Post a bookmark's correction signals to the task board as oracle notes; the delivered mark says verify regardless. */
128
+ function postCorrections(board: string | undefined, corrections: readonly string[]): void {
129
+ for (const text of corrections) {
130
+ try {
131
+ appendPost(board!, { from: "oracle", kind: "note", text });
132
+ } catch {
133
+ // No board (or it is busy): the delivered bookmark still carries its changed/stale mark.
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Select and deliver one recipient's assignment bookmarks: bounded catalogue
140
+ * metadata in, at most eight references out (the classifier's picks, or the
141
+ * lexical fallback when it is off, slow or failing), persisted in the task's
142
+ * storage, revalidated at delivery, rendered within the 3,000-character
143
+ * metadata budget. Returns "" in legacy mode; a coverage line when nothing
144
+ * was selected, so the agent knows the retrieval tools are there.
145
+ */
146
+ async function prepareBookmarks(
147
+ request: { taskId: string; cwd: string; dataRoots: readonly string[]; config: BotLobbyConfig; knowledge?: KnowledgePicker; board?: string; signal?: AbortSignal },
148
+ recipient: string,
149
+ query: string,
150
+ ): Promise<string> {
151
+ const dir = request.dataRoots[0];
152
+ if (!request.config.workflow.knowledgeRetrieval || !dir) return "";
153
+ const { catalogue, coverage } = refreshCatalogue(request.dataRoots);
154
+ const input: BookmarkSelectorInput = {
155
+ assignment: query,
156
+ entries: catalogue.entries.filter((entry) => entry.freshness !== "stale"),
157
+ catalogueRevision: catalogue.revision,
158
+ cacheKey: `${request.taskId}:${recipient}`,
159
+ ...(request.signal ? { signal: request.signal } : {}),
160
+ };
161
+ const picks = request.knowledge?.bookmarks
162
+ ? await within(request.knowledge.bookmarks.select(input), BOOKMARK_DEADLINE_MS + 500, lexicalPicks(input))
163
+ : lexicalPicks(input);
164
+ const selections: Selection[] = picks.map(({ ref, reason, revision }) => ({ ref, reason, revision }));
165
+ const authority = picks.some((pick) => pick.source === "classifier") ? "classifier" : "lexical";
166
+ const state = bookmarkState(dir, request, recipient, query, catalogue.revision, selections, authority);
167
+ const access = { dataRoots: request.dataRoots, cwd: request.cwd };
168
+ const notes = notesFor(dir);
169
+ postCorrections(request.board, deliverBookmarks(state, recipient, catalogue, access, notes).corrections);
170
+ return bookmarkBlock(state, recipient, catalogue, access, notes) || `No bookmarks were selected for this assignment (catalogue coverage: ${coverage}); search_knowledge finds the recorded knowledge.`;
171
+ }
172
+
173
+ /** The persisted bookmark list after this selection; a busy list still delivers this run's picks. */
174
+ function bookmarkState(
175
+ dir: string,
176
+ request: { taskId: string },
177
+ recipient: string,
178
+ query: string,
179
+ sourceRevision: number,
180
+ selections: Selection[],
181
+ authority: "classifier" | "lexical",
182
+ ): BookmarkState {
183
+ try {
184
+ return applySelection(dir, request.taskId, recipient, assignmentKey(query), sourceRevision, selections, authority);
185
+ } catch {
186
+ return {
187
+ version: BOOKMARKS_VERSION, taskId: request.taskId, assignment: assignmentKey(query), sourceRevision,
188
+ records: selections.map((pick) => ({ ref: pick.ref, recipient, authority, reason: pick.reason, sourceVersion: `${sourceRevision}`, revision: pick.revision, at: new Date().toISOString() })),
189
+ };
190
+ }
191
+ }
192
+
193
+ function scoutContext(request: ScoutRequest, domain: Domain, selected: KnowledgeSelection, likely = "", bookmarks = ""): AgentRequest["context"] {
101
194
  return {
102
195
  task: request.taskText,
103
196
  ...selected,
197
+ ...(bookmarks ? { bookmarks } : {}),
104
198
  instructions: request.config.agents[domain].instructions,
105
199
  workflowContext: [`Task state: scouting. Domain: ${domain}. Read-only reconnaissance; no implementation.`, toolBudgetNote(request.config.scout.maxToolCalls), likely].filter(Boolean).join("\n\n"),
106
200
  };
@@ -176,20 +270,23 @@ async function remember(dataDir: string, request: ScoutRequest, outcomes: readon
176
270
  }
177
271
 
178
272
  async function scoutDomains(request: ScoutRequest, run: ProcessRunner): Promise<ScoutOutcome[]> {
179
- // Each scout gets the files most likely to answer its own instruction and focus.
180
- const [likely, selected] = await Promise.all([
273
+ // Each scout gets the files most likely to answer its own instruction and focus, and its assignment's bookmarks.
274
+ const [likely, selected, bookmarked] = await Promise.all([
181
275
  Promise.all(request.domains.map((domain) => likelyFor(request.hints, `${request.instruction}\n${domainSpec(domain).scoutFocus}`, request.signal, request.taskText))),
182
276
  Promise.all(request.domains.map((domain) => pickKnowledge(request, domain, `${request.instruction}\n${domainSpec(domain).scoutFocus}\n\n${request.taskText}`))),
277
+ Promise.all(request.domains.map((domain) => prepareBookmarks(request, domain, `${request.instruction}\n${domainSpec(domain).scoutFocus}\n\n${request.taskText}`))),
183
278
  ]);
184
279
  const extraTools = request.hints?.tools() ?? [];
185
- const requests: AgentRequest[] = request.domains.map((domain, index) => ({
280
+ const requests: AgentRequest[] = request.domains.map((domain, index) => {
281
+ const hints = fitHints(bookmarked[index] ?? "", likely[index] ?? "");
282
+ return {
186
283
  delegationQuota: { used: 0 },
187
284
  effort: request.effort,
188
285
  taskId: request.taskId,
189
286
  domain,
190
287
  role: "scout",
191
288
  instruction: scoutInstruction(request, domain),
192
- context: scoutContext(request, domain, selected[index]!, likely[index]),
289
+ context: scoutContext(request, domain, selected[index]!, hints.likely, hints.bookmarks),
193
290
  ...(extraTools.length > 0 ? { extraTools } : {}),
194
291
  env: toolBudgetEnv(request.config.scout.maxToolCalls),
195
292
  ...profileFields(request.config, request.profile, domain, "scout"),
@@ -197,7 +294,8 @@ async function scoutDomains(request: ScoutRequest, run: ProcessRunner): Promise<
197
294
  signal: request.signal,
198
295
  onUpdate: request.onUpdate,
199
296
  ...watchdogOptions(request.config.workflow),
200
- }));
297
+ };
298
+ });
201
299
  // Scouts think at a fixed level; a trivial one may move to the cheaper model (a pi model, so not on another harness).
202
300
  const routes = await Promise.all(requests.map((entry) => entry.harness ? undefined : routeFor(request.effort, entry.instruction, { ...(entry.model ? { model: entry.model } : {}), thinking: entry.thinking ?? "low" }, { thinkingFixed: true, context: request.taskText, ...(request.signal ? { signal: request.signal } : {}) })));
203
301
  const first = requests.map((entry, index) => (routes[index] ? { ...entry, ...routedFields(routes[index]!) } : entry));
@@ -283,6 +381,8 @@ export interface WorkerRequest {
283
381
  risk?: string;
284
382
  /** The task board's file: the worker reads what its role follows and posts contracts, decisions and blockers. */
285
383
  board?: string;
384
+ /** The worker's board identity (`backend:9f2c1e`): one per assignment, so two same-role workers never share delivery state. Minted when omitted. */
385
+ boardFrom?: string;
286
386
  /** Domains working at the same time as this one, in a parallel batch. */
287
387
  alongside?: readonly Domain[];
288
388
  /** The worker has a worktree of its own for the batch; its change is applied back when the batch ends. */
@@ -292,11 +392,13 @@ export interface WorkerRequest {
292
392
  }
293
393
 
294
394
  /** How a worker uses the task board, and what it already says for its role. */
295
- function boardGuidance(request: WorkerRequest): string {
395
+ function boardGuidance(request: WorkerRequest, reader: string): string {
296
396
  if (!request.board) return "";
397
+ // The board lines below are a delivery: record them before any push can race the read.
398
+ seedDelivery(request.board, reader);
297
399
  return [
298
- "Task board: post with `post_board` whenever you settle something another agent builds against or must follow (`contract`: a route, payload, type, event or file location; `decision`), when you are blocked on someone else's part (`blocker`), and when a piece is finished and testable (`done`). Exact facts in a line or two; no progress chatter. `read_board` shows what the others posted.",
299
- boardContext(request.board, request.domain),
400
+ "Task board: post with `post_board` whenever you settle something another agent builds against or must follow (`contract`: a route, payload, type, event or file location; `decision`), when you are blocked on someone else's part (`blocker`), and when a piece is finished and testable (`done`). Exact facts in a line or two; no progress chatter. `read_board` shows what the others posted; `ack_board` records the events you actually incorporated — reading alone does not.",
401
+ boardContext(request.board, reader),
300
402
  ].filter(Boolean).join("\n\n");
301
403
  }
302
404
 
@@ -327,7 +429,7 @@ function askGuidance(previews: string): string {
327
429
  ].join(" ");
328
430
  }
329
431
 
330
- function workerWorkflowContext(request: WorkerRequest, likely = ""): string {
432
+ function workerWorkflowContext(request: WorkerRequest, boardFrom: string, likely = ""): string {
331
433
  const spec = domainSpec(request.domain);
332
434
  const own = request.scoutOutcomes.filter(
333
435
  (outcome) => outcome.result.domain === request.domain && outcome.usable,
@@ -342,7 +444,7 @@ function workerWorkflowContext(request: WorkerRequest, likely = ""): string {
342
444
  likely,
343
445
  request.risk ?? "",
344
446
  alongsideGuidance(request),
345
- boardGuidance(request),
447
+ boardGuidance(request, boardFrom),
346
448
  request.ask ? askGuidance(request.ask.previews) : "",
347
449
  request.dataRoots[0] ? `Scripts you draft for the shared library go in \`${join(taskDir(request.dataRoots[0], request.taskId), "scripts")}\`.` : "",
348
450
  request.dataRoots[0] ? `Long findings (an investigation, a list of call sites, a log excerpt) go in a file under \`${join(taskDir(request.dataRoots[0], request.taskId), "notes")}\`: give its path under Notes instead of pasting them into your report, so whoever needs them reads them whole.` : "",
@@ -355,14 +457,17 @@ export async function runWorker(
355
457
  run: ProcessRunner = spawnPiProcess,
356
458
  ): Promise<WorkerOutcome> {
357
459
  const configured = profileFields(request.config, request.profile, request.domain, "worker");
358
- const [selected, likely, route] = await Promise.all([
460
+ const [selected, likely, route, bookmarked] = await Promise.all([
359
461
  pickKnowledge(request, request.domain, `${request.instruction}\n\n${request.taskText}`),
360
462
  likelyFor(request.hints, request.instruction, request.signal, request.taskText),
361
463
  routeFor(request.effort, request.instruction, { ...(configured.model ? { model: configured.model } : {}), thinking: configured.thinking }, { context: request.taskText, ...(request.signal ? { signal: request.signal } : {}) }),
464
+ prepareBookmarks(request, request.domain, `${request.instruction}\n\n${request.taskText}`),
362
465
  ]);
466
+ const hints = fitHints(bookmarked, likely);
363
467
  const hintTools = request.hints?.tools() ?? [];
468
+ const boardFrom = request.boardFrom ?? assignmentId(request.domain);
364
469
  const extraTools = [...(request.agent?.extraTools ?? []), ...hintTools, ...(request.board ? BOARD_TOOLS : [])];
365
- const env = request.board ? { ...request.agent?.env, [BOARD_ENV]: request.board, BOT_LOBBY_BOARD_FROM: request.domain } : request.agent?.env;
470
+ const env = request.board ? { ...request.agent?.env, [BOARD_ENV]: request.board, BOT_LOBBY_BOARD_FROM: boardFrom } : request.agent?.env;
366
471
  const base: AgentRequest = {
367
472
  delegationQuota: { used: 0 },
368
473
  ...(request.expert ? { expert: request.expert } : {}),
@@ -375,8 +480,9 @@ export async function runWorker(
375
480
  context: {
376
481
  task: request.taskText,
377
482
  ...selected,
483
+ ...(hints.bookmarks ? { bookmarks: hints.bookmarks } : {}),
378
484
  instructions: request.config.agents[request.domain].instructions,
379
- workflowContext: workerWorkflowContext(request, likely),
485
+ workflowContext: workerWorkflowContext(request, boardFrom, hints.likely),
380
486
  },
381
487
  ...configured,
382
488
  cwd: request.cwd,
@@ -480,7 +586,7 @@ function isRetryableReviewRun(run: AgentRun): boolean {
480
586
  return run.status !== "cancelled" && (run.status !== "success" || !hasReviewVerdict(run.output));
481
587
  }
482
588
 
483
- function reviewerAgentRequest(request: ReviewerRequest, selected: KnowledgeSelection): AgentRequest {
589
+ function reviewerAgentRequest(request: ReviewerRequest, selected: KnowledgeSelection, bookmarks = ""): AgentRequest {
484
590
  return {
485
591
  taskId: request.taskId,
486
592
  domain: request.domain,
@@ -492,6 +598,7 @@ function reviewerAgentRequest(request: ReviewerRequest, selected: KnowledgeSelec
492
598
  context: {
493
599
  task: request.taskText,
494
600
  ...selected,
601
+ ...(bookmarks ? { bookmarks } : {}),
495
602
  instructions: request.config.agents[request.domain].instructions,
496
603
  workflowContext: reviewerContext(request),
497
604
  },
@@ -515,8 +622,12 @@ export async function runReviewer(
515
622
  request: ReviewerRequest,
516
623
  run: ProcessRunner = spawnPiProcess,
517
624
  ): Promise<ReviewerOutcome> {
518
- const selected = await pickKnowledge(request, request.domain, `${request.taskText} ${request.workerSummary}`);
519
- const agentRequest = reviewerAgentRequest(request, selected);
625
+ const query = `${request.taskText} ${request.workerSummary}`;
626
+ const [selected, bookmarked] = await Promise.all([
627
+ pickKnowledge(request, request.domain, query),
628
+ prepareBookmarks(request, request.domain, query),
629
+ ]);
630
+ const agentRequest = reviewerAgentRequest(request, selected, bookmarked);
520
631
  const attempts = Math.max(1, request.config.workflow.maxAgentRetries + 1);
521
632
  let agentRun = await runAgent(agentRequest, run);
522
633
  for (let attempt = 2; attempt <= attempts && isRetryableReviewRun(agentRun); attempt++) {
package/src/pi/events.ts CHANGED
@@ -3,7 +3,11 @@ import { activeTask } from "../state/persistence.ts";
3
3
  import { detectProjectRoot, loadConfig, readDataRoots } from "../state/project.ts";
4
4
  import { compilePrompt } from "../prompts/compiler.ts";
5
5
  import { readAgentKnowledge } from "../knowledge/store.ts";
6
- import { selectKnowledge } from "../knowledge/selector.ts";
6
+ import { selectKnowledge, selectRelevant } from "../knowledge/selector.ts";
7
+ import { applySelection, assignmentKey, bookmarkBlock, lexicalBookmarks } from "../knowledge/bookmarks.ts";
8
+ import { refreshCatalogue } from "../knowledge/catalogue.ts";
9
+ import { readNotes } from "../knowledge/notes.ts";
10
+ import { KNOWLEDGE_BUDGET_CHARS } from "../classifier/knowledge.ts";
7
11
  import { libraryBlock } from "../scripts/pick.ts";
8
12
  import { cancelAllRuns } from "../execution/agent-runner.ts";
9
13
  import { describeTask } from "../workflow/workflow.ts";
@@ -63,6 +67,26 @@ export const LOBBY_CHAT_NOTE = [
63
67
  "Bot Lobby's workflow (scouts, a plan, the QA gate) runs only when the user types /plan (plan it with the panel) or /task (start it as a task). When a request is large, risky or spans several areas, suggest one of them in a line; never start one yourself.",
64
68
  ].join("\n");
65
69
 
70
+ /**
71
+ * The oracle's own bookmarks for the active task, in retrieval mode: lexical
72
+ * picks over the catalogue (the event path is synchronous, so no model call),
73
+ * persisted in the task's storage and revalidated at delivery. Standards and
74
+ * the task context stay direct; the knowledge and decision dumps are replaced.
75
+ */
76
+ function masterBookmarks(root: string, configDir: string, dataRoots: readonly string[], taskId: string, query: string): string {
77
+ const dir = dataRoots[0];
78
+ if (!dir) return "";
79
+ try {
80
+ const { catalogue, coverage } = refreshCatalogue(dataRoots);
81
+ const picks = lexicalBookmarks(query, catalogue.entries.filter((entry) => entry.freshness !== "stale"));
82
+ const state = applySelection(dir, taskId, "master", assignmentKey(query), catalogue.revision, picks, "lexical");
83
+ const block = bookmarkBlock(state, "master", catalogue, { dataRoots, cwd: root }, readNotes(dir));
84
+ return block || `No bookmarks were selected for this assignment (catalogue coverage: ${coverage}); search_knowledge finds the recorded knowledge.`;
85
+ } catch {
86
+ return "";
87
+ }
88
+ }
89
+
66
90
  export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
67
91
  // Web tools the oracle put away for the task in hand, given back when it ends.
68
92
  let hiddenWeb: string[] = [];
@@ -137,13 +161,18 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
137
161
  }
138
162
  const dataRoots = readDataRoots(root, configDir);
139
163
  const slices = readAgentKnowledge(dataRoots, "master");
140
- const selected = selectKnowledge(`${taskRequest(task)} ${task.proposal ?? ""}`, slices);
164
+ const query = `${taskRequest(task)} ${task.proposal ?? ""}`;
165
+ const retrieval = loadConfig().workflow.knowledgeRetrieval;
166
+ const selected = retrieval
167
+ ? { knowledge: "", decisions: "", standards: selectRelevant(query, slices.standards ?? "", KNOWLEDGE_BUDGET_CHARS) }
168
+ : selectKnowledge(query, slices);
141
169
  event.systemPromptOptions.sections["bot-lobby"] = compilePrompt({
142
170
  domain: "master",
143
171
  task: masterTaskContext(task, readPlanComments(root, configDir, task.id), isAutoMode(root, configDir, task.id)),
144
172
  standards: selected.standards,
145
173
  knowledge: selected.knowledge,
146
174
  decisions: selected.decisions,
175
+ bookmarks: retrieval ? masterBookmarks(root, configDir, dataRoots, task.id, query) : "",
147
176
  scripts: dataRoots[0] ? libraryBlock(dataRoots[0]) : "",
148
177
  workflowContext: masterWorkflowContext(task),
149
178
  instructions: loadConfig().master.instructions,
package/src/pi/tools.ts CHANGED
@@ -68,6 +68,16 @@ const OrchestrateSchema = Type.Object({
68
68
  description: "knowledge: which persistent file the text belongs to",
69
69
  }),
70
70
  ),
71
+ op: Type.Optional(
72
+ StringEnum(["add", "remove", "pin", "unpin", "exclude"] as const, {
73
+ description: "bookmark: the operation on the task's assignment bookmark list",
74
+ }),
75
+ ),
76
+ refs: Type.Optional(
77
+ Type.Array(Type.String(), {
78
+ description: "bookmark: the reference ids (K-…), exactly as search_knowledge returned them",
79
+ }),
80
+ ),
71
81
  });
72
82
 
73
83
  const DESCRIPTION = [
@@ -76,7 +86,8 @@ const DESCRIPTION = [
76
86
  "read-only researcher for cited internet evidence on a complex change, tool, plugin, doc set or",
77
87
  "dependency), propose (record the proposal and request approval), plan (record the internal",
78
88
  "plan, or amend it later with the full revised plan), implement (delegate a step to a domain worker, or several domains in parallel with assignments), qa (final quality gate and the only",
79
- "review), knowledge (record approved project knowledge or a decision; works without a task),",
89
+ "review), knowledge (record approved project knowledge or a decision; works without a task),"
90
+ + "bookmark (manage the task's assignment bookmark list: op=add|remove|pin|unpin|exclude with refs — reference ids from search_knowledge — and optionally domain and note),",
80
91
  "compact (replace a knowledge file with a rewritten version, archiving the old one; works without a task; disperse domain-relevant facts to action=knowledge (domain=designer|backend|qa) first), script (save a script to the shared scripts library, or replace one, for a job that comes back; works without a task),",
81
92
  "resolve_approval (approve or reject a request), complete (declare the task done after the gates",
82
93
  "pass, then ask the user to confirm merging and pushing main and removing its worktree), block/resume (escalate or continue),",
@@ -15,6 +15,8 @@ export interface CompileInput {
15
15
  standards?: string;
16
16
  knowledge?: string;
17
17
  decisions?: string;
18
+ /** Assignment bookmarks: bounded reference metadata pointing into the knowledge catalogue. */
19
+ bookmarks?: string;
18
20
  /** The shared scripts that fit the step, or the library for the oracle. */
19
21
  scripts?: string;
20
22
  workflowContext?: string;
@@ -30,24 +32,39 @@ function section(title: string, body?: string): string {
30
32
  }
31
33
 
32
34
  /**
33
- * Compose the layered prompt: global + domain + role + task context +
34
- * standards + knowledge + decisions + scripts + workflow context + output contract.
35
- * Empty layers are dropped so agents never receive empty headings.
35
+ * The invariant layers for a domain/role: global + domain + role prompt and the
36
+ * role's output contract. Determined by domain and role alone, so identical
37
+ * configuration yields a byte-identical prefix ahead of task-selected context.
36
38
  */
37
- export function compilePrompt(input: CompileInput): string {
38
- const spec = input.role ? ROLE_SPECS[input.role] : undefined;
39
+ export function invariantPrefix(domain: PromptDomain, role?: Role): string {
40
+ const spec = role ? ROLE_SPECS[role] : undefined;
39
41
  const layers = [
40
42
  loadPrompt("global.md"),
41
- loadPrompt(domainPromptFile(input.domain)),
43
+ loadPrompt(domainPromptFile(domain)),
42
44
  spec ? loadPrompt(spec.promptFile) : "",
45
+ spec?.contract ?? "",
46
+ ];
47
+ return layers.filter((layer) => layer.trim().length > 0).join("\n\n---\n\n");
48
+ }
49
+
50
+ /**
51
+ * Compose the layered prompt: invariant prefix (global + domain + role + output
52
+ * contract) + custom instructions + task context + standards + knowledge +
53
+ * decisions + scripts + workflow context. Empty layers are dropped so agents
54
+ * never receive empty headings. Custom instructions keep their precedence over
55
+ * the role layers, including the contract.
56
+ */
57
+ export function compilePrompt(input: CompileInput): string {
58
+ const layers = [
59
+ invariantPrefix(input.domain, input.role),
43
60
  section("Custom Instructions", input.instructions),
44
61
  section("Task Context", input.task),
45
62
  section("Standards", input.standards),
46
63
  section("Knowledge", input.knowledge),
47
64
  section("Decisions", input.decisions),
65
+ section("Bookmarks", input.bookmarks),
48
66
  section("Scripts", input.scripts),
49
67
  section("Workflow Context", input.workflowContext),
50
- spec?.contract ?? "",
51
68
  ];
52
69
  return layers.filter((layer) => layer.trim().length > 0).join("\n\n---\n\n");
53
70
  }
@@ -98,6 +98,8 @@ export interface WorkflowConfig {
98
98
  gitIsolation: GitIsolation;
99
99
  /** Scouts, workers, lobby chat replies and the planner get the repository map: the layout and the files matching their step, with each file's purpose, names and unchanged scout notes. */
100
100
  repoMap: boolean;
101
+ /** Assignment bookmarks and the knowledge catalogue replace the historical knowledge and decision dumps in prompts; false keeps the legacy keyword-selected dumps (the rollback switch; never both). */
102
+ knowledgeRetrieval: boolean;
101
103
  /** A scout asked the same thing again for the same task, on a tree that has not changed, reuses its findings instead of running. */
102
104
  reuseScouts: boolean;
103
105
  /** How a parallel batch shares the repository: one checkout with the file desk (`desk`), or a worktree per worker whose changes are merged back when the batch ends (`worktrees`). */
@@ -299,6 +301,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
299
301
  briefCheck: true,
300
302
  gitIsolation: "off",
301
303
  repoMap: true,
304
+ knowledgeRetrieval: true,
302
305
  reuseScouts: true,
303
306
  parallelMode: "desk",
304
307
  cleanReview: true,
@@ -528,6 +531,7 @@ export function resolveConfig(partial: unknown): BotLobbyConfig {
528
531
  workflow.fastTrack = workflow.fastTrack !== false;
529
532
  workflow.briefCheck = workflow.briefCheck !== false;
530
533
  workflow.repoMap = workflow.repoMap !== false;
534
+ workflow.knowledgeRetrieval = workflow.knowledgeRetrieval !== false;
531
535
  workflow.reuseScouts = workflow.reuseScouts !== false;
532
536
  workflow.parallelMode = isParallelMode(workflow.parallelMode) ? workflow.parallelMode : DEFAULT_CONFIG.workflow.parallelMode;
533
537
  workflow.cleanReview = workflow.cleanReview !== false;
@@ -1,6 +1,7 @@
1
1
  import type { LintReport } from "./lint.ts";
2
2
  import type { Domain, Role } from "./agent.ts";
3
3
  import type { Delivery } from "../delivery/types.ts";
4
+ import type { TaskCheckpoint } from "../state/checkpoint.ts";
4
5
 
5
6
  export const TASK_STATES = [
6
7
  "created",
@@ -74,6 +75,33 @@ export interface WorkerRunRecord {
74
75
  /** Upper bound on persisted worker records; plans cap at 50 steps. */
75
76
  export const MAX_WORKER_RECORDS = 64;
76
77
 
78
+ /** What one assignment-scoped identity may do: reads only, granted by the parent that issued it. */
79
+ export type AssignmentGrant = "artifact.read" | "checkpoint.read";
80
+ export type AssignmentState = "active" | "cancelled" | "revoked";
81
+
82
+ /**
83
+ * A parent-issued assignment identity for one agent run: the durable basis
84
+ * for reading output artifacts and checkpoints, revalidated live on every use.
85
+ */
86
+ export interface AssignmentRecord {
87
+ /** A-<uuid>; the identity artifacts, checkpoints and child assignments are bound to. */
88
+ id: string;
89
+ ownerSessionId: string;
90
+ runId: string;
91
+ worktree: string;
92
+ domain: Domain;
93
+ role: Role;
94
+ state: AssignmentState;
95
+ grants: AssignmentGrant[];
96
+ /** The assignment that issued this one (delegated children). */
97
+ parentAssignmentId?: string;
98
+ issuedAt: string;
99
+ endedAt?: string;
100
+ }
101
+
102
+ /** Upper bound on the persisted assignment registry; terminal records are never trimmed. */
103
+ export const MAX_ASSIGNMENTS = 64;
104
+
77
105
  /** One finished subagent run of any role, kept for `/bot-lobby runs`. */
78
106
  export interface RunLogEntry {
79
107
  runId: string;
@@ -226,6 +254,8 @@ export interface PhaseTiming {
226
254
  }
227
255
 
228
256
  export interface Task {
257
+ /** Advisory task-state checkpoint; external references require live revalidation. */
258
+ checkpoint?: TaskCheckpoint;
229
259
  /** Canonical waits, including legacy tasks without a reliable clock. */
230
260
  blockingRequestIds?: string[];
231
261
  phaseTiming?: PhaseTiming;
@@ -248,6 +278,8 @@ export interface Task {
248
278
  approvals: Approval[];
249
279
  /** Worker delegations in start order; absent on tasks created before tracking. */
250
280
  workerRuns?: WorkerRunRecord[];
281
+ /** Parent-issued assignment identities for artifact/checkpoint reads; terminal records are final. */
282
+ assignments?: AssignmentRecord[];
251
283
  /** Recent finished runs of every role, newest last. */
252
284
  runLog?: RunLogEntry[];
253
285
  createdAt: string;