borgmcp 2.0.8 → 2.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +2 -2
  2. package/dist/agent-runtime.d.ts +2 -0
  3. package/dist/agent-runtime.d.ts.map +1 -1
  4. package/dist/agent-runtime.js +5 -1
  5. package/dist/agent-runtime.js.map +1 -1
  6. package/dist/assimilate-cmd.d.ts +2 -0
  7. package/dist/assimilate-cmd.d.ts.map +1 -1
  8. package/dist/assimilate-cmd.js +2 -0
  9. package/dist/assimilate-cmd.js.map +1 -1
  10. package/dist/assimilate-deps.d.ts.map +1 -1
  11. package/dist/assimilate-deps.js +6 -0
  12. package/dist/assimilate-deps.js.map +1 -1
  13. package/dist/index.d.ts +13 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +38 -23
  16. package/dist/index.js.map +1 -1
  17. package/dist/regen-format.d.ts.map +1 -1
  18. package/dist/regen-format.js +6 -4
  19. package/dist/regen-format.js.map +1 -1
  20. package/dist/regen.js +2 -0
  21. package/dist/regen.js.map +1 -1
  22. package/dist/remote-client.d.ts +24 -9
  23. package/dist/remote-client.d.ts.map +1 -1
  24. package/dist/remote-client.js +329 -49
  25. package/dist/remote-client.js.map +1 -1
  26. package/dist/roster-render.d.ts +11 -0
  27. package/dist/roster-render.d.ts.map +1 -1
  28. package/dist/roster-render.js +41 -8
  29. package/dist/roster-render.js.map +1 -1
  30. package/dist/runtime-metadata.d.ts +13 -0
  31. package/dist/runtime-metadata.d.ts.map +1 -0
  32. package/dist/runtime-metadata.js +52 -0
  33. package/dist/runtime-metadata.js.map +1 -0
  34. package/dist/server-handshake.d.ts +2 -1
  35. package/dist/server-handshake.d.ts.map +1 -1
  36. package/dist/server-handshake.js +3 -0
  37. package/dist/server-handshake.js.map +1 -1
  38. package/dist/sync-roles-render.d.ts +2 -0
  39. package/dist/sync-roles-render.d.ts.map +1 -1
  40. package/dist/sync-roles-render.js +26 -7
  41. package/dist/sync-roles-render.js.map +1 -1
  42. package/dist/working-repo.d.ts +5 -7
  43. package/dist/working-repo.d.ts.map +1 -1
  44. package/dist/working-repo.js +30 -40
  45. package/dist/working-repo.js.map +1 -1
  46. package/docs/EXTRACTION_PROVENANCE.md +8 -7
  47. package/docs/LOCAL_SERVER.md +1 -1
  48. package/docs/RELEASING.md +19 -1
  49. package/package.json +2 -2
  50. package/src/agent-runtime.ts +8 -1
  51. package/src/assimilate-cmd.ts +3 -1
  52. package/src/assimilate-deps.ts +10 -4
  53. package/src/index.ts +58 -27
  54. package/src/regen-format.ts +12 -5
  55. package/src/regen.ts +2 -0
  56. package/src/remote-client.ts +382 -43
  57. package/src/roster-render.ts +58 -8
  58. package/src/runtime-metadata.ts +67 -0
  59. package/src/server-handshake.ts +7 -2
  60. package/src/sync-roles-render.ts +24 -7
  61. package/src/working-repo.ts +30 -41
