borgmcp-shared 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +8 -56
  2. package/RELEASES.md +33 -0
  3. package/dist/conformance/adapter.d.ts +22 -0
  4. package/dist/conformance/adapter.d.ts.map +1 -1
  5. package/dist/conformance/adapter.js +169 -2
  6. package/dist/conformance/adapter.js.map +1 -1
  7. package/dist/conformance/index.d.ts +22 -1
  8. package/dist/conformance/index.d.ts.map +1 -1
  9. package/dist/conformance/index.js +78 -1
  10. package/dist/conformance/index.js.map +1 -1
  11. package/dist/protocol/contract.d.ts +32 -1
  12. package/dist/protocol/contract.d.ts.map +1 -1
  13. package/dist/protocol/contract.js +212 -2
  14. package/dist/protocol/contract.js.map +1 -1
  15. package/dist/protocol/errors.d.ts +1 -0
  16. package/dist/protocol/errors.d.ts.map +1 -1
  17. package/dist/protocol/errors.js +1 -0
  18. package/dist/protocol/errors.js.map +1 -1
  19. package/dist/protocol/sse.d.ts +6 -2
  20. package/dist/protocol/sse.d.ts.map +1 -1
  21. package/dist/protocol/sse.js +8 -2
  22. package/dist/protocol/sse.js.map +1 -1
  23. package/dist/protocol/version.d.ts +1 -1
  24. package/dist/protocol/version.js +1 -1
  25. package/dist/templates.d.ts +1 -0
  26. package/dist/templates.d.ts.map +1 -1
  27. package/dist/templates.js +14 -3
  28. package/dist/templates.js.map +1 -1
  29. package/docs/compatibility.md +15 -4
  30. package/docs/enrollment.md +5 -4
  31. package/docs/releasing.md +4 -3
  32. package/package.json +2 -1
  33. package/src/conformance/adapter.ts +321 -0
  34. package/src/conformance/index.ts +102 -1
  35. package/src/protocol/contract.ts +260 -2
  36. package/src/protocol/errors.ts +1 -0
  37. package/src/protocol/sse.ts +17 -2
  38. package/src/protocol/version.ts +2 -2
  39. package/src/templates.ts +15 -3
@@ -13,12 +13,13 @@ import type {
13
13
  } from './types.js';
14
14
 
15
15
  export const SHARED_PACKAGE_NAME = 'borgmcp-shared' as const;
16
- export const SHARED_PACKAGE_VERSION = '0.7.0' as const;
16
+ export const SHARED_PACKAGE_VERSION = '0.8.0' as const;
17
17
 
18
18
  export const HEALTH_PATH = '/healthz' as const;
19
19
  export const PROTOCOL_INFO_PATH = '/api/protocol' as const;
20
20
  export const ENROLLMENT_EXCHANGE_PATH = '/api/enrollment/exchange' as const;
21
21
  export const CUBES_PATH = '/api/cubes' as const;
22
+ export const CUBE_PATH = '/api/cubes/:cubeId' as const;
22
23
  export const REPOSITORY_CUBE_RESOLVE_PATH = '/api/repository-cubes/resolve' as const;
23
24
  export const REPOSITORY_CUBE_ASSOCIATION_PATH = '/api/repository-cubes/association' as const;
24
25
  export const ATTACH_PATH = '/api/client/attach' as const;
@@ -29,6 +30,13 @@ export const PROTOCOL_HTTP_CONTRACT = {
29
30
  protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated: false, success_status: 200 },
30
31
  enrollment: { method: 'POST', path: ENROLLMENT_EXCHANGE_PATH, authenticated: 'invitation', success_status: 201 },
31
32
  cubes: { method: 'POST', path: CUBES_PATH, authenticated: true, success_status: 201 },
