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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.59",
3
+ "version": "0.6.61",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -47,16 +47,16 @@
47
47
  "test": "node --test --test-reporter=dot test/*.test.ts"
48
48
  },
49
49
  "peerDependencies": {
50
- "@earendil-works/pi-ai": ">=1.3.0 <1.4.0",
51
- "@earendil-works/pi-coding-agent": ">=1.3.0 <1.4.0",
52
- "@earendil-works/pi-tui": ">=1.3.0 <1.4.0",
50
+ "@earendil-works/pi-ai": ">=1.4.0 <1.5.0",
51
+ "@earendil-works/pi-coding-agent": ">=1.4.0 <1.5.0",
52
+ "@earendil-works/pi-tui": ">=1.4.0 <1.5.0",
53
53
  "typebox": "*"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@axe-core/playwright": "4.13.0",
57
- "@earendil-works/pi-ai": "1.3.0",
58
- "@earendil-works/pi-coding-agent": "1.3.0",
59
- "@earendil-works/pi-tui": "1.3.0",
57
+ "@earendil-works/pi-ai": "1.4.0",
58
+ "@earendil-works/pi-coding-agent": "1.4.0",
59
+ "@earendil-works/pi-tui": "1.4.0",
60
60
  "@fontsource-variable/dm-sans": "^5.3.0",
61
61
  "@fontsource-variable/figtree": "^5.3.0",
62
62
  "@fontsource-variable/fira-code": "^5.3.0",
package/prompts/global.md CHANGED
@@ -38,7 +38,8 @@ are your authority, so:
38
38
  - Start from what your context already gives you: the brief's paths and
39
39
  `path:line` anchors, the **Repository map** (files matching your step, with
40
40
  what each is for and the names it declares) and any **Likely files**. Search
41
- only for what they do not answer.
41
+ only for what they do not answer. All of them are hints: verify what you
42
+ rely on.
42
43
  - When you need several reads or searches that do not depend on each other,
43
44
  make them all in one turn (several tool calls in one message): they run in
44
45
  parallel and save a round trip each.
package/prompts/worker.md CHANGED
@@ -9,10 +9,8 @@ domain-specific responsibility.
9
9
  - Inspect existing patterns.
10
10
  - Verify Scout findings against the repository.
11
11
  - Grep/find callers before changing shared behavior.
12
- - Open the files and `path:line` anchors your brief and the scout findings
13
- name first, then the **Repository map** and any **Likely files** section;
14
- with the `find_relevant_files` tool, describe what you need in plain words
15
- before walking the tree. All of them are hints: verify what you rely on.
12
+ - When the `find_relevant_files` tool is available, describe what you need in
13
+ plain words before walking the tree.
16
14
  - Identify relevant tests.
17
15
 
18
16
  You may disagree with Scout findings when repository evidence contradicts
@@ -20,11 +18,6 @@ them.
20
18
 
21
19
  ## Implementation
22
20
 
23
- - Follow the approved plan and your brief exactly: they are the Master's
24
- decisions. Do not substitute your own design, rename things, or widen the
25
- step. If the code contradicts the brief, do not improvise: report it.
26
- - Follow domain boundaries.
27
-
28
21
  If you need a new dependency, or you believe a significant architectural
29
22
  change is required, do not make that change. Report it under the matching
30
23
  section of your output instead and continue with the rest of the work.
@@ -13,11 +13,12 @@
13
13
  * indexed.
14
14
  */
15
15
  import { execFile } from "node:child_process";
16
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
+ import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
17
17
  import { readdir, readFile, stat } from "node:fs/promises";
18
18
  import { dirname, join } from "node:path";
19
19
  import { promisify } from "node:util";
20
20
  import { dataRoot } from "../state/project.ts";
21
+ import { tempName, withFileLock } from "../desk/lock.ts";
21
22
  import type { Classifier } from "./classifier.ts";
22
23
  import { noul, yesOf, type SystemOneRequest } from "./client.ts";
23
24
  import { clip, LIMITS } from "./limits.ts";
@@ -240,7 +241,15 @@ interface CacheFile {
240
241
  }
241
242
 
242
243
  export function fileCachePath(scope: FileScope): string {
243
- return join(dataRoot(scope.root, scope.configDir), "cache", "files.json");
244
+ // One cache per canonical worktree (symlinks resolved): parallel worktrees share a data root but not a cache, so they stop evicting each other.
245
+ let slug = "default";
246
+ try {
247
+ slug = realpathSync(scope.cwd).replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+/, "").slice(-48) || "default";
248
+ } catch {
249
+ // A missing tree gets no cache to thrash; the slug stays distinct from any real one.
250
+ slug = `missing-${scope.cwd.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+/, "").slice(-40)}` || "default";
251
+ }
252
+ return join(dataRoot(scope.root, scope.configDir), "cache", `files-${slug}.json`);
244
253
  }
