borgmcp 2.0.8 → 2.0.9

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.
@@ -29,6 +29,9 @@ import { debugLog } from './debug.js';
29
29
  import { assertUuidShape } from './evict-drone.js';
30
30
  import { DroneEvictedError, DRONE_EVICTED_CODE } from './drone-lifecycle.js';
31
31
  import type { MessageTaxonomy, MessageTaxonomyClass } from 'borgmcp-shared/templates';
32
+ import { getTemplate, type Template, type TemplateRole } from 'borgmcp-shared/templates';
33
+ import { parseRoleSections } from 'borgmcp-shared/role-section';
34
+ import type { FragmentView, NonClobberSyncResult } from './sync-roles-render.js';
32
35
  import type { WorkingRepo } from './working-repo.js';
33
36
  import { loadBorgServerTrust, type ServerFetch } from './server-trust.js';
34
37
  import {
@@ -250,6 +253,11 @@ export interface LocalManageOperation {
250
253
  noMutation: string;
251
254
  }
252
255
 
256
+ export interface LocalManageAuthority {
257
+ active: ActiveCube;
258
+ connection: RemoteConnection;
259
+ }
260
+
253
261
  function manageCopyValue(value: string): string {
254
262
  return JSON.stringify(value);
255
263
  }
@@ -280,6 +288,13 @@ async function localManageConnection(
280
288
  return { apiUrl: active.apiUrl, authToken, serverTrustIdentity: trustIdentity };
281
289
  }
282
290
 
291
+ export async function resolveLocalManageAuthority(
292
+ active: ActiveCube,
293
+ operation: LocalManageOperation,
294
+ ): Promise<LocalManageAuthority> {
295
+ return { active, connection: await localManageConnection(active, operation) };
296
+ }
297
+
283
298
  async function localManageRequest<T>(
284
299
  active: ActiveCube,
285
300
  path: string,
@@ -287,8 +302,9 @@ async function localManageRequest<T>(
287
302
  operation: LocalManageOperation,
288
303
  payload?: Record<string, unknown>,
289
304
  decodePayload?: (value: unknown) => T,
305
+ connectionOverride?: RemoteConnection,
290
306
  ): Promise<T | null> {
291
- const connection = await localManageConnection(active, operation);
307
+ const connection = connectionOverride ?? await localManageConnection(active, operation);
292
308
  try {
293
309
  return await decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
294
310
  method,
@@ -335,6 +351,24 @@ async function localConnectionRequest<T>(
335
351
  }), false) as Promise<T>;
336
352
  }
337
353
 
354
+ async function localConnectionMutation<T>(
355
+ connection: RemoteConnection,
356
+ path: string,
357
+ method: 'POST' | 'PATCH',
358
+ payload: Record<string, unknown>,
359
+ ): Promise<T> {
360
+ return decodeLocalProtocolResponse<T>((signal) => authedFetch(path, {
361
+ method,
362
+ signal,
363
+ apiUrl: connection.apiUrl,
364
+ authToken: connection.authToken,
365
+ serverTrustIdentity: connection.serverTrustIdentity,
366
+ redirect: 'error',
367
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
368
+ body: JSON.stringify(createProtocolEnvelope(randomUUID(), payload)),
369
+ }), false) as Promise<T>;
370
+ }
371
+
338
372
  async function localOwnerConnection(connection?: RemoteConnection): Promise<RemoteConnection> {
339
373
  if (connection) return connection;
340
374
  const active = await getActiveCube();
@@ -738,9 +772,32 @@ export async function getRoster(
738
772
  serverTrustIdentity?: string,
739
773
  ): Promise<{ drones: any[]; roles: any[]; message_taxonomy?: MessageTaxonomy | null; since?: string | null }> {
740
774
  const local = await localAuthorityContext(sessionToken, apiUrl, serverTrustIdentity);
741
- if (since !== undefined) localUnsupported('roster liveness filtering');
775
+ if (since !== undefined) {
776
+ const [dronePayload, rolePayload, cubePayload] = await Promise.all([
777
+ localServerRequest<{ drones: any[]; since?: string | null }>(
778
+ local,
779
+ `/api/cubes/${local.cubeId}/drones?since=${encodeURIComponent(since)}`,
780
+ 'GET',
781
+ ),
782
+ localServerRequest<{ roles: any[] }>(local, `/api/cubes/${local.cubeId}/roles`, 'GET'),
783
+ localServerRequest<{ cube: any }>(local, `/api/cubes/${local.cubeId}`, 'GET'),
784
+ ]);
785
+ if (!dronePayload || !rolePayload || !cubePayload) {
786
+ throw new Error('Local Borg server returned an incomplete roster response');
787
+ }
788
+ return {
789
+ drones: dronePayload.drones,
790
+ roles: rolePayload.roles,
791
+ message_taxonomy: cubePayload.cube.message_taxonomy ?? null,
792
+ since: dronePayload.since ?? since,
793
+ };
794
+ }
742
795
  const composed = await localCubeComposition(local);
743
- return { drones: composed.drones, roles: composed.roles, message_taxonomy: null };
796
+ return {
797
+ drones: composed.drones,
798
+ roles: composed.roles,
799
+ message_taxonomy: composed.cube.message_taxonomy ?? null,
800
+ };
744
801
  }
745
802
 
746
803
  /**
@@ -1071,11 +1128,25 @@ export async function createCube(
1071
1128
  opts?: { template?: string; message_taxonomy?: MessageTaxonomy | null },
1072
1129
  connection?: RemoteConnection,
1073
1130
  ): 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');
1131
+ if (!name?.trim()) throw new Error('Local Borg server cube creation requires a cube name');
1132
+ if (opts?.template !== undefined && opts.template !== 'default') {
1133
+ throw new Error('Local Borg server supports only the default cube seed');
1134
+ }
1135
+ const resolved = await localOwnerConnection(connection);
1136
+ const created = await localConnectionMutation<{
1137
+ cube_id: string;
1138
+ human_seat_role_id: string;
1139
+ default_worker_role_id: string;
1140
+ }>(resolved, '/api/cubes', 'POST', {
1141
+ retry_key: randomUUID(),
1142
+ name: name.trim(),
1143
+ template: 'default',
1144
+ });
1145
+ if (!created?.cube_id) throw new Error('Local Borg server returned an invalid cube creation response');
1146
+ const patch: Record<string, unknown> = { cube_directive: cubeDirective };
1147
+ if (opts?.message_taxonomy !== undefined) patch.message_taxonomy = opts.message_taxonomy;
1148
+ await localConnectionMutation(resolved, `/api/cubes/${created.cube_id}`, 'PATCH', patch);
1149
+ return getCube(created.cube_id, resolved);
1079
1150
  }
1080
1151
 
1081
1152
  /**
@@ -1084,10 +1155,12 @@ export async function createCube(
1084
1155
  */
1085
1156
  export async function updateCube(
1086
1157
  cubeId: string,
1087
- updates: { name?: string; cube_directive?: string; message_taxonomy?: MessageTaxonomy | null }
1158
+ updates: { name?: string; cube_directive?: string; message_taxonomy?: MessageTaxonomy | null },
1159
+ activeOverride?: ActiveCube,
1160
+ connectionOverride?: RemoteConnection,
1088
1161
  ): Promise<{ cube: any }> {
1089
1162
  assertUuidShape(cubeId, 'cube_id');
1090
- const active = await getActiveCube();
1163
+ const active = activeOverride ?? await getActiveCube();
1091
1164
  if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1092
1165
  if (updates.name !== undefined) localUnsupported('cube rename');
1093
1166
  const payload: Record<string, unknown> = {};
@@ -1105,6 +1178,8 @@ export async function updateCube(
1105
1178
  noMutation: 'No cube settings were changed.',
1106
1179
  },
1107
1180
  payload,
1181
+ undefined,
1182
+ connectionOverride,
1108
1183
  );
1109
1184
  if (!result) throw new Error('Local Borg server returned an empty cube response');
1110
1185
  return result;
@@ -1120,11 +1195,13 @@ export async function patchTaxonomyClass(
1120
1195
  cubeId: string,
1121
1196
  op:
1122
1197
  | { action: 'add'; class_def: MessageTaxonomyClass }
1123
- | { action: 'replace'; class_def: MessageTaxonomyClass }
1124
- | { action: 'remove'; class: string }
1198
+ | { action: 'replace'; class_def: MessageTaxonomyClass }
1199
+ | { action: 'remove'; class: string },
1200
+ activeOverride?: ActiveCube,
1201
+ connectionOverride?: RemoteConnection,
1125
1202
  ): Promise<{ cube: any }> {
1126
1203
  assertUuidShape(cubeId, 'cube_id');
1127
- const active = await getActiveCube();
1204
+ const active = activeOverride ?? await getActiveCube();
1128
1205
  if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1129
1206
  const className = op.action === 'remove' ? op.class : op.class_def.class;
1130
1207
  const preposition = op.action === 'add' ? 'to' : op.action === 'replace' ? 'in' : 'from';
@@ -1140,6 +1217,8 @@ export async function patchTaxonomyClass(
1140
1217
  noMutation: `No message class was ${pastTense}.`,
1141
1218
  },
1142
1219
  op,
1220
+ undefined,
1221
+ connectionOverride,
1143
1222
  );
1144
1223
  if (!result) throw new Error('Local Borg server returned an empty taxonomy response');
1145
1224
  return result;
@@ -1160,10 +1239,12 @@ export async function deleteCube(cubeId: string): Promise<void> {
1160
1239
  */
1161
1240
  export async function createRole(
1162
1241
  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' }
1242
+ 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' },
1243
+ activeOverride?: ActiveCube,
1244
+ connectionOverride?: RemoteConnection,
1164
1245
  ): Promise<{ role: any }> {
1165
1246
  assertUuidShape(cubeId, 'cube_id');
1166
- const active = await getActiveCube();
1247
+ const active = activeOverride ?? await getActiveCube();
1167
1248
  if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1168
1249
  if (data.default_model !== undefined) localUnsupported('per-role default model');
1169
1250
  const result = await localManageRequest<{ role: any }>(
@@ -1176,6 +1257,8 @@ export async function createRole(
1176
1257
  noMutation: 'No role was created.',
1177
1258
  },
1178
1259
  buildLocalRoleFields(data),
1260
+ undefined,
1261
+ connectionOverride,
1179
1262
  );
1180
1263
  if (!result) throw new Error('Local Borg server returned an empty role response');
1181
1264
  return result;
@@ -1186,23 +1269,29 @@ export async function createRole(
1186
1269
  */
1187
1270
  export async function updateRole(
1188
1271
  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' }
1272
+ 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' },
1273
+ targetCubeId?: string,
1274
+ activeOverride?: ActiveCube,
1275
+ connectionOverride?: RemoteConnection,
1190
1276
  ): Promise<{ role: any }> {
1191
1277
  assertUuidShape(roleId, 'role_id');
1192
- const active = await getActiveCube();
1278
+ const active = activeOverride ?? await getActiveCube();
1193
1279
  if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1194
- assertUuidShape(active.cubeId, 'cube_id');
1280
+ const cubeId = targetCubeId ?? active.cubeId;
1281
+ assertUuidShape(cubeId, 'cube_id');
1195
1282
  if (updates.default_model !== undefined) localUnsupported('per-role default model');
1196
1283
  const result = await localManageRequest<{ role: any }>(
1197
1284
  active,
1198
- `/api/cubes/${active.cubeId}/roles/${roleId}`,
1285
+ `/api/cubes/${cubeId}/roles/${roleId}`,
1199
1286
  'PATCH',
1200
1287
  {
1201
- operation: `update role ${manageCopyValue(roleId)} in cube ${manageCopyValue(active.name)}`,
1202
- cubeName: active.name,
1288
+ operation: `update role ${manageCopyValue(roleId)} in cube ${manageCopyValue(cubeId === active.cubeId ? active.name : cubeId)}`,
1289
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1203
1290
  noMutation: 'No role was updated.',
1204
1291
  },
1205
1292
  buildLocalRoleFields(updates),
1293
+ undefined,
1294
+ connectionOverride,
1206
1295
  );
1207
1296
  if (!result) throw new Error('Local Borg server returned an empty role response');
1208
1297
  return result;
@@ -1256,22 +1345,28 @@ export async function patchRoleSection(
1256
1345
  op:
1257
1346
  | { action: 'replace'; heading: string; body: string }
1258
1347
  | { action: 'insert'; heading: string; body: string; after?: string | null }
1259
- | { action: 'delete'; heading: string }
1348
+ | { action: 'delete'; heading: string },
1349
+ targetCubeId?: string,
1350
+ activeOverride?: ActiveCube,
1351
+ connectionOverride?: RemoteConnection,
1260
1352
  ): Promise<{ role: any }> {
1261
1353
  assertUuidShape(roleId, 'role_id');
1262
- const active = await getActiveCube();
1354
+ const active = activeOverride ?? await getActiveCube();
1263
1355
  if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1264
- assertUuidShape(active.cubeId, 'cube_id');
1356
+ const cubeId = targetCubeId ?? active.cubeId;
1357
+ assertUuidShape(cubeId, 'cube_id');
1265
1358
  const result = await localManageRequest<{ role: any }>(
1266
1359
  active,
1267
- `/api/cubes/${active.cubeId}/roles/${roleId}/section-patch`,
1360
+ `/api/cubes/${cubeId}/roles/${roleId}/section-patch`,
1268
1361
  'POST',
1269
1362
  {
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,
1363
+ operation: `${op.action} section ${manageCopyValue(op.heading)} ${op.action === 'delete' ? 'from' : 'in'} role ${manageCopyValue(roleId)} in cube ${manageCopyValue(cubeId === active.cubeId ? active.name : cubeId)}`,
1364
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1272
1365
  noMutation: `No role section was ${op.action === 'insert' ? 'inserted' : op.action === 'replace' ? 'replaced' : 'deleted'}.`,
1273
1366
  },
1274
1367
  { ...op },
1368
+ undefined,
1369
+ connectionOverride,
1275
1370
  );
1276
1371
  if (!result) throw new Error('Local Borg server returned an empty role response');
1277
1372
  return result;
@@ -1374,11 +1469,12 @@ export async function getCubeForManagement(
1374
1469
  cubeId: string,
1375
1470
  operation: LocalManageOperation,
1376
1471
  activeOverride?: ActiveCube,
1472
+ connectionOverride?: RemoteConnection,
1377
1473
  ): Promise<{ id: string; name: string; roles: any[]; drones: any[]; [k: string]: any }> {
1378
1474
  assertUuidShape(cubeId, 'cube_id');
1379
1475
  const active = activeOverride ?? await getActiveCube();
1380
1476
  if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1381
- return getCube(cubeId, await localManageConnection(active, operation));
1477
+ return getCube(cubeId, connectionOverride ?? await localManageConnection(active, operation));
1382
1478
  }
1383
1479
 
1384
1480
  /**
@@ -1413,11 +1509,44 @@ export async function getCube(cubeId: string, connection?: RemoteConnection): Pr
1413
1509
  */
1414
1510
  export async function applyTemplate(
1415
1511
  cubeId: string,
1416
- templateName: string
1512
+ templateName: string,
1513
+ authorityOverride?: LocalManageAuthority,
1417
1514
  ): Promise<{ created: number; updated: number }> {
1418
- void cubeId;
1419
- void templateName;
1420
- localUnsupported('template application');
1515
+ assertUuidShape(cubeId, 'cube_id');
1516
+ const template = getTemplate(templateName);
1517
+ if (!template) throw new Error(`Unknown Borg template ${JSON.stringify(templateName)}`);
1518
+ const active = authorityOverride?.active ?? await getActiveCube();
1519
+ if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
1520
+ const authority = authorityOverride ?? await resolveLocalManageAuthority(active, {
1521
+ operation: `apply template ${manageCopyValue(templateName)}`,
1522
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1523
+ noMutation: 'No template fragments were changed.',
1524
+ });
1525
+ const current = await getCubeForManagement(cubeId, {
1526
+ operation: `apply template ${manageCopyValue(templateName)}`,
1527
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1528
+ noMutation: 'No template fragments were changed.',
1529
+ }, active, authority.connection);
1530
+ let created = 0;
1531
+ let updated = 0;
1532
+ for (const role of template.roles) {
1533
+ const existing = current.roles.find((candidate) => candidate.name === role.name);
1534
+ if (!existing) {
1535
+ await createRole(cubeId, role, active, authority.connection);
1536
+ created++;
1537
+ continue;
1538
+ }
1539
+ if (await applyMissingRoleFields(existing, role, cubeId, active, authority.connection)) updated++;
1540
+ updated += await applyMissingRoleSections(existing, role, cubeId, active, authority.connection);
1541
+ }
1542
+ for (const classDef of template.message_taxonomy ?? []) {
1543
+ const currentClasses = (current.message_taxonomy ?? []) as MessageTaxonomy;
1544
+ if (!currentClasses.some((candidate) => candidate.class === classDef.class)) {
1545
+ await patchTaxonomyClass(cubeId, { action: 'add', class_def: classDef }, active, authority.connection);
1546
+ updated++;
1547
+ }
1548
+ }
1549
+ return { created, updated };
1421
1550
  }
1422
1551
 
1423
1552
  /**
@@ -1435,11 +1564,166 @@ export async function syncRoles(
1435
1564
  cubeId: string,
1436
1565
  templateName: string = 'software-dev',
1437
1566
  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');
1567
+ decisions?: Record<string, 'accept' | 'reject'>,
1568
+ authorityOverride?: LocalManageAuthority,
1569
+ ): Promise<NonClobberSyncResult> {
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: `sync template ${manageCopyValue(templateName)}`,
1577
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1578
+ noMutation: 'No role synchronization changes were applied.',
1579
+ });
1580
+ const current = await getCubeForManagement(cubeId, {
1581
+ operation: `sync template ${manageCopyValue(templateName)}`,
1582
+ cubeName: cubeId === active.cubeId ? active.name : cubeId,
1583
+ noMutation: 'No role synchronization changes were applied.',
1584
+ }, active, authority.connection);
1585
+ const roles: NonClobberSyncResult['roles'] = [];
1586
+ const taxonomy: FragmentView[] = [];
1587
+ const additions: Array<{ key: string; run: () => Promise<void> }> = [];
1588
+ const conflictKeys = new Set<string>();
1589
+ for (const role of template.roles) {
1590
+ const existing = current.roles.find((candidate) => candidate.name === role.name);
1591
+ if (!existing) {
1592
+ const key = `role:${role.name}`;
1593
+ const fragments: FragmentView[] = [{
1594
+ key,
1595
+ kind: 'add',
1596
+ label: 'role',
1597
+ cubeValue: null,
1598
+ templateValue: role.name,
1599
+ }];
1600
+ roles.push({ name: role.name, status: 'new', fragments });
1601
+ additions.push({ key, run: async () => { await createRole(cubeId, role, active, authority.connection); } });
1602
+ continue;
1603
+ }
1604
+ const fragments: FragmentView[] = [];
1605
+ addRoleScalarFragment(fragments, additions, conflictKeys, decisions, existing, role, 'short_description', 'short description', cubeId, active, authority.connection);
1606
+ for (const field of ['is_default', 'is_mandatory', 'is_human_seat', 'can_broadcast', 'receives_all_direct'] as const) {
1607
+ if (role[field] !== undefined) addRoleScalarFragment(fragments, additions, conflictKeys, decisions, existing, role, field, field, cubeId, active, authority.connection);
1608
+ }
1609
+ const currentSections = new Map(parseRoleSections(String(existing.detailed_description ?? '')).map((section) => [section.heading, section]));
1610
+ for (const section of parseRoleSections(role.detailed_description)) {
1611
+ if (!section.heading) continue;
1612
+ const key = `role:${role.name}:section:${section.heading}`;
1613
+ const previous = currentSections.get(section.heading);
1614
+ const kind = !previous ? 'add' : previous.body === section.body ? 'unchanged' : 'conflict';
1615
+ fragments.push({ key, kind, label: `section ${section.heading}`, cubeValue: previous?.body ?? null, templateValue: section.body });
1616
+ if (kind === 'add') additions.push({ key, run: async () => { await patchRoleSection(existing.id, { action: 'insert', heading: section.heading!, body: section.body }, cubeId, active, authority.connection); } });
1617
+ if (kind === 'conflict') conflictKeys.add(key);
1618
+ 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); } });
1619
+ }
1620
+ roles.push({ name: role.name, status: 'existing', fragments });
1621
+ }
1622
+ for (const existing of current.roles) {
1623
+ if (!template.roles.some((role) => role.name === existing.name)) {
1624
+ roles.push({ name: existing.name, status: 'custom-skipped', fragments: [] });
1625
+ }
1626
+ }
1627
+ for (const classDef of template.message_taxonomy ?? []) {
1628
+ const key = `taxonomy:${classDef.class}`;
1629
+ const currentClass = (current.message_taxonomy ?? []).find((candidate: MessageTaxonomyClass) => candidate.class === classDef.class);
1630
+ const currentValue = currentClass ? stableJson(currentClass) : null;
1631
+ const templateValue = stableJson(classDef);
1632
+ const kind = !currentClass ? 'add' : currentValue === templateValue ? 'unchanged' : 'conflict';
1633
+ taxonomy.push({ key, kind, label: `taxonomy class ${classDef.class}`, cubeValue: currentValue, templateValue });
1634
+ if (kind === 'add') additions.push({ key, run: async () => { await patchTaxonomyClass(cubeId, { action: 'add', class_def: classDef }, active, authority.connection); } });
1635
+ if (kind === 'conflict') conflictKeys.add(key);
1636
+ if (kind === 'conflict' && decisions?.[key] === 'accept') additions.push({ key, run: async () => { await patchTaxonomyClass(cubeId, { action: 'replace', class_def: classDef }, active, authority.connection); } });
1637
+ }
1638
+ const acceptedConflicts = [...conflictKeys].filter((key) => decisions?.[key] === 'accept');
1639
+ const rejectedConflicts = [...conflictKeys].filter((key) => decisions?.[key] !== 'accept');
1640
+ const classifiedKeys = new Set([...conflictKeys]);
1641
+ const unmatchedDecisions = Object.keys(decisions ?? {}).filter((key) => !classifiedKeys.has(key));
1642
+ const addedKeys = additions.filter(({ key }) => !conflictKeys.has(key)).map(({ key }) => key);
1643
+ if (apply) {
1644
+ for (const addition of additions) {
1645
+ if (conflictKeys.has(addition.key) && decisions?.[addition.key] !== 'accept') continue;
1646
+ await addition.run();
1647
+ }
1648
+ }
1649
+ return {
1650
+ dryRun: !apply,
1651
+ roles,
1652
+ taxonomy,
1653
+ applied: {
1654
+ added: apply ? addedKeys : [],
1655
+ acceptedConflicts: apply ? acceptedConflicts : [],
1656
+ },
1657
+ rejectedConflicts,
1658
+ unmatchedDecisions,
1659
+ };
1660
+ }
1661
+
1662
+ function stableJson(value: unknown): string {
1663
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
1664
+ if (value !== null && typeof value === 'object') {
1665
+ return `{${Object.entries(value as Record<string, unknown>)
1666
+ .sort(([left], [right]) => left.localeCompare(right))
1667
+ .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
1668
+ .join(',')}}`;
1669
+ }
1670
+ return JSON.stringify(value);
1671
+ }
1672
+
1673
+ function addRoleScalarFragment(
1674
+ fragments: FragmentView[],
1675
+ additions: Array<{ key: string; run: () => Promise<void> }>,
1676
+ conflictKeys: Set<string>,
1677
+ decisions: Record<string, 'accept' | 'reject'> | undefined,
1678
+ existing: any,
1679
+ template: TemplateRole,
1680
+ field: 'short_description' | 'is_default' | 'is_mandatory' | 'is_human_seat' | 'can_broadcast' | 'receives_all_direct',
1681
+ label: string,
1682
+ cubeId: string,
1683
+ active: ActiveCube,
1684
+ connection: RemoteConnection,
1685
+ ): void {
1686
+ const templateValue = template[field];
1687
+ if (templateValue === undefined) return;
1688
+ const key = `role:${template.name}:${field}`;
1689
+ const currentValue = existing[field];
1690
+ const missing = currentValue === undefined || (field === 'short_description' && currentValue === '');
1691
+ const kind = missing ? 'add' : currentValue === templateValue ? 'unchanged' : 'conflict';
1692
+ fragments.push({ key, kind, label, cubeValue: missing ? null : String(currentValue), templateValue: String(templateValue) });
1693
+ if (kind === 'add') additions.push({
1694
+ key,
1695
+ run: async () => { await updateRole(existing.id, { [field]: templateValue } as Parameters<typeof updateRole>[1], cubeId, active, connection); },
1696
+ });
1697
+ if (kind === 'conflict') {
1698
+ conflictKeys.add(key);
1699
+ if (decisions?.[key] === 'accept') additions.push({
1700
+ key,
1701
+ run: async () => { await updateRole(existing.id, { [field]: templateValue } as Parameters<typeof updateRole>[1], cubeId, active, connection); },
1702
+ });
1703
+ }
1704
+ }
1705
+
1706
+ async function applyMissingRoleFields(existing: any, template: TemplateRole, cubeId: string, active: ActiveCube, connection: RemoteConnection): Promise<boolean> {
1707
+ const updates: Record<string, unknown> = {};
1708
+ if ((existing.short_description === undefined || existing.short_description === '') && template.short_description) {
1709
+ updates.short_description = template.short_description;
1710
+ }
1711
+ for (const field of ['is_default', 'is_mandatory', 'is_human_seat', 'can_broadcast', 'receives_all_direct'] as const) {
1712
+ if (existing[field] === undefined && template[field] !== undefined) updates[field] = template[field];
1713
+ }
1714
+ if (Object.keys(updates).length === 0) return false;
1715
+ await updateRole(existing.id, updates as Parameters<typeof updateRole>[1], cubeId, active, connection);
1716
+ return true;
1717
+ }
1718
+
1719
+ async function applyMissingRoleSections(existing: any, template: TemplateRole, cubeId: string, active: ActiveCube, connection: RemoteConnection): Promise<number> {
1720
+ const currentSections = new Map(parseRoleSections(String(existing.detailed_description ?? '')).map((section) => [section.heading, section]));
1721
+ let updated = 0;
1722
+ for (const section of parseRoleSections(template.detailed_description)) {
1723
+ if (section.heading && !currentSections.has(section.heading)) {
1724
+ await patchRoleSection(existing.id, { action: 'insert', heading: section.heading, body: section.body }, cubeId, active, connection);
1725
+ updated++;
1726
+ }
1727
+ }
1728
+ return updated;
1445
1729
  }
@@ -12,6 +12,8 @@
12
12
 
13
13
  export type FragmentKind = 'add' | 'unchanged' | 'conflict';
14
14
 
15
+ const BIDI_CONTROL_RE = /\p{Bidi_Control}/u;
16
+
15
17
  export interface FragmentView {
16
18
  key: string;
17
19
  kind: FragmentKind;
@@ -35,10 +37,25 @@ export interface NonClobberSyncResult {
35
37
  unmatchedDecisions?: string[];
36
38
  }
37
39
 
40
+ /** Escape cube-controlled text before it reaches Markdown or a terminal. */
41
+ export function escapeSyncDisplay(value: string): string {
42
+ return [...value].map((char) => {
43
+ const code = char.codePointAt(0)!;
44
+ if (code === 0x0a) return '⏎';
45
+ if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) return `\\u{${code.toString(16)}}`;
46
+ if (BIDI_CONTROL_RE.test(char) || code === 0x2028 || code === 0x2029) {
47
+ return `\\u{${code.toString(16)}}`;
48
+ }
49
+ if (char === '`') return '\\u{60}';
50
+ if ('\\*_[]()<>&#|~'.includes(char)) return `\\${char}`;
51
+ return char;
52
+ }).join('');
53
+ }
54
+
38
55
  /** Truncate long fragment bodies for at-a-glance diffs. */
39
56
  function trunc(s: string | null, n = 200): string {
40
57
  if (s == null) return '(absent)';
41
- const flat = s.replace(/\n/g, '⏎');
58
+ const flat = escapeSyncDisplay(s);
42
59
  return flat.length > n ? flat.slice(0, n) + '…' : flat;
43
60
  }
44
61
 
@@ -75,7 +92,7 @@ export function renderSyncRolesResult(
75
92
  const mode = result.dryRun
76
93
  ? '**DRY RUN** (review conflicts below; re-run with `apply: true` + a `decisions` map to commit)'
77
94
  : '**APPLIED**';
78
- const lines: string[] = [`## borg_sync-roles — ${mode}`, `Template: ${templateName}`, ''];
95
+ const lines: string[] = [`## borg_sync-roles — ${mode}`, `Template: ${escapeSyncDisplay(templateName)}`, ''];
79
96
 
80
97
  // Gather all fragments across roles + taxonomy for tallying.
81
98
  const allFragments: FragmentView[] = [
@@ -109,7 +126,7 @@ export function renderSyncRolesResult(
109
126
  : applied
110
127
  ? '✓ accepted — template version applied'
111
128
  : '↩ kept your version';
112
- lines.push(`- **${f.label}** \`${f.key}\` ${status}`);
129
+ lines.push(`- **${escapeSyncDisplay(f.label)}** \`${escapeSyncDisplay(f.key)}\` ${status}`);
113
130
  lines.push(` - cube (current): "${trunc(f.cubeValue)}"`);
114
131
  lines.push(` - template (new): "${trunc(f.templateValue)}"`);
115
132
  }
@@ -127,7 +144,7 @@ export function renderSyncRolesResult(
127
144
  '(typo or stale key) — their intended accept had NO effect. Check the exact keys against the conflicts above:'
128
145
  );
129
146
  for (const k of unmatched) {
130
- lines.push(`- \`${k}\``);
147
+ lines.push(`- \`${escapeSyncDisplay(k)}\``);
131
148
  }
132
149
  lines.push('');
133
150
  }
@@ -137,11 +154,11 @@ export function renderSyncRolesResult(
137
154
  lines.push(`### Additions (safe — auto-applied, zero clobber risk)`);
138
155
  for (const r of newRoles) {
139
156
  const note = result.dryRun ? '(new role — would be created)' : '✓ created';
140
- lines.push(`- new role **${r.name}** ${note}`);
157
+ lines.push(`- new role **${escapeSyncDisplay(r.name)}** ${note}`);
141
158
  }
142
159
  for (const f of adds) {
143
160
  const note = result.dryRun ? '(would be added)' : '✓ added';
144
- lines.push(`- **${f.label}** \`${f.key}\` ${note}`);
161
+ lines.push(`- **${escapeSyncDisplay(f.label)}** \`${escapeSyncDisplay(f.key)}\` ${note}`);
145
162
  }
146
163
  lines.push('');
147
164
  }
@@ -149,7 +166,7 @@ export function renderSyncRolesResult(
149
166
  // ── Custom roles (never touched) ──
150
167
  if (customRoles.length > 0) {
151
168
  lines.push(
152
- `### Custom roles (untouched): ${customRoles.map((r) => r.name).join(', ')}`
169
+ `### Custom roles (untouched): ${customRoles.map((r) => escapeSyncDisplay(r.name)).join(', ')}`
153
170
  );
154
171
  lines.push('');
155
172
  }