@@ -16,11 +16,14 @@ import {
16
16
  import { randomUUID } from 'node:crypto';
17
17
  import {
18
18
  createProtocolEnvelope,
19
+ decodeDroneRuntimeMetadataState,
19
20
  decodeEvictDroneResult,
20
21
  decodeProtocolEnvelope,
21
22
  decodeProtocolErrorEnvelope,
22
23
  decodeReassignDroneResult,
24
+ decodeUpdateDroneRuntimeMetadataResponse,
23
25
  ErrorCode,
26
+ type AgentKind,
24
27
  type EvictDroneResult,
25
28
  type ReassignDroneResult,
26
29
  } from 'borgmcp-shared/protocol';
@@ -29,7 +32,11 @@ import { debugLog } from './debug.js';
29
32
  import { assertUuidShape } from './evict-drone.js';
30
33
  import { DroneEvictedError, DRONE_EVICTED_CODE } from './drone-lifecycle.js';
31
34
  import type { MessageTaxonomy, MessageTaxonomyClass } from 'borgmcp-shared/templates';
35
+ import { getTemplate, type Template, type TemplateRole } from 'borgmcp-shared/templates';
36
+ import { parseRoleSections } from 'borgmcp-shared/role-section';
37
+ import type { FragmentView, NonClobberSyncResult } from './sync-roles-render.js';
32
38
  import type { WorkingRepo } from './working-repo.js';
39
+ import { buildRuntimeMetadataPatch } from './runtime-metadata.js';
33
40
  import { loadBorgServerTrust, type ServerFetch } from './server-trust.js';
34
41
  import {
35
42
  BorgServerError,
@@ -250,6 +257,11 @@ export interface LocalManageOperation {
250
257
  noMutation: string;
251
258
  }
252
259
 
260
+ export interface LocalManageAuthority {
261
+ active: ActiveCube;
262
+ connection: RemoteConnection;
263
+ }
264
+
253
265
  function manageCopyValue(value: string): string {
254
266
  return JSON.stringify(value);
255
267
  }
@@ -280,6 +292,13 @@ async function localManageConnection(
280
292
  return { apiUrl: active.apiUrl, authToken, serverTrustIdentity: trustIdentity };
281
293
  }
282
294
 
295
+ export async function resolveLocalManageAuthority(
296
+ active: ActiveCube,
297
+ operation: LocalManageOperation,
298
+ ): Promise<LocalManageAuthority> {
299
+ return { active, connection: await localManageConnection(active, operation) };
300
+ }
301
+
283
302
  async function localManageRequest<T>(
284
303
  active: ActiveCube,
285
304
  path: string,
@@ -287,8 +306,9 @@ async function localManageRequest<T>(
287
306
  operation: LocalManageOperation,
288
307
  payload?: Record<string, unknown>,
289
308
  decodePayload?: (value: unknown) => T,
309
+ connectionOverride?: RemoteConnection,
290
310
  ): Promise<T | null> {
291
- const connection = await localManageConnection(active, operation);
311
+ const connection = connectionOverride ?? await localManageConnection(active, operation);
292
312
  try {
293
313
  return await decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
294
314
  method,
@@ -335,6 +355,24 @@ async function localConnectionRequest<T>(
335
355
  }), false) as Promise<T>;
336
356
  }
337
357
 
358
+ async function localConnectionMutation<T>(
359
+ connection: RemoteConnection,
360
+ path: string,
361
+ method: 'POST' | 'PATCH',
362
+ payload: Record<string, unknown>,
363
+ ): Promise<T> {
364
+ return decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
365
+ method,
366
+ signal,
367
+ apiUrl: connection.apiUrl,
368
+ authToken: connection.authToken,
369
+ serverTrustIdentity: connection.serverTrustIdentity,
370
+ redirect: 'error',
371
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
372
+ body: JSON.stringify(createProtocolEnvelope(randomUUID(), payload)),
373
+ }), false) as Promise<T>;
374
+ }
375
+
338
376
  async function localOwnerConnection(connection?: RemoteConnection): Promise<RemoteConnection> {
339
377
  if (connection) return connection;
340
378
  const active = await getActiveCube();
@@ -366,18 +404,42 @@ async function localCubeComposition(active: ActiveCube): Promise<{
366
404
  if (!cubePayload || !rolePayload || !dronePayload) {
367
405
  throw new Error('Local Borg server returned an incomplete cube response');
368
406
  }
369
- const drone = dronePayload.drones.find((candidate) => candidate.id === active.droneId);
407
+ const drones = dronePayload.drones.map(withValidatedRuntimeMetadata);
408
+ const drone = drones.find((candidate) => candidate.id === active.droneId);
370
409
  const role = rolePayload.roles.find((candidate) => candidate.id === drone?.role_id);
371
410
  if (!drone || !role) throw new Error('Local Borg server no longer recognizes this drone seat');
372
411
  return {
373
412
  cube: cubePayload.cube,
374
413
  roles: rolePayload.roles,
375
- drones: dronePayload.drones,
414
+ drones,
376
415
  role,
377
416
  drone,
378
417
  };
379
418
  }
380
419
 
420
+ function withValidatedRuntimeMetadata<T extends Record<string, unknown>>(drone: T): T {
421
+ const state = decodeDroneRuntimeMetadataState(drone);
422
+ return {
423
+ ...drone,
424
+ ...state.runtime_metadata,
425
+ runtime_metadata_reported: state.runtime_metadata_reported,
426
+ };
427
+ }
428
+
429
+ async function updateOwnRuntimeMetadata(
430
+ active: ActiveCube,
431
+ patch: ReturnType<typeof buildRuntimeMetadataPatch>,
432
+ ): Promise<void> {
433
+ const payload = await localServerRequest<Record<string, unknown>>(
434
+ active,
435
+ `/api/cubes/${active.cubeId}/drones/self/metadata`,
436
+ 'PATCH',
437
+ { ...patch },
438
+ );
439
+ if (!payload) throw new Error('Local Borg server returned an empty runtime metadata response');
440
+ decodeUpdateDroneRuntimeMetadataResponse(payload);
441
+ }
442
+
381
443
  function localCursorBinding(active: ActiveCube) {
382
444
  return {
383
445
  origin: active.apiUrl,
@@ -706,7 +768,7 @@ export async function whoami(
706
768
  sessionToken: string,
707
769
  apiUrl: string,
708
770
  serverTrustIdentity?: string,
709
- ): Promise<{ cube_id: string; cube_name: string; drone_id: string; drone_label: string; role_id: string; role_name: string }> {
771
+ ): Promise<{ cube_id: string; cube_name: string; drone_id: string; drone_label: string; role_id: string; role_name: string; runtime_metadata: { agent_kind: AgentKind | null; reported_model: string | null; working_repo_name: string | null; working_repo_origin: string | null }; runtime_metadata_reported: boolean }> {
710
772
  const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
711
773
  const composed = await localCubeComposition(local);
712
774
  return {
@@ -716,6 +778,13 @@ export async function whoami(
716
778
  drone_label: composed.drone.label,
717
779
  role_id: composed.role.id,
718
780
  role_name: composed.role.name,
781
+ runtime_metadata: {
782
+ agent_kind: composed.drone.agent_kind,
783
+ reported_model: composed.drone.reported_model,
784
+ working_repo_name: composed.drone.working_repo_name,
785
+ working_repo_origin: composed.drone.working_repo_origin,
786
+ },
787
+ runtime_metadata_reported: composed.drone.runtime_metadata_reported,
719
788
  };
720
789
  }
721
790
 
@@ -738,9 +807,32 @@ export async function getRoster(
738
807
  serverTrustIdentity?: string,
739
808
  ): Promise<{ drones: any[]; roles: any[]; message_taxonomy?: MessageTaxonomy | null; since?: string | null }> {
740
809
  const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
741
- if (since !== undefined) localUnsupported('roster liveness filtering');
810
+ if (since !== undefined) {
811
+ const [dronePayload, rolePayload, cubePayload] = await Promise.all([
812
+ localServerRequest<{ drones: any[]; since?: string | null }>(
813
+ local,
814
+ `/api/cubes/${local.cubeId}/drones?since=${encodeURIComponent(since)}`,
815
+ 'GET',
816
+ ),
817
+ localServerRequest<{ roles: any[] }>(local, `/api/cubes/${local.cubeId}/roles`, 'GET'),
818
+ localServerRequest<{ cube: any }>(local, `/api/cubes/${local.cubeId}`, 'GET'),
819
+ ]);
820
+ if (!dronePayload || !rolePayload || !cubePayload) {
821
+ throw new Error('Local Borg server returned an incomplete roster response');
822
+ }
823
+ return {
824
+ drones: dronePayload.drones.map(withValidatedRuntimeMetadata),
825
+ roles: rolePayload.roles,
826
+ message_taxonomy: cubePayload.cube.message_taxonomy ?? null,
827
+ since: dronePayload.since ?? since,
828
+ };
829
+ }
742
830
  const composed = await localCubeComposition(local);
743
- return { drones: composed.drones, roles: composed.roles, message_taxonomy: null };
831
+ return {
832
+ drones: composed.drones,
833
+ roles: composed.roles,
834
+ message_taxonomy: composed.cube.message_taxonomy ?? null,
835
+ };
744
836
  }
745
837
 
746
838
  /**
@@ -887,6 +979,8 @@ export async function regen(
887
979
  since?: string;
888
980
  /** Advisory self-report from the running agent; never model-routing config. */
889
981
  reportedModel?: string;
982
+ /** Positively identified running Agent CLI; null means explicitly unknown. */
983
+ agentKind?: AgentKind | null;
890
984
  /** Current cwd-derived identity; refreshed each regen to avoid stale routing data. */
891
985
  workingRepo?: WorkingRepo;
892
986
  /** Verified self-hosted authority from the caller's first active-state read. */
@@ -913,6 +1007,24 @@ export async function regen(
913
1007
  apiUrl,
914
1008
  opts.serverTrustIdentity,
915
1009
  );
1010
+ if (
1011
+ opts.agentKind !== undefined ||
1012
+ opts.reportedModel !== undefined ||
1013
+ opts.workingRepo !== undefined
1014
+ ) {
1015
+ const patch = buildRuntimeMetadataPatch({
1016
+ agentKind: opts.agentKind ?? null,
1017
+ reportedModel: opts.reportedModel,
1018
+ workingRepo: opts.workingRepo,
1019
+ });
1020
+ try {
1021
+ await updateOwnRuntimeMetadata(local, patch);
1022
+ } catch {
1023
+ // Metadata is advisory. Preserve the prior server value and continue with
1024
+ // the authenticated identity read; never echo rejected local input.
1025
+ console.warn('Local regen: runtime metadata update unavailable; preserving the prior safe report.');
1026
+ }
1027
+ }
916
1028
  const composed = await localCubeComposition(local);
917
1029
  const cursor = opts.since === undefined
918
1030
  ? await getLocalServerCursor(localCursorBinding(local))
@@ -1071,11 +1183,25 @@ export async function createCube(
1071
1183
  opts?: { template?: string; message_taxonomy?: MessageTaxonomy | null },
1072
1184
  connection?: RemoteConnection,
1073
1185
  ): Promise<{ id: string; name: string; cube_directive?: string; roles: any[]; drones?: any[]; [k: string]: any }> {
1074
- void name;
1075
- void cubeDirective;
1076
- void opts;
1077
- void connection;
1078
- localUnsupported('cube creation');
1186
+ if (!name?.trim()) throw new Error('Local Borg server cube creation requires a cube name');
1187
+ if (opts?.template !== undefined && opts.template !== 'default') {
1188
+ throw new Error('Local Borg server supports only the default cube seed');
1189
+ }
1190
+ const resolved = await localOwnerConnection(connection);
1191
+ const created = await localConnectionMutation<{
1192
+ cube_id: string;
1193
+ human_seat_role_id: string;
1194
+ default_worker_role_id: string;
1195
+ }>(resolved, '/api/cubes', 'POST', {
1196
+ retry_key: randomUUID(),
1197
+ name: name.trim(),
1198
+ template: 'default',
1199
+ });
1200
+ if (!created?.cube_id) throw new Error('Local Borg server returned an invalid cube creation response');
1201
+ const patch: Record<string, unknown> = { cube_directive: cubeDirective };
1202
+ if (opts?.message_taxonomy !== undefined) patch.message_taxonomy = opts.message_taxonomy;
1203
+ await localConnectionMutation(resolved, `/api/cubes/${created.cube_id}`, 'PATCH', patch);
1204
+ return getCube(created.cube_id, resolved);
1079
1205
  }
1080
1206
 
1081
1207
  /**
@@ -1084,10 +1210,12 @@ export async function createCube(
1084
1210
  */
1085
1211
  export async function updateCube(
1086
1212
  cubeId: string,
1087
- updates: { name?: string; cube_directive?: string; message_taxonomy?: MessageTaxonomy | null }
1213
+ updates: { name?: string; cube_directive?: string; message_taxonomy?: MessageTaxonomy | null },
1214
+ activeOverride?: ActiveCube,
1215
+ connectionOverride?: RemoteConnection,
1088
1216
  ): Promise<{ cube: any }> {
1089
1217
  assertUuidShape(cubeId, 'cube_id');
1090
- const active = await getActiveCube();
1218
+ const active = activeOverride ?? await getActiveCube();
1091
1219
  if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1092
1220
  if (updates.name !== undefined) localUnsupported('cube rename');
1093
1221
  const payload: Record<string, unknown> = {};
@@ -1105,6 +1233,8 @@ export async function updateCube(
1105
1233
  noMutation: 'No cube settings were changed.',
1106
1234
  },
1107
1235
  payload,
1236
+ undefined,
1237
+ connectionOverride,
1108
1238
  );
1109
1239
  if (!result) throw new Error('Local Borg server returned an empty cube response');
1110
1240
  return result;
@@ -1120,11 +1250,13 @@ export async function patchTaxonomyClass(
1120
1250
  cubeId: string,
1121
1251
  op:
1122
1252
  | { action: 'add'; class_def: MessageTaxonomyClass }
1123
- | { action: 'replace'; class_def: MessageTaxonomyClass }
1124
- | { action: 'remove'; class: string }
1253
+ | { action: 'replace'; class_def: MessageTaxonomyClass }
1254
+ | { action: 'remove'; class: string },
1255
+ activeOverride?: ActiveCube,
1256
+ connectionOverride?: RemoteConnection,
1125
1257
  ): Promise<{ cube: any }> {
1126
1258
  assertUuidShape(cubeId, 'cube_id');
1127
- const active = await getActiveCube();
1259
+ const active = activeOverride ?? await getActiveCube();
1128
1260
  if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1129
1261
  const className = op.action === 'remove' ? op.class : op.class_def.class;
1130
1262
  const preposition = op.action === 'add' ? 'to' : op.action === 'replace' ? 'in' : 'from';
@@ -1140,6 +1272,8 @@ export async function patchTaxonomyClass(
1140
1272
  noMutation: `No message class was ${pastTense}.`,
1141
1273
  },
1142
1274
  op,
1275
+ undefined,
1276
+ connectionOverride,
1143
1277
  );
1144
1278
  if (!result) throw new Error('Local Borg server returned an empty taxonomy response');
1145
1279
  return result;
@@ -1160,10 +1294,12 @@ export async function deleteCube(cubeId: string): Promise<void> {
1160
1294
  */
1161
1295
  export async function createRole(
1162
1296
  cubeId: string,
1163
- data: { name: string; short_description: string; detailed_description: string; is_default?: boolean; is_mandatory?: boolean; is_human_seat?: boolean; can_broadcast?: boolean; receives_all_direct?: boolean; default_model?: string; role_class?: 'queen' | 'worker' }
1297
+ data: { name: string; short_description: string; detailed_description: string; is_default?: boolean; is_mandatory?: boolean; is_human_seat?: boolean; can_broadcast?: boolean; receives_all_direct?: boolean; default_model?: string; role_class?: 'queen' | 'worker' },
1298
+ activeOverride?: ActiveCube,
1299
+ connectionOverride?: RemoteConnection,
1164
1300
  ): Promise<{ role: any }> {
1165
1301
  assertUuidShape(cubeId, 'cube_id');
1166
- const active = await getActiveCube();
1302
+ const active = activeOverride ?? await getActiveCube();
1167
1303
  if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1168
1304
  if (data.default_model !== undefined) localUnsupported('per-role default model');
1169
1305
  const result = await localManageRequest<{ role: any }>(
@@ -1176,6 +1312,8 @@ export async function createRole(
1176
1312
  noMutation: 'No role was created.',
1177
1313
  },
1178
1314
  buildLocalRoleFields(data),
1315
+ undefined,
1316
+ connectionOverride,
1179
1317
  );
1180
1318
  if (!result) throw new Error('Local Borg server returned an empty role response');
1181
1319
  return result;
@@ -1186,23 +1324,29 @@ export async function createRole(
1186
1324
  */
1187
1325
  export async function updateRole(
1188
1326
  roleId: string,
1189
- updates: { name?: string; short_description?: string; detailed_description?: string; is_default?: boolean; is_mandatory?: boolean; is_human_seat?: boolean; can_broadcast?: boolean; receives_all_direct?: boolean; default_model?: string; role_class?: 'queen' | 'worker' }
1327
+ updates: { name?: string; short_description?: string; detailed_description?: string; is_default?: boolean; is_mandatory?: boolean; is_human_seat?: boolean; can_broadcast?: boolean; receives_all_direct?: boolean; default_model?: string; role_class?: 'queen' | 'worker' },
1328
+ targetCubeId?: string,
1329
+ activeOverride?: ActiveCube,
1330
+ connectionOverride?: RemoteConnection,
1190
1331
  ): Promise<{ role: any }> {
1191
1332
  assertUuidShape(roleId, 'role_id');
1192
- const active = await getActiveCube();
1333
+ const active = activeOverride ?? await getActiveCube();
1193
1334
  if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1194
- assertUuidShape(active.cubeId, 'cube_id');
1335
+ const cubeId = targetCubeId ?? active.cubeId;
1336
+ assertUuidShape(cubeId, 'cube_id');
1195
1337
  if (updates.default_model !== undefined) localUnsupported('per-role default model');
1196
1338
  const result = await localManageRequest<{ role: any }>(
1197
1339
  active,
1198
- `/api/cubes/${active.cubeId}/roles/${roleId}`,
1340
+ `/api/cubes/${cubeId}/roles/${roleId}`,
1199
1341
  'PATCH',
1200
1342
  {
1201
- operation: `update role ${manageCopyValue(roleId)} in cube ${manageCopyValue(active.name)}`,
1202
- cubeName: active.name,
1343
+ operation: `update role ${manageCopyValue(roleId)} in cube ${manageCopyValue(cubeId === active.cubeId ? active.name : cubeId)}`,
1344
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1203
1345
  noMutation: 'No role was updated.',
1204
1346
  },
1205
1347
  buildLocalRoleFields(updates),
1348
+ undefined,
1349
+ connectionOverride,
1206
1350
  );
1207
1351
  if (!result) throw new Error('Local Borg server returned an empty role response');
1208
1352
  return result;
@@ -1256,22 +1400,28 @@ export async function patchRoleSection(
1256
1400
  op:
1257
1401
  | { action: 'replace'; heading: string; body: string }
1258
1402
  | { action: 'insert'; heading: string; body: string; after?: string | null }
1259
- | { action: 'delete'; heading: string }
1403
+ | { action: 'delete'; heading: string },
1404
+ targetCubeId?: string,
1405
+ activeOverride?: ActiveCube,
1406
+ connectionOverride?: RemoteConnection,
1260
1407
  ): Promise<{ role: any }> {
1261
1408
  assertUuidShape(roleId, 'role_id');
1262
- const active = await getActiveCube();
1409
+ const active = activeOverride ?? await getActiveCube();
1263
1410
  if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1264
- assertUuidShape(active.cubeId, 'cube_id');
1411
+ const cubeId = targetCubeId ?? active.cubeId;
1412
+ assertUuidShape(cubeId, 'cube_id');
1265
1413
  const result = await localManageRequest<{ role: any }>(
1266
1414
  active,
1267
- `/api/cubes/${active.cubeId}/roles/${roleId}/section-patch`,
1415
+ `/api/cubes/${cubeId}/roles/${roleId}/section-patch`,
1268
1416
  'POST',
1269
1417
  {
1270
- operation: `${op.action} section ${manageCopyValue(op.heading)} ${op.action === 'delete' ? 'from' : 'in'} role ${manageCopyValue(roleId)} in cube ${manageCopyValue(active.name)}`,
1271
- cubeName: active.name,
1418
+ operation: `${op.action} section ${manageCopyValue(op.heading)} ${op.action === 'delete' ? 'from' : 'in'} role ${manageCopyValue(roleId)} in cube ${manageCopyValue(cubeId === active.cubeId ? active.name : cubeId)}`,
1419
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1272
1420
  noMutation: `No role section was ${op.action === 'insert' ? 'inserted' : op.action === 'replace' ? 'replaced' : 'deleted'}.`,
1273
1421
  },
1274
1422
  { ...op },
1423
+ undefined,
1424
+ connectionOverride,
1275
1425
  );
1276
1426
  if (!result) throw new Error('Local Borg server returned an empty role response');
1277
1427
  return result;
@@ -1374,11 +1524,12 @@ export async function getCubeForManagement(
1374
1524
  cubeId: string,
1375
1525
  operation: LocalManageOperation,
1376
1526
  activeOverride?: ActiveCube,
1527
+ connectionOverride?: RemoteConnection,
1377
1528
  ): Promise<{ id: string; name: string; roles: any[]; drones: any[]; [k: string]: any }> {
1378
1529
  assertUuidShape(cubeId, 'cube_id');
1379
1530
  const active = activeOverride ?? await getActiveCube();
1380
1531
  if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1381
- return getCube(cubeId, await localManageConnection(active, operation));
1532
+ return getCube(cubeId, connectionOverride ?? await localManageConnection(active, operation));
1382
1533
  }
1383
1534
 
1384
1535
  /**
@@ -1398,7 +1549,7 @@ export async function getCube(cubeId: string, connection?: RemoteConnection): Pr
1398
1549
  return {
1399
1550
  ...cubePayload.cube,
1400
1551
  roles: rolePayload.roles,
1401
- drones: dronePayload.drones,
1552
+ drones: dronePayload.drones.map(withValidatedRuntimeMetadata),
1402
1553
  };
1403
1554
  }
1404
1555
 
@@ -1413,11 +1564,44 @@ export async function getCube(cubeId: string, connection?: RemoteConnection): Pr
1413
1564
  */
1414
1565
  export async function applyTemplate(
1415
1566
  cubeId: string,
1416
- templateName: string
1567
+ templateName: string,
1568
+ authorityOverride?: LocalManageAuthority,
1417
1569
  ): Promise<{ created: number; updated: number }> {
1418
- void cubeId;
1419
- void templateName;
1420
- localUnsupported('template application');
1570
+ assertUuidShape(cubeId, 'cube_id');
1571
+ const template = getTemplate(templateName);
1572
+ if (!template) throw new Error(`Unknown Borg template ${JSON.stringify(templateName)}`);
1573
+ const active = authorityOverride?.active ?? await getActiveCube();
1574
+ if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1575
+ const authority = authorityOverride ?? await resolveLocalManageAuthority(active, {
1576
+ operation: `apply template ${manageCopyValue(templateName)}`,
1577
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1578
+ noMutation: 'No template fragments were changed.',
1579
+ });
1580
+ const current = await getCubeForManagement(cubeId, {
1581
+ operation: `apply template ${manageCopyValue(templateName)}`,
1582
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1583
+ noMutation: 'No template fragments were changed.',
1584
+ }, active, authority.connection);
1585
+ let created = 0;
1586
+ let updated = 0;
1587
+ for (const role of template.roles) {
1588
+ const existing = current.roles.find((candidate) => candidate.name === role.name);
1589
+ if (!existing) {
1590
+ await createRole(cubeId, role, active, authority.connection);
1591
+ created++;
1592
+ continue;
1593
+ }
1594
+ if (await applyMissingRoleFields(existing, role, cubeId, active, authority.connection)) updated++;
1595
+ updated += await applyMissingRoleSections(existing, role, cubeId, active, authority.connection);
1596
+ }
1597
+ for (const classDef of template.message_taxonomy ?? []) {
1598
+ const currentClasses = (current.message_taxonomy ?? []) as MessageTaxonomy;
1599
+ if (!currentClasses.some((candidate) => candidate.class === classDef.class)) {
1600
+ await patchTaxonomyClass(cubeId, { action: 'add', class_def: classDef }, active, authority.connection);
1601
+ updated++;
1602
+ }
1603
+ }
1604
+ return { created, updated };
1421
1605
  }
1422
1606
 
1423
1607
  /**
@@ -1435,11 +1619,166 @@ export async function syncRoles(
1435
1619
  cubeId: string,
1436
1620
  templateName: string = 'software-dev',
1437
1621
  apply: boolean = false,
1438
- decisions?: Record<string, 'accept' | 'reject'>
1439
- ): Promise<any> {
1440
- void cubeId;
1441
- void templateName;
1442
- void apply;
1443
- void decisions;
1444
- localUnsupported('role synchronization');
1622
+ decisions?: Record<string, 'accept' | 'reject'>,
1623
+ authorityOverride?: LocalManageAuthority,
1624
+ ): Promise<NonClobberSyncResult> {
1625
+ assertUuidShape(cubeId, 'cube_id');
1626
+ const template = getTemplate(templateName);
1627
+ if (!template) throw new Error(`Unknown Borg template ${JSON.stringify(templateName)}`);
1628
+ const active = authorityOverride?.active ?? await getActiveCube();
1629
+ if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1630
+ const authority = authorityOverride ?? await resolveLocalManageAuthority(active, {
1631
+ operation: `sync template ${manageCopyValue(templateName)}`,
1632
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1633
+ noMutation: 'No role synchronization changes were applied.',
1634
+ });
1635
+ const current = await getCubeForManagement(cubeId, {
1636
+ operation: `sync template ${manageCopyValue(templateName)}`,
1637
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1638
+ noMutation: 'No role synchronization changes were applied.',
1639
+ }, active, authority.connection);
1640
+ const roles: NonClobberSyncResult['roles'] = [];
1641
+ const taxonomy: FragmentView[] = [];
1642
+ const additions: Array<{ key: string; run: () => Promise<void> }> = [];
1643
+ const conflictKeys = new Set<string>();
1644
+ for (const role of template.roles) {
1645
+ const existing = current.roles.find((candidate) => candidate.name === role.name);
1646
+ if (!existing) {
1647
+ const key = `role:${role.name}`;
1648
+ const fragments: FragmentView[] = [{
1649
+ key,
1650
+ kind: 'add',
1651
+ label: 'role',
1652
+ cubeValue: null,
1653
+ templateValue: role.name,
1654
+ }];
1655
+ roles.push({ name: role.name, status: 'new', fragments });
1656
+ additions.push({ key, run: async () => { await createRole(cubeId, role, active, authority.connection); } });
1657
+ continue;
1658
+ }
1659
+ const fragments: FragmentView[] = [];
1660
+ addRoleScalarFragment(fragments, additions, conflictKeys, decisions, existing, role, 'short_description', 'short description', cubeId, active, authority.connection);
1661
+ for (const field of ['is_default', 'is_mandatory', 'is_human_seat', 'can_broadcast', 'receives_all_direct'] as const) {
1662
+ if (role[field] !== undefined) addRoleScalarFragment(fragments, additions, conflictKeys, decisions, existing, role, field, field, cubeId, active, authority.connection);
1663
+ }
1664
+ const currentSections = new Map(parseRoleSections(String(existing.detailed_description ?? '')).map((section) => [section.heading, section]));
1665
+ for (const section of parseRoleSections(role.detailed_description)) {
1666
+ if (!section.heading) continue;
1667
+ const key = `role:${role.name}:section:${section.heading}`;
1668
+ const previous = currentSections.get(section.heading);
1669
+ const kind = !previous ? 'add' : previous.body === section.body ? 'unchanged' : 'conflict';
1670
+ fragments.push({ key, kind, label: `section ${section.heading}`, cubeValue: previous?.body ?? null, templateValue: section.body });
1671
+ if (kind === 'add') additions.push({ key, run: async () => { await patchRoleSection(existing.id, { action: 'insert', heading: section.heading!, body: section.body }, cubeId, active, authority.connection); } });
1672
+ if (kind === 'conflict') conflictKeys.add(key);
1673
+ if (kind === 'conflict' && decisions?.[key] === 'accept') additions.push({ key, run: async () => { await patchRoleSection(existing.id, { action: 'replace', heading: section.heading!, body: section.body }, cubeId, active, authority.connection); } });
1674
+ }
1675
+ roles.push({ name: role.name, status: 'existing', fragments });
1676
+ }
1677
+ for (const existing of current.roles) {
1678
+ if (!template.roles.some((role) => role.name === existing.name)) {
1679
+ roles.push({ name: existing.name, status: 'custom-skipped', fragments: [] });
1680
+ }
1681
+ }
1682
+ for (const classDef of template.message_taxonomy ?? []) {
1683
+ const key = `taxonomy:${classDef.class}`;
1684
+ const currentClass = (current.message_taxonomy ?? []).find((candidate: MessageTaxonomyClass) => candidate.class === classDef.class);
1685
+ const currentValue = currentClass ? stableJson(currentClass) : null;
1686
+ const templateValue = stableJson(classDef);
1687
+ const kind = !currentClass ? 'add' : currentValue === templateValue ? 'unchanged' : 'conflict';
1688
+ taxonomy.push({ key, kind, label: `taxonomy class ${classDef.class}`, cubeValue: currentValue, templateValue });
1689
+ if (kind === 'add') additions.push({ key, run: async () => { await patchTaxonomyClass(cubeId, { action: 'add', class_def: classDef }, active, authority.connection); } });
1690
+ if (kind === 'conflict') conflictKeys.add(key);
1691
+ if (kind === 'conflict' && decisions?.[key] === 'accept') additions.push({ key, run: async () => { await patchTaxonomyClass(cubeId, { action: 'replace', class_def: classDef }, active, authority.connection); } });
1692
+ }
1693
+ const acceptedConflicts = [...conflictKeys].filter((key) => decisions?.[key] === 'accept');
1694
+ const rejectedConflicts = [...conflictKeys].filter((key) => decisions?.[key] !== 'accept');
1695
+ const classifiedKeys = new Set([...conflictKeys]);
1696
+ const unmatchedDecisions = Object.keys(decisions ?? {}).filter((key) => !classifiedKeys.has(key));
1697
+ const addedKeys = additions.filter(({ key }) => !conflictKeys.has(key)).map(({ key }) => key);
1698
+ if (apply) {
1699
+ for (const addition of additions) {
1700
+ if (conflictKeys.has(addition.key) && decisions?.[addition.key] !== 'accept') continue;
1701
+ await addition.run();
1702
+ }
1703
+ }
1704
+ return {
1705
+ dryRun: !apply,
1706
+ roles,
1707
+ taxonomy,
1708
+ applied: {
1709
+ added: apply ? addedKeys : [],
1710
+ acceptedConflicts: apply ? acceptedConflicts : [],
1711
+ },
1712
+ rejectedConflicts,
1713
+ unmatchedDecisions,
1714
+ };
1715
+ }
1716
+
1717
+ function stableJson(value: unknown): string {
1718
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
1719
+ if (value !== null && typeof value === 'object') {
1720
+ return `{${Object.entries(value as Record<string, unknown>)
1721
+ .sort(([left], [right]) => left.localeCompare(right))
1722
+ .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
1723
+ .join(',')}}`;
1724
+ }
1725
+ return JSON.stringify(value);
1726
+ }
1727
+
1728
+ function addRoleScalarFragment(
1729
+ fragments: FragmentView[],
1730
+ additions: Array<{ key: string; run: () => Promise<void> }>,
1731
+ conflictKeys: Set<string>,
1732
+ decisions: Record<string, 'accept' | 'reject'> | undefined,
1733
+ existing: any,
1734
+ template: TemplateRole,
1735
+ field: 'short_description' | 'is_default' | 'is_mandatory' | 'is_human_seat' | 'can_broadcast' | 'receives_all_direct',
1736
+ label: string,
1737
+ cubeId: string,
1738
+ active: ActiveCube,
1739
+ connection: RemoteConnection,
1740
+ ): void {
1741
+ const templateValue = template[field];
1742
+ if (templateValue === undefined) return;
1743
+ const key = `role:${template.name}:${field}`;
1744
+ const currentValue = existing[field];
1745
+ const missing = currentValue === undefined || (field === 'short_description' && currentValue === '');
1746
+ const kind = missing ? 'add' : currentValue === templateValue ? 'unchanged' : 'conflict';
1747
+ fragments.push({ key, kind, label, cubeValue: missing ? null : String(currentValue), templateValue: String(templateValue) });
1748
+ if (kind === 'add') additions.push({
1749
+ key,
1750
+ run: async () => { await updateRole(existing.id, { [field]: templateValue } as Parameters<typeof updateRole>[1], cubeId, active, connection); },
1751
+ });
1752
+ if (kind === 'conflict') {
1753
+ conflictKeys.add(key);
1754
+ if (decisions?.[key] === 'accept') additions.push({
1755
+ key,
1756
+ run: async () => { await updateRole(existing.id, { [field]: templateValue } as Parameters<typeof updateRole>[1], cubeId, active, connection); },
1757
+ });
1758
+ }
1759
+ }
1760
+
1761
+ async function applyMissingRoleFields(existing: any, template: TemplateRole, cubeId: string, active: ActiveCube, connection: RemoteConnection): Promise<boolean> {
1762
+ const updates: Record<string, unknown> = {};
1763
+ if ((existing.short_description === undefined || existing.short_description === '') && template.short_description) {
1764
+ updates.short_description = template.short_description;
1765
+ }
1766
+ for (const field of ['is_default', 'is_mandatory', 'is_human_seat', 'can_broadcast', 'receives_all_direct'] as const) {
1767
+ if (existing[field] === undefined && template[field] !== undefined) updates[field] = template[field];
1768
+ }
1769
+ if (Object.keys(updates).length === 0) return false;
1770
+ await updateRole(existing.id, updates as Parameters<typeof updateRole>[1], cubeId, active, connection);
1771
+ return true;
1772
+ }
1773
+
1774
+ async function applyMissingRoleSections(existing: any, template: TemplateRole, cubeId: string, active: ActiveCube, connection: RemoteConnection): Promise<number> {
1775
+ const currentSections = new Map(parseRoleSections(String(existing.detailed_description ?? '')).map((section) => [section.heading, section]));
1776
+ let updated = 0;
1777
+ for (const section of parseRoleSections(template.detailed_description)) {
1778
+ if (section.heading && !currentSections.has(section.heading)) {
1779
+ await patchRoleSection(existing.id, { action: 'insert', heading: section.heading, body: section.body }, cubeId, active, connection);
1780
+ updated++;
1781
+ }
1782
+ }
1783
+ return updated;
1445
1784
  }