245
254
 
246
255
  function readCache(scope: FileScope): Record<string, CacheEntry> {
@@ -255,11 +264,14 @@ function readCache(scope: FileScope): Record<string, CacheEntry> {
255
264
  function writeCache(scope: FileScope, files: Record<string, CacheEntry>): void {
256
265
  const path = fileCachePath(scope);
257
266
  try {
258
- mkdirSync(dirname(path), { recursive: true });
259
- writeFileSync(`${path}.tmp`, JSON.stringify({ version: 1, cwd: scope.cwd, files } satisfies CacheFile));
260
- renameSync(`${path}.tmp`, path);
267
+ withFileLock(path, () => {
268
+ mkdirSync(dirname(path), { recursive: true });
269
+ const temp = tempName(path);
270
+ writeFileSync(temp, JSON.stringify({ version: 1, cwd: scope.cwd, files } satisfies CacheFile));
271
+ renameSync(temp, path);
272
+ });
261
273
  } catch {
262
- // The cache is an optimisation; a read-only tree just re-reads next time.
274
+ // The cache is an optimisation; a busy or read-only tree just re-reads next time.
263
275
  }
264
276
  }
265
277
 
@@ -14,7 +14,7 @@ import type { ScriptPicker } from "../scripts/pick.ts";
14
14
  import type { KeySource, KeyStatus } from "./hosts.ts";
15
15
  import { fileHinter, type FileHinter, type FileScope } from "./files.ts";
16
16
  import { withRepoMap } from "../knowledge/repo-map.ts";
17
- import { knowledgePicker, type KnowledgePicker } from "./knowledge.ts";
17
+ import { bookmarkSelector, knowledgePicker, type KnowledgePicker } from "./knowledge.ts";
18
18
  import { lobbyFeed } from "../lobby/feed.ts";
19
19
  import { triageLine, triageWithContext } from "./triage.ts";
20
20
  import type { TaskTriage } from "../schemas/task.ts";
@@ -67,7 +67,7 @@ export function hintsFor(scope: FileScope): FileHinter {
67
67
  /** Relevant knowledge and shared scripts for agents, with what Jev kept and matched logged to the lobby's activity feed. */
68
68
  export function knowledgeFor(): KnowledgePicker {
69
69
  const log = (text: string) => lobbyFeed.log("CLASSIFIER", text, "info");
70
- return { ...knowledgePicker(classifier(), log), scripts: scriptPicker(classifier(), log) };
70
+ return { ...knowledgePicker(classifier(), log), scripts: scriptPicker(classifier(), log), bookmarks: bookmarkSelector(classifier(), log) };
71
71
  }
72
72
 
73
73
  /** Shared scripts for a lobby chat agent, with what Jev matched logged to the lobby's activity feed. */
@@ -12,10 +12,13 @@
12
12
  * keyword selection, which is what agents got before.
13
13
  */
14
14
  import { basename } from "node:path";
15
+ import { createHash } from "node:crypto";
15
16
  import { score, selectRelevant, splitSections, tokenize, type KnowledgeInput, type KnowledgeSelection } from "../knowledge/selector.ts";
17
+ import { BOOKMARK_DEADLINE_MS, lexicalBookmarks, MAX_AUTO_BOOKMARKS, type Selection } from "../knowledge/bookmarks.ts";
18
+ import type { CatalogueEntry } from "../knowledge/catalogue.ts";
16
19
  import type { Classifier } from "./classifier.ts";
17
- import { noul, yesOf, type SystemOneRequest } from "./client.ts";
18
20
  import { clip } from "./limits.ts";
21
+ import { noul, yesOf, type SystemOneRequest } from "./client.ts";
19
22
  import type { ScriptPicker } from "../scripts/pick.ts";
20
23
 
21
24
  /** How many characters of each knowledge file a prompt has room for. */
@@ -106,6 +109,8 @@ export interface KnowledgePicker {
106
109
  select(task: string, files: KnowledgeInput, options?: { signal?: AbortSignal; paths?: KnowledgePaths }): Promise<KnowledgeSelection>;
107
110
  /** Which shared scripts fit a step; shared words when absent. */
108
111
  scripts?: ScriptPicker;
112
+ /** Picks assignment bookmarks from bounded catalogue metadata; absent leaves bookmarks to the lexical fallback. */
113
+ bookmarks?: BookmarkSelector;
109
114
  }
110
115
 
111
116
  const SLICES: readonly KnowledgeSlice[] = ["knowledge", "standards", "decisions"];
@@ -159,3 +164,121 @@ export function knowledgePicker(classifier: Classifier, log?: (text: string) =>
159
164
  },
160
165
  };
161
166
  }
167
+
168
+ /**
169
+ * Bookmark selection. The classifier sees bounded catalogue metadata — one
170
+ * short line per candidate reference, never a knowledge passage — and answers
171
+ * one yes/no per candidate. Candidates are the lexically best twelve, so the
172
+ * call is small and bounded; the whole preparation waits at most
173
+ * `BOOKMARK_DEADLINE_MS` and any failure, disablement or timeout falls back to
174
+ * the deterministic lexical selection, which never blocks work. Selections are
175
+ * cached by assignment input and catalogue revision, so unrelated task
176
+ * activity does not rebuild them.
177
+ */
178
+ export const BOOKMARK_CANDIDATES = 12;
179
+ const META_LINE_CHARS = 400;
180
+ const CACHE_LIMIT = 64;
181
+
182
+ /** A candidate reference as the classifier sees it: id, title, kind, agent and a bounded description. */
183
+ export function metaLine(entry: CatalogueEntry): string {
184
+ const heading = entry.locator.heading ? ` > ${entry.locator.heading}` : "";
185
+ return clip(`${entry.id} · ${entry.title}${heading} · ${entry.kind} by ${entry.agent} · ${entry.description}`, META_LINE_CHARS);
186
+ }
187
+
188
+ /** One request that asks about each candidate reference. */
189
+ export function refRequest(assignment: string, metas: readonly string[]): SectionCall {
190
+ const entries: Record<string, string> = {};
191
+ const questions: SystemOneRequest["questions"] = {};
192
+ const keys = new Map<string, number>();
193
+ metas.forEach((meta, position) => {
194
+ const key = `r${position + 1}`;
195
+ entries[key] = meta;
196
+ questions[key] = noul(`Should reference \`sections.${key}\` be bookmarked for an agent doing \`task\`? Yes when the knowledge, standard, decision or finding it points to bears on the code, files, conventions or choices the task touches; no when it is about something else or only shares words with it.`);
197
+ keys.set(key, position);
198
+ });
199
+ return { request: { state: { task: clip(assignment, 3000), sections: entries }, questions }, keys };
200
+ }
201
+
202
+ export interface BookmarkPick {
203
+ ref: string;
204
+ reason: string;
205
+ revision: number;
206
+ /** Who picked it: the classifier judged it, or the lexical fallback did. */
207
+ source: "classifier" | "lexical";
208
+ }
209
+
210
+ export interface BookmarkSelectorInput {
211
+ assignment: string;
212
+ /** Non-stale catalogue entries this caller may see. */
213
+ entries: readonly CatalogueEntry[];
214
+ /** The catalogue revision the entries stand for; it forms the cache key. */
215
+ catalogueRevision: number;
216
+ /** Distinguishes parallel selections with the same assignment (task and recipient). */
217
+ cacheKey?: string;
218
+ signal?: AbortSignal;
219
+ }
220
+
221
+ export type BookmarkSource = BookmarkPick["source"];
222
+
223
+ export interface BookmarkSelector {
224
+ select(input: BookmarkSelectorInput): Promise<BookmarkPick[]>;
225
+ }
226
+
227
+ /** The picks the classifier's answers keep: relevant candidates, best first, within the automatic cap. */
228
+ export function picksFromAnswers(relevance: ReadonlyMap<number, number>, candidates: readonly CatalogueEntry[], floor: number): BookmarkPick[] {
229
+ return [...relevance]
230
+ .filter(([, value]) => value >= floor)
231
+ .sort((a, b) => b[1] - a[1] || a[0] - b[0])
232
+ .slice(0, MAX_AUTO_BOOKMARKS)
233
+ .map(([index]) => ({ ref: candidates[index]!.id, reason: "judged relevant to this assignment", revision: candidates[index]!.revision, source: "classifier" as const }));
234
+ }
235
+
236
+ function lexicalPicks(input: BookmarkSelectorInput): BookmarkPick[] {
237
+ const picks: Selection[] = lexicalBookmarks(input.assignment, input.entries);
238
+ return picks.map((pick) => ({ ref: pick.ref, reason: pick.reason, revision: pick.revision, source: "lexical" as const }));
239
+ }
240
+
241
+ /** The cache key: one assignment of one recipient, against one catalogue revision. */
242
+ function cacheKeyOf(input: BookmarkSelectorInput): string {
243
+ const assignment = createHash("sha1").update(input.assignment).digest("hex").slice(0, 16);
244
+ return `${input.cacheKey ?? ""}:${assignment}:${input.catalogueRevision}`;
245
+ }
246
+
247
+ const cache = new Map<string, BookmarkPick[]>();
248
+
249
+ /** Ask Jev about the lexically best candidates; anything short of an answer falls back to the lexical picks. */
250
+ async function classifierPicks(classifier: Classifier, input: BookmarkSelectorInput, log?: (text: string) => void): Promise<BookmarkPick[]> {
251
+ const ranked: Selection[] = lexicalBookmarks(input.assignment, input.entries, BOOKMARK_CANDIDATES);
252
+ if (ranked.length === 0) return [];
253
+ const byRef = new Map(ranked.map((pick) => [pick.ref, input.entries.find((entry) => entry.id === pick.ref)!]));
254
+ const candidates = ranked.map((pick) => byRef.get(pick.ref)!);
255
+ const call = refRequest(input.assignment, candidates.map(metaLine));
256
+ const result = await classifier.ask("knowledge", call.request, { ...(input.signal ? { signal: input.signal } : {}), timeoutMs: Math.min(classifier.config.timeoutMs, BOOKMARK_DEADLINE_MS) });
257
+ if (!result) return lexicalPicks(input);
258
+ const relevance = new Map<number, number>();
259
+ for (const [key, index] of call.keys) {
260
+ const value = yesOf(result.answers, key);
261
+ if (value !== undefined) relevance.set(index, value);
262
+ }
263
+ if (relevance.size === 0) return lexicalPicks(input);
264
+ const picks = picksFromAnswers(relevance, candidates, classifier.config.thresholds.knowledgeRelevantAt);
265
+ log?.(`bookmarks: kept ${picks.length} of ${candidates.length} catalogue references · ${result.ms} ms`);
266
+ return picks.length > 0 ? picks : lexicalPicks(input);
267
+ }
268
+
269
+ /** A bookmark selector on Jev: metadata in, references out, deterministic fallback on any failure. */
270
+ export function bookmarkSelector(classifier: Classifier, log?: (text: string) => void): BookmarkSelector {
271
+ return {
272
+ async select(input) {
273
+ const key = cacheKeyOf(input);
274
+ const cached = cache.get(key);
275
+ if (cached) return cached;
276
+ const picks = !classifier.enabled("knowledge") || !input.assignment.trim()
277
+ ? lexicalPicks(input)
278
+ : await classifierPicks(classifier, input, log).catch(() => lexicalPicks(input));
279
+ if (cache.size >= CACHE_LIMIT) cache.clear();
280
+ cache.set(key, picks);
281
+ return picks;
282
+ },
283
+ };
284
+ }
@@ -1,19 +1,38 @@
1
1
  /**
2
2
  * The task board inside a worker's pi process: `post_board` adds a typed post
3
3
  * (through the desk when one runs, so peers hear about contracts and
4
- * blockers at once; straight to the board's file otherwise) and `read_board`
5
- * reads what the task's agents posted, filtered to the reader's role.
4
+ * blockers at once; straight to the board's file otherwise), `read_board`
5
+ * reads what the task's agents posted, filtered to the reader's role, and
6
+ * `ack_board` records the events the worker actually incorporated — reading
7
+ * alone proves receipt, not incorporation.
6
8
  */
7
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
10
  import { Type } from "typebox";
9
11
  import { createDeskClient, type DeskClient } from "./ipc.ts";
10
- import { appendPost, BOARD_ENV, filterPosts, formatPosts, isPostKind, POST_KINDS, readBoard, subscriptionFor, type PostKind } from "./board.ts";
12
+ import { appendPost, BOARD_ENV, formatPosts, isPostKind, POST_KINDS, recentPost, readBoard, subscriptionFor, type BoardPost, type PostKind } from "./board.ts";
13
+ import { ackRelevant, deliveryPath, markDelivered, markHandled, markSeen, provenThrough, readDelivery } from "./delivery.ts";
11
14
  import { deskAddress } from "./client-extension.ts";
12
15
 
13
16
  function text(value: string) {
14
17
  return { content: [{ type: "text" as const, text: value }], details: undefined };
15
18
  }
16
19
 
20
+ /**
21
+ * A read proves receipt of what its window showed: the delivered cursor grows
22
+ * only over the posts relevant to the reader that the window included — it
23
+ * stops at the first one the window omitted — and shown posts beyond that are
24
+ * recorded out of order. Posts routed only by files wait for the desk that
25
+ * knows the claims. Nothing is marked handled: receipt is not incorporation.
26
+ */
27
+ function recordRead(path: string, self: string, kinds: readonly PostKind[], shownPosts: readonly BoardPost[]): void {
28
+ const statePath = deliveryPath(path);
29
+ const prior = readDelivery(statePath).recipients[self]?.lastSeq ?? 0;
30
+ const shown = new Set(shownPosts.map((post) => post.seq));
31
+ const through = provenThrough(readBoard(path), shown, (post) => ackRelevant(post, self, kinds), prior);
32
+ if (through > prior) markDelivered(statePath, self, through);
33
+ for (const post of shownPosts) if (post.seq > through) markSeen(statePath, self, post.seq);
34
+ }
35
+
17
36
  export function registerBoardClient(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, client?: DeskClient): void {
18
37
  const path = env[BOARD_ENV];
19
38
  if (!path) return;
@@ -27,21 +46,33 @@ export function registerBoardClient(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
27
46
  name: "post_board",
28
47
  label: "Post to the task board",
29
48
  description:
30
- "Post a short, typed entry on the task board that every agent on this task can read: `contract` (an interface others build against: a route, a payload shape, a type, an event name), `decision` (a choice others should follow), `blocker` (something stopping you that someone else must change), `question` (open, for the oracle), `done` (a piece is finished and ready to test), `note`. Contracts and blockers reach the other running workers at once. Post facts, not progress chatter.",
49
+ "Post a short, typed entry on the task board that every agent on this task can read: `contract` (an interface others build against: a route, a payload shape, a type, an event name, file location), `decision` (a choice others should follow), `blocker` (something stopping you that someone else must change), `question` (open, for the oracle), `done` (a piece is finished and ready to test), `note`. Unaddressed contracts and blockers reach the other running workers at once; `to` (worker ids: backend, designer, qa, a child id) or `files` narrows who is pushed it. Post facts, not progress chatter.",
31
50
  parameters: Type.Object({
32
51
  kind: Type.Union(POST_KINDS.map((kind) => Type.Literal(kind))),
33
52
  text: Type.String({ description: "The post: the exact facts, at most a short paragraph" }),
34
- files: Type.Optional(Type.Array(Type.String(), { description: "Files the post is about" })),
53
+ files: Type.Optional(Type.Array(Type.String(), { description: "Files the post is about; pushed to the workers holding them" })),
54
+ to: Type.Optional(Type.Array(Type.String(), { description: "Recipients the post is addressed to: a domain (`backend`) reaches every worker of that role, an assignment id (`backend:9f2c1e`) that worker alone; pushed only to them" })),
35
55
  }),
36
56
  async execute(_id, params) {
57
+ const post = { from: self, kind: params.kind, text: params.text, ...(params.files ? { files: params.files } : {}), ...(params.to ? { to: params.to } : {}) };
37
58
  if (desk) {
38
- const answer = await desk.request({ op: "post", from: self, kind: params.kind, text: params.text, files: params.files ?? [] });
39
- // Only a desk without the board, or one never reached, leaves the post to the file below; any other answer may follow a write.
40
- if (answer.ok || !/^This task has no board\.$|^the file desk is unavailable$/.test(answer.text)) return text(answer.text);
59
+ let answer: { ok: boolean; text: string } | undefined;
60
+ try {
61
+ answer = await desk.request({ op: "post", from: self, kind: params.kind, text: params.text, files: params.files ?? [], targets: params.to ?? [] });
62
+ } catch {
63
+ // The request failed mid-flight; the post may already be on the board, so a retry must not duplicate it.
64
+ }
65
+ if (answer) {
66
+ // Only a desk without the board, or one never reached, leaves the post to the file below; any other answer may follow a write.
67
+ if (answer.ok || !/^This task has no board\.$|^the file desk is unavailable$/.test(answer.text)) return text(answer.text);
68
+ } else {
69
+ const landed = recentPost(path, self, params.kind, params.text);
70
+ if (landed) return text(`Posted #${landed.seq} (already on the board).`);
71
+ }
41
72
  }
42
73
  try {
43
- const post = appendPost(path, { from: self, kind: params.kind, text: params.text, ...(params.files ? { files: params.files } : {}) });
44
- return text(`Posted #${post.seq}.`);
74
+ const stored = appendPost(path, post);
75
+ return text(`Posted #${stored.seq}.`);
45
76
  } catch (error) {
46
77
  return text(`Not posted: ${(error as Error).message}`);
47
78
  }
@@ -51,15 +82,51 @@ export function registerBoardClient(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
51
82
  pi.registerTool({
52
83
  name: "read_board",
53
84
  label: "Read the task board",
54
- description: "Read the task board. By default you see the kinds your role follows, newest last; pass kinds to see others, or after to see only posts newer than one you have read.",
85
+ description: "Read the task board. By default you see the kinds your role follows, newest last; pass kinds to see others, or after to see only posts newer than one you have read. Reading is receipt only: acknowledge what you incorporate with ack_board.",
55
86
  parameters: Type.Object({
56
87
  kinds: Type.Optional(Type.Array(Type.String(), { description: `Any of: ${POST_KINDS.join(", ")}` })),
57
88
  after: Type.Optional(Type.Number({ description: "Only posts after this number" })),
58
89
  }),
59
90
  async execute(_id, params) {
60
91
  const kinds: readonly PostKind[] = params.kinds?.filter(isPostKind).length ? params.kinds.filter(isPostKind) : subscriptionFor(self);
61
- const posts = filterPosts(readBoard(path), { kinds, ...(params.after ? { after: params.after } : {}), limit: 50 });
92
+ const matching = readBoard(path)
93
+ .filter((post) => kinds.includes(post.kind) && (!params.after || post.seq > params.after))
94
+ .sort((a, b) => a.seq - b.seq);
95
+ const posts = matching.slice(-50);
96
+ if (posts.length > 0) recordRead(path, self, kinds, posts);
62
97
  return text(posts.length > 0 ? formatPosts(posts) : "Nothing on the board for those kinds yet.");
63
98
  },
64
99
  });
100
+
101
+ pi.registerTool({
102
+ name: "ack_board",
103
+ label: "Acknowledge board events",
104
+ description:
105
+ "Acknowledge the board events you have actually incorporated into your work: the numbers shown as `#3` (or the posts' ids). Receipt is not acknowledgement — reading or being steered a post does not count; only this does. Out-of-order acknowledgements are kept, and events relevant to you that you have not acknowledged yet stay pending.",
106
+ parameters: Type.Object({
107
+ events: Type.Array(Type.Union([Type.String(), Type.Number()]), { description: "The events: `#3`-style numbers, plain numbers, or post ids" }),
108
+ }),
109
+ async execute(_id, params) {
110
+ if (desk) {
111
+ let answer: { ok: boolean; text: string } | undefined;
112
+ try {
113
+ answer = await desk.request({ op: "ack", events: params.events });
114
+ } catch {
115
+ // Fall through to the file: acknowledging is idempotent, so a retry cannot double-record.
116
+ }
117
+ if (answer && (answer.ok || !/^the file desk is unavailable$|^the file desk closed$/.test(answer.text))) return text(answer.text);
118
+ }
119
+ return text(ackText(markHandled(path, self, params.events)));
120
+ },
121
+ });
122
+ }
123
+
124
+ /** The worker-facing result of one acknowledgement, whether anything was recorded or not. */
125
+ export function ackText(result: { acknowledged: { seq: number }[]; unknown: string[]; refused: string[]; state: { handled?: number[] } }): string {
126
+ if (result.refused.length > 0) return `Not acknowledged: ${result.refused.join(", ")} ${result.refused.length === 1 ? "is" : "are"} outside what your role follows. Nothing was recorded.`;
127
+ const parts = [`Acknowledged ${result.acknowledged.map((post) => `#${post.seq}`).join(", ")}.`];
128
+ if (result.unknown.length > 0) parts.push(`No board event matches: ${result.unknown.join(", ")}.`);
129
+ const pending = result.state.handled ?? [];
130
+ if (pending.length > 0) parts.push(`${pending.length} earlier event${pending.length === 1 ? "" : "s"} you have not acknowledged still hold${pending.length === 1 ? "s" : ""} your acknowledgement cursor.`);
131
+ return parts.join(" ");
65
132
  }
package/src/desk/board.ts CHANGED
@@ -7,10 +7,15 @@
7
7
  *
8
8
  * It lives in the task's directory as `board.jsonl`, so later steps, the QA
9
9
  * gate and the oracle read the same record; a worker without a running desk
10
- * (a single step) appends to the file directly.
10
+ * (a single step) appends to the file directly. A post may name recipients
11
+ * (`to`) or files: those route its *delivery* — an addressed post is pushed
12
+ * only to its targets, a file-named one to the workers holding those files —
13
+ * while the board itself stays readable to every role.
11
14
  */
12
- import { appendFileSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
15
+ import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
13
16
  import { dirname, join } from "node:path";
17
+ import { randomUUID } from "node:crypto";
18
+ import { LOCK_BUSY, withFileLock } from "./lock.ts";
14
19
 
15
20
  export const POST_KINDS = ["contract", "decision", "blocker", "question", "done", "note"] as const;
16
21
  export type PostKind = (typeof POST_KINDS)[number];
@@ -22,12 +27,16 @@ export function isPostKind(value: unknown): value is PostKind {
22
27
  export interface BoardPost {
23
28
  /** Order on the board, from 1. */
24
29
  seq: number;
25
- /** Who posted it: a domain (`backend`, `designer`, `qa`), a child run id, or `oracle`. */
30
+ /** Stable unique id minted at append; posts written before it exists have none, and stay readable. */
31
+ id?: string;
32
+ /** Who posted it: a domain (`backend`, `designer`, `qa`), an assignment id (`backend:9f2c1e`), a child run id, or `oracle`. */
26
33
  from: string;
27
34
  kind: PostKind;
28
35
  text: string;
29
36
  /** Repository files the post is about, when it names any. */
30
37
  files?: string[];
38
+ /** Recipients the post is addressed to (worker ids or domains): only they are pushed it; everyone can still read it. */
39
+ to?: string[];
31
40
  at: string;
32
41
  }
33
42
 
@@ -35,7 +44,7 @@ export interface BoardPost {
35
44
  export const BOARD_ENV = "BOT_LOBBY_BOARD";
36
45
 
37
46
  /** Tools every worker gets when its task has a board. */
38
- export const BOARD_TOOLS: readonly string[] = ["post_board", "read_board"];
47
+ export const BOARD_TOOLS: readonly string[] = ["post_board", "read_board", "ack_board"];
39
48
 
40
49
  /**
41
50
  * What each role reads by default. A builder needs the contracts and
@@ -51,11 +60,30 @@ export const SUBSCRIPTIONS: Readonly<Record<string, readonly PostKind[]>> = {
51
60
 
52
61
  const DEFAULT_SUBSCRIPTION: readonly PostKind[] = ["contract", "decision", "blocker"];
53
62
 
54
- /** Kinds a running peer is told about the moment they are posted, not only when it next reads. */
55
- export const PUSHED_KINDS: ReadonlySet<PostKind> = new Set(["contract", "blocker"]);
63
+ /** Kinds a running peer is told about the moment they are posted, not only when it next reads: the consequential ones, never routine notes. */
64
+ export const PUSHED_KINDS: ReadonlySet<PostKind> = new Set(["contract", "decision", "blocker"]);
56
65
 
57
66
  export function subscriptionFor(reader: string): readonly PostKind[] {
58
- return SUBSCRIPTIONS[reader] ?? DEFAULT_SUBSCRIPTION;
67
+ return SUBSCRIPTIONS[reader] ?? SUBSCRIPTIONS[roleOf(reader) ?? ""] ?? DEFAULT_SUBSCRIPTION;
68
+ }
69
+
70
+ /** A recipient named the way posts address it: DEV/design/backend all mean `backend`; anything else (a child run id, `oracle`) is itself. */
71
+ export function recipientId(name: string): string {
72
+ const key = name.trim().toLowerCase();
73
+ const ids: Record<string, string> = { dev: "backend", backend: "backend", design: "designer", designer: "designer", qa: "qa", oracle: "oracle" };
74
+ return ids[key] ?? name.trim();
75
+ }
76
+
77
+ /** The role a recipient answers to: `backend`, `backend:9f2c1e` and `DEV` are backend; an unrelated id (a bare child run id) has none. */
78
+ export function roleOf(name: string): string | undefined {
79
+ const [head = ""] = name.trim().split(":", 1);
80
+ const role = recipientId(head);
81
+ return SUBSCRIPTIONS[role] ? role : undefined;
82
+ }
83
+
84
+ /** One assignment's board identity: unique per worker run (`backend:9f2c1e`), so two same-role workers never share delivery state while posts addressed to their role still reach both. */
85
+ export function assignmentId(domain: string): string {
86
+ return `${recipientId(domain)}:${randomUUID().slice(0, 8)}`;
59
87
  }
60
88
 
61
89
  export const MAX_POST_CHARS = 1500;
@@ -102,61 +130,41 @@ export interface NewPost {
102
130
  kind: PostKind;
103
131
  text: string;
104
132
  files?: readonly string[];
133
+ /** Recipients the post is addressed to; an addressed post is pushed only to them. */
134
+ to?: readonly string[];
105
135
  }
106
136
 
107
137
  /** Longest a board path in a post may be. */
108
138
  const MAX_FILE_CHARS = 300;
109
- /** How long a writer waits for another process's append, and when a lock is taken to be left behind. */
110
- const LOCK_WAIT_MS = 2000;
111
- const STALE_LOCK_MS = 10_000;
112
139
 
113
- function sleep(ms: number): void {
114
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
115
- }
140
+ /** Most recipients one post may name. */
141
+ const MAX_TARGETS = 8;
116
142
 
117
- /**
118
- * Run `write` holding the board's lock (a directory beside it, made
119
- * atomically), so posts from several processes get distinct sequence
120
- * numbers. A lock left by a crashed writer is broken once stale; past the
121
- * wait the post goes in anyway rather than being lost.
122
- */
123
- function withLock<T>(path: string, write: () => T): T {
124
- const lock = `${path}.lock`;
125
- const until = Date.now() + LOCK_WAIT_MS;
126
- let held = false;
127
- while (!held) {
128
- try {
129
- mkdirSync(lock);
130
- held = true;
131
- } catch {
132
- try {
133
- if (Date.now() - statSync(lock).mtimeMs > STALE_LOCK_MS) rmSync(lock, { recursive: true, force: true });
134
- } catch {
135
- // Released meanwhile.
136
- }
137
- if (Date.now() > until) break;
138
- sleep(5);
139
- }
140
- }
141
- try {
142
- return write();
143
- } finally {
144
- if (held) rmSync(lock, { recursive: true, force: true });
145
- }
146
- }
147
-
148
- /** Append a post and return it as stored. Throws on an empty text, so the caller can tell the agent. */
143
+ /** Append a post and return it as stored. Throws on an empty text or a busy board, so the caller can tell the agent. */
149
144
  export function appendPost(path: string, post: NewPost, now = new Date()): BoardPost {
150
145
  const text = clean(post.text, MAX_POST_CHARS);
151
146
  if (!text) throw new Error("a board post needs text");
152
147
  const files = (post.files ?? []).map((file) => clean(file, MAX_FILE_CHARS)).filter(Boolean).slice(0, MAX_FILES);
148
+ const to = [...new Set((post.to ?? []).map((target) => clean(target, 80)).filter(Boolean))].slice(0, MAX_TARGETS);
153
149
  mkdirSync(dirname(path), { recursive: true });
154
- return withLock(path, () => {
155
- const seq = (readBoard(path).at(-1)?.seq ?? 0) + 1;
156
- const stored: BoardPost = { seq, from: post.from, kind: post.kind, text, ...(files.length > 0 ? { files } : {}), at: now.toISOString() };
157
- appendFileSync(path, `${JSON.stringify(stored)}\n`, "utf8");
158
- return stored;
159
- });
150
+ try {
151
+ return withFileLock(path, () => {
152
+ const seq = (readBoard(path).at(-1)?.seq ?? 0) + 1;
153
+ const stored: BoardPost = { seq, id: randomUUID(), from: post.from, kind: post.kind, text, ...(files.length > 0 ? { files } : {}), ...(to.length > 0 ? { to } : {}), at: now.toISOString() };
154
+ appendFileSync(path, `${JSON.stringify(stored)}\n`, "utf8");
155
+ return stored;
156
+ });
157
+ } catch (error) {
158
+ throw new Error((error as Error).message === LOCK_BUSY ? `Not posted: ${LOCK_BUSY}` : (error as Error).message);
159
+ }
160
+ }
161
+
162
+ /** The newest post already on the board with this sender, kind and exact text, if one exists: a retry can adopt it instead of duplicating it. */
163
+ export function recentPost(path: string, from: string, kind: PostKind, text: string): BoardPost | undefined {
164
+ const wanted = clean(text, MAX_POST_CHARS);
165
+ return readBoard(path)
166
+ .filter((post) => post.from === from && post.kind === kind && post.text === wanted)
167
+ .at(-1);
160
168
  }
161
169
 
162
170
  export interface BoardFilter {
@@ -179,13 +187,16 @@ export function filterPosts(posts: readonly BoardPost[], filter: BoardFilter): B
179
187
  const NAMES: Record<string, string> = { backend: "DEV", designer: "DESIGN", qa: "QA", oracle: "ORACLE" };
180
188
 
181
189
  export function posterName(from: string): string {
190
+ const role = roleOf(from);
191
+ if (role && from !== role) return `${NAMES[role] ?? role.toUpperCase()}(${from.slice(role.length + 1)})`;
182
192
  return NAMES[from] ?? (from.length > 12 ? `child ${from.slice(0, 8)}` : from.toUpperCase());
183
193
  }
184
194
 
185
195
  /** One post on one line: its text is flattened, so a post can never pass for a line of the engine's own. */
186
196
  export function formatPost(post: BoardPost): string {
187
197
  const files = post.files?.length ? ` (${post.files.join(", ")})` : "";
188
- return `#${post.seq} [${post.kind}] ${posterName(post.from)}${files}: ${post.text.replace(/\s+/g, " ")}`;
198
+ const to = post.to?.length ? ` → ${post.to.map(posterName).join(", ")}` : "";
199
+ return `#${post.seq} [${post.kind}] ${posterName(post.from)}${to}${files}: ${post.text.replace(/\s+/g, " ")}`;
189
200
  }
190
201
 
191
202
  export function formatPosts(posts: readonly BoardPost[]): string {