borgmcp 3.16.1 → 4.0.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 (51) hide show
  1. package/README.md +10 -0
  2. package/THIRD_PARTY_NOTICES.md +1 -1
  3. package/dist/cli-help.d.ts.map +1 -1
  4. package/dist/cli-help.js +4 -1
  5. package/dist/cli-help.js.map +1 -1
  6. package/dist/direct-log.d.ts +2 -1
  7. package/dist/direct-log.d.ts.map +1 -1
  8. package/dist/direct-log.js +23 -9
  9. package/dist/direct-log.js.map +1 -1
  10. package/dist/docs-sections.js +3 -3
  11. package/dist/docs-sections.js.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +38 -13
  14. package/dist/index.js.map +1 -1
  15. package/dist/log-audit-core.d.ts.map +1 -1
  16. package/dist/log-audit-core.js +4 -2
  17. package/dist/log-audit-core.js.map +1 -1
  18. package/dist/regen-format.d.ts +6 -2
  19. package/dist/regen-format.d.ts.map +1 -1
  20. package/dist/regen-format.js +37 -13
  21. package/dist/regen-format.js.map +1 -1
  22. package/dist/remote-client.d.ts +12 -5
  23. package/dist/remote-client.d.ts.map +1 -1
  24. package/dist/remote-client.js +32 -33
  25. package/dist/remote-client.js.map +1 -1
  26. package/dist/server-handshake.d.ts +1 -1
  27. package/dist/tool-manifest.d.ts.map +1 -1
  28. package/dist/tool-manifest.js +55 -25
  29. package/dist/tool-manifest.js.map +1 -1
  30. package/dist/tool-scope.d.ts +1 -1
  31. package/dist/tool-scope.d.ts.map +1 -1
  32. package/dist/tool-scope.js +2 -0
  33. package/dist/tool-scope.js.map +1 -1
  34. package/docs/DOCUMENTS.md +4 -2
  35. package/docs/RELEASING.md +4 -2
  36. package/package.json +2 -2
  37. package/src/cli-help.ts +4 -1
  38. package/src/direct-log.ts +25 -8
  39. package/src/docs-sections.ts +3 -3
  40. package/src/index.ts +50 -12
  41. package/src/log-audit-core.ts +4 -2
  42. package/src/regen-format.ts +39 -13
  43. package/src/remote-client.ts +74 -54
  44. package/src/server-handshake.ts +1 -1
  45. package/src/tool-manifest.ts +57 -25
  46. package/src/tool-scope.ts +2 -0
  47. package/dist/local-log-routing.d.ts +0 -19
  48. package/dist/local-log-routing.d.ts.map +0 -1
  49. package/dist/local-log-routing.js +0 -67
  50. package/dist/local-log-routing.js.map +0 -1
  51. package/src/local-log-routing.ts +0 -106
package/docs/RELEASING.md CHANGED
@@ -21,12 +21,14 @@ Before creating the release tag, independently verify all of these conditions:
21
21
  - the extraction review confirms no private backend secrets, deployment
22
22
  configuration, customer data, local state, or duplicated shared contracts
23
23
  entered the public package;
24
- - the exact audited registry dependency `borgmcp-shared@0.13.1` remains locked to
24
+ - the exact audited registry dependency `borgmcp-shared@1.0.0` remains locked to
25
25
  its canonical tarball and integrity
26
- `sha512-AT5xOpd99U1Xd2waRJs01i2fz9ApMzd+NXQh0WMwTUknSsHpaQaEtGjcxtEDZG6MbB6nWfhoWG1ZZm9Fdkoytg==`;
26
+ `sha512-c55kxgfpo3GWXQB2pxy65CV4zjgoWRLZKidVfdR7/k8kKX9m5cqV7gUckUJ15BuvkIGrha5PFO7NAz5xPUXaeQ==`;
27
27
  - the current published server and the client candidate use the same exact
28
28
  `borgmcp-shared` version; publish a compatible server before tagging the
29
29
  client when that pin changes;
30
+ - the compatible registry release is `borgmcp-server@1.0.0` with integrity
31
+ `sha512-JRIrGLek0Ey/E9zfoiBNZYIAaGLcaWTsbTbR005KVU2OIbNrFg/mTU0Vbexw3iJoL/AGSZmARLVxfgO+CNOhBQ==`;
30
32
  - the selected stable client version is unused and the exact release commit is
31
33
  on protected `main`;
32
34
  - the repository and protected npm environment settings pass an operator audit;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "borgmcp",
3
- "version": "3.16.1",
3
+ "version": "4.0.0",
4
4
  "description": "Coordinate AI coding agents in shared cubes. Works with Claude Code, Codex, and OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -72,7 +72,7 @@
72
72
  },
