borgmcp-shared 0.5.1 → 0.6.2

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.
@@ -216,7 +216,17 @@ const ATTACH_RESPONSE = {
216
216
  result: 'created',
217
217
  cube: { id: '10000000-0000-4000-8000-000000000001', name: 'test-cube' },
218
218
  role: { id: '20000000-0000-4000-8000-000000000001', name: 'Coordinator' },
219
- drone: { id: '30000000-0000-4000-8000-000000000001', label: 'one-of-one-coordinator' },
219
+ drone: {
220
+ id: '30000000-0000-4000-8000-000000000001',
221
+ label: 'one-of-one-coordinator',
222
+ runtime_metadata: {
223
+ agent_kind: null,
224
+ reported_model: null,
225
+ working_repo_name: null,
226
+ working_repo_origin: null,
227
+ },
228
+ runtime_metadata_reported: false,
229
+ },
220
230
  session: { id: '40000000-0000-4000-8000-000000000001' },
221
231
  } satisfies AttachResponse;
222
232
 
@@ -237,3 +247,58 @@ export const ATTACH_SESSION_CONFORMANCE: readonly AttachSessionConformanceVector
237
247
  accepts: false,
238
248
  },
239
249
  ];
250
+
251
+ export interface RuntimeMetadataRepositoryConformanceVector {
252
+ name: string;
253
+ origin: string;
254
+ expected: { working_repo_name: string; working_repo_origin: string } | null;
255
+ }
256
+
257
+ /** One canonical corpus consumed unchanged by shared, server, and client tests. */
258
+ export const RUNTIME_METADATA_REPOSITORY_CONFORMANCE:
259
+ readonly RuntimeMetadataRepositoryConformanceVector[] = [
260
+ {
261
+ name: 'canonical HTTPS',
262
+ origin: 'https://github.com/Byte-Ventures/borg-mcp',
263
+ expected: {
264
+ working_repo_name: 'Byte-Ventures/borg-mcp',
265
+ working_repo_origin: 'https://github.com/Byte-Ventures/borg-mcp',
266
+ },
267
+ },
268
+ {
269
+ name: 'HTTPS default port and suffix',
270
+ origin: 'https://GITHUB.com:443/Byte-Ventures/borg-mcp.git',
271
+ expected: {
272
+ working_repo_name: 'Byte-Ventures/borg-mcp',
273
+ working_repo_origin: 'https://github.com/Byte-Ventures/borg-mcp',
274
+ },
275
+ },
276
+ {
277
+ name: 'SSH URL',
278
+ origin: 'ssh://git@github.com:22/Byte-Ventures/borg-mcp.git',
279
+ expected: {
280
+ working_repo_name: 'Byte-Ventures/borg-mcp',
281
+ working_repo_origin: 'https://github.com/Byte-Ventures/borg-mcp',
282
+ },
283
+ },
284
+ {
285
+ name: 'SCP syntax',
286
+ origin: 'git@github.com:Byte-Ventures/borg-mcp.git',
287
+ expected: {
288
+ working_repo_name: 'Byte-Ventures/borg-mcp',
289
+ working_repo_origin: 'https://github.com/Byte-Ventures/borg-mcp',
290
+ },
291
+ },
292
+ { name: 'HTTPS userinfo', origin: 'https://user@github.com/owner/repo', expected: null },
293
+ { name: 'SSH arbitrary user', origin: 'ssh://owner@github.com/owner/repo', expected: null },
294
+ { name: 'query', origin: 'https://github.com/owner/repo?token=value', expected: null },
295
+ { name: 'fragment', origin: 'https://github.com/owner/repo#fragment', expected: null },
296
+ { name: 'percent encoding', origin: 'https://github.com/owner/re%70o', expected: null },
297
+ { name: 'non-default port', origin: 'https://github.com:444/owner/repo', expected: null },
298
+ { name: 'local file URL', origin: ['file:/', '', 'home', 'user', 'repo'].join('/'), expected: null },
299
+ { name: 'relative path', origin: ['..', 'repo'].join('/'), expected: null },
300
+ { name: 'loopback host', origin: 'https://127.0.0.1/owner/repo', expected: null },
301
+ { name: 'IPv6 host', origin: 'https://[::1]/owner/repo', expected: null },
302
+ { name: 'single-label host', origin: 'https://git/owner/repo', expected: null },
303
+ { name: 'private suffix', origin: 'https://git.internal/owner/repo', expected: null },
304
+ ];
package/src/index.ts CHANGED
@@ -2,3 +2,4 @@ export * from './protocol/index.js';
2
2
  export * from './domain/index.js';
