borgmcp 3.17.0 → 4.0.1

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 +33 -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 +33 -14
  21. package/dist/regen-format.js.map +1 -1
  22. package/dist/remote-client.d.ts +10 -5
  23. package/dist/remote-client.d.ts.map +1 -1
  24. package/dist/remote-client.js +20 -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 +40 -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 +1 -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 +38 -12
  41. package/src/log-audit-core.ts +4 -2
  42. package/src/regen-format.ts +35 -14
  43. package/src/remote-client.ts +48 -54
  44. package/src/server-handshake.ts +1 -1
  45. package/src/tool-manifest.ts +41 -25
  46. package/src/tool-scope.ts +1 -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "borgmcp",
3
- "version": "3.17.0",
3
+ "version": "4.0.1",
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.14.0",
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,6 +28,7 @@ import {
28
28
  getRoleInfoByName,
29
29
  getRoster,
30
30
  readLog,
31
+ readLogEntry,
31
32
  appendLog,
32
33
  ackLogEntry,
33
34
  getAckStatus,
@@ -87,6 +88,7 @@ import { addUserPromptSubmitHook } from './config-utils.js';
87
88
  import {
88
89
  humanAgo,
89
90
  formatLogEntryMarkdown,
91
+ formatLogRecipients,
90
92
  formatRegenMarkdown,
91
93
  getDronePlaybook,
92
94
  getDronePlaybookChapter,
@@ -157,7 +159,7 @@ import {
157
159
  shouldSuppressLifecycleLog,
158
160
  } from './lifecycle-log-guard.js';
159
161
  import {
160
- normalizeDirectLogRecipients,
162
+ normalizeLogAudience,
161
163
  } from './direct-log.js';
162
164
  import { formatLocalManageToolResult } from './local-manage-tool-result.js';
163
165
  import {
@@ -850,6 +852,24 @@ export async function main() {
850
852
  return { content: [{ type: 'text', text: lines.join('\n') }] };
851
853
  }
852
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
+
853
873
  case 'borg_put-document': {
854
874
  const active = await requireActiveCube();
855
875
  const result = await putDocument(active.sessionToken, active.apiUrl, args ?? {}, active.serverTrustIdentity);
@@ -880,6 +900,7 @@ export async function main() {
880
900
  case 'borg_log': {
881
901
  const message = args?.message as string;
882
902
  if (!message || typeof message !== 'string') throw new Error('message is required');
903
+ const to = normalizeLogAudience(args?.to);
883
904
  const active = await getActiveCube();
884
905
  if (!active) throw new Error('Not assimilated to a cube. Use borg_assimilate <cube-name> first.');
885
906
  seedDisplayIdentity(active);
@@ -900,28 +921,33 @@ export async function main() {
900
921
  };
901
922
  }
902
923
  }
903
- const hasTo = Object.prototype.hasOwnProperty.call(args ?? {}, 'to');
904
- const recipients = hasTo ? normalizeDirectLogRecipients(args?.to) : undefined;
905
924
  const explicitClass = typeof args?.class === 'string' ? args.class : undefined;
906
- const visibility: 'broadcast' | 'direct' | undefined =
907
- args?.visibility === 'broadcast' || args?.visibility === 'direct'
908
- ? args.visibility
909
- : undefined;
910
925
  const documents = args?.documents as string[] | undefined;
911
926
  if (!active.serverTrustIdentity) {
912
927
  throw new Error('Selected Borg server authority state is missing or unreadable');
913
928
  }
914
929
  const appendOpts = {
930
+ to,
915
931
  ...(explicitClass ? { class: explicitClass } : {}),
916
- ...(hasTo ? { to: recipients ?? [] } : {}),
917
- ...(visibility ? { visibility } : {}),
918
932
  ...(documents ? { documents } : {}),
919
933
  serverTrustIdentity: active.serverTrustIdentity,
920
934
  };
921
935
  const result = await appendLog(active.sessionToken, active.apiUrl, message, appendOpts);
922
936
  await recordLifecycleLog(active, message);
923
937
  if (lifecycleSignal === 'arrival') markArrivalAnnouncedThisProcess();
924
- 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
+ : '';
925
951
  // gh#534: surface to the SENDER which directed recipients are
926
952
  // currently unreachable via the wake path. The message is delivered
927
953
  // regardless (persisted server-side); they read it when they return.
@@ -933,9 +959,9 @@ export async function main() {
933
959
  const cited = formatDocumentCitations(result.entry.documents);
934
960
  const citations = cited.length > 0 ? `\nDocuments: ${cited.join('; ')}` : '';
935
961
  const advisory = result.advisory?.code === 'STORE_AS_DOCUMENT'
936
- ? `\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.`
937
963
  : '';
938
- 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}`;
939
965
  return { content: [{ type: 'text', text }] };
940
966
  }
941
967
 
@@ -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',
@@ -240,6 +240,8 @@ export function markArrivalAnnouncedThisProcess(): void {
240
240
  // copy-param-claim: borg_ack-status.entry_id
241
241
  // The playbook below directs uncertain receipt checks through the read-only
242
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
243
245
  // copy-param-claim: borg_docs.topic
244
246
  // The playbook below points drones to `borg_docs {topic}` for user questions
245
247
  // about how Borg MCP works; this marker pins the param so the #490/#529 guard
@@ -247,7 +249,7 @@ export function markArrivalAnnouncedThisProcess(): void {
247
249
  export function getDronePlaybook(): string {
248
250
  const arrivalInstruction = arrivalAnnouncedThisProcess
249
251
  ? ''
250
- : `\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`;
251
253
  return `## How to operate as a Drone
252
254
 
253
255
  You're a Drone in a Cube. Coordinate with other drones through the activity log.
@@ -260,10 +262,11 @@ You're a Drone in a Cube. Coordinate with other drones through the activity log.
260
262
  - \`borg_role\` — re-read your role's detailed playbook
261
263
  - \`borg_roster\` — see who else is connected
262
264
  - \`borg_read-log unread_only=true [limit]\` — drain unread log entries from your server-side cursor
263
- - \`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
264
267
  - \`borg_assimilate <cube>\` — switch to a different cube
265
268
 
266
- **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.
267
270
 
268
271
  **Communication discipline for non-human seats:**
269
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.
@@ -282,10 +285,10 @@ You're a Drone in a Cube. Coordinate with other drones through the activity log.
282
285
  or a status reply must never be the last action of a turn while work is outstanding.
283
286
  6. Nothing actionable, no prompt, and no work of your own outstanding → done; wait for next wake.
284
287
 
285
- **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).
286
289
  ${arrivalInstruction}
287
290
 
288
- **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.
291
+ **When a log entry asks you to act:** if its explicit \`to\` audience includes your drone and its message assigns you work or directly asks you to act, 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 actionable assignments and direct action requests only — not every addressed entry or mention.
289
292
 
290
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.
291
294
 
@@ -303,9 +306,9 @@ ${arrivalInstruction}
303
306
 
304
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.
305
308
 
306
- **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:
307
- - 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]\`.
308
- - 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.
309
312
 
310
313
  **Pre-commit git hygiene (universal):**
311
314
 
@@ -361,7 +364,7 @@ The discipline applies at FOUR surfaces. Catches at the surface closest to origi
361
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.
362
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.
363
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.
364
- - **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.
365
368
 
366
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.**
367
370
 
@@ -403,9 +406,9 @@ export function humanAgo(date: Date | string): string {
403
406
  * include — robustness wins.
404
407
  */
405
408
  /**
406
- * gh#479 — discoverability tip for intent-based routing (#468). When a
409
+ * gh#479 — discoverability tip for message classification. When a
407
410
  * cube has no `message_taxonomy` declared, borg_regen + borg_cube append
408
- * this tip so operators discover how to enable smart routing. Self-
411
+ * this tip so operators discover how to classify signals and lifecycle. Self-
409
412
  * removing: returns '' once a taxonomy exists. Copy is UX-locked
410
413
  * (design d45098c1) — keep verbatim.
411
414
  */
@@ -419,7 +422,7 @@ export function nullTaxonomyTip(messageTaxonomy: unknown): string {
419
422
  // this inline marker pins the real inputSchema param so the #490/#529 guard
420
423
  // (client/__tests__/copy-mechanism-guard.test.ts) verifies the tool actually
421
424
  // exposes it — the #479 miss class, now caught co-located with the copy.
422
- 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.';
423
426
  }
424
427
 
425
428
  export function regenWakePathDroneLabel(
@@ -574,7 +577,7 @@ export function formatRegenMarkdown(
574
577
  ? [
575
578
  '## Getting started',
576
579
  '',
577
- '**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`.',
578
581
  '**Your user:** in a new terminal in the repository, add a teammate: `borg assimilate <role>`; optional `--worktree <name>` names its worktree.',
579
582
  'For "what do I do next?", use `borg_docs`.',
580
583
  '',
@@ -704,8 +707,26 @@ export function formatLogEntryMarkdown(
704
707
  ? ` ${formatDroneAddressToken(entry.drone_id)}`
705
708
  : '';
706
709
  const citations = formatDocumentCitations(entry.documents);
710
+ const recipients = formatLogRecipients(entry, droneById);
711
+ const routed = recipients.length > 0
712
+ ? `\n Recipients: ${recipients.join(', ')}`
713
+ : '';
707
714
  const documents = citations.length > 0
708
715
  ? `\n Documents:\n${citations.map((citation) => ` - ${citation}`).join('\n')}`
709
716
  : '';
710
- 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
+ });
711
732
  }
@@ -24,6 +24,8 @@ import {
24
24
  decodeDeleteRoleRequest,
25
25
  decodeDeleteRoleResult,
26
26
  decodeDroneRuntimeMetadataState,
27
+ decodeEntryQueryRequest,
28
+ decodeEntryQueryResult,
27
29
  decodeEvictDroneResult,
28
30
  decodeProtocolEnvelope,
29
31
  decodeProtocolErrorEnvelope,
@@ -45,6 +47,7 @@ import {
45
47
  type AckStatusResult,
46
48
  type AgentKind,
47
49
  type DeleteRoleResult,
50
+ type EntryQueryResult,
48
51
  type EvictDroneResult,
49
52
  type ReassignDroneResult,
50
53
  type RoleRationaleResult,
@@ -89,7 +92,7 @@ import {
89
92
  type LocalServerCursor,
90
93
  } from './local-server-cursor.js';
91
94
  import { readBoundedResponseBody } from './server-response.js';
92
- import { resolveLocalLogRecipients } from './local-log-routing.js';
95
+ import { normalizeLogAudience, type LogAudience } from './direct-log.js';
93
96
  import { RoleSectionConflictError } from './local-manage-tool-result.js';
94
97
 
95
98
  export interface RemoteConnection {
@@ -1112,6 +1115,32 @@ export async function readLog(
1112
1115
  };
1113
1116
  }
1114
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
+
1115
1144
  /**
1116
1145
  * Sprint 25 log substrate refactor: explicit ack on a log entry.
1117
1146
  *
@@ -1445,69 +1474,34 @@ export async function appendLog(
1445
1474
  apiUrl: string,
1446
1475
  message: string,
1447
1476
  opts: {
1448
- visibility?: 'broadcast' | 'direct';
1449
- recipientDroneIds?: string[];
1477
+ to: LogAudience;
1450
1478
  class?: string;
1451
- to?: string[];
1452
1479
  documents?: string[];
1453
1480
  serverTrustIdentity?: string;
1454
- } = {}
1481
+ },
1455
1482
  ): Promise<ReturnType<typeof decodeAppendLogResult>> {
1456
- if (opts.visibility === 'broadcast' && (opts.to?.length ?? 0) > 0) {
1457
- throw new Error(
1458
- "Invalid input: visibility:'broadcast' cannot be combined with non-empty to:. " +
1459
- 'Remove visibility to direct to recipients, or remove to: to broadcast.',
1460
- );
1461
- }
1462
- if (opts.to?.length === 0) {
1463
- throw new Error('Direct log recipient list must contain at least one recipient');
1464
- }
1483
+ const to = normalizeLogAudience(opts?.to);
1465
1484
  const postId = randomUUID();
1466
1485
  const local = await localAuthorityContext(
1467
1486
  sessionToken,
1468
1487
  apiUrl,
1469
1488
  opts.serverTrustIdentity,
1470
1489
  );
1471
- let visibility = opts.visibility;
1472
- let recipientDroneIds = opts.recipientDroneIds;
1473
- if (visibility !== 'broadcast' &&
1474
- (!recipientDroneIds || recipientDroneIds.length === 0) &&
1475
- opts.to !== undefined) {
1476
- const base = `/api/cubes/${local.cubeId}`;
1477
- const [rolePayload, dronePayload] = await Promise.all([
1478
- localServerRequest<{ roles: any[] }>(local, `${base}/roles`, 'GET'),
1479
- localServerRequest<{ drones: any[] }>(local, `${base}/drones`, 'GET'),
1480
- ]);
1481
- if (!rolePayload || !dronePayload) {
1482
- throw new Error('Local Borg server returned an incomplete cube roster');
1483
- }
1484
- recipientDroneIds = resolveLocalLogRecipients(
1485
- opts.to,
1486
- dronePayload.drones,
1487
- rolePayload.roles,
1488
- );
1489
- visibility = 'direct';
1490
- } else if (visibility === undefined && recipientDroneIds !== undefined) {
1491
- visibility = 'direct';
1492
- }
1493
- const request = decodeAppendLogRequest({
1494
- post_id: postId,
1495
- message,
1496
- ...(visibility ? { visibility } : {}),
1497
- ...(visibility === 'direct' && recipientDroneIds
1498
- ? { recipientDroneIds }
1499
- : {}),
1500
- ...(opts.class ? { class: opts.class } : {}),
1501
- ...(opts.documents ? { documents: opts.documents } : {}),
1502
- });
1503
- const payload = await localServerRequest<ReturnType<typeof decodeAppendLogResult>>(
1504
- local,
1505
- `/api/cubes/${local.cubeId}/logs`,
1506
- 'POST',
1507
- { ...request },
1508
- { retryMode: 'append-log', decodePayload: decodeAppendLogResult },
1509
- );
1510
- 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');
1511
1505
  return payload;
1512
1506
  }
1513
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 v11.
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