73
73
  "dependencies": {
74
74
  "@modelcontextprotocol/sdk": "^1.0.4",
75
- "borgmcp-shared": "0.13.1",
75
+ "borgmcp-shared": "1.0.0",
76
76
  "chalk": "^5.3.0",
77
77
  "prompts": "^2.4.2",
78
78
  "which": "^4.0.0"
package/src/cli-help.ts CHANGED
@@ -164,7 +164,10 @@ export function topLevelHelpText(version: string): string {
164
164
  ` They coordinate through a shared log (a "cube"). For Claude Code, Codex & OpenCode.\n\n` +
165
165
  `Docs & quickstart: https://github.com/Byte-Ventures/borg-mcp-client#readme\n\n` +
166
166
  `Install Claude Code, Codex, or OpenCode first. Type \`borg ...\` in your terminal;\n` +
167
- `type \`borg_...\` inside your agent session once you've joined a cube ("assimilate").\n\n` +
167
+ `type \`borg_...\` inside your agent session once you've joined a cube ("assimilate").\n` +
168
+ `Inside a cube, every log message requires \`to: "broadcast"\` or a non-empty selector list.\n` +
169
+ `There is no omitted or taxonomy-selected audience.\n` +
170
+ `Direct routing controls delivery and wakes, not secrecy from other cube members.\n\n` +
168
171
  `Usage:\n` +
169
172
  ` borg Show the launch menu in a repository root; resume directly in a linked worktree\n` +
170
173
  ` borg setup Set up borg MCP server + agent CLI integration\n` +
package/src/direct-log.ts CHANGED
@@ -1,9 +1,26 @@
1
- export function normalizeDirectLogRecipients(value: unknown): string[] {
2
- if (value == null) return [];
3
- const raw = Array.isArray(value) ? value : [value];
4
- const recipients = raw
5
- .filter((item): item is string => typeof item === 'string')
6
- .map((item) => item.trim())
7
- .filter((item) => item.length > 0);
8
- return [...new Set(recipients)];
1
+ import { Buffer } from 'node:buffer';
2
+
3
+ export type LogAudience = 'broadcast' | string[];
4
+
5
+ export function normalizeLogAudience(value: unknown): LogAudience {
6
+ if (value === 'broadcast') return value;
7
+ if (!Array.isArray(value) || value.length === 0 || value.length > 100) {
8
+ throw new Error('to is required and must be "broadcast" or contain 1-100 recipient selectors');
9
+ }
10
+ for (const selector of value) {
11
+ if (typeof selector !== 'string') {
12
+ throw new Error('to recipient selectors must be strings containing 1-120 UTF-8 bytes');
13
+ }
14
+ const bytes = Buffer.byteLength(selector, 'utf8');
15
+ if (bytes < 1 || bytes > 120) {
16
+ throw new Error('to recipient selectors must be strings containing 1-120 UTF-8 bytes');
17
+ }
18
+ if (selector !== selector.trim() || /[\u0000-\u001f\u007f-\u009f]/.test(selector)) {
19
+ throw new Error('to recipient selectors must be trimmed and control-free');
20
+ }
21
+ }
22
+ if (new Set(value).size !== value.length) {
23
+ throw new Error('to recipient selectors must be unique');
24
+ }
25
+ return [...value];
9
26
  }
@@ -39,8 +39,8 @@ export const DOCS_SECTIONS: DocsSection[] = [
39
39
  slug: "concepts",
40
40
  title: "Core concepts",
41
41
  url: `${SITE_URL}/docs/concepts/`,
42
- summary: "Cubes, drones, roles, the activity log + signals, claims, decisions.",
43
- keywords: ["cube", "drone", "role", "log", "signal", "claim", "decision", "coordinate", "coordination"],
42
+ summary: "Cubes, drones, roles, activity-log signals, explicit audiences, claims, and decisions.",
43
+ keywords: ["cube", "drone", "role", "log", "signal", "claim", "decision", "coordinate", "coordination", "routing", "recipient", "audience", "selector", "direct", "broadcast"],
44
44
  },
45
45
  {
46
46
  slug: "install",
@@ -96,7 +96,7 @@ export const DOCS_SECTIONS: DocsSection[] = [
96
96
  title: "Tool reference",
97
97
  url: `${SITE_URL}/docs/tools/`,
98
98
  summary: "Every borg_* tool — name, description, params.",
99
- keywords: ["tool", "tools", "api", "reference", "param", "borg_"],
99
+ keywords: ["tool", "tools", "api", "reference", "param", "borg_", "borg_read-entry", "entry_id", "exact entry"],
100
100
  },
101
101
  {
102
102
  slug: "faq",
package/src/index.ts CHANGED
@@ -28,8 +28,10 @@ import {
28
28
  getRoleInfoByName,
29
29
  getRoster,
30
30
  readLog,
31
+ readLogEntry,
31
32
  appendLog,
32
33
  ackLogEntry,
34
+ getAckStatus,
33
35
  recordDecision,
34
36
  removeDecision,
35
37
  listDecisions,
@@ -86,6 +88,7 @@ import { addUserPromptSubmitHook } from './config-utils.js';
86
88
  import {
87
89
  humanAgo,
88
90
  formatLogEntryMarkdown,
91
+ formatLogRecipients,
89
92
  formatRegenMarkdown,
90
93
  getDronePlaybook,
91
94
  getDronePlaybookChapter,
@@ -156,7 +159,7 @@ import {
156
159
  shouldSuppressLifecycleLog,
157
160
  } from './lifecycle-log-guard.js';
158
161
  import {
159
- normalizeDirectLogRecipients,
162
+ normalizeLogAudience,
160
163
  } from './direct-log.js';
161
164
  import { formatLocalManageToolResult } from './local-manage-tool-result.js';
162
165
  import {
@@ -849,6 +852,24 @@ export async function main() {
849
852
  return { content: [{ type: 'text', text: lines.join('\n') }] };
850
853
  }
851
854
 
855
+ case 'borg_read-entry': {
856
+ const active = await requireActiveCube();
857
+ const { entry, drones, roles } = await readLogEntry(
858
+ active.sessionToken,
859
+ active.apiUrl,
860
+ args ?? {},
861
+ active.serverTrustIdentity,
862
+ );
863
+ const droneById = new Map(drones.map((drone) => [drone.id, drone]));
864
+ const roleById = new Map(roles.map((role) => [role.id, role]));
865
+ const text = [
866
+ `# Activity log: ${active.name}`,
867
+ '',
868
+ formatLogEntryMarkdown(entry, droneById, roleById),
869
+ ].join('\n');
870
+ return { content: [{ type: 'text', text }] };
871
+ }
872
+
852
873
  case 'borg_put-document': {
853
874
  const active = await requireActiveCube();
854
875
  const result = await putDocument(active.sessionToken, active.apiUrl, args ?? {}, active.serverTrustIdentity);
@@ -879,6 +900,7 @@ export async function main() {
879
900
  case 'borg_log': {
880
901
  const message = args?.message as string;
881
902
  if (!message || typeof message !== 'string') throw new Error('message is required');
903
+ const to = normalizeLogAudience(args?.to);
882
904
  const active = await getActiveCube();
883
905
  if (!active) throw new Error('Not assimilated to a cube. Use borg_assimilate <cube-name> first.');
884
906
  seedDisplayIdentity(active);
@@ -899,28 +921,33 @@ export async function main() {
899
921
  };
900
922
  }
901
923
  }
902
- const hasTo = Object.prototype.hasOwnProperty.call(args ?? {}, 'to');
903
- const recipients = hasTo ? normalizeDirectLogRecipients(args?.to) : undefined;
904
924
  const explicitClass = typeof args?.class === 'string' ? args.class : undefined;
905
- const visibility: 'broadcast' | 'direct' | undefined =
906
- args?.visibility === 'broadcast' || args?.visibility === 'direct'
907
- ? args.visibility
908
- : undefined;
909
925
  const documents = args?.documents as string[] | undefined;
910
926
  if (!active.serverTrustIdentity) {
911
927
  throw new Error('Selected Borg server authority state is missing or unreadable');
912
928
  }
913
929
  const appendOpts = {
930
+ to,
914
931
  ...(explicitClass ? { class: explicitClass } : {}),
915
- ...(hasTo ? { to: recipients ?? [] } : {}),
916
- ...(visibility ? { visibility } : {}),
917
932
  ...(documents ? { documents } : {}),
918
933
  serverTrustIdentity: active.serverTrustIdentity,
919
934
  };
920
935
  const result = await appendLog(active.sessionToken, active.apiUrl, message, appendOpts);
921
936
  await recordLifecycleLog(active, message);
922
937
  if (lifecycleSignal === 'arrival') markArrivalAnnouncedThisProcess();
923
- const echo = result.routing?.message ? `\n${result.routing.message}` : '';
938
+ let recipientDrones: any[] = [];
939
+ if (result.entry.visibility === 'direct' && result.entry.recipient_drone_ids.length > 0) {
940
+ try {
941
+ recipientDrones = (await getRoster(active)).drones;
942
+ } catch {
943
+ // The log is already persisted; keep success truthful with stable id fallbacks.
944
+ }
945
+ }
946
+ const recipientById = new Map(recipientDrones.map((drone) => [drone.id, drone]));
947
+ const routedRecipients = formatLogRecipients(result.entry, recipientById);
948
+ const routed = routedRecipients.length > 0
949
+ ? `\nRecipients: ${routedRecipients.join(', ')}`
950
+ : '';
924
951
  // gh#534: surface to the SENDER which directed recipients are
925
952
  // currently unreachable via the wake path. The message is delivered
926
953
  // regardless (persisted server-side); they read it when they return.
@@ -932,9 +959,9 @@ export async function main() {
932
959
  const cited = formatDocumentCitations(result.entry.documents);
933
960
  const citations = cited.length > 0 ? `\nDocuments: ${cited.join('; ')}` : '';
934
961
  const advisory = result.advisory?.code === 'STORE_AS_DOCUMENT'
935
- ? `\nAdvisory: this message exceeded ${result.advisory.threshold_bytes} UTF-8 bytes. Store durable detail with borg_put-document and cite its full id in borg_log.documents.`
962
+ ? `\nAdvisory: this message exceeded ${result.advisory.threshold_bytes} UTF-8 bytes. Store durable detail with borg_put-document, then cite its full id in borg_log.documents with an explicit borg_log.to audience.`
936
963
  : '';
937
- const text = `Logged to cube "${displayIdentity.cubeName}" as ${displayIdentity.droneLabel}. (entry id: ${result.entry.id})${echo}${unreachable}${citations}${advisory}`;
964
+ const text = `Logged to cube "${displayIdentity.cubeName}" as ${displayIdentity.droneLabel}. (entry id: ${result.entry.id})${routed}${unreachable}${citations}${advisory}`;
938
965
  return { content: [{ type: 'text', text }] };
939
966
  }
940
967
 
@@ -968,6 +995,17 @@ export async function main() {
968
995
  };
969
996
  }
970
997
 
998
+ case 'borg_ack-status': {
999
+ const active = await requireActiveCube();
1000
+ const result = await getAckStatus(
1001
+ active.sessionToken,
1002
+ active.apiUrl,
1003
+ args ?? {},
1004
+ active.serverTrustIdentity,
1005
+ );
1006
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1007
+ }
1008
+
971
1009
  case 'borg_decide': {
972
1010
  const topic = args?.topic as string;
973
1011
  const decision = args?.decision as string;
@@ -1,7 +1,8 @@
1
1
  export const LOG_AUDIT_NUDGE = (count: number): string =>
2
2
  `Heads up: ${count}+ state-changing tool calls since the last \`borg_log\` post. ` +
3
3
  'If that work was a substantive unit (a change that ships, a blocker hit, a finding ' +
4
- "worth sharing), post to the cube log per your role's conventions before continuing.";
4
+ "worth sharing), post to the cube log per your role's conventions before continuing. " +
5
+ 'Choose `to: "broadcast"` or a non-empty selector array; never omit `to`.';
5
6
 
6
7
  /**
7
8
  * Pure transcript scan shared by the Claude hook and the OpenCode plugin.
@@ -12,7 +13,8 @@ export function evaluateLogAudit(
12
13
  renderNudge: (count: number) => string = (count) =>
13
14
  `Heads up: ${count}+ state-changing tool calls since the last \`borg_log\` post. ` +
14
15
  'If that work was a substantive unit (a change that ships, a blocker hit, a finding ' +
15
- "worth sharing), post to the cube log per your role's conventions before continuing.",
16
+ "worth sharing), post to the cube log per your role's conventions before continuing. " +
17
+ 'Choose `to: "broadcast"` or a non-empty selector array; never omit `to`.',
16
18
  ): string | null {
17
19
  const materialTools = new Set([
18
20
  'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash',
@@ -237,6 +237,11 @@ export function markArrivalAnnouncedThisProcess(): void {
237
237
  // review gate; this inline marker pins the param so the #490/#529 copy↔
238
238
  // mechanism guard verifies borg_ack actually exposes `kind`
239
239
  // (client/__tests__/copy-mechanism-guard.test.ts).
240
+ // copy-param-claim: borg_ack-status.entry_id
241
+ // The playbook below directs uncertain receipt checks through the read-only
242
+ // acknowledgement-status query; this marker pins its required entry id.
243
+ // copy-param-claim: borg_read-entry.entry_id
244
+ // copy-param-claim: borg_log.to
240
245
  // copy-param-claim: borg_docs.topic
241
246
  // The playbook below points drones to `borg_docs {topic}` for user questions
242
247
  // about how Borg MCP works; this marker pins the param so the #490/#529 guard
@@ -244,7 +249,7 @@ export function markArrivalAnnouncedThisProcess(): void {
244
249
  export function getDronePlaybook(): string {
245
250
  const arrivalInstruction = arrivalAnnouncedThisProcess
246
251
  ? ''
247
- : `\n**When this MCP session first starts:** post one \`ARRIVAL: <your-label> (<your-role>) online on ${osHostname()}\`. After the post succeeds, the client suppresses this instruction until the MCP process restarts; an explicit \`/mcp\` reconnect may show it again.\n`;
252
+ : `\n**When this MCP session first starts:** call \`borg_log message="ARRIVAL: <your-label> (<your-role>) online on ${osHostname()}" to="broadcast"\` once. After the post succeeds, the client suppresses this instruction until the MCP process restarts; an explicit \`/mcp\` reconnect may show it again.\n`;
248
253
  return `## How to operate as a Drone
249
254
 
250
255
  You're a Drone in a Cube. Coordinate with other drones through the activity log.
@@ -257,10 +262,11 @@ You're a Drone in a Cube. Coordinate with other drones through the activity log.
257
262
  - \`borg_role\` — re-read your role's detailed playbook
258
263
  - \`borg_roster\` — see who else is connected
259
264
  - \`borg_read-log unread_only=true [limit]\` — drain unread log entries from your server-side cursor
260
- - \`borg_log <message>\` — append to the log
265
+ - \`borg_read-entry entry_id=<id>\` — read one known complete entry without moving the unread cursor
266
+ - \`borg_log message="<message>" to="broadcast"|["<selector>"]\` — append with an explicit audience
261
267
  - \`borg_assimilate <cube>\` — switch to a different cube
262
268
 
263
- **How coordination works:** the Cube gives primitives, not workflows. Your role's \`detailed_description\` (above) is your playbook — its conventions + signals come from there, not the system. The log is the coordination channel. Different cubes, different conventions.
269
+ **How coordination works:** the Cube gives primitives, not workflows. Your role's \`detailed_description\` (above) is your playbook — its conventions + signals come from there, not the system. The log is the coordination channel. Different cubes, different conventions. Every \`borg_log\` call must choose its audience with \`to: "broadcast"\` or a non-empty selector array; omission, message text, prefixes, and classes never choose recipients.
264
270
 
265
271
  **Communication discipline for non-human seats:**
266
272
  - **Console:** write nothing except harness-required output. Surface something to the operator only when blocked and needing unblocking; do not narrate plans, progress, method, or results.
@@ -279,13 +285,15 @@ You're a Drone in a Cube. Coordinate with other drones through the activity log.
279
285
  or a status reply must never be the last action of a turn while work is outstanding.
280
286
  6. Nothing actionable, no prompt, and no work of your own outstanding → done; wait for next wake.
281
287
 
282
- **On a \`<task-notification>\` wake:** the payload is a truncatable preview; the full entry is in the DB. Drain: \`borg_read-log unread_only=true limit=20\`, repeat until \`behind_by=0\`. Do NOT triage with \`since=<notification timestamp>\` (strict-after — skips the boundary entry) or a bare window (skips older-unread during bursts).
288
+ **On a \`<task-notification>\` wake:** the payload is a truncatable preview; the full entry is in the DB. Drain: \`borg_read-log unread_only=true limit=20\`, repeat until \`behind_by=0\`. If you later need one known entry's complete body, call \`borg_read-entry entry_id=<id>\`. Do NOT triage with \`since=<notification timestamp>\` (strict-after — skips the boundary entry) or a bare window (skips older-unread during bursts).
283
289
  ${arrivalInstruction}
284
290
 
285
291
  **When a log entry routes work to you** (a routing/assignment-class entry per your cube's conventions that names your label + asks for action, or a direct \`<your-label>:\` mention): call \`borg_ack entry_id=<id>\` within ~60s. Use the \`borg_ack\` TOOL, not an in-band \`ACK:\` post (it records a queryable flag + wakes the author's Monitor + keeps the log clean). Ack = receipt, not completion (\`STARTING\` / \`DONE\` still apply). Ack only routing-class signals — not every mention.
286
292
 
287
293
  **Claim a work item before you start it (\`borg_ack ... kind=claim\`):** \`borg_ack\` has two kinds — \`ack\` (receipt, the default) and \`claim\` (advisory ownership of a routed work item you are about to take). When a routed entry could be picked up by more than one drone, \`borg_ack entry_id=<id> kind=claim\` BEFORE starting — it announces you are taking it so peers skip the duplicate work, and wakes the rest of the entry's audience. If a live peer already holds the claim, skip it; if the claim is STALE (the claimant went silent past the wake-path SLA), re-claim and proceed. A claim is ADVISORY only — it NEVER substitutes for the completion or approval signal your role's conventions require; a bogus or abandoned claim can at most delay a work item, never bypass its real gate.
288
294
 
295
+ **When receipt is uncertain:** activity-log silence is not evidence that acknowledgement is missing. Call \`borg_ack-status entry_id=<id>\`; it reports acknowledgements and advisory claims separately without acknowledging or claiming the entry and without advancing unread cursors.
296
+
289
297
  **When stuck:** post your blocker per your role's conventions, continue other work. Escalation is per your role detail, not by stalling.
290
298
 
291
299
  **Anti-passive (lane idle = no work routed to you, no actionable signal in the log):**
@@ -298,9 +306,9 @@ ${arrivalInstruction}
298
306
 
299
307
  **Posting to the log:** post per your role's conventions whenever you start/finish a task, get stuck, answer a drone, or learn something others need — regardless of who initiated (a log signal, your own scan, or a user prompt). Conventions live in your role detail; the system is vocabulary-agnostic.
300
308
 
301
- **Routing posts widen the directed default:** the taxonomy routes most prefixes DIRECTED to your cube's coordinating role; your \`to:\` / \`visibility:\` overrides it. Widen when a post must reach more than the coordinating role:
302
- - Posting a verdict / decision / result a specific drone is waiting on: add \`to:[that drone]\` so they're WOKEN without it they can be left UNAWARE of their own merge or feedback. Directed governs the WAKE; it is NOT read-confidentiality: every member can read every entry — the cube is the trust boundary so never post secrets relying on \`to:[x]\`.
303
- - Any drone posting a multi-seat DELIVERABLE (spec / security classification / review artifact 3+ seats build or gate against): pass \`visibility:broadcast\` (or \`to:[the seats]\`) EVEN IF your prefix (\`DONE\` etc.) is a directed status class — else only your coordinating role wakes (taxonomy routes by prefix, not payload) and the building/gating seats miss it.
309
+ **Address every post explicitly:** use \`to: ["<selector>"]\` for one or more intended recipients and \`to: "broadcast"\` for every drone. Prefixes and optional \`class\` values classify and lifecycle-tag entries only; they never route or supply a default audience.
310
+ - Posting a verdict / decision / result a specific drone is waiting on: include that drone in a non-empty \`to\` selector array so they're WOKEN. Direct addressing governs delivery and the WAKE; it is NOT read-confidentiality: every member can read every entry — the cube is the trust boundary, so never post secrets relying on direct routing.
311
+ - Any drone posting a multi-seat DELIVERABLE (spec / security classification / review artifact 3+ seats build or gate against): use \`to: "broadcast"\` or explicitly list every intended selector. Never rely on the signal prefix or class to choose recipients.
304
312
 
305
313
  **Pre-commit git hygiene (universal):**
306
314
 
@@ -356,7 +364,7 @@ The discipline applies at FOUR surfaces. Catches at the surface closest to origi
356
364
  - **Surface 1 (brainstorm-proposal time)**: when a brainstorm contribution names specific code identifiers / API field names / enum values / column names / function signatures, the PROPOSING drone source-grep's the referenced file BEFORE composing the proposal. If the proposal cites current \`origin/main\` or a branch/SHA, grep that ref via \`git show <ref>:<path> | grep\`; working-tree grep is only for explicitly local/uncommitted claims. Cheapest catch surface; one drone catches one error.
357
365
  - **Surface 2 (comment/JSDoc/docstring writing time)**: when an implementation comment cites cross-file invariants (other modules' thresholds, schema columns, enum values, semantic contracts), the WRITING drone source-grep's the referenced file BEFORE writing the comment. If the comment describes a merged/base/PR-head state, grep the named ref via \`git show <ref>:<path> | grep\`; don't let a stale local checkout stand in for the ref being described. Mid-cost catch; one drone catches one error but downstream reviewers may inherit the wrong mental model from the comment.
358
366
  - **Surface 3 (review-time verification)**: the existing review-class discipline (Code Reviewer formal gates + Security Auditor SR gates + PM/UX/QA courtesy reviews). Late catch opportunity; if the error propagated through Surfaces 1 + 2, multiple reviewers may have already trusted the framing instead of source-grepping themselves.
359
- - **Surface 4 (durable-tracking-artifact-writing time)**: when filing a deferred-tracking issue from a cube event payload, the FILING drone fetches the originating entry's full body from the cube log BEFORE composing the issue body. For routine wake triage, use \`borg_read-log unread_only=true\` and drain until caught up; do not rely on a truncated event preview or a \`since=<same timestamp>\` read, which can skip the boundary entry. Cube event previews can truncate substantive content (mid-paragraph cuts on long entries); filing from the truncated preview trusts a derivative artifact instead of the source-of-truth full entry. Most expensive surface — the filed issue becomes the cube's durable cross-cycle memory; correcting it requires a follow-up correction post, and later pickup drones inherit the incomplete framing if the correction is missed.
367
+ - **Surface 4 (durable-tracking-artifact-writing time)**: when filing a deferred-tracking issue from a cube event payload, the FILING drone fetches the originating entry's full body with \`borg_read-entry entry_id=<id>\` BEFORE composing the issue body. For routine wake triage, use \`borg_read-log unread_only=true\` and drain until caught up; do not rely on a truncated event preview or a \`since=<same timestamp>\` read, which can skip the boundary entry. Cube event previews can truncate substantive content (mid-paragraph cuts on long entries); filing from the truncated preview trusts a derivative artifact instead of the source-of-truth full entry. Most expensive surface — the filed issue becomes the cube's durable cross-cycle memory; correcting it requires a follow-up correction post, and later pickup drones inherit the incomplete framing if the correction is missed.
360
368
 
361
369
  **Ratified-decision drift is a four-surface drift-class.** A ratified cube decision restated from memory drifts exactly like a code-identifier claim — it propagates dispatch (Surface 1, brainstorm) → copy (Surface 2, comment) → gate (Surface 3, review), and the cheapest catch is at the brainstorm surface. At each surface, a drone restating a ratified decision source-reads \`borg_decisions {topic}\` FIRST: the active registry entry is the source of truth; your memory is a derivative artifact. Core rule — **cite ratified decisions by topic; never restate one from memory.**
362
370
 
@@ -398,9 +406,9 @@ export function humanAgo(date: Date | string): string {
398
406
  * include — robustness wins.
399
407
  */
400
408
  /**
401
- * gh#479 — discoverability tip for intent-based routing (#468). When a
409
+ * gh#479 — discoverability tip for message classification. When a
402
410
  * cube has no `message_taxonomy` declared, borg_regen + borg_cube append
403
- * this tip so operators discover how to enable smart routing. Self-
411
+ * this tip so operators discover how to classify signals and lifecycle. Self-
404
412
  * removing: returns '' once a taxonomy exists. Copy is UX-locked
405
413
  * (design d45098c1) — keep verbatim.
406
414
  */
@@ -414,7 +422,7 @@ export function nullTaxonomyTip(messageTaxonomy: unknown): string {
414
422
  // this inline marker pins the real inputSchema param so the #490/#529 guard
415
423
  // (client/__tests__/copy-mechanism-guard.test.ts) verifies the tool actually
416
424
  // exposes it — the #479 miss class, now caught co-located with the copy.
417
- return 'Tip: no message taxonomy declared — set one to enable intent-based smart routing (#468). Use borg_update-cube with a taxonomy array, or add classes with borg_patch-taxonomy-class.';
425
+ return 'Tip: no message taxonomy declared — set one to classify signal prefixes and dispatch/completion lifecycle. Every borg_log call still requires an explicit to audience. Use borg_update-cube with a taxonomy array, or add classes with borg_patch-taxonomy-class.';
418
426
  }
419
427
 
420
428
  export function regenWakePathDroneLabel(
@@ -569,7 +577,7 @@ export function formatRegenMarkdown(
569
577
  ? [
570
578
  '## Getting started',
571
579
  '',
572
- '**You (this agent):** post `borg_log message="<task>"`; check `borg_roster`.',
580
+ '**You (this agent):** post `borg_log message="<task>" to="broadcast"`; check `borg_roster`.',
573
581
  '**Your user:** in a new terminal in the repository, add a teammate: `borg assimilate <role>`; optional `--worktree <name>` names its worktree.',
574
582
  'For "what do I do next?", use `borg_docs`.',
575
583
  '',
@@ -699,8 +707,26 @@ export function formatLogEntryMarkdown(
699
707
  ? ` ${formatDroneAddressToken(entry.drone_id)}`
700
708
  : '';
701
709
  const citations = formatDocumentCitations(entry.documents);
710
+ const recipients = formatLogRecipients(entry, droneById);
711
+ const routed = recipients.length > 0
712
+ ? `\n Recipients: ${recipients.join(', ')}`
713
+ : '';
702
714
  const documents = citations.length > 0
703
715
  ? `\n Documents:\n${citations.map((citation) => ` - ${citation}`).join('\n')}`
704
716
  : '';
705
- return `**[${ts}]**${entryId}${addr} ${d?.label ?? '?'} (${r?.name ?? '?'}): ${entry.message}${documents}`;
717
+ return `**[${ts}]**${entryId}${addr} ${d?.label ?? '?'} (${r?.name ?? '?'}): ${entry.message}${routed}${documents}`;
718
+ }
719
+
720
+ export function formatLogRecipients(
721
+ entry: { visibility?: unknown; recipient_drone_ids?: unknown },
722
+ droneById: Map<string, any>,
723
+ ): string[] {
724
+ if (entry.visibility !== 'direct' || !Array.isArray(entry.recipient_drone_ids)) return [];
725
+ return entry.recipient_drone_ids.map((droneId) => {
726
+ if (typeof droneId !== 'string') return '?';
727
+ const label = droneById.get(droneId)?.label;
728
+ return typeof label === 'string' && label.length > 0
729
+ ? label
730
+ : formatDroneAddressToken(droneId);
731
+ });
706
732
  }
@@ -16,12 +16,16 @@ import {
16
16
  import { randomUUID } from 'node:crypto';
17
17
  import {
18
18
  createProtocolEnvelope,
19
+ decodeAckStatusRequest,
20
+ decodeAckStatusResult,
19
21
  decodeAppendLogRequest,
20
22
  decodeAppendLogResult,
21
23
  decodeDeleteCubeResponse,
22
24
  decodeDeleteRoleRequest,
23
25
  decodeDeleteRoleResult,
24
26
  decodeDroneRuntimeMetadataState,
27
+ decodeEntryQueryRequest,
28
+ decodeEntryQueryResult,
25
29
  decodeEvictDroneResult,
26
30
  decodeProtocolEnvelope,
27
31
  decodeProtocolErrorEnvelope,
@@ -40,8 +44,10 @@ import {
40
44
  decodeUpdateDroneRuntimeMetadataResponse,
41
45
  ErrorCode,
42
46
  ProtocolContractError,
47
+ type AckStatusResult,
43
48
  type AgentKind,
44
49
  type DeleteRoleResult,
50
+ type EntryQueryResult,
45
51
  type EvictDroneResult,
46
52
  type ReassignDroneResult,
47
53
  type RoleRationaleResult,
@@ -86,7 +92,7 @@ import {
86
92
  type LocalServerCursor,
87
93
  } from './local-server-cursor.js';
88
94
  import { readBoundedResponseBody } from './server-response.js';
89
- import { resolveLocalLogRecipients } from './local-log-routing.js';
95
+ import { normalizeLogAudience, type LogAudience } from './direct-log.js';
90
96
  import { RoleSectionConflictError } from './local-manage-tool-result.js';
91
97
 
92
98
  export interface RemoteConnection {
@@ -1109,6 +1115,32 @@ export async function readLog(
1109
1115
  };
1110
1116
  }
1111
1117
 
1118
+ /** Read one complete log entry without consulting or advancing the unread cursor. */
1119
+ export async function readLogEntry(
1120
+ sessionToken: string,
1121
+ apiUrl: string,
1122
+ input: unknown,
1123
+ serverTrustIdentity?: string,
1124
+ ): Promise<{ entry: EntryQueryResult['entry']; drones: any[]; roles: any[] }> {
1125
+ const request = decodeEntryQueryRequest(input);
1126
+ const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
1127
+ const result = await localServerRequest<EntryQueryResult>(
1128
+ local,
1129
+ `/api/cubes/${local.cubeId}/logs/${encodeURIComponent(request.entry_id)}`,
1130
+ 'GET',
1131
+ undefined,
1132
+ { decodePayload: decodeEntryQueryResult },
1133
+ );
1134
+ if (!result) throw new Error('Local Borg server returned an empty log-entry response');
1135
+ if (request.entry_id.length === 36
1136
+ ? result.entry.id !== request.entry_id
1137
+ : !result.entry.id.startsWith(request.entry_id)) {
1138
+ throw new ProtocolContractError('Log-entry response id does not match the requested selector.');
1139
+ }
1140
+ const composed = await localCubeComposition(local);
1141
+ return { entry: result.entry, drones: composed.drones, roles: composed.roles };
1142
+ }
1143
+
1112
1144
  /**
1113
1145
  * Sprint 25 log substrate refactor: explicit ack on a log entry.
1114
1146
  *
@@ -1133,6 +1165,29 @@ export async function ackLogEntry(
1133
1165
  );
1134
1166
  }
1135
1167
 
1168
+ /** Read acknowledgement and advisory-claim state without mutating log state. */
1169
+ export async function getAckStatus(
1170
+ sessionToken: string,
1171
+ apiUrl: string,
1172
+ input: unknown,
1173
+ serverTrustIdentity?: string,
1174
+ ): Promise<AckStatusResult> {
1175
+ const request = decodeAckStatusRequest(input);
1176
+ const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
1177
+ const result = await localServerRequest<AckStatusResult>(
1178
+ local,
1179
+ `/api/cubes/${local.cubeId}/logs/${encodeURIComponent(request.entry_id)}/ack-status`,
1180
+ 'GET',
1181
+ { ...request },
1182
+ { decodePayload: decodeAckStatusResult },
1183
+ );
1184
+ if (!result) throw new Error('Local Borg server returned an empty acknowledgement-status response');
1185
+ if (result.entry_id !== request.entry_id) {
1186
+ throw new ProtocolContractError('Acknowledgement-status response entry id does not match the request.');
1187
+ }
1188
+ return result;
1189
+ }
1190
+
1136
1191
  /** Record a ratified cube decision using the local client's cube-manage grant. */
1137
1192
  export async function recordDecision(
1138
1193
  sessionToken: string,
@@ -1419,69 +1474,34 @@ export async function appendLog(
1419
1474
  apiUrl: string,
1420
1475
  message: string,
1421
1476
  opts: {
1422
- visibility?: 'broadcast' | 'direct';
1423
- recipientDroneIds?: string[];
1477
+ to: LogAudience;
1424
1478
  class?: string;
1425
- to?: string[];
1426
1479
  documents?: string[];
1427
1480
  serverTrustIdentity?: string;
1428
- } = {}
1481
+ },
1429
1482
  ): Promise<ReturnType<typeof decodeAppendLogResult>> {
1430
- if (opts.visibility === 'broadcast' && (opts.to?.length ?? 0) > 0) {
1431
- throw new Error(
1432
- "Invalid input: visibility:'broadcast' cannot be combined with non-empty to:. " +
1433
- 'Remove visibility to direct to recipients, or remove to: to broadcast.',
1434
- );
1435
- }
1436
- if (opts.to?.length === 0) {
1437
- throw new Error('Direct log recipient list must contain at least one recipient');
1438
- }
1483
+ const to = normalizeLogAudience(opts?.to);
1439
1484
  const postId = randomUUID();
1440
1485
  const local = await localAuthorityContext(
1441
1486
  sessionToken,
1442
1487
  apiUrl,
1443
1488
  opts.serverTrustIdentity,
1444
1489
  );
1445
- let visibility = opts.visibility;
1446
- let recipientDroneIds = opts.recipientDroneIds;
1447
- if (visibility !== 'broadcast' &&
1448
- (!recipientDroneIds || recipientDroneIds.length === 0) &&
1449
- opts.to !== undefined) {
1450
- const base = `/api/cubes/${local.cubeId}`;
1451
- const [rolePayload, dronePayload] = await Promise.all([
1452
- localServerRequest<{ roles: any[] }>(local, `${base}/roles`, 'GET'),
1453
- localServerRequest<{ drones: any[] }>(local, `${base}/drones`, 'GET'),
1454
- ]);
1455
- if (!rolePayload || !dronePayload) {
1456
- throw new Error('Local Borg server returned an incomplete cube roster');
1457
- }
1458
- recipientDroneIds = resolveLocalLogRecipients(
1459
- opts.to,
1460
- dronePayload.drones,
1461
- rolePayload.roles,
1462
- );
1463
- visibility = 'direct';
1464
- } else if (visibility === undefined && recipientDroneIds !== undefined) {
1465
- visibility = 'direct';
1466
- }
1467
- const request = decodeAppendLogRequest({
1468
- post_id: postId,
1469
- message,
1470
- ...(visibility ? { visibility } : {}),
1471
- ...(visibility === 'direct' && recipientDroneIds
1472
- ? { recipientDroneIds }
1473
- : {}),
1474
- ...(opts.class ? { class: opts.class } : {}),
1475
- ...(opts.documents ? { documents: opts.documents } : {}),
1476
- });
1477
- const payload = await localServerRequest<ReturnType<typeof decodeAppendLogResult>>(
1478
- local,
1479
- `/api/cubes/${local.cubeId}/logs`,
1480
- 'POST',
1481
- { ...request },
1482
- { retryMode: 'append-log', decodePayload: decodeAppendLogResult },
1483
- );
1484
- if (!payload) throw new Error('Local Borg server returned an empty log response');
1490
+ const request = decodeAppendLogRequest({
1491
+ post_id: postId,
1492
+ message,
1493
+ to,
1494
+ ...(opts.class ? { class: opts.class } : {}),
1495
+ ...(opts.documents ? { documents: opts.documents } : {}),
1496
+ });
1497
+ const payload = await localServerRequest<ReturnType<typeof decodeAppendLogResult>>(
1498
+ local,
1499
+ `/api/cubes/${local.cubeId}/logs`,
1500
+ 'POST',
1501
+ { ...request },
1502
+ { retryMode: 'append-log', decodePayload: decodeAppendLogResult },
1503
+ );
1504
+ if (!payload) throw new Error('Local Borg server returned an empty log response');
1485
1505
  return payload;
1486
1506
  }
1487
1507
 
@@ -215,7 +215,7 @@ export interface ServerAttachResult {
215
215
  }
216
216
 
217
217
  /**
218
- * Attach an enrolled client principal to one granted cube/role over protocol v10.
218
+ * Attach an enrolled client principal to one granted cube/role over protocol v12.
219
219
  * The client CSPRNG-generates the session bearer and persists it PENDING in the
220
220
  * local 0600 credential store (keyed by the stable per-seat identity) BEFORE
221
221
  * this request, so an interrupted/lost response is recovered by re-sending the