overlord-cli 3.5.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.
@@ -0,0 +1,1271 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+
8
+ import { buildAuthHeaders, resolveAuth } from './credentials.mjs';
9
+
10
+ /**
11
+ * Parse simple CLI flags: --key value or --key=value
12
+ * @param {string[]} args
13
+ * @returns {Record<string, string | boolean>}
14
+ */
15
+ function parseFlags(args) {
16
+ const result = {};
17
+ for (let i = 0; i < args.length; i++) {
18
+ const arg = args[i];
19
+ if (arg.startsWith('--')) {
20
+ const eqIdx = arg.indexOf('=');
21
+ if (eqIdx !== -1) {
22
+ const key = arg.slice(2, eqIdx);
23
+ result[key] = arg.slice(eqIdx + 1);
24
+ } else {
25
+ const key = arg.slice(2);
26
+ if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
27
+ result[key] = args[i + 1];
28
+ i++;
29
+ } else {
30
+ result[key] = true;
31
+ }
32
+ }
33
+ }
34
+ }
35
+ return result;
36
+ }
37
+
38
+ /**
39
+ * Default request timeout in milliseconds. Overridable via --timeout flag or
40
+ * OVERLORD_TIMEOUT env var. A bounded timeout prevents indefinite spinner hangs
41
+ * in sandboxed runtimes where deliver requests can stall without a connection error.
42
+ */
43
+ const DEFAULT_TIMEOUT_MS = 30000;
44
+
45
+ async function apiPost(platformUrl, token, localSecret, path, body, timeoutMs = DEFAULT_TIMEOUT_MS) {
46
+ const requestUrl = `${platformUrl}${path}`;
47
+ const requestStart = Date.now();
48
+ let res;
49
+ try {
50
+ res = await fetch(requestUrl, {
51
+ method: 'POST',
52
+ headers: {
53
+ ...buildAuthHeaders(token, localSecret),
54
+ 'Content-Type': 'application/json'
55
+ },
56
+ body: JSON.stringify(body),
57
+ signal: AbortSignal.timeout(timeoutMs)
58
+ });
59
+ } catch (error) {
60
+ const message = error instanceof Error ? error.message : String(error);
61
+
62
+ // AbortSignal.timeout() throws a DOMException with name 'TimeoutError'
63
+ if (error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
64
+ throw new Error(
65
+ `Request timed out after ${timeoutMs}ms calling ${requestUrl}.\n` +
66
+ `Tip: Ensure Overlord is running and reachable from this environment. ` +
67
+ `Increase the limit with --timeout <ms> or OVERLORD_TIMEOUT=<ms>.`
68
+ );
69
+ }
70
+
71
+ const causeCode = (
72
+ typeof error === 'object' &&
73
+ error !== null &&
74
+ 'cause' in error &&
75
+ typeof error.cause === 'object' &&
76
+ error.cause !== null &&
77
+ 'code' in error.cause
78
+ ) ? String(error.cause.code) : '';
79
+
80
+ let hint = 'Check your network and Overlord server settings.';
81
+ if (causeCode === 'ECONNREFUSED') {
82
+ hint = 'Connection refused. Verify Overlord is running and OVERLORD_URL points to the correct port.';
83
+ } else if (causeCode === 'ENOTFOUND') {
84
+ hint = 'Host not found. Verify OVERLORD_URL uses a valid hostname.';
85
+ } else if (causeCode === 'ETIMEDOUT') {
86
+ hint = 'Connection timed out. Verify server availability and local firewall/VPN settings.';
87
+ } else if (requestUrl.includes('localhost') || requestUrl.includes('127.0.0.1')) {
88
+ hint = 'Local server unreachable. Start Overlord (usually http://localhost:3000) or update OVERLORD_URL.';
89
+ }
90
+
91
+ throw new Error(
92
+ `Network error calling ${requestUrl}: ${message}${causeCode ? ` (${causeCode})` : ''}\n${hint}`
93
+ );
94
+ }
95
+
96
+ const durationMs = Date.now() - requestStart;
97
+ process.stderr.write(`[protocol] ${path} → ${res.status} (${durationMs}ms)\n`);
98
+
99
+ const data = await res.json().catch(() => ({}));
100
+
101
+ if (res.status === 401) {
102
+ throw new Error(
103
+ `Authentication failed (401): ${data.error ?? 'Invalid or missing token.'}\n` +
104
+ `IMPORTANT: Stop all work immediately. Your agent token is invalid, expired, or revoked.\n` +
105
+ `The user should open Overlord Settings → Agent Tokens and retrieve an updated token for this project.\n` +
106
+ `Ask the user if they would like to proceed without submitting updates to Overlord.`
107
+ );
108
+ }
109
+
110
+ if (!res.ok) {
111
+ throw new Error(`API error (${res.status}): ${data.error ?? JSON.stringify(data)}`);
112
+ }
113
+
114
+ return data;
115
+ }
116
+
117
+ async function uploadToSignedUrl(uploadUrl, bytes, contentType, timeoutMs = DEFAULT_TIMEOUT_MS) {
118
+ let res;
119
+ try {
120
+ res = await fetch(uploadUrl, {
121
+ method: 'PUT',
122
+ headers: {
123
+ 'Content-Type': contentType,
124
+ 'x-upsert': 'false'
125
+ },
126
+ body: bytes,
127
+ signal: AbortSignal.timeout(timeoutMs)
128
+ });
129
+ } catch (error) {
130
+ const message = error instanceof Error ? error.message : String(error);
131
+ if (error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
132
+ throw new Error(`Upload timed out after ${timeoutMs}ms.`);
133
+ }
134
+ throw new Error(`Upload failed: ${message}`);
135
+ }
136
+
137
+ if (!res.ok) {
138
+ const text = await res.text().catch(() => '');
139
+ throw new Error(`Upload failed (${res.status}): ${text || 'Unknown storage error.'}`);
140
+ }
141
+ }
142
+
143
+ /** Read SESSION_KEY and TICKET_ID from env if flags not provided */
144
+ function resolveSessionFlags(flags) {
145
+ return {
146
+ sessionKey: String(flags['session-key'] ?? process.env.SESSION_KEY ?? ''),
147
+ ticketId: String(flags['ticket-id'] ?? process.env.TICKET_ID ?? '')
148
+ };
149
+ }
150
+
151
+ /** Resolve request timeout from --timeout flag or OVERLORD_TIMEOUT env var. */
152
+ function resolveTimeout(flags) {
153
+ const raw = flags['timeout'] ?? process.env.OVERLORD_TIMEOUT;
154
+ if (raw) {
155
+ const ms = parseInt(String(raw), 10);
156
+ if (!isNaN(ms) && ms > 0) return ms;
157
+ }
158
+ return DEFAULT_TIMEOUT_MS;
159
+ }
160
+
161
+ function requireFlag(flags, name, envAlias) {
162
+ const value = flags[name] ?? (envAlias ? process.env[envAlias] : undefined);
163
+ if (!value) {
164
+ throw new Error(`--${name} is required (or set ${envAlias ?? name.toUpperCase()})`);
165
+ }
166
+ return String(value);
167
+ }
168
+
169
+ function readTextFile(filePath, label) {
170
+ try {
171
+ return fs.readFileSync(filePath, 'utf8');
172
+ } catch (err) {
173
+ throw new Error(
174
+ `${label}: could not read "${filePath}": ${err instanceof Error ? err.message : String(err)}`
175
+ );
176
+ }
177
+ }
178
+
179
+ function readJsonFile(filePath, label) {
180
+ try {
181
+ return JSON.parse(readTextFile(filePath, label));
182
+ } catch (err) {
183
+ if (err instanceof Error && err.message.startsWith(`${label}: could not read`)) {
184
+ throw err;
185
+ }
186
+ throw new Error(
187
+ `${label}: could not parse "${filePath}": ${err instanceof Error ? err.message : String(err)}`
188
+ );
189
+ }
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // changeRationales helper
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * Resolve changeRationales from --change-rationales-json or --change-rationales-file flags.
198
+ * @param {Record<string, string | boolean>} flags
199
+ * @returns {Promise<Array<object>>}
200
+ */
201
+ async function resolveChangeRationales(flags) {
202
+ if (flags['change-rationales-file']) {
203
+ return readJsonFile(String(flags['change-rationales-file']), '--change-rationales-file');
204
+ }
205
+ if (flags['change-rationales-json']) {
206
+ try {
207
+ return JSON.parse(String(flags['change-rationales-json']));
208
+ } catch {
209
+ throw new Error('--change-rationales-json must be valid JSON');
210
+ }
211
+ }
212
+ return [];
213
+ }
214
+
215
+ function normalizeRepoRelativeFilePath(filePath, repoRoot) {
216
+ if (typeof filePath !== 'string') return null;
217
+
218
+ const trimmed = filePath.trim();
219
+ if (!trimmed) return null;
220
+
221
+ if (path.isAbsolute(trimmed)) {
222
+ const relative = path.relative(repoRoot, trimmed);
223
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
224
+ return null;
225
+ }
226
+ return relative.replaceAll(path.sep, '/');
227
+ }
228
+
229
+ return trimmed.replace(/^[.][/\\]+/, '').replaceAll('\\', '/');
230
+ }
231
+
232
+ function getGitChangedFiles() {
233
+ try {
234
+ const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
235
+ cwd: process.cwd(),
236
+ encoding: 'utf8',
237
+ stdio: ['ignore', 'pipe', 'pipe']
238
+ }).trim();
239
+
240
+ if (!repoRoot) return null;
241
+
242
+ const output = execFileSync('git', ['status', '--porcelain=v1', '-z'], {
243
+ cwd: repoRoot,
244
+ encoding: 'utf8',
245
+ stdio: ['ignore', 'pipe', 'pipe']
246
+ });
247
+
248
+ const changedFiles = new Set();
249
+ const entries = output.split('\0');
250
+
251
+ for (let i = 0; i < entries.length; i++) {
252
+ const entry = entries[i];
253
+ if (!entry) continue;
254
+
255
+ const status = entry.slice(0, 2);
256
+ const normalizedPath = normalizeRepoRelativeFilePath(entry.slice(3), repoRoot);
257
+ if (normalizedPath) {
258
+ changedFiles.add(normalizedPath);
259
+ }
260
+
261
+ if (status.includes('R') || status.includes('C')) {
262
+ i += 1;
263
+ }
264
+ }
265
+
266
+ return { repoRoot, changedFiles };
267
+ } catch {
268
+ return null;
269
+ }
270
+ }
271
+
272
+ function createFileChangeCheckError(message, changedFiles, rationalePaths = []) {
273
+ const changedPreview = [...changedFiles].slice(0, 10).join(', ');
274
+ const rationalePreview = rationalePaths.slice(0, 10).join(', ');
275
+
276
+ return new Error(
277
+ `${message}\n` +
278
+ `Overlord persists file changes through \`changeRationales\`, not \`file_changes\` artifacts.\n` +
279
+ `Re-run with --change-rationales-json or --change-rationales-file, or pass --skip-file-change-check if this was intentional.` +
280
+ `${changedPreview ? `\nChanged files: ${changedPreview}${changedFiles.size > 10 ? ', ...' : ''}` : ''}` +
281
+ `${rationalePreview ? `\nProvided rationale paths: ${rationalePreview}${rationalePaths.length > 10 ? ', ...' : ''}` : ''}`
282
+ );
283
+ }
284
+
285
+ function validateDeliverFileChanges(flags, changeRationales) {
286
+ if (flags['skip-file-change-check']) return;
287
+
288
+ const gitState = getGitChangedFiles();
289
+ if (!gitState || gitState.changedFiles.size === 0) return;
290
+
291
+ const rationalePaths = changeRationales
292
+ .map(rationale => normalizeRepoRelativeFilePath(rationale?.file_path, gitState.repoRoot))
293
+ .filter(Boolean);
294
+
295
+ if (rationalePaths.length === 0) {
296
+ throw createFileChangeCheckError(
297
+ 'Git shows changed files in this workspace, but this delivery did not include matching `changeRationales`.',
298
+ gitState.changedFiles
299
+ );
300
+ }
301
+
302
+ const hasMatch = rationalePaths.some(filePath => gitState.changedFiles.has(filePath));
303
+ if (!hasMatch) {
304
+ throw createFileChangeCheckError(
305
+ 'Git shows changed files in this workspace, but none of the supplied `changeRationales.file_path` entries match them.',
306
+ gitState.changedFiles,
307
+ rationalePaths
308
+ );
309
+ }
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // Auto-detect native agent session IDs
314
+ // ---------------------------------------------------------------------------
315
+
316
+ /**
317
+ * Attempt to detect the current Claude Code session ID by finding the most
318
+ * recently modified .jsonl conversation file in ~/.claude/projects/<project>/.
319
+ * Returns the UUID or null if detection fails.
320
+ */
321
+ function detectClaudeSessionId() {
322
+ try {
323
+ const cwd = process.cwd();
324
+ const projectDir = cwd.replace(/\//g, '-');
325
+ const sessionsDir = path.join(os.homedir(), '.claude', 'projects', projectDir);
326
+
327
+ if (!fs.existsSync(sessionsDir)) return null;
328
+
329
+ const files = fs.readdirSync(sessionsDir)
330
+ .filter(f => f.endsWith('.jsonl'))
331
+ .map(f => ({
332
+ name: f,
333
+ mtime: fs.statSync(path.join(sessionsDir, f)).mtimeMs
334
+ }))
335
+ .sort((a, b) => b.mtime - a.mtime);
336
+
337
+ if (files.length === 0) return null;
338
+
339
+ const uuid = files[0].name.replace('.jsonl', '');
340
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(uuid)) {
341
+ return uuid;
342
+ }
343
+ return null;
344
+ } catch {
345
+ return null;
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Codex exposes the active resumable thread id directly in the runtime
351
+ * environment, so prefer that over filesystem heuristics.
352
+ */
353
+ function detectCodexSessionId() {
354
+ const sessionId = process.env.CODEX_THREAD_ID?.trim() || process.env.CODEX_SESSION_ID?.trim();
355
+ return sessionId || null;
356
+ }
357
+
358
+ /**
359
+ * Resolve the external session ID from flags, env, or auto-detection.
360
+ * Priority: explicit flag > env var > auto-detect.
361
+ */
362
+ function resolveExternalSessionId(flags) {
363
+ if (flags['external-session-id']) {
364
+ const val = String(flags['external-session-id']).trim();
365
+ return val.toLowerCase() === 'null' ? null : val;
366
+ }
367
+
368
+ const agentId = String(flags.agent ?? process.env.AGENT_IDENTIFIER ?? '').toLowerCase();
369
+ if (agentId.includes('codex')) {
370
+ const detected = detectCodexSessionId();
371
+ if (detected) return detected;
372
+ }
373
+
374
+ if (agentId.includes('claude') || agentId === '' || agentId === 'claude-code') {
375
+ const detected = detectClaudeSessionId();
376
+ if (detected) return detected;
377
+ }
378
+
379
+ return undefined; // undefined = omit from payload
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // attach
384
+ // ---------------------------------------------------------------------------
385
+
386
+ async function protocolAttach(args) {
387
+ const flags = parseFlags(args);
388
+ const ticketId = requireFlag(flags, 'ticket-id', 'TICKET_ID');
389
+ const { platformUrl, agentToken, localSecret } = resolveAuth();
390
+ const timeoutMs = resolveTimeout(flags);
391
+
392
+ const externalSessionId = resolveExternalSessionId(flags);
393
+
394
+ const body = {
395
+ ticketId,
396
+ agentIdentifier: String(flags.agent ?? process.env.AGENT_IDENTIFIER ?? 'claude-code'),
397
+ connectionMethod: String(flags.method ?? 'cli'),
398
+ ...(externalSessionId !== undefined ? { externalSessionId } : {}),
399
+ metadata: {
400
+ cwd: process.cwd()
401
+ }
402
+ };
403
+
404
+ const data = await apiPost(
405
+ platformUrl,
406
+ agentToken,
407
+ localSecret,
408
+ '/api/protocol/attach',
409
+ body,
410
+ timeoutMs
411
+ );
412
+
413
+ const sessionKey = data.session?.sessionKey;
414
+ console.log(JSON.stringify(data, null, 2));
415
+
416
+ if (sessionKey) {
417
+ // Emit a machine-readable line for easy shell capture:
418
+ // SESSION_KEY=$(ovld protocol attach --ticket-id ... | grep ^SESSION_KEY= | cut -d= -f2)
419
+ process.stderr.write(`\nSESSION_KEY=${sessionKey}\n`);
420
+ }
421
+ }
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // update
425
+ // ---------------------------------------------------------------------------
426
+
427
+ async function protocolUpdate(args) {
428
+ const flags = parseFlags(args);
429
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
430
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
431
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
432
+ const summary = flags['summary-file']
433
+ ? readTextFile(String(flags['summary-file']), '--summary-file')
434
+ : requireFlag(flags, 'summary', undefined);
435
+
436
+ const { platformUrl, agentToken, localSecret } = resolveAuth();
437
+ const timeoutMs = resolveTimeout(flags);
438
+ const changeRationales = await resolveChangeRationales(flags);
439
+ const externalSessionId = resolveExternalSessionId(flags);
440
+
441
+ const body = {
442
+ sessionKey,
443
+ ticketId,
444
+ summary,
445
+ ...(externalSessionId !== undefined ? { externalSessionId } : {}),
446
+ ...(flags['external-url']
447
+ ? {
448
+ externalUrl:
449
+ String(flags['external-url']).trim().toLowerCase() === 'null'
450
+ ? null
451
+ : String(flags['external-url'])
452
+ }
453
+ : {}),
454
+ ...(flags.phase ? { phase: String(flags.phase) } : {}),
455
+ ...(flags['event-type'] ? { eventType: String(flags['event-type']) } : {}),
456
+ ...(flags['payload-json'] ? { payload: JSON.parse(String(flags['payload-json'])) } : {}),
457
+ ...(changeRationales.length > 0 ? { changeRationales } : {})
458
+ };
459
+
460
+ const data = await apiPost(
461
+ platformUrl,
462
+ agentToken,
463
+ localSecret,
464
+ '/api/protocol/update',
465
+ body,
466
+ timeoutMs
467
+ );
468
+ console.log(JSON.stringify(data, null, 2));
469
+ }
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // record-change-rationales
473
+ // ---------------------------------------------------------------------------
474
+
475
+ async function protocolRecordChangeRationales(args) {
476
+ const flags = parseFlags(args);
477
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
478
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
479
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
480
+
481
+ const changeRationales = await resolveChangeRationales(flags);
482
+ if (changeRationales.length === 0) {
483
+ throw new Error(
484
+ 'Provide at least one rationale with --change-rationales-json or --change-rationales-file'
485
+ );
486
+ }
487
+
488
+ const { platformUrl, agentToken, localSecret } = resolveAuth();
489
+ const timeoutMs = resolveTimeout(flags);
490
+
491
+ const body = {
492
+ sessionKey,
493
+ ticketId,
494
+ changeRationales,
495
+ ...(flags['summary-file']
496
+ ? { summary: readTextFile(String(flags['summary-file']), '--summary-file') }
497
+ : flags.summary
498
+ ? { summary: String(flags.summary) }
499
+ : {}),
500
+ ...(flags.phase ? { phase: String(flags.phase) } : {})
501
+ };
502
+
503
+ const data = await apiPost(
504
+ platformUrl,
505
+ agentToken,
506
+ localSecret,
507
+ '/api/protocol/change-rationales',
508
+ body,
509
+ timeoutMs
510
+ );
511
+ console.log(JSON.stringify(data, null, 2));
512
+ }
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // ask
516
+ // ---------------------------------------------------------------------------
517
+
518
+ async function protocolAsk(args) {
519
+ const flags = parseFlags(args);
520
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
521
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
522
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
523
+ const question = flags['question-file']
524
+ ? readTextFile(String(flags['question-file']), '--question-file')
525
+ : requireFlag(flags, 'question', undefined);
526
+
527
+ const { platformUrl, agentToken, localSecret } = resolveAuth();
528
+ const timeoutMs = resolveTimeout(flags);
529
+
530
+ const body = {
531
+ sessionKey,
532
+ ticketId,
533
+ question,
534
+ ...(flags.phase ? { phase: String(flags.phase) } : {}),
535
+ ...(flags['payload-json'] ? { payload: JSON.parse(String(flags['payload-json'])) } : {})
536
+ };
537
+
538
+ const data = await apiPost(platformUrl, agentToken, localSecret, '/api/protocol/ask', body, timeoutMs);
539
+ console.log(JSON.stringify(data, null, 2));
540
+ }
541
+
542
+ // ---------------------------------------------------------------------------
543
+ // read-context
544
+ // ---------------------------------------------------------------------------
545
+
546
+ async function protocolReadContext(args) {
547
+ const flags = parseFlags(args);
548
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
549
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
550
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
551
+
552
+ const { platformUrl, agentToken, localSecret } = resolveAuth();
553
+ const timeoutMs = resolveTimeout(flags);
554
+
555
+ const body = {
556
+ sessionKey,
557
+ ticketId,
558
+ ...(flags.query ? { query: String(flags.query) } : {}),
559
+ ...(flags.limit ? { limit: parseInt(String(flags.limit), 10) } : {})
560
+ };
561
+
562
+ const data = await apiPost(
563
+ platformUrl,
564
+ agentToken,
565
+ localSecret,
566
+ '/api/protocol/read-context',
567
+ body,
568
+ timeoutMs
569
+ );
570
+ console.log(JSON.stringify(data, null, 2));
571
+ }
572
+
573
+ // ---------------------------------------------------------------------------
574
+ // write-context
575
+ // ---------------------------------------------------------------------------
576
+
577
+ async function protocolWriteContext(args) {
578
+ const flags = parseFlags(args);
579
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
580
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
581
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
582
+ const key = requireFlag(flags, 'key', undefined);
583
+
584
+ if (flags.value === undefined) {
585
+ throw new Error('--value is required');
586
+ }
587
+
588
+ let value;
589
+ try {
590
+ value = JSON.parse(String(flags.value));
591
+ } catch {
592
+ value = String(flags.value);
593
+ }
594
+
595
+ const { platformUrl, agentToken, localSecret } = resolveAuth();
596
+ const timeoutMs = resolveTimeout(flags);
597
+
598
+ const body = {
599
+ sessionKey,
600
+ ticketId,
601
+ key,
602
+ value,
603
+ ...(flags.tags ? { tags: String(flags.tags).split(',').map(t => t.trim()) } : {})
604
+ };
605
+
606
+ const data = await apiPost(
607
+ platformUrl,
608
+ agentToken,
609
+ localSecret,
610
+ '/api/protocol/write-context',
611
+ body,
612
+ timeoutMs
613
+ );
614
+ console.log(JSON.stringify(data, null, 2));
615
+ }
616
+
617
+ // ---------------------------------------------------------------------------
618
+ // deliver
619
+ // ---------------------------------------------------------------------------
620
+
621
+ async function protocolDeliver(args) {
622
+ const flags = parseFlags(args);
623
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
624
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
625
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
626
+ const deliverPayload = flags['payload-file']
627
+ ? readJsonFile(String(flags['payload-file']), '--payload-file')
628
+ : null;
629
+ const summary = deliverPayload?.summary ??
630
+ (flags['summary-file']
631
+ ? readTextFile(String(flags['summary-file']), '--summary-file')
632
+ : requireFlag(flags, 'summary', undefined));
633
+
634
+ const { platformUrl, agentToken, localSecret } = resolveAuth();
635
+ const timeoutMs = resolveTimeout(flags);
636
+
637
+ let artifacts = deliverPayload?.artifacts ?? [];
638
+ if (deliverPayload && flags['artifacts-file']) {
639
+ throw new Error('Use either --payload-file or --artifacts-file, not both');
640
+ }
641
+ if (deliverPayload && flags['artifacts-json']) {
642
+ throw new Error('Use either --payload-file or --artifacts-json, not both');
643
+ }
644
+ if (flags['artifacts-file']) {
645
+ artifacts = readJsonFile(String(flags['artifacts-file']), '--artifacts-file');
646
+ } else if (flags['artifacts-json']) {
647
+ try {
648
+ artifacts = JSON.parse(String(flags['artifacts-json']));
649
+ } catch {
650
+ throw new Error('--artifacts-json must be valid JSON');
651
+ }
652
+ }
653
+
654
+ if (deliverPayload && (flags['change-rationales-file'] || flags['change-rationales-json'])) {
655
+ throw new Error('Use either --payload-file or change-rationale flags, not both');
656
+ }
657
+
658
+ const changeRationales = deliverPayload?.changeRationales ?? await resolveChangeRationales(flags);
659
+ validateDeliverFileChanges(flags, changeRationales);
660
+
661
+ const body = {
662
+ sessionKey,
663
+ ticketId,
664
+ summary,
665
+ artifacts,
666
+ ...(changeRationales.length > 0 ? { changeRationales } : {})
667
+ };
668
+
669
+ const data = await apiPost(
670
+ platformUrl,
671
+ agentToken,
672
+ localSecret,
673
+ '/api/protocol/deliver',
674
+ body,
675
+ timeoutMs
676
+ );
677
+ console.log(JSON.stringify(data, null, 2));
678
+ }
679
+
680
+ // ---------------------------------------------------------------------------
681
+ // artifacts
682
+ // ---------------------------------------------------------------------------
683
+
684
+ async function protocolArtifactPrepareUpload(args) {
685
+ const flags = parseFlags(args);
686
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
687
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
688
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
689
+ const fileName = requireFlag(flags, 'file-name', undefined);
690
+
691
+ const { platformUrl, agentToken, localSecret } = resolveAuth();
692
+ const timeoutMs = resolveTimeout(flags);
693
+
694
+ const body = {
695
+ sessionKey,
696
+ ticketId,
697
+ fileName,
698
+ ...(flags.label ? { label: String(flags.label) } : {}),
699
+ ...(flags['artifact-type'] ? { artifactType: String(flags['artifact-type']) } : {}),
700
+ ...(flags['content-type'] ? { contentType: String(flags['content-type']) } : {}),
701
+ ...(flags['file-size'] ? { fileSize: parseInt(String(flags['file-size']), 10) } : {}),
702
+ ...(flags['metadata-json'] ? { metadata: JSON.parse(String(flags['metadata-json'])) } : {})
703
+ };
704
+
705
+ const data = await apiPost(
706
+ platformUrl,
707
+ agentToken,
708
+ localSecret,
709
+ '/api/protocol/artifacts/prepare-upload',
710
+ body,
711
+ timeoutMs
712
+ );
713
+ console.log(JSON.stringify(data, null, 2));
714
+ }
715
+
716
+ async function protocolArtifactFinalizeUpload(args) {
717
+ const flags = parseFlags(args);
718
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
719
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
720
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
721
+ const storagePath = requireFlag(flags, 'storage-path', undefined);
722
+ const label = requireFlag(flags, 'label', undefined);
723
+
724
+ const { platformUrl, agentToken, localSecret } = resolveAuth();
725
+ const timeoutMs = resolveTimeout(flags);
726
+
727
+ const body = {
728
+ sessionKey,
729
+ ticketId,
730
+ storagePath,
731
+ label,
732
+ ...(flags['artifact-type'] ? { artifactType: String(flags['artifact-type']) } : {}),
733
+ ...(flags['content-type'] ? { contentType: String(flags['content-type']) } : {}),
734
+ ...(flags['file-size'] ? { fileSize: parseInt(String(flags['file-size']), 10) } : {}),
735
+ ...(flags['metadata-json'] ? { metadata: JSON.parse(String(flags['metadata-json'])) } : {})
736
+ };
737
+
738
+ const data = await apiPost(
739
+ platformUrl,
740
+ agentToken,
741
+ localSecret,
742
+ '/api/protocol/artifacts/finalize-upload',
743
+ body,
744
+ timeoutMs
745
+ );
746
+ console.log(JSON.stringify(data, null, 2));
747
+ }
748
+
749
+ async function protocolArtifactGetDownloadUrl(args) {
750
+ const flags = parseFlags(args);
751
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
752
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
753
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
754
+ if (!flags['artifact-id'] && !flags['storage-path']) {
755
+ throw new Error('--artifact-id or --storage-path is required');
756
+ }
757
+
758
+ const { platformUrl, agentToken, localSecret } = resolveAuth();
759
+ const timeoutMs = resolveTimeout(flags);
760
+
761
+ const body = {
762
+ sessionKey,
763
+ ticketId,
764
+ ...(flags['artifact-id'] ? { artifactId: String(flags['artifact-id']) } : {}),
765
+ ...(flags['storage-path'] ? { storagePath: String(flags['storage-path']) } : {}),
766
+ ...(flags['expires-in'] ? { expiresIn: parseInt(String(flags['expires-in']), 10) } : {})
767
+ };
768
+
769
+ const data = await apiPost(
770
+ platformUrl,
771
+ agentToken,
772
+ localSecret,
773
+ '/api/protocol/artifacts/get-download-url',
774
+ body,
775
+ timeoutMs
776
+ );
777
+ console.log(JSON.stringify(data, null, 2));
778
+ }
779
+
780
+ async function protocolArtifactUploadFile(args) {
781
+ const flags = parseFlags(args);
782
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
783
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
784
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
785
+ const filePath = requireFlag(flags, 'file', undefined);
786
+
787
+ const { readFile, stat } = await import('node:fs/promises');
788
+ const path = await import('node:path');
789
+ const fileName = String(flags['file-name'] ?? path.basename(filePath));
790
+ const contentType = String(flags['content-type'] ?? 'application/octet-stream');
791
+ const label = String(flags.label ?? fileName);
792
+
793
+ const fileStats = await stat(filePath);
794
+ const fileBytes = await readFile(filePath);
795
+
796
+ const { platformUrl, agentToken, localSecret } = resolveAuth();
797
+ const timeoutMs = resolveTimeout(flags);
798
+
799
+ const prepared = await apiPost(
800
+ platformUrl,
801
+ agentToken,
802
+ localSecret,
803
+ '/api/protocol/artifacts/prepare-upload',
804
+ {
805
+ sessionKey,
806
+ ticketId,
807
+ fileName,
808
+ label,
809
+ artifactType: String(flags['artifact-type'] ?? 'document'),
810
+ contentType,
811
+ fileSize: fileStats.size,
812
+ ...(flags['metadata-json'] ? { metadata: JSON.parse(String(flags['metadata-json'])) } : {})
813
+ },
814
+ timeoutMs
815
+ );
816
+
817
+ const uploadUrl = prepared?.upload?.url;
818
+ const storagePath = prepared?.draft?.storagePath;
819
+ if (!uploadUrl || !storagePath) {
820
+ throw new Error('Prepare upload response missing upload URL or storagePath.');
821
+ }
822
+
823
+ await uploadToSignedUrl(uploadUrl, fileBytes, contentType, timeoutMs);
824
+
825
+ const finalized = await apiPost(
826
+ platformUrl,
827
+ agentToken,
828
+ localSecret,
829
+ '/api/protocol/artifacts/finalize-upload',
830
+ {
831
+ sessionKey,
832
+ ticketId,
833
+ storagePath,
834
+ label,
835
+ artifactType: String(flags['artifact-type'] ?? 'document'),
836
+ contentType,
837
+ fileSize: fileStats.size,
838
+ ...(flags['metadata-json'] ? { metadata: JSON.parse(String(flags['metadata-json'])) } : {})
839
+ },
840
+ timeoutMs
841
+ );
842
+
843
+ console.log(JSON.stringify(finalized, null, 2));
844
+ }
845
+
846
+ // ---------------------------------------------------------------------------
847
+ // discover-project (resolve project from working directory)
848
+ // ---------------------------------------------------------------------------
849
+
850
+ async function protocolDiscoverProject(args) {
851
+ const flags = parseFlags(args);
852
+ const { platformUrl, agentToken, localSecret } = resolveAuth();
853
+ const timeoutMs = resolveTimeout(flags);
854
+
855
+ const workingDirectory = String(flags['working-directory'] ?? process.cwd());
856
+
857
+ const data = await apiPost(
858
+ platformUrl,
859
+ agentToken,
860
+ localSecret,
861
+ '/api/protocol/discover-project',
862
+ { workingDirectory },
863
+ timeoutMs
864
+ );
865
+ console.log(JSON.stringify(data, null, 2));
866
+
867
+ if (data.project?.id) {
868
+ process.stderr.write(`\nPROJECT_ID=${data.project.id}\n`);
869
+ }
870
+ }
871
+
872
+ // ---------------------------------------------------------------------------
873
+ // connect (lightweight session, no context returned)
874
+ // ---------------------------------------------------------------------------
875
+
876
+ async function protocolConnect(args) {
877
+ const flags = parseFlags(args);
878
+ const ticketId = requireFlag(flags, 'ticket-id', 'TICKET_ID');
879
+ const { platformUrl, agentToken, localSecret } = resolveAuth();
880
+ const timeoutMs = resolveTimeout(flags);
881
+
882
+ const body = {
883
+ ticketId,
884
+ agentIdentifier: String(flags.agent ?? process.env.AGENT_IDENTIFIER ?? 'claude-code'),
885
+ connectionMethod: String(flags.method ?? 'cli'),
886
+ metadata: {}
887
+ };
888
+
889
+ const data = await apiPost(
890
+ platformUrl,
891
+ agentToken,
892
+ localSecret,
893
+ '/api/protocol/connect',
894
+ body,
895
+ timeoutMs
896
+ );
897
+
898
+ const sessionKey = data.session?.sessionKey;
899
+ console.log(JSON.stringify(data, null, 2));
900
+
901
+ if (sessionKey) {
902
+ process.stderr.write(`\nSESSION_KEY=${sessionKey}\n`);
903
+ }
904
+ }
905
+
906
+ // ---------------------------------------------------------------------------
907
+ // load-context (read-only ticket fetch, no session)
908
+ // ---------------------------------------------------------------------------
909
+
910
+ async function protocolLoadContext(args) {
911
+ const flags = parseFlags(args);
912
+ const ticketId = requireFlag(flags, 'ticket-id', 'TICKET_ID');
913
+ const { platformUrl, agentToken, localSecret } = resolveAuth();
914
+ const timeoutMs = resolveTimeout(flags);
915
+
916
+ const body = { ticketId };
917
+
918
+ const data = await apiPost(
919
+ platformUrl,
920
+ agentToken,
921
+ localSecret,
922
+ '/api/protocol/load-context',
923
+ body,
924
+ timeoutMs
925
+ );
926
+ console.log(JSON.stringify(data, null, 2));
927
+ }
928
+
929
+ // ---------------------------------------------------------------------------
930
+ // spawn (create ticket + connect in one call)
931
+ // ---------------------------------------------------------------------------
932
+
933
+ async function protocolSpawn(args) {
934
+ const flags = parseFlags(args);
935
+ const objective = requireFlag(flags, 'objective', undefined);
936
+ const { platformUrl, agentToken, localSecret } = resolveAuth();
937
+ const timeoutMs = resolveTimeout(flags);
938
+
939
+ // When --project-id is not provided, auto-send cwd as workingDirectory
940
+ // so the server can resolve the project from the local_working_directory setting.
941
+ const workingDirectory = flags['working-directory'] ?? (!flags['project-id'] ? process.cwd() : undefined);
942
+
943
+ const body = {
944
+ objective,
945
+ agentIdentifier: String(flags.agent ?? process.env.AGENT_IDENTIFIER ?? 'claude-code'),
946
+ connectionMethod: String(flags.method ?? 'cli'),
947
+ metadata: {},
948
+ ...(flags.title ? { title: String(flags.title) } : {}),
949
+ ...(flags.priority ? { priority: String(flags.priority) } : {}),
950
+ ...(flags['project-id'] ? { projectId: String(flags['project-id']) } : {}),
951
+ ...(workingDirectory ? { workingDirectory: String(workingDirectory) } : {}),
952
+ ...(flags['acceptance-criteria'] ? { acceptanceCriteria: String(flags['acceptance-criteria']) } : {}),
953
+ ...(flags['available-tools'] ? { availableTools: String(flags['available-tools']) } : {}),
954
+ ...(flags['execution-target'] ? { executionTarget: String(flags['execution-target']) } : {}),
955
+ ...(flags.delegate ? { delegate: String(flags.delegate) } : {}),
956
+ ...(flags['parent-session-key'] ? { parentSessionKey: String(flags['parent-session-key']) } : {}),
957
+ ...(flags['parent-ticket-id'] ? { parentTicketId: String(flags['parent-ticket-id'] ?? process.env.TICKET_ID ?? '') } : {})
958
+ };
959
+
960
+ const data = await apiPost(
961
+ platformUrl,
962
+ agentToken,
963
+ localSecret,
964
+ '/api/protocol/spawn',
965
+ body,
966
+ timeoutMs
967
+ );
968
+
969
+ const sessionKey = data.session?.sessionKey;
970
+ const ticketId = data.ticket?.id;
971
+ console.log(JSON.stringify(data, null, 2));
972
+
973
+ if (sessionKey) {
974
+ process.stderr.write(`\nSESSION_KEY=${sessionKey}\n`);
975
+ }
976
+ if (ticketId) {
977
+ process.stderr.write(`TICKET_ID=${ticketId}\n`);
978
+ }
979
+ }
980
+
981
+ // ---------------------------------------------------------------------------
982
+ // Router
983
+ // ---------------------------------------------------------------------------
984
+
985
+ export async function runProtocolCommand(subcommand, args) {
986
+ if (!subcommand || subcommand === 'help' || subcommand === '--help') {
987
+ console.log(`ovld protocol <subcommand> [flags]
988
+
989
+ Use this for agent workflow on a ticket: create one with \`ovld protocol spawn\`,
990
+ attach with \`ovld protocol attach --ticket-id <id>\`, then begin executing with
991
+ \`ovld protocol update --phase execute\`.
992
+
993
+ Project discovery:
994
+ When spawning or creating tickets, the CLI automatically resolves the correct
995
+ project by matching your current working directory against each project's
996
+ configured "Local working directory" (set in Project Settings in the Overlord UI).
997
+ You can also discover the project explicitly:
998
+
999
+ ovld protocol discover-project
1000
+ ovld protocol discover-project --working-directory /path/to/repo
1001
+
1002
+ Use --project-id to override automatic resolution on spawn or ticket creation.
1003
+
1004
+ Subcommands:
1005
+ discover-project Resolve a project from the current working directory
1006
+ attach Start a ticket session and return full working context
1007
+ connect Start a lightweight session without full context
1008
+ load-context Read ticket context without creating a session
1009
+ spawn Create a follow-up ticket and attach to it immediately
1010
+ update Post progress, activity events, and optional change rationales
1011
+ record-change-rationales Persist structured change rationales without a progress update
1012
+ ask Post a blocking question and move the ticket to review
1013
+ read-context Read shared persistent context for this ticket
1014
+ write-context Write shared persistent context for future sessions
1015
+ deliver Finish work, send artifacts, and move the ticket to review
1016
+ artifact-prepare-upload Get a signed upload URL for a ticket artifact
1017
+ artifact-finalize-upload Finalize an uploaded artifact row after storage upload
1018
+ artifact-download-url Get a signed download URL for an existing artifact
1019
+ artifact-upload-file Prepare, upload, and finalize a local file in one command
1020
+
1021
+ Environment fallback:
1022
+ --session-key <- SESSION_KEY
1023
+ --ticket-id <- TICKET_ID
1024
+ auth/host <- OVERLORD_URL, AGENT_TOKEN
1025
+ --timeout <- OVERLORD_TIMEOUT
1026
+
1027
+ Common flags:
1028
+ --timeout <ms> Request timeout in milliseconds (default: ${DEFAULT_TIMEOUT_MS})
1029
+ --ticket-id <id> Ticket id when the subcommand operates on an existing ticket
1030
+ --session-key <key> Session key returned by attach/connect/spawn
1031
+ --agent <identifier> Agent identifier sent to Overlord (default: AGENT_IDENTIFIER or claude-code)
1032
+ --method <connectionMethod> Connection method sent to Overlord (default: cli)
1033
+
1034
+ discover-project:
1035
+ Purpose:
1036
+ Resolve the Overlord project that corresponds to the current (or given) working directory.
1037
+ Uses each project's "Local working directory" setting for matching.
1038
+ Optional:
1039
+ --working-directory <path> Directory to match (default: current working directory)
1040
+ Returns:
1041
+ Project JSON with id, name, organizationId. Prints PROJECT_ID=<id> on stderr.
1042
+ Notes:
1043
+ Set the local working directory for a project in the Overlord UI under Project Settings.
1044
+ When no match is found, returns a 404 with a hint.
1045
+
1046
+ attach:
1047
+ Purpose:
1048
+ Create the working session for an agent on an existing ticket. This is the normal first call.
1049
+ Required:
1050
+ --ticket-id <id>
1051
+ Optional:
1052
+ --agent <identifier>
1053
+ --method <connectionMethod>
1054
+ --external-session-id <id|null> Store the native agent thread/session id, or clear it with null
1055
+ Returns:
1056
+ Full JSON including session.sessionKey, ticket, history, artifacts, sharedState, and promptContext
1057
+ Notes:
1058
+ If --external-session-id is omitted, the CLI may auto-detect Codex or Claude session ids
1059
+
1060
+ connect:
1061
+ Purpose:
1062
+ Create a lightweight session when you only need a session key and not the full ticket payload
1063
+ Required:
1064
+ --ticket-id <id>
1065
+ Optional:
1066
+ --agent <identifier>
1067
+ --method <connectionMethod>
1068
+ Returns:
1069
+ Session JSON and SESSION_KEY on stderr when available
1070
+
1071
+ load-context:
1072
+ Purpose:
1073
+ Read ticket details without creating a session
1074
+ Required:
1075
+ --ticket-id <id>
1076
+
1077
+ update:
1078
+ Purpose:
1079
+ Post progress or activity events during execution
1080
+ Required:
1081
+ --session-key <key>
1082
+ --ticket-id <id>
1083
+ --summary <text> or --summary-file <path>
1084
+ Optional:
1085
+ --phase <status> draft | execute | review | deliver | complete | blocked | cancelled
1086
+ --event-type <type> update | user_follow_up | alert
1087
+ --payload-json <json> Additional structured payload, for example notifications
1088
+ --external-url <url|null> Store or clear a deep link to the live agent session
1089
+ --external-session-id <id|null>
1090
+ --change-rationales-json <json>
1091
+ --change-rationales-file <path>
1092
+ Notes:
1093
+ Use phase=execute while actively working. user_follow_up is for verbatim human follow-up messages.
1094
+
1095
+ record-change-rationales:
1096
+ Purpose:
1097
+ Persist structured file-change rationale records without also posting a normal update
1098
+ Required:
1099
+ --session-key <key>
1100
+ --ticket-id <id>
1101
+ --change-rationales-json <json> or --change-rationales-file <path>
1102
+ Optional:
1103
+ --summary <text> or --summary-file <path>
1104
+ --phase <status>
1105
+
1106
+ ask:
1107
+ Purpose:
1108
+ Raise a blocking question for a human reviewer/PM
1109
+ Required:
1110
+ --session-key <key>
1111
+ --ticket-id <id>
1112
+ --question <text> or --question-file <path>
1113
+ Optional:
1114
+ --phase <status>
1115
+ --payload-json <json>
1116
+ Notes:
1117
+ After ask succeeds, stop working until the human responds
1118
+
1119
+ read-context:
1120
+ Purpose:
1121
+ Read persistent shared context written by earlier sessions
1122
+ Required:
1123
+ --session-key <key>
1124
+ --ticket-id <id>
1125
+ Optional:
1126
+ --query <text> Filter by key substring
1127
+ --limit <n> Max entries to return
1128
+
1129
+ write-context:
1130
+ Purpose:
1131
+ Save shared facts for future sessions
1132
+ Required:
1133
+ --session-key <key>
1134
+ --ticket-id <id>
1135
+ --key <name>
1136
+ --value <json-or-string> Parsed as JSON first; stored as a string if JSON parsing fails
1137
+ Optional:
1138
+ --tags <csv>
1139
+
1140
+ deliver:
1141
+ Purpose:
1142
+ Conclude the session and submit the final narrative plus artifacts/change rationales
1143
+ Required:
1144
+ --session-key <key>
1145
+ --ticket-id <id>
1146
+ --summary <text> or --summary-file <path>
1147
+ or: --payload-file <path> containing { summary, artifacts, changeRationales }
1148
+ Optional:
1149
+ --artifacts-json <json>
1150
+ --artifacts-file <path>
1151
+ --change-rationales-json <json>
1152
+ --change-rationales-file <path>
1153
+ --skip-file-change-check Bypass local git vs changeRationales validation
1154
+ Notes:
1155
+ Do not combine --payload-file with --artifacts-json/--artifacts-file or change-rationale flags.
1156
+ In a git workspace, deliver validates that changed files are represented by changeRationales unless skipped.
1157
+
1158
+ spawn:
1159
+ Purpose:
1160
+ Create a follow-up ticket and attach to it in one call.
1161
+ When --project-id is omitted, automatically resolves the project from the
1162
+ current working directory (matching against each project's local_working_directory).
1163
+ Required:
1164
+ --objective <text>
1165
+ Optional:
1166
+ --title <text>
1167
+ --priority <level> low | medium | high | urgent
1168
+ --project-id <id> Explicit project; skips working-directory resolution
1169
+ --working-directory <path> Override cwd for project resolution (default: cwd)
1170
+ --acceptance-criteria <text>
1171
+ --available-tools <text>
1172
+ --execution-target <t> agent | human
1173
+ --delegate <model> Model or delegate identifier that created the ticket
1174
+ --parent-session-key <key>
1175
+ --parent-ticket-id <id>
1176
+ --agent <identifier>
1177
+ --method <connectionMethod>
1178
+ Returns:
1179
+ New ticket/session JSON plus SESSION_KEY and TICKET_ID on stderr when available
1180
+
1181
+ artifact-prepare-upload:
1182
+ Required:
1183
+ --session-key <key>
1184
+ --ticket-id <id>
1185
+ --file-name <name>
1186
+ Optional:
1187
+ --label <text>
1188
+ --artifact-type <type>
1189
+ --content-type <mime>
1190
+ --file-size <bytes>
1191
+ --metadata-json <json>
1192
+
1193
+ artifact-finalize-upload:
1194
+ Required:
1195
+ --session-key <key>
1196
+ --ticket-id <id>
1197
+ --storage-path <path>
1198
+ --label <text>
1199
+ Optional:
1200
+ --artifact-type <type>
1201
+ --content-type <mime>
1202
+ --file-size <bytes>
1203
+ --metadata-json <json>
1204
+
1205
+ artifact-download-url:
1206
+ Required:
1207
+ --session-key <key>
1208
+ --ticket-id <id>
1209
+ one of: --artifact-id <id> | --storage-path <path>
1210
+ Optional:
1211
+ --expires-in <seconds>
1212
+
1213
+ artifact-upload-file:
1214
+ Required:
1215
+ --session-key <key>
1216
+ --ticket-id <id>
1217
+ --file <path>
1218
+ Optional:
1219
+ --file-name <name> Defaults to basename of --file
1220
+ --label <text> Defaults to file name
1221
+ --artifact-type <type> Defaults to document
1222
+ --content-type <mime> Defaults to application/octet-stream
1223
+ --metadata-json <json>
1224
+
1225
+ Examples:
1226
+ ovld protocol discover-project
1227
+ ovld protocol discover-project --working-directory /path/to/repo
1228
+ ovld protocol spawn --objective "Implement feature X" # auto-resolves project from cwd
1229
+ ovld protocol attach --ticket-id abc-123
1230
+ ovld protocol attach --ticket-id abc-123 --external-session-id null
1231
+ ovld protocol connect --ticket-id abc-123
1232
+ ovld protocol load-context --ticket-id abc-123
1233
+ ovld protocol spawn --objective "Implement user auth" --priority high
1234
+ ovld protocol update --session-key <key> --ticket-id <id> --summary "Did X" --phase execute
1235
+ ovld protocol update --session-key <key> --ticket-id <id> --summary-file ./update.txt --event-type user_follow_up
1236
+ ovld protocol record-change-rationales --session-key <key> --ticket-id <id> --change-rationales-json '[{"label":"...","file_path":"...","summary":"...","why":"...","impact":"...","hunks":[{"header":"@@ ... @@"}]}]'
1237
+ ovld protocol ask --session-key <key> --ticket-id <id> --question-file ./question.txt
1238
+ ovld protocol read-context --session-key <key> --ticket-id <id> --query arch --limit 5
1239
+ ovld protocol write-context --session-key <key> --ticket-id <id> --key "arch" --value '"monorepo"' --tags repo,agent
1240
+ ovld protocol artifact-prepare-upload --session-key <key> --ticket-id <id> --file-name spec.pdf --content-type application/pdf
1241
+ ovld protocol artifact-upload-file --session-key <key> --ticket-id <id> --file ./spec.pdf --content-type application/pdf
1242
+ ovld protocol artifact-download-url --session-key <key> --ticket-id <id> --artifact-id <artifact-id>
1243
+ ovld protocol deliver --session-key <key> --ticket-id <id> --summary "Done"
1244
+ ovld protocol deliver --session-key <key> --ticket-id <id> --summary "Done" --artifacts-file ./artifacts.json
1245
+ ovld protocol deliver --session-key <key> --ticket-id <id> --payload-file ./deliver.json
1246
+ ovld protocol deliver --session-key <key> --ticket-id <id> --summary "Done" --skip-file-change-check
1247
+ ovld protocol deliver --session-key <key> --ticket-id <id> --summary "Done" --timeout 60000
1248
+ `);
1249
+ return;
1250
+ }
1251
+
1252
+ if (subcommand === 'discover-project') { await protocolDiscoverProject(args); return; }
1253
+ if (subcommand === 'attach') { await protocolAttach(args); return; }
1254
+ if (subcommand === 'connect') { await protocolConnect(args); return; }
1255
+ if (subcommand === 'load-context') { await protocolLoadContext(args); return; }
1256
+ if (subcommand === 'spawn') { await protocolSpawn(args); return; }
1257
+ if (subcommand === 'artifact-prepare-upload') { await protocolArtifactPrepareUpload(args); return; }
1258
+ if (subcommand === 'artifact-finalize-upload') { await protocolArtifactFinalizeUpload(args); return; }
1259
+ if (subcommand === 'artifact-download-url') { await protocolArtifactGetDownloadUrl(args); return; }
1260
+ if (subcommand === 'artifact-upload-file') { await protocolArtifactUploadFile(args); return; }
1261
+ if (subcommand === 'update') { await protocolUpdate(args); return; }
1262
+ if (subcommand === 'record-change-rationales') { await protocolRecordChangeRationales(args); return; }
1263
+ if (subcommand === 'ask') { await protocolAsk(args); return; }
1264
+ if (subcommand === 'read-context') { await protocolReadContext(args); return; }
1265
+ if (subcommand === 'write-context') { await protocolWriteContext(args); return; }
1266
+ if (subcommand === 'deliver') { await protocolDeliver(args); return; }
1267
+
1268
+ console.error(`Unknown protocol subcommand: ${subcommand}\n`);
1269
+ console.log('Run: ovld protocol help');
1270
+ process.exit(1);
1271
+ }