3
3
  export * from './conformance/index.js';
4
4
  export * from './templates.js';
5
+ export * from './runtime-metadata.js';
@@ -1,19 +1,32 @@
1
1
  import { ErrorCode } from './errors.js';
2
2
  import { PROTOCOL_VERSION, type ProtocolVersion } from './version.js';
3
+ import {
4
+ RuntimeMetadataValidationError,
5
+ validateRuntimeMetadata,
6
+ validateRuntimeMetadataPatch,
7
+ validateRuntimeMetadataReportState,
8
+ } from '../runtime-metadata.js';
9
+ import type {
10
+ DroneRuntimeMetadata,
11
+ DroneRuntimeMetadataPatch,
12
+ } from './types.js';
3
13
 
4
14
  export const SHARED_PACKAGE_NAME = 'borgmcp-shared' as const;
5
- export const SHARED_PACKAGE_VERSION = '0.5.1' as const;
15
+ export const SHARED_PACKAGE_VERSION = '0.6.2' as const;
6
16
 
7
17
  export const HEALTH_PATH = '/healthz' as const;
8
18
  export const PROTOCOL_INFO_PATH = '/api/protocol' as const;
9
19
  export const ENROLLMENT_EXCHANGE_PATH = '/api/enrollment/exchange' as const;
10
20
  export const CUBES_PATH = '/api/cubes' as const;
21
+ export const ATTACH_PATH = '/api/client/attach' as const;
22
+ export const SELF_RUNTIME_METADATA_PATH = '/api/cubes/:cubeId/drones/self/metadata' as const;
11
23
 
12
24
  export const PROTOCOL_HTTP_CONTRACT = {
13
25
  health: { method: 'GET', path: HEALTH_PATH, authenticated: false, success_status: 204, bodyless: true },
14
26
  protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated: false, success_status: 200 },
15
27
  enrollment: { method: 'POST', path: ENROLLMENT_EXCHANGE_PATH, authenticated: 'invitation', success_status: 201 },
16
28
  cubes: { method: 'POST', path: CUBES_PATH, authenticated: true, success_status: 201 },
29
+ attach: { method: 'POST', path: ATTACH_PATH, authenticated: true, success_status: 200 },
17
30
  drone_reassign: {
18
31
  method: 'PATCH',
19
32
  path: '/api/cubes/:cubeId/drones/:droneId',
@@ -26,6 +39,12 @@ export const PROTOCOL_HTTP_CONTRACT = {
26
39
  authenticated: true,
27
40
  success_status: 200,
28
41
  },
42
+ drone_self_metadata: {
43
+ method: 'PATCH',
44
+ path: SELF_RUNTIME_METADATA_PATH,
45
+ authenticated: 'drone-session',
46
+ success_status: 200,
47
+ },
29
48
  auth_missing_status: 401,
30
49
  auth_invalid_status: 401,
31
50
  auth_expired_status: 401,
@@ -607,15 +626,86 @@ export function maxLogCursor(a: LogCursor | null, b: LogCursor | null): LogCurso
607
626
  return compareLogCursor(a, b) >= 0 ? decodeLogCursor(a) : decodeLogCursor(b);
608
627
  }
609
628
 
610
- // ── v3 clean-slate wire types ──────────────────────────────────────────────
629
+ function metadataValidation<T>(fn: () => T): T {
630
+ try {
631
+ return fn();
632
+ } catch (error) {
633
+ if (error instanceof RuntimeMetadataValidationError) {
634
+ fail(`${error.field}: ${error.reason}`, [error.field]);
635
+ }
636
+ throw error;
637
+ }
638
+ }
611
639
 