33
+ cube_delete: {
34
+ method: 'DELETE',
35
+ path: CUBE_PATH,
36
+ authenticated: true,
37
+ success_status: 200,
38
+ mutation: true,
39
+ },
32
40
  repository_cube_resolve: {
33
41
  method: 'POST',
34
42
  path: REPOSITORY_CUBE_RESOLVE_PATH,
@@ -68,6 +76,7 @@ export const PROTOCOL_HTTP_CONTRACT = {
68
76
  session_revoked_status: 401,
69
77
  session_rejected_status: 401,
70
78
  cursor_expired_status: 410,
79
+ cube_deleted_status: 410,
71
80
  drone_evicted_status: 410,
72
81
  content_too_large_status: 413,
73
82
  unsupported_protocol_status: 426,
@@ -116,6 +125,23 @@ export interface EnrollmentExchangeRequest {
116
125
  client_name?: string;
117
126
  }
118
127
 
128
+ export const INVITATION_ARTIFACT_VERSION = 2 as const;
129
+ export type InvitationAuthority = 'client' | 'owner';
130
+
131
+ /**
132
+ * The single opaque value transported between machines for enrollment. The
133
+ * integrity field is produced and verified by the issuing implementation; the
134
+ * shared codec preserves it as a bounded canonical field.
135
+ */
136
+ export interface InvitationArtifact {
137
+ version: typeof INVITATION_ARTIFACT_VERSION;
138
+ endpoint: string;
139
+ ca_spki_sha256: string;
140
+ authority: InvitationAuthority;
141
+ secret: string;
142
+ integrity: string;
143
+ }
144
+
119
145
  export const SERVER_CAPABILITIES = ['create_cube'] as const;
120
146
  export type ServerCapability = (typeof SERVER_CAPABILITIES)[number];
121
147
 
@@ -164,6 +190,13 @@ export interface CreateCubeResponse {
164
190
  access: 'manage';
165
191
  }
166
192
 
193
+ export type DeleteCubeRequest = Record<string, never>;
194
+
195
+ export interface DeleteCubeResponse {
196
+ cube_id: string;
197
+ deleted: true;
198
+ }
199
+
167
200
  export interface ResolveRepositoryCubeRequest {
168
201
  working_repo_name: string;
169
202
  repository: CreateCubeRepository;
@@ -299,6 +332,207 @@ function opaqueToken(value: unknown, path: readonly (string | number)[]): string
299
332
  return token;
300
333
  }
301
334
 
335
+ const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
336
+ const INVITATION_MAGIC = 'B2';
337
+ const INVITATION_LENGTH_HEX_DIGITS = 3;
338
+
339
+ function encodeBase64UrlAscii(value: string): string {
340
+ let output = '';
341
+ for (let index = 0; index < value.length; index += 3) {
342
+ const first = value.charCodeAt(index);
343
+ const hasSecond = index + 1 < value.length;
344
+ const hasThird = index + 2 < value.length;
345
+ const second = hasSecond ? value.charCodeAt(index + 1) : 0;
346
+ const third = hasThird ? value.charCodeAt(index + 2) : 0;
347
+ output += BASE64URL_ALPHABET[first >> 2];
348
+ output += BASE64URL_ALPHABET[((first & 0x03) << 4) | (second >> 4)];
349
+ if (hasSecond) output += BASE64URL_ALPHABET[((second & 0x0f) << 2) | (third >> 6)];
350
+ if (hasThird) output += BASE64URL_ALPHABET[third & 0x3f];
351
+ }
352
+ return output;
353
+ }
354
+
355
+ function decodeBase64UrlAscii(value: string, path: readonly (string | number)[]): string {
356
+ if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
357
+ fail('Expected an unpadded base64url value.', path);
358
+ }
359
+ let output = '';
360
+ for (let index = 0; index < value.length; index += 4) {
361
+ const first = BASE64URL_ALPHABET.indexOf(value[index]);
362
+ const second = BASE64URL_ALPHABET.indexOf(value[index + 1]);
363
+ const third = index + 2 < value.length ? BASE64URL_ALPHABET.indexOf(value[index + 2]) : 0;
364
+ const fourth = index + 3 < value.length ? BASE64URL_ALPHABET.indexOf(value[index + 3]) : 0;
365
+ if (first < 0 || second < 0 || (index + 2 < value.length && third < 0) ||
366
+ (index + 3 < value.length && fourth < 0)) {
367
+ fail('Expected an unpadded base64url value.', path);
368
+ }
369
+ const bytes = [
370
+ (first << 2) | (second >> 4),
371
+ ((second & 0x0f) << 4) | (third >> 2),
372
+ ((third & 0x03) << 6) | fourth,
373
+ ];
374
+ const byteCount = Math.min(3, value.length - index - 1);
375
+ for (let byteIndex = 0; byteIndex < byteCount; byteIndex++) {
376
+ if (bytes[byteIndex] > 0x7f) fail('Invitation fields must contain ASCII bytes.', path);
377
+ output += String.fromCharCode(bytes[byteIndex]);
378
+ }
379
+ }
380
+ if (encodeBase64UrlAscii(output) !== value) {
381
+ fail('Expected canonical unpadded base64url encoding.', path);
382
+ }
383
+ return output;
384
+ }
385
+
386
+ function encodedBase64UrlLength(byteLength: number): number {
387
+ const remainder = byteLength % 3;
388
+ return Math.floor(byteLength / 3) * 4 + (remainder === 0 ? 0 : remainder + 1);
389
+ }
390
+
391
+ function invitationFieldLength(value: string, path: readonly (string | number)[]): string {
392
+ const length = value.length;
393
+ if (length > 0xfff) fail('Invitation field is too long.', path);
394
+ return length.toString(16).padStart(INVITATION_LENGTH_HEX_DIGITS, '0');
395
+ }
396
+
397
+ function decodeInvitationField(
398
+ payload: string,
399
+ cursor: { value: number },
400
+ path: readonly (string | number)[],
401
+ ): string {
402
+ const lengthText = payload.slice(cursor.value, cursor.value + INVITATION_LENGTH_HEX_DIGITS);
403
+ if (!/^[0-9a-f]{3}$/.test(lengthText)) fail('Invitation field length is invalid.', path);
404
+ cursor.value += INVITATION_LENGTH_HEX_DIGITS;
405
+ const byteLength = Number.parseInt(lengthText, 16);
406
+ const encodedLength = encodedBase64UrlLength(byteLength);
407
+ const encoded = payload.slice(cursor.value, cursor.value + encodedLength);
408
+ if (encoded.length !== encodedLength) fail('Invitation field is truncated.', path);
409
+ cursor.value += encodedLength;
410
+ const decoded = decodeBase64UrlAscii(encoded, path);
411
+ if (decoded.length !== byteLength) fail('Invitation field length does not match its value.', path);
412
+ return decoded;
413
+ }
414
+
415
+ function canonicalInvitationEndpoint(value: unknown, path: readonly (string | number)[]): string {
416
+ const endpoint = boundedString(value, 1, 512, path);
417
+ type ParsedUrl = {
418
+ protocol: string;
419
+ origin: string;
420
+ hostname: string;
421
+ port: string;
422
+ pathname: string;
423
+ search: string;
424
+ hash: string;
425
+ username: string;
426
+ password: string;
427
+ };
428
+ const UrlParser = (globalThis as unknown as { URL?: new (value: string) => ParsedUrl }).URL;
429
+ if (UrlParser === undefined) fail('Invitation endpoint URL parsing is unavailable.', path);
430
+ let parsed: ParsedUrl;
431
+ try {
432
+ parsed = new UrlParser(endpoint);
433
+ } catch {
434
+ fail('Invitation endpoint must be a valid URL.', path);
435
+ }
436
+ if (parsed.protocol !== 'https:' || !parsed.hostname || parsed.username || parsed.password ||
437
+ parsed.pathname !== '/' || parsed.search || parsed.hash || endpoint !== parsed.origin ||
438
+ (parsed.port !== '' && (Number.parseInt(parsed.port, 10) < 1 || Number.parseInt(parsed.port, 10) > 65_535))) {
439
+ fail('Invitation endpoint must be a canonical HTTPS origin.', path);
440
+ }
441
+ return endpoint;
442
+ }
443
+
444
+ function validateInvitationArtifact(value: unknown): InvitationArtifact {
445
+ const input = record(value);
446
+ exactKeys(input, ['version', 'endpoint', 'ca_spki_sha256', 'authority', 'secret', 'integrity'], [
447
+ 'version',
448
+ 'endpoint',
449
+ 'ca_spki_sha256',
450
+ 'authority',
451
+ 'secret',
452
+ 'integrity',
453
+ ]);
454
+ if (input.version !== INVITATION_ARTIFACT_VERSION) {
455
+ fail('Unsupported invitation artifact version.', ['version']);
456
+ }
457
+ const endpoint = canonicalInvitationEndpoint(input.endpoint, ['endpoint']);
458
+ const caSpkiSha256 = boundedString(input.ca_spki_sha256, 64, 64, ['ca_spki_sha256']);
459
+ if (!/^[0-9a-f]{64}$/.test(caSpkiSha256)) {
460
+ fail('CA SPKI SHA-256 must be lowercase hexadecimal.', ['ca_spki_sha256']);
461
+ }
462
+ if (input.authority !== 'client' && input.authority !== 'owner') {
463
+ fail('Invitation authority is invalid.', ['authority']);
464
+ }
465
+ return {
466
+ version: INVITATION_ARTIFACT_VERSION,
467
+ endpoint,
468
+ ca_spki_sha256: caSpkiSha256,
469
+ authority: input.authority,
470
+ secret: opaqueToken(input.secret, ['secret']),
471
+ integrity: opaqueToken(input.integrity, ['integrity']),
472
+ };
473
+ }
474
+
475
+ /**
476
+ * Return the canonical ASCII preimage for the artifact integrity binding.
477
+ * Implementations hash these exact bytes with their agreed secret algorithm;
478
+ * the shared package intentionally does not own a crypto runtime.
479
+ */
480
+ export function getInvitationArtifactIntegrityInput(value: InvitationArtifact): string {
481
+ const artifact = validateInvitationArtifact(value);
482
+ const endpoint = encodeBase64UrlAscii(artifact.endpoint);
483
+ const secret = encodeBase64UrlAscii(artifact.secret);
484
+ return [
485
+ INVITATION_MAGIC,
486
+ invitationFieldLength(artifact.endpoint, ['endpoint']),
487
+ endpoint,
488
+ artifact.ca_spki_sha256,
489
+ artifact.authority === 'client' ? 'c' : 'o',
490
+ invitationFieldLength(artifact.secret, ['secret']),
491
+ secret,
492
+ ].join('');
493
+ }
494
+
495
+ /** Encode an invitation artifact as one canonical, unpadded base64url token. */
496
+ export function encodeInvitationArtifact(value: InvitationArtifact): string {
497
+ const artifact = validateInvitationArtifact(value);
498
+ const payload = [
499
+ getInvitationArtifactIntegrityInput(artifact),
500
+ invitationFieldLength(artifact.integrity, ['integrity']),
501
+ encodeBase64UrlAscii(artifact.integrity),
502
+ ].join('');
503
+ const token = encodeBase64UrlAscii(payload);
504
+ if (token.length < 43 || token.length > 1024) {
505
+ fail('Encoded invitation artifact exceeds the supported token bound.');
506
+ }
507
+ return token;
508
+ }
509
+
510
+ /** Decode and strictly validate a canonical invitation artifact token. */
511
+ export function decodeInvitationArtifact(value: unknown): InvitationArtifact {
512
+ const token = opaqueToken(value, ['invitation']);
513
+ const payload = decodeBase64UrlAscii(token, ['invitation']);
514
+ if (!payload.startsWith(INVITATION_MAGIC)) {
515
+ fail('Invitation uses an unsupported or legacy format.', ['invitation']);
516
+ }
517
+ const cursor = { value: INVITATION_MAGIC.length };
518
+ const endpoint = decodeInvitationField(payload, cursor, ['endpoint']);
519
+ const caSpkiSha256 = payload.slice(cursor.value, cursor.value + 64);
520
+ if (caSpkiSha256.length !== 64) fail('Invitation pin is truncated.', ['ca_spki_sha256']);
521
+ cursor.value += 64;
522
+ const authority = payload[cursor.value++];
523
+ const secret = decodeInvitationField(payload, cursor, ['secret']);
524
+ const integrity = decodeInvitationField(payload, cursor, ['integrity']);
525
+ if (cursor.value !== payload.length) fail('Invitation contains trailing fields.', ['invitation']);
526
+ return validateInvitationArtifact({
527
+ version: INVITATION_ARTIFACT_VERSION,
528
+ endpoint,
529
+ ca_spki_sha256: caSpkiSha256,
530
+ authority: authority === 'c' ? 'client' : authority === 'o' ? 'owner' : authority,
531
+ secret,
532
+ integrity,
533
+ });
534
+ }
535
+
302
536
  function decodeRequestId(value: unknown, path: readonly (string | number)[]): string {
303
537
  const decoded = boundedString(value, 8, 128, path);
304
538
  if (!/^[A-Za-z0-9._-]+$/.test(decoded)) {
@@ -327,7 +561,7 @@ export function decodeProtocolTagPreflight(value: unknown): ProtocolTagPreflight
327
561
  exactKeys(input, ['protocol_version'], ['protocol_version']);
328
562
  if (input.protocol_version !== PROTOCOL_VERSION) {
329
563
  throw new ProtocolContractError(
330
- 'This client requires protocol v6. The peer presents a different version. Update `borgmcp-server` and `borgmcp` to matching releases — server first, then client.',
564
+ 'This client requires protocol v7. The peer presents a different version. Update `borgmcp-server` and `borgmcp` to matching releases — server first, then client.',
331
565
  ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
332
566
  ['protocol_version'],
333
567
  );
@@ -596,6 +830,30 @@ export function decodeCreateCubeResponseEnvelope(value: unknown): ProtocolEnvelo
596
830
  return decodeProtocolEnvelope(value, decodeCreateCubeResponse);
597
831
  }
598
832
 
833
+ export function decodeDeleteCubeRequest(value: unknown): DeleteCubeRequest {
834
+ const input = record(value);
835
+ exactKeys(input, [], []);
836
+ return {};
837
+ }
838
+
839
+ export function decodeDeleteCubeRequestEnvelope(value: unknown): ProtocolEnvelope<DeleteCubeRequest> {
840
+ return decodeProtocolEnvelope(value, decodeDeleteCubeRequest);
841
+ }
842
+
843
+ export function decodeDeleteCubeResponse(value: unknown): DeleteCubeResponse {
844
+ const input = record(value);
845
+ exactKeys(input, ['cube_id', 'deleted'], ['cube_id', 'deleted']);
846
+ if (input.deleted !== true) fail('Cube deletion result must be terminal.', ['deleted']);
847
+ return {
848
+ cube_id: decodeUuid(input.cube_id, ['cube_id']),
849
+ deleted: true,
850
+ };
851
+ }
852
+
853
+ export function decodeDeleteCubeResponseEnvelope(value: unknown): ProtocolEnvelope<DeleteCubeResponse> {
854
+ return decodeProtocolEnvelope(value, decodeDeleteCubeResponse);
855
+ }
856
+
599
857
  export function decodeResolveRepositoryCubeRequest(value: unknown): ResolveRepositoryCubeRequest {
600
858
  const input = record(value);
601
859
  exactKeys(input, ['working_repo_name', 'repository'], ['working_repo_name', 'repository']);
@@ -17,6 +17,7 @@ export enum ErrorCode {
17
17
  CUBE_ALREADY_ASSOCIATED = 'CUBE_ALREADY_ASSOCIATED',
18
18
  ROLE_IN_USE = 'ROLE_IN_USE',
19
19
  ROLE_HAS_FROZEN_DRONES = 'ROLE_HAS_FROZEN_DRONES',
20
+ CUBE_DELETED = 'CUBE_DELETED',
20
21
  DRONE_EVICTED = 'DRONE_EVICTED',
21
22
  DRONE_FROZEN = 'DRONE_FROZEN',
22
23
  UNSUPPORTED_PROTOCOL_VERSION = 'UNSUPPORTED_PROTOCOL_VERSION',
@@ -2,11 +2,13 @@ import type { EnrichedStreamEntry } from './types.js';
2
2
  import {
3
3
  ProtocolContractError,
4
4
  decodeCanonicalTimestamp,
5
+ decodeProtocolErrorEnvelope,
5
6
  decodeLogCursor,
6
7
  decodeOpaqueIdentifier,
7
8
  decodeUuid,
8
9
  utf8ByteLength,
9
10
  type LogCursor,
11
+ type ProtocolErrorEnvelope,
10
12
  } from './contract.js';
11
13
 
12
14
  export const SSE_LIMITS = {
@@ -31,6 +33,7 @@ export type StreamEvent =
31
33
  actor_drone_id: string;
32
34
  occurred_at: string;
33
35
  }
36
+ | StreamErrorEvent
34
37
  | { type: 'heartbeat'; at: string; broadcast_hwm: LogCursor | null }
35
38
  | {
36
39
  type: 'bookmark';
@@ -41,6 +44,12 @@ export type StreamEvent =
41
44
  }
42
45
  | { type: 'unknown'; event: string; raw_data: string };
43
46
 
47
+ /** A terminal stream failure; the server sends this frame once and then closes. */
48
+ export interface StreamErrorEvent {
49
+ type: 'error';
50
+ error: ProtocolErrorEnvelope;
51
+ }
52
+
44
53
  function object(value: unknown): Record<string, unknown> {
45
54
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
46
55
  throw new ProtocolContractError('SSE data must be a JSON object.');
@@ -124,7 +133,7 @@ export function decodeEnrichedStreamEntry(value: unknown): EnrichedStreamEntry {
124
133
 
125
134
  export function encodeSseEvent(event: Exclude<StreamEvent, { type: 'unknown' }>): string {
126
135
  const lines = [`event: ${event.type}`];
127
- let data: Record<string, unknown>;
136
+ let data: unknown;
128
137
  if (event.type === 'log') {
129
138
  const cursor = decodeLogCursor(event.cursor);
130
139
  const entry = decodeEnrichedStreamEntry(event.entry);
@@ -142,6 +151,8 @@ export function encodeSseEvent(event: Exclude<StreamEvent, { type: 'unknown' }>)
142
151
  actor_drone_id: decodeUuid(event.actor_drone_id, ['actor_drone_id']),
143
152
  occurred_at: decodeCanonicalTimestamp(event.occurred_at, ['occurred_at']),
144
153
  };
154
+ } else if (event.type === 'error') {
155
+ data = decodeProtocolErrorEnvelope(event.error);
145
156
  } else if (event.type === 'heartbeat') {
146
157
  data = {
147
158
  at: decodeCanonicalTimestamp(event.at, ['at']),
@@ -217,7 +228,7 @@ function decodeFrame(frame: string): StreamEvent {
217
228
  if (rawDataBytes > SSE_LIMITS.data_bytes) {
218
229
  throw new ProtocolContractError('SSE data exceeds the byte limit.');
219
230
  }
220
- if (!['log', 'ack', 'claim', 'heartbeat', 'bookmark'].includes(eventName)) {
231
+ if (!['log', 'ack', 'claim', 'error', 'heartbeat', 'bookmark'].includes(eventName)) {
221
232
  if (rawDataBytes > SSE_LIMITS.unknown_data_bytes) {
222
233
  throw new ProtocolContractError('Unknown SSE event data exceeds the byte limit.');
223
234
  }
@@ -247,6 +258,10 @@ function decodeFrame(frame: string): StreamEvent {
247
258
  throw new ProtocolContractError(`${eventName} SSE events must not carry a resume id.`);
248
259
  }
249
260
 
261
+ if (eventName === 'error') {
262
+ return { type: 'error', error: decodeProtocolErrorEnvelope(parsed) };
263
+ }
264
+
250
265
  if (eventName === 'ack' || eventName === 'claim') {
251
266
  exactKeys(data, ['log_entry_id', 'actor_drone_id', 'occurred_at'], [
252
267
  'log_entry_id',
@@ -1,4 +1,4 @@
1
- /** Current Borg coordination protocol generation. Clean-slate v6. */
2
- export const PROTOCOL_VERSION = '6' as const;
1
+ /** Current Borg coordination protocol generation. Clean-slate v7. */
2
+ export const PROTOCOL_VERSION = '7' as const;
3
3
 
4
4
  export type ProtocolVersion = typeof PROTOCOL_VERSION;
package/src/templates.ts CHANGED
@@ -136,6 +136,18 @@ Push discipline:
136
136
  - Push only the assigned branch after verifying the staged paths and final diff.
137
137
  - Do not force-push, rebase a shared branch, or publish from a local substitute artifact.`;
138
138
 
139
+ export const SAME_REPOSITORY_WORKFLOW_DISCIPLINE = `
140
+
141
+ Same-repository worktrees and handover:
142
+ - One seat uses one stable worktree, created once at assimilation under the standard worktree root and approved once by the operator. All seats for a repository are worktrees of the same clone family, sharing its object database and refs.
143
+ - Start each new work item by switching branches in that seat's worktree with \`git checkout -b <branch>\`; never create a new worktree or folder per work item.
144
+ - Create a branch only for a routed work item and announce its name in STARTING. One branch equals one work item and one owning seat; hand a branch to another seat only through an explicit log event.
145
+ - Use merge-only history: no rebases and no force-pushes, because another seat may have the branch checked out or fetched.
146
+ - Hand over a ref and exact commit SHA, never a filesystem path. Reviewers check out the SHA in their own worktree with \`git checkout --detach <SHA>\`; never read another seat's folder. Each review round binds to one exact SHA, and a new SHA restarts the gate sequence.
147
+ - With a hosted origin, push the branch at creation with \`git push -u origin <branch>\`; a branch is cube-visible and REVIEW-READY only after that push.
148
+ - With no hosted remote, the commit itself is the durable handover artifact because clone-family worktrees share refs; omit the push step. If push/fetch semantics are needed locally, use a local bare repository as the origin path.
149
+ - After every merge to the protected or main branch, broadcast the merge SHA. When an origin exists, include \`git fetch origin && git merge origin/main\` as the merge-only sync instruction.`;
150
+
139
151
  export const UNIVERSAL_SAFETY_DISCIPLINES = [WAKE_PATH_MONITOR_DISCIPLINE];
140
152
 
141
153
  export const ROLE_SCOPED_SAFETY_DISCIPLINES = [
@@ -162,7 +174,7 @@ const SOFTWARE_DEV_DIRECTIVE = `## Scope and coordination
162
174
  - Reviewers assess the routed exact revision and do not create or expand work.
163
175
  - Waiting is valid when no authorized action is available.
164
176
  - Merge, deploy, publish, tag, release, credential, and live-operator actions require explicit authority.
165
- - Keep cube-log signals concise. Put durable reasoning in the relevant issue, change, or existing maintained documentation only when it has an operational consumer.`;
177
+ - Keep cube-log signals concise. Put durable reasoning in the relevant issue, change, or existing maintained documentation only when it has an operational consumer.${SAME_REPOSITORY_WORKFLOW_DISCIPLINE}`;
166
178
 
167
179
  const SOFTWARE_DEV_TAXONOMY: MessageTaxonomy = [
168
180
  {
@@ -469,7 +481,7 @@ const STARTER: Template = {
469
481
  - Assignment, review, and completion do not authorize unrelated work or integration.
470
482
  - ACK is receipt only; STARTING or substantive PROGRESS confirms activation.
471
483
  - Findings outside scope are reported, not automatically fixed.
472
- - Waiting is valid when no authorized action is available.`,
484
+ - Waiting is valid when no authorized action is available.${SAME_REPOSITORY_WORKFLOW_DISCIPLINE}`,
473
485
  message_taxonomy: STARTER_TAXONOMY,
474
486
  roles: [
475
487
  {
@@ -591,7 +603,7 @@ const LOCAL_MODEL_DIRECTIVE = `## Verification-cost workflow
591
603
  - A fourth seat is optional: add a second Executor when throughput-bound, or a second capable Director as an independent review lens when correctness-bound. Never use a cheap model as a review lens.
592
604
  - Waiting is valid only when no authorized action or active assigned work remains, or while a role is awaiting a named predecessor and has no independent action it can advance.
593
605
  - Dispatch, packet echo, status, and answers are not completion. Each role continues its active item in the same turn until it posts a terminal signal from its own vocabulary.
594
- - Merge, publish, deploy, tag, release, credential, and irreversible actions require explicit authority.`;
606
+ - Merge, publish, deploy, tag, release, credential, and irreversible actions require explicit authority.${SAME_REPOSITORY_WORKFLOW_DISCIPLINE}`;
595
607
 
596
608
  const LOCAL_MODEL_DIRECTOR = `You own authorized intent, priorities, decisions, and verification that requires careful reading. Never implement a change.
597
609