612
- export const ATTACH_PATH = '/api/client/attach' as const;
640
+ /** Decode a complete runtime report. When present on attach, all four keys are required. */
641
+ export function decodeDroneRuntimeMetadata(value: unknown): DroneRuntimeMetadata {
642
+ return metadataValidation(() => validateRuntimeMetadata(value));
643
+ }
644
+
645
+ /** Decode an atomic self-heal patch. Omitted means unchanged; null means clear. */
646
+ export function decodeDroneRuntimeMetadataPatch(value: unknown): DroneRuntimeMetadataPatch {
647
+ return metadataValidation(() => validateRuntimeMetadataPatch(value));
648
+ }
649
+
650
+ /** Decode the flat runtime state carried by Drone objects in roster and regen responses. */
651
+ export function decodeDroneRuntimeMetadataState(value: unknown): UpdateDroneRuntimeMetadataResponse {
652
+ const input = record(value);
653
+ return metadataValidation(() => validateRuntimeMetadataReportState({
654
+ agent_kind: input.agent_kind,
655
+ reported_model: input.reported_model,
656
+ working_repo_name: input.working_repo_name,
657
+ working_repo_origin: input.working_repo_origin,
658
+ }, input.runtime_metadata_reported));
659
+ }
660
+
661
+ /** Decode the nested runtime state carried by the own-seat identity response. */
662
+ export function decodeWhoAmIRuntimeMetadataState(value: unknown): UpdateDroneRuntimeMetadataResponse {
663
+ const input = record(value);
664
+ return metadataValidation(() => validateRuntimeMetadataReportState(
665
+ input.runtime_metadata,
666
+ input.runtime_metadata_reported,
667
+ ));
668
+ }
669
+
670
+ export interface UpdateDroneRuntimeMetadataResponse {
671
+ runtime_metadata: DroneRuntimeMetadata;
672
+ runtime_metadata_reported: boolean;
673
+ }
674
+
675
+ export function decodeUpdateDroneRuntimeMetadataResponse(
676
+ value: unknown,
677
+ ): UpdateDroneRuntimeMetadataResponse {
678
+ const input = record(value);
679
+ exactKeys(input, ['runtime_metadata', 'runtime_metadata_reported'], [
680
+ 'runtime_metadata',
681
+ 'runtime_metadata_reported',
682
+ ]);
683
+ return metadataValidation(() => validateRuntimeMetadataReportState(
684
+ input.runtime_metadata,
685
+ input.runtime_metadata_reported,
686
+ ));
687
+ }
688
+
689
+ export function decodeUpdateDroneRuntimeMetadataRequestEnvelope(
690
+ value: unknown,
691
+ ): ProtocolEnvelope<DroneRuntimeMetadataPatch> {
692
+ return decodeProtocolEnvelope(value, decodeDroneRuntimeMetadataPatch);
693
+ }
694
+
695
+ export function decodeUpdateDroneRuntimeMetadataResponseEnvelope(
696
+ value: unknown,
697
+ ): ProtocolEnvelope<UpdateDroneRuntimeMetadataResponse> {
698
+ return decodeProtocolEnvelope(value, decodeUpdateDroneRuntimeMetadataResponse);
699
+ }
700
+
701
+ // ── v3 clean-slate wire types ──────────────────────────────────────────────
613
702
 
614
703
  export interface AttachRequest {
615
704
  cube_id: string;
616
705
  role_id: string;
617
706
  session_credential: string;
618
707
  prior_drone_id?: string;
708
+ runtime_metadata?: DroneRuntimeMetadata;
619
709
  }
620
710
 
621
711
  export interface AttachCube {
@@ -635,6 +725,8 @@ export interface AttachRole {
635
725
  export interface AttachDrone {
636
726
  id: string;
637
727
  label: string;
728
+ runtime_metadata: DroneRuntimeMetadata;
729
+ runtime_metadata_reported: boolean;
638
730
  }
639
731
 
640
732
  export interface AttachSession {
@@ -682,10 +774,20 @@ function decodeAttachRole(value: unknown, path: readonly (string | number)[]): A
682
774
 
683
775
  function decodeAttachDrone(value: unknown, path: readonly (string | number)[]): AttachDrone {
684
776
  const input = record(value, path);
685
- exactKeys(input, ['id', 'label'], ['id', 'label'], path);
777
+ exactKeys(
778
+ input,
779
+ ['id', 'label', 'runtime_metadata', 'runtime_metadata_reported'],
780
+ ['id', 'label', 'runtime_metadata', 'runtime_metadata_reported'],
781
+ path,
782
+ );
783
+ const state = metadataValidation(() => validateRuntimeMetadataReportState(
784
+ input.runtime_metadata,
785
+ input.runtime_metadata_reported,
786
+ ));
686
787
  return {
687
788
  id: decodeUuid(input.id, [...path, 'id']),
688
789
  label: boundedString(input.label, 1, 128, [...path, 'label']),
790
+ ...state,
689
791
  };
690
792
  }
691
793
 
@@ -703,7 +805,7 @@ function decodeAttachSession(value: unknown, path: readonly (string | number)[])
703
805
  */
704
806
  export function decodeAttachRequest(value: unknown): AttachRequest {
705
807
  const input = record(value);
706
- exactKeys(input, ['cube_id', 'role_id', 'session_credential', 'prior_drone_id'], [
808
+ exactKeys(input, ['cube_id', 'role_id', 'session_credential', 'prior_drone_id', 'runtime_metadata'], [
707
809
  'cube_id',
708
810
  'role_id',
709
811
  'session_credential',
@@ -716,6 +818,9 @@ export function decodeAttachRequest(value: unknown): AttachRequest {
716
818
  if (input.prior_drone_id !== undefined) {
717
819
  result.prior_drone_id = decodeUuid(input.prior_drone_id, ['prior_drone_id']);
718
820
  }
821
+ if (input.runtime_metadata !== undefined) {
822
+ result.runtime_metadata = decodeDroneRuntimeMetadata(input.runtime_metadata);
823
+ }
719
824
  return result;
720
825
  }
721
826
 
@@ -14,6 +14,20 @@ export type WakePathAlertClass =
14
14
  | 'systemic-wake-path-deaf'
15
15
  | 'independent';
16
16
 
17
+ export interface DroneRuntimeMetadata {
18
+ agent_kind: AgentKind | null;
19
+ reported_model: string | null;
20
+ working_repo_name: string | null;
21
+ working_repo_origin: string | null;
22
+ }
23
+
24
+ export interface DroneRuntimeMetadataPatch {
25
+ agent_kind?: AgentKind | null;
26
+ reported_model?: string | null;
27
+ working_repo_name?: string | null;
28
+ working_repo_origin?: string | null;
29
+ }
30
+
17
31
  export interface Cube {
18
32
  id: string;
19
33
  owner_id: string;
@@ -45,7 +59,7 @@ export interface Role {
45
59
 
46
60
  export type PublicRole = Omit<Role, 'detailed_description' | 'detailed_description_hash'>;
47
61
 
48
- export interface Drone {
62
+ export interface Drone extends DroneRuntimeMetadata {
49
63
  id: string;
50
64
  cube_id: string;
51
65
  role_id: string;
@@ -61,10 +75,7 @@ export interface Drone {
61
75
  wake_path_client_sse_connected?: boolean | null;
62
76
  wake_path_client_monitor_armed?: boolean | null;
63
77
  wake_path_alert_class?: WakePathAlertClass | null;
64
- agent_kind?: AgentKind | null;
65
- reported_model?: string | null;
66
- working_repo_name?: string | null;
67
- working_repo_origin?: string | null;
78
+ runtime_metadata_reported: boolean;
68
79
  evicted_at?: null;
69
80
  created_at: string;
70
81
  seen_since?: boolean;
@@ -122,6 +133,8 @@ export interface WhoAmIResponse {
122
133
  drone_label: string;
123
134
  role_id: string;
124
135
  role_name: string;
136
+ runtime_metadata: DroneRuntimeMetadata;
137
+ runtime_metadata_reported: boolean;
125
138
  }
126
139
 
127
140
  export interface RosterResponse {
@@ -0,0 +1,304 @@
1
+ import type {
2
+ DroneRuntimeMetadata,
3
+ DroneRuntimeMetadataPatch,
4
+ } from './protocol/types.js';
5
+
6
+ export const RUNTIME_METADATA_LIMITS = {
7
+ reported_model_bytes: 160,
8
+ repo_segment_bytes: 100,
9
+ repo_name_bytes: 201,
10
+ repo_origin_bytes: 512,
11
+ } as const;
12
+
13
+ export type RuntimeMetadataField =
14
+ | 'runtime_metadata'
15
+ | 'runtime_metadata_reported'
16
+ | 'agent_kind'
17
+ | 'reported_model'
18
+ | 'working_repo_name'
19
+ | 'working_repo_origin';
20
+
21
+ export class RuntimeMetadataValidationError extends Error {
22
+ constructor(
23
+ public readonly field: RuntimeMetadataField,
24
+ public readonly reason: string,
25
+ ) {
26
+ super(`${field}: ${reason}`);
27
+ this.name = 'RuntimeMetadataValidationError';
28
+ }
29
+ }
30
+
31
+ const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/@:+-]*$/;
32
+ const REPO_SEGMENT_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$/;
33
+ const UNSAFE_UNICODE_PATTERN = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]|\p{Bidi_Control}/u;
34
+
35
+ function byteLength(value: string): number {
36
+ let bytes = 0;
37
+ for (let index = 0; index < value.length; index++) {
38
+ const code = value.charCodeAt(index);
39
+ if (code <= 0x7f) bytes += 1;
40
+ else if (code <= 0x7ff) bytes += 2;
41
+ else if (code >= 0xd800 && code <= 0xdbff) {
42
+ bytes += 4;
43
+ index++;
44
+ } else bytes += 3;
45
+ }
46
+ return bytes;
47
+ }
48
+
49
+ function hasMalformedUnicode(value: string): boolean {
50
+ for (let index = 0; index < value.length; index++) {
51
+ const code = value.charCodeAt(index);
52
+ if (code >= 0xd800 && code <= 0xdbff) {
53
+ const next = value.charCodeAt(index + 1);
54
+ if (next < 0xdc00 || next > 0xdfff) return true;
55
+ index++;
56
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
57
+ return true;
58
+ }
59
+ }
60
+ return false;
61
+ }
62
+
63
+ function invalid(field: RuntimeMetadataField, reason: string): never {
64
+ throw new RuntimeMetadataValidationError(field, reason);
65
+ }
66
+
67
+ const METADATA_KEYS = [
68
+ 'agent_kind',
69
+ 'reported_model',
70
+ 'working_repo_name',
71
+ 'working_repo_origin',
72
+ ] as const;
73
+
74
+ function metadataRecord(value: unknown): Record<string, unknown> {
75
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
76
+ invalid('runtime_metadata', 'must be an object');
77
+ }
78
+ const prototype = Object.getPrototypeOf(value);
79
+ if (prototype !== Object.prototype && prototype !== null) {
80
+ invalid('runtime_metadata', 'must be a plain object');
81
+ }
82
+ const keys = Reflect.ownKeys(value);
83
+ if (keys.some((key) => typeof key !== 'string' || !METADATA_KEYS.includes(key as typeof METADATA_KEYS[number]))) {
84
+ invalid('runtime_metadata', 'contains an unsupported field');
85
+ }
86
+ if (keys.some((key) => {
87
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
88
+ return descriptor === undefined || !Object.prototype.hasOwnProperty.call(descriptor, 'value');
89
+ })) {
90
+ invalid('runtime_metadata', 'fields must be plain data values');
91
+ }
92
+ return value as Record<string, unknown>;
93
+ }
94
+
95
+ function agentKind(value: unknown): DroneRuntimeMetadata['agent_kind'] {
96
+ if (value === null || value === 'claude' || value === 'codex' || value === 'opencode') {
97
+ return value;
98
+ }
99
+ invalid('agent_kind', 'must be claude, codex, opencode, or null');
100
+ }
101
+
102
+ function nullableString(value: unknown, field: RuntimeMetadataField): string | null {
103
+ if (value === null || typeof value === 'string') return value;
104
+ invalid(field, 'must be a string or null');
105
+ }
106
+
107
+ export function validateReportedModel(value: string): string {
108
+ const bytes = byteLength(value);
109
+ if (bytes < 1 || bytes > RUNTIME_METADATA_LIMITS.reported_model_bytes) {
110
+ invalid('reported_model', 'must be 1-160 UTF-8 bytes');
111
+ }
112
+ if (hasMalformedUnicode(value) || UNSAFE_UNICODE_PATTERN.test(value) || !MODEL_PATTERN.test(value)) {
113
+ invalid('reported_model', 'must be a printable model identifier');
114
+ }
115
+ return value;
116
+ }
117
+
118
+ export function validateWorkingRepoName(value: string): string {
119
+ if (byteLength(value) > RUNTIME_METADATA_LIMITS.repo_name_bytes) {
120
+ invalid('working_repo_name', 'must be at most 201 bytes');
121
+ }
122
+ const segments = value.split('/');
123
+ if (
124
+ segments.length !== 2 ||
125
+ segments.some((segment) =>
126
+ segment === '.' ||
127
+ segment === '..' ||
128
+ byteLength(segment) > RUNTIME_METADATA_LIMITS.repo_segment_bytes ||
129
+ !REPO_SEGMENT_PATTERN.test(segment)
130
+ )
131
+ ) {
132
+ invalid('working_repo_name', 'must be a canonical owner/repository name');
133
+ }
134
+ return value;
135
+ }
136
+
137
+ function validatePublicHost(hostname: string): string {
138
+ const host = hostname.toLowerCase();
139
+ if (
140
+ host.length > 253 ||
141
+ !host.includes('.') ||
142
+ host.includes(':') ||
143
+ /^(?:0x[0-9a-f]+|\d+)(?:\.(?:0x[0-9a-f]+|\d+)){1,3}$/.test(host) ||
144
+ /(?:^|\.)(?:localhost|local|internal|lan)$/.test(host)
145
+ ) {
146
+ invalid('working_repo_origin', 'must use a public DNS host');
147
+ }
148
+ const labels = host.split('.');
149
+ if (labels.some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) {
150
+ invalid('working_repo_origin', 'must use a public DNS host');
151
+ }
152
+ return host;
153
+ }
154
+
155
+ function originParts(value: string): { host: string; path: string } {
156
+ if (
157
+ byteLength(value) > RUNTIME_METADATA_LIMITS.repo_origin_bytes ||
158
+ value.includes('%') ||
159
+ hasMalformedUnicode(value) ||
160
+ UNSAFE_UNICODE_PATTERN.test(value) ||
161
+ /[\u0080-\uffff]/u.test(value)
162
+ ) {
163
+ invalid('working_repo_origin', 'must be a safe canonical repository URL');
164
+ }
165
+
166
+ const scp = /^git@([^/:]+):(.+)$/.exec(value);
167
+ if (scp) return { host: validatePublicHost(scp[1]), path: scp[2] };
168
+
169
+ const hierarchical = /^(https|ssh):\/\/([^/]+)\/(.+)$/.exec(value);
170
+ if (!hierarchical) {
171
+ invalid('working_repo_origin', 'must be an HTTPS or Git SSH repository URL');
172
+ }
173
+ const [, scheme, authority, path] = hierarchical;
174
+ if (path.includes('?') || path.includes('#')) {
175
+ invalid('working_repo_origin', 'must not include a query or fragment');
176
+ }
177
+ if (scheme === 'https') {
178
+ if (authority.includes('@')) {
179
+ invalid('working_repo_origin', 'must not include credentials or a non-default port');
180
+ }
181
+ const hostMatch = /^([^:]+)(?::443)?$/.exec(authority);
182
+ if (!hostMatch) {
183
+ invalid('working_repo_origin', 'must not include credentials or a non-default port');
184
+ }
185
+ return { host: validatePublicHost(hostMatch[1]), path };
186
+ }
187
+ const sshMatch = /^git@([^:]+)(?::22)?$/.exec(authority);
188
+ if (!sshMatch) {
189
+ invalid('working_repo_origin', 'SSH repository URLs require the literal git user');
190
+ }
191
+ return { host: validatePublicHost(sshMatch[1]), path };
192
+ }
193
+
194
+ export interface CanonicalRepositoryIdentity {
195
+ working_repo_name: string;
196
+ working_repo_origin: string;
197
+ }
198
+
199
+ export function canonicalizeRepositoryIdentity(
200
+ inputOrigin: string,
201
+ expectedName?: string,
202
+ ): CanonicalRepositoryIdentity {
203
+ const { host, path: rawPath } = originParts(inputOrigin);
204
+ const path = rawPath.endsWith('.git') ? rawPath.slice(0, -4) : rawPath;
205
+ if (path.includes('\\') || path.startsWith('/') || path.endsWith('/')) {
206
+ invalid('working_repo_origin', 'must identify exactly one owner/repository path');
207
+ }
208
+ const name = validateWorkingRepoName(path);
209
+ if (expectedName !== undefined && validateWorkingRepoName(expectedName) !== name) {
210
+ invalid('working_repo_name', 'must match the canonical repository origin');
211
+ }
212
+ return {
213
+ working_repo_name: name,
214
+ working_repo_origin: `https://${host}/${name}`,
215
+ };
216
+ }
217
+
218
+ export function validateRuntimeMetadata(value: unknown): DroneRuntimeMetadata {
219
+ const input = metadataRecord(value);
220
+ if (METADATA_KEYS.some((key) => !Object.prototype.hasOwnProperty.call(input, key))) {
221
+ invalid('runtime_metadata', 'must contain all four metadata fields');
222
+ }
223
+ const metadata: DroneRuntimeMetadata = {
224
+ agent_kind: agentKind(input.agent_kind),
225
+ reported_model: nullableString(input.reported_model, 'reported_model'),
226
+ working_repo_name: nullableString(input.working_repo_name, 'working_repo_name'),
227
+ working_repo_origin: nullableString(input.working_repo_origin, 'working_repo_origin'),
228
+ };
229
+ if (metadata.reported_model !== null) validateReportedModel(metadata.reported_model);
230
+ if ((metadata.working_repo_name === null) !== (metadata.working_repo_origin === null)) {
231
+ invalid('working_repo_name', 'repository name and origin must be set or cleared together');
232
+ }
233
+ if (metadata.working_repo_name !== null && metadata.working_repo_origin !== null) {
234
+ const canonical = canonicalizeRepositoryIdentity(
235
+ metadata.working_repo_origin,
236
+ metadata.working_repo_name,
237
+ );
238
+ return { ...metadata, ...canonical };
239
+ }
240
+ return { ...metadata };
241
+ }
242
+
243
+ export function validateRuntimeMetadataPatch(
244
+ value: unknown,
245
+ ): DroneRuntimeMetadataPatch {
246
+ const input = metadataRecord(value);
247
+ if (Reflect.ownKeys(input).length === 0) invalid('runtime_metadata', 'patch must not be empty');
248
+ const patch: DroneRuntimeMetadataPatch = {};
249
+ if (Object.prototype.hasOwnProperty.call(input, 'agent_kind')) {
250
+ patch.agent_kind = agentKind(input.agent_kind);
251
+ }
252
+ if (Object.prototype.hasOwnProperty.call(input, 'reported_model')) {
253
+ patch.reported_model = nullableString(input.reported_model, 'reported_model');
254
+ }
255
+ if (Object.prototype.hasOwnProperty.call(input, 'working_repo_name')) {
256
+ patch.working_repo_name = nullableString(input.working_repo_name, 'working_repo_name');
257
+ }
258
+ if (Object.prototype.hasOwnProperty.call(input, 'working_repo_origin')) {
259
+ patch.working_repo_origin = nullableString(input.working_repo_origin, 'working_repo_origin');
260
+ }
261
+ if (patch.reported_model !== undefined && patch.reported_model !== null) {
262
+ validateReportedModel(patch.reported_model);
263
+ }
264
+ const hasName = patch.working_repo_name !== undefined;
265
+ const hasOrigin = patch.working_repo_origin !== undefined;
266
+ if (hasName !== hasOrigin) {
267
+ invalid('working_repo_name', 'repository name and origin must be patched together');
268
+ }
269
+ if (hasName && hasOrigin) {
270
+ const name = patch.working_repo_name;
271
+ const origin = patch.working_repo_origin;
272
+ if ((name === null) !== (origin === null)) {
273
+ invalid('working_repo_name', 'repository name and origin must be set or cleared together');
274
+ }
275
+ if (name !== null && name !== undefined && origin !== null && origin !== undefined) {
276
+ const canonical = canonicalizeRepositoryIdentity(
277
+ origin,
278
+ name,
279
+ );
280
+ return { ...patch, ...canonical };
281
+ }
282
+ }
283
+ return { ...patch };
284
+ }
285
+
286
+ export interface ValidatedRuntimeMetadataReportState {
287
+ runtime_metadata: DroneRuntimeMetadata;
288
+ runtime_metadata_reported: boolean;
289
+ }
290
+
291
+ /** Enforce the response invariant shared by attach, self-update, and identity reads. */
292
+ export function validateRuntimeMetadataReportState(
293
+ metadataValue: unknown,
294
+ reportedValue: unknown,
295
+ ): ValidatedRuntimeMetadataReportState {
296
+ const runtime_metadata = validateRuntimeMetadata(metadataValue);
297
+ if (typeof reportedValue !== 'boolean') {
298
+ invalid('runtime_metadata_reported', 'must be a boolean');
299
+ }
300
+ if (!reportedValue && Object.values(runtime_metadata).some((value) => value !== null)) {
301
+ invalid('runtime_metadata_reported', 'false requires all metadata values to be null');
302
+ }
303
+ return { runtime_metadata, runtime_metadata_reported: reportedValue };
304
+ }
package/src/templates.ts CHANGED
@@ -222,9 +222,11 @@ Scope:
222
222
  - Ask the human before rescoping, reprioritizing, abandoning, waiving a gate, creating an external issue or pull request, merging, deploying, publishing, tagging, or releasing unless that action was already explicitly delegated.
223
223
 
224
224
  Activation:
225
- - Route authorized work with START NOW, RESUME NOW, REVIEW NOW, or HOLD; name the exact item and first concrete action.
226
- - ACK is receipt only. Assignment is incomplete until STARTING or substantive PROGRESS.
227
- - Verify start within two minutes when the wake path is live. Kick a miss; probe liveness before reassigning. Expect concrete progress during active work.
225
+ - Order named drones to start exact authorized work with START NOW, RESUME NOW, REVIEW NOW, or HOLD; name the exact item and first concrete action.
226
+ - ACK and claim are receipt only; neither means work has started or a review is complete.
227
+ - Unless HOLD, require STARTING or substantive PROGRESS within 2 minutes of routing. Directly kick a miss.
228
+ - After 5 more minutes without substantive response, probe liveness; reassign only when eligible and authorized.
229
+ - While work is active, require substantive PROGRESS at least every 10 minutes. Require immediate BLOCKED when safe work stops, naming the missing input while independent work continues.
228
230
  - Waiting is valid when work is complete, blocked, under active review, or awaiting human authority. Never manufacture work to avoid idleness.
229
231
 
230
232
  Review: