overlord-cli 0.2605101225.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 (42) hide show
  1. package/README.md +128 -0
  2. package/bin/_cli/attach.mjs +357 -0
  3. package/bin/_cli/auth.mjs +696 -0
  4. package/bin/_cli/cli-update.mjs +119 -0
  5. package/bin/_cli/credentials.mjs +723 -0
  6. package/bin/_cli/index.mjs +170 -0
  7. package/bin/_cli/launcher.mjs +474 -0
  8. package/bin/_cli/new-ticket.mjs +306 -0
  9. package/bin/_cli/postinstall.mjs +43 -0
  10. package/bin/_cli/protocol.mjs +1799 -0
  11. package/bin/_cli/setup.mjs +1634 -0
  12. package/bin/_cli/ticket.mjs +55 -0
  13. package/bin/_cli/tickets.mjs +121 -0
  14. package/bin/_cli/version.mjs +7 -0
  15. package/bin/ovld.mjs +13 -0
  16. package/package.json +36 -0
  17. package/plugins/claude/.claude-plugin/plugin.json +34 -0
  18. package/plugins/claude/README.md +42 -0
  19. package/plugins/claude/commands/connect.md +19 -0
  20. package/plugins/claude/commands/create.md +17 -0
  21. package/plugins/claude/commands/load.md +19 -0
  22. package/plugins/claude/commands/spawn.md +17 -0
  23. package/plugins/claude/hooks/hooks.json +15 -0
  24. package/plugins/claude/scripts/permission-hook.sh +21 -0
  25. package/plugins/claude/skills/overlord-ticket/SKILL.md +164 -0
  26. package/plugins/cursor/.cursor-plugin/plugin.json +14 -0
  27. package/plugins/cursor/commands/connect.md +3 -0
  28. package/plugins/cursor/commands/create.md +11 -0
  29. package/plugins/cursor/commands/load.md +3 -0
  30. package/plugins/cursor/commands/spawn.md +3 -0
  31. package/plugins/cursor/mcp.json +8 -0
  32. package/plugins/cursor/rules/overlord-local.mdc +10 -0
  33. package/plugins/cursor/scripts/overlord-mcp.mjs +150 -0
  34. package/plugins/cursor/skills/overlord-ticket/SKILL.md +49 -0
  35. package/plugins/overlord/.codex-plugin/plugin.json +35 -0
  36. package/plugins/overlord/.mcp.json +11 -0
  37. package/plugins/overlord/README.md +42 -0
  38. package/plugins/overlord/assets/icon.png +0 -0
  39. package/plugins/overlord/assets/logo.png +0 -0
  40. package/plugins/overlord/assets/screenshot.svg +28 -0
  41. package/plugins/overlord/scripts/overlord-mcp.mjs +688 -0
  42. package/plugins/overlord/skills/overlord-ticket/SKILL.md +146 -0
@@ -0,0 +1,1799 @@
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, getAuthStatus, 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
+ export function resolveProtocolAgentIdentifier(flags = {}) {
39
+ const explicitAgent = typeof flags.agent === 'string' ? flags.agent.trim() : '';
40
+ if (explicitAgent) return explicitAgent;
41
+
42
+ const envAgent = process.env.AGENT_IDENTIFIER?.trim();
43
+ return envAgent || 'claude-code';
44
+ }
45
+
46
+ export function resolveProtocolTicketDelegate(
47
+ flags = {},
48
+ modelIdentifier = '',
49
+ agentIdentifier = ''
50
+ ) {
51
+ const explicitDelegate = typeof flags.delegate === 'string' ? flags.delegate.trim() : '';
52
+ if (explicitDelegate) return explicitDelegate;
53
+
54
+ const resolvedModel = typeof modelIdentifier === 'string' ? modelIdentifier.trim() : '';
55
+ if (resolvedModel) return resolvedModel;
56
+
57
+ const resolvedAgent = String(agentIdentifier).trim();
58
+ return resolvedAgent || null;
59
+ }
60
+
61
+ export function resolveProtocolModelIdentifier(flags = {}) {
62
+ const explicitModel = typeof flags.model === 'string' ? flags.model.trim() : '';
63
+ if (explicitModel) return explicitModel;
64
+
65
+ const envModel =
66
+ process.env.OVERLORD_MODEL_IDENTIFIER?.trim() ||
67
+ process.env.MODEL_IDENTIFIER?.trim() ||
68
+ process.env.AGENT_MODEL?.trim();
69
+ return envModel || null;
70
+ }
71
+
72
+ function resolveProtocolMetadata(flags = {}, base = {}) {
73
+ const metadata = { ...base };
74
+
75
+ if (flags['metadata-json']) {
76
+ const parsed = parseJsonFlag('--metadata-json', flags['metadata-json']);
77
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
78
+ throw new Error('--metadata-json must be a JSON object');
79
+ }
80
+ Object.assign(metadata, parsed);
81
+ }
82
+
83
+ const modelIdentifier = resolveProtocolModelIdentifier(flags);
84
+ if (modelIdentifier) {
85
+ metadata.model = modelIdentifier;
86
+ }
87
+
88
+ return metadata;
89
+ }
90
+
91
+ /**
92
+ * Default request timeout in milliseconds. Overridable via --timeout flag or
93
+ * OVERLORD_TIMEOUT env var. A bounded timeout prevents indefinite spinner hangs
94
+ * in sandboxed runtimes where deliver requests can stall without a connection error.
95
+ */
96
+ const DEFAULT_TIMEOUT_MS = 30000;
97
+
98
+ async function apiPost(
99
+ platformUrl,
100
+ token,
101
+ localSecret,
102
+ organizationId,
103
+ path,
104
+ body,
105
+ timeoutMs = DEFAULT_TIMEOUT_MS
106
+ ) {
107
+ const requestUrl = `${platformUrl}${path}`;
108
+ const requestStart = Date.now();
109
+ let res;
110
+ try {
111
+ res = await fetch(requestUrl, {
112
+ method: 'POST',
113
+ headers: {
114
+ ...buildAuthHeaders(token, localSecret, organizationId),
115
+ 'Content-Type': 'application/json'
116
+ },
117
+ body: JSON.stringify(body),
118
+ signal: AbortSignal.timeout(timeoutMs)
119
+ });
120
+ } catch (error) {
121
+ const message = error instanceof Error ? error.message : String(error);
122
+
123
+ // AbortSignal.timeout() throws a DOMException with name 'TimeoutError'
124
+ if (error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
125
+ throw new Error(
126
+ `Request timed out after ${timeoutMs}ms calling ${requestUrl}.\n` +
127
+ `Tip: Ensure Overlord is running and reachable from this environment. ` +
128
+ `Increase the limit with --timeout <ms> or OVERLORD_TIMEOUT=<ms>.`
129
+ );
130
+ }
131
+
132
+ const causeCode =
133
+ typeof error === 'object' &&
134
+ error !== null &&
135
+ 'cause' in error &&
136
+ typeof error.cause === 'object' &&
137
+ error.cause !== null &&
138
+ 'code' in error.cause
139
+ ? String(error.cause.code)
140
+ : '';
141
+
142
+ let hint = 'Check your network and Overlord server settings.';
143
+ if (causeCode === 'ECONNREFUSED') {
144
+ hint =
145
+ 'Connection refused. Verify Overlord is running and OVERLORD_URL points to the correct port. If this environment is sandboxed or network-restricted, request permission escalation before retrying.';
146
+ } else if (causeCode === 'ENOTFOUND') {
147
+ hint =
148
+ 'Host not found. Verify OVERLORD_URL uses a valid hostname. If this environment is sandboxed or network-restricted, request permission escalation before retrying.';
149
+ } else if (causeCode === 'ETIMEDOUT') {
150
+ hint =
151
+ 'Connection timed out. Verify server availability and local firewall/VPN settings. If this environment is sandboxed or network-restricted, request permission escalation before retrying.';
152
+ } else if (requestUrl.includes('localhost') || requestUrl.includes('127.0.0.1')) {
153
+ hint =
154
+ 'Local server unreachable. Start Overlord (usually http://localhost:3000) or update OVERLORD_URL. If this environment is sandboxed or network-restricted, request permission escalation before retrying.';
155
+ }
156
+
157
+ throw new Error(
158
+ `Network error calling ${requestUrl}: ${message}${causeCode ? ` (${causeCode})` : ''}\n${hint}`
159
+ );
160
+ }
161
+
162
+ const durationMs = Date.now() - requestStart;
163
+ process.stderr.write(`[protocol] ${path} → ${res.status} (${durationMs}ms)\n`);
164
+
165
+ const data = await res.json().catch(() => ({}));
166
+
167
+ if (res.status === 401) {
168
+ throw new Error(
169
+ `Authentication failed (401): ${data.error ?? 'Invalid or missing token.'}\n` +
170
+ `IMPORTANT: Stop all work immediately. Your Overlord auth session is invalid, expired, or missing required scope.\n` +
171
+ `First run \`ovld auth repair\` yourself.\n` +
172
+ `If repair does not fix it, ask the user to sign in again with Overlord Desktop or \`ovld auth login\` if needed.\n` +
173
+ `Then ask whether they would like to proceed without submitting updates to Overlord.`
174
+ );
175
+ }
176
+
177
+ if (!res.ok) {
178
+ throw new Error(`API error (${res.status}): ${data.error ?? JSON.stringify(data)}`);
179
+ }
180
+
181
+ return data;
182
+ }
183
+
184
+ async function uploadToSignedUrl(uploadUrl, bytes, contentType, timeoutMs = DEFAULT_TIMEOUT_MS) {
185
+ let res;
186
+ try {
187
+ res = await fetch(uploadUrl, {
188
+ method: 'PUT',
189
+ headers: {
190
+ 'Content-Type': contentType,
191
+ 'x-upsert': 'false'
192
+ },
193
+ body: bytes,
194
+ signal: AbortSignal.timeout(timeoutMs)
195
+ });
196
+ } catch (error) {
197
+ const message = error instanceof Error ? error.message : String(error);
198
+ if (error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
199
+ throw new Error(`Upload timed out after ${timeoutMs}ms.`);
200
+ }
201
+ throw new Error(`Upload failed: ${message}`);
202
+ }
203
+
204
+ if (!res.ok) {
205
+ const text = await res.text().catch(() => '');
206
+ throw new Error(`Upload failed (${res.status}): ${text || 'Unknown storage error.'}`);
207
+ }
208
+ }
209
+
210
+ /** Read SESSION_KEY and TICKET_ID from env if flags not provided */
211
+ function resolveSessionFlags(flags) {
212
+ return {
213
+ sessionKey: String(flags['session-key'] ?? process.env.SESSION_KEY ?? ''),
214
+ ticketId: String(flags['ticket-id'] ?? process.env.TICKET_ID ?? '')
215
+ };
216
+ }
217
+
218
+ /** Resolve request timeout from --timeout flag or OVERLORD_TIMEOUT env var. */
219
+ function resolveTimeout(flags) {
220
+ const raw = flags['timeout'] ?? process.env.OVERLORD_TIMEOUT;
221
+ if (raw) {
222
+ const ms = parseInt(String(raw), 10);
223
+ if (!isNaN(ms) && ms > 0) return ms;
224
+ }
225
+ return DEFAULT_TIMEOUT_MS;
226
+ }
227
+
228
+ function requireFlag(flags, name, envAlias) {
229
+ const value = flags[name] ?? (envAlias ? process.env[envAlias] : undefined);
230
+ if (!value) {
231
+ throw new Error(`--${name} is required (or set ${envAlias ?? name.toUpperCase()})`);
232
+ }
233
+ return String(value);
234
+ }
235
+
236
+ function parseJsonFlag(flagName, rawValue) {
237
+ try {
238
+ return JSON.parse(String(rawValue));
239
+ } catch (err) {
240
+ const detail = err instanceof Error ? err.message : String(err);
241
+ throw new Error(`${flagName} must be valid JSON: ${detail}`);
242
+ }
243
+ }
244
+
245
+ function readTextFile(filePath, label) {
246
+ try {
247
+ return fs.readFileSync(filePath, 'utf8');
248
+ } catch (err) {
249
+ throw new Error(
250
+ `${label}: could not read "${filePath}": ${err instanceof Error ? err.message : String(err)}`
251
+ );
252
+ }
253
+ }
254
+
255
+ function readJsonFile(filePath, label) {
256
+ try {
257
+ return JSON.parse(readTextFile(filePath, label));
258
+ } catch (err) {
259
+ if (err instanceof Error && err.message.startsWith(`${label}: could not read`)) {
260
+ throw err;
261
+ }
262
+ throw new Error(
263
+ `${label}: could not parse "${filePath}": ${err instanceof Error ? err.message : String(err)}`
264
+ );
265
+ }
266
+ }
267
+
268
+ async function readTextFromStdin(label) {
269
+ const chunks = [];
270
+ try {
271
+ for await (const chunk of process.stdin) {
272
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
273
+ }
274
+ } catch (err) {
275
+ throw new Error(
276
+ `${label}: could not read stdin: ${err instanceof Error ? err.message : String(err)}`
277
+ );
278
+ }
279
+ return Buffer.concat(chunks).toString('utf8');
280
+ }
281
+
282
+ async function readJsonFileOrStdin(filePath, label) {
283
+ if (filePath !== '-') {
284
+ return readJsonFile(filePath, label);
285
+ }
286
+
287
+ try {
288
+ return JSON.parse(await readTextFromStdin(label));
289
+ } catch (err) {
290
+ if (err instanceof Error && err.message.startsWith(`${label}: could not read stdin`)) {
291
+ throw err;
292
+ }
293
+ throw new Error(
294
+ `${label}: could not parse stdin: ${err instanceof Error ? err.message : String(err)}`
295
+ );
296
+ }
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // changeRationales helper
301
+ // ---------------------------------------------------------------------------
302
+
303
+ /**
304
+ * Resolve changeRationales from --change-rationales-json or --change-rationales-file flags.
305
+ * @param {Record<string, string | boolean>} flags
306
+ * @returns {Promise<Array<object>>}
307
+ */
308
+ async function resolveChangeRationales(flags) {
309
+ if (flags['change-rationales-file']) {
310
+ return await readJsonFileOrStdin(
311
+ String(flags['change-rationales-file']),
312
+ '--change-rationales-file'
313
+ );
314
+ }
315
+ if (flags['change-rationales-json']) {
316
+ return parseJsonFlag('--change-rationales-json', flags['change-rationales-json']);
317
+ }
318
+ return [];
319
+ }
320
+
321
+ function normalizeRepoRelativeFilePath(filePath, repoRoot) {
322
+ if (typeof filePath !== 'string') return null;
323
+
324
+ const trimmed = filePath.trim();
325
+ if (!trimmed) return null;
326
+
327
+ if (path.isAbsolute(trimmed)) {
328
+ const relative = path.relative(repoRoot, trimmed);
329
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
330
+ return null;
331
+ }
332
+ return relative.replaceAll(path.sep, '/');
333
+ }
334
+
335
+ return trimmed.replace(/^[.][/\\]+/, '').replaceAll('\\', '/');
336
+ }
337
+
338
+ function getGitChangedFiles() {
339
+ try {
340
+ const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
341
+ cwd: process.cwd(),
342
+ encoding: 'utf8',
343
+ stdio: ['ignore', 'pipe', 'pipe']
344
+ }).trim();
345
+
346
+ if (!repoRoot) return null;
347
+
348
+ const output = execFileSync('git', ['status', '--porcelain=v1', '-z'], {
349
+ cwd: repoRoot,
350
+ encoding: 'utf8',
351
+ stdio: ['ignore', 'pipe', 'pipe']
352
+ });
353
+
354
+ const changedFiles = new Set();
355
+ const entries = output.split('\0');
356
+
357
+ for (let i = 0; i < entries.length; i++) {
358
+ const entry = entries[i];
359
+ if (!entry) continue;
360
+
361
+ const status = entry.slice(0, 2);
362
+ const normalizedPath = normalizeRepoRelativeFilePath(entry.slice(3), repoRoot);
363
+ if (normalizedPath) {
364
+ changedFiles.add(normalizedPath);
365
+ }
366
+
367
+ if (status.includes('R') || status.includes('C')) {
368
+ i += 1;
369
+ }
370
+ }
371
+
372
+ return { repoRoot, changedFiles };
373
+ } catch {
374
+ return null;
375
+ }
376
+ }
377
+
378
+ function createFileChangeCheckError(message, changedFiles, rationalePaths = []) {
379
+ const changedPreview = [...changedFiles].slice(0, 10).join(', ');
380
+ const rationalePreview = rationalePaths.slice(0, 10).join(', ');
381
+
382
+ return new Error(
383
+ `${message}\n` +
384
+ `Overlord persists file changes through \`changeRationales\`, not \`file_changes\` artifacts.\n` +
385
+ `Re-run with --change-rationales-json or --change-rationales-file, or pass --skip-file-change-check if this was intentional.` +
386
+ `${changedPreview ? `\nChanged files: ${changedPreview}${changedFiles.size > 10 ? ', ...' : ''}` : ''}` +
387
+ `${rationalePreview ? `\nProvided rationale paths: ${rationalePreview}${rationalePaths.length > 10 ? ', ...' : ''}` : ''}`
388
+ );
389
+ }
390
+
391
+ function validateDeliverFileChanges(flags, changeRationales) {
392
+ if (flags['skip-file-change-check']) return;
393
+
394
+ const gitState = getGitChangedFiles();
395
+ if (!gitState || gitState.changedFiles.size === 0) return;
396
+
397
+ const rationalePaths = changeRationales
398
+ .map(rationale => normalizeRepoRelativeFilePath(rationale?.file_path, gitState.repoRoot))
399
+ .filter(Boolean);
400
+
401
+ if (rationalePaths.length === 0) {
402
+ throw createFileChangeCheckError(
403
+ 'Git shows changed files in this workspace, but this delivery did not include matching `changeRationales`.',
404
+ gitState.changedFiles
405
+ );
406
+ }
407
+
408
+ const hasMatch = rationalePaths.some(filePath => gitState.changedFiles.has(filePath));
409
+ if (!hasMatch) {
410
+ throw createFileChangeCheckError(
411
+ 'Git shows changed files in this workspace, but none of the supplied `changeRationales.file_path` entries match them.',
412
+ gitState.changedFiles,
413
+ rationalePaths
414
+ );
415
+ }
416
+ }
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // Auto-detect native agent session IDs
420
+ // ---------------------------------------------------------------------------
421
+
422
+ /**
423
+ * Attempt to detect the current Claude Code session ID by finding the most
424
+ * recently modified .jsonl conversation file in ~/.claude/projects/<project>/.
425
+ * Returns the UUID or null if detection fails.
426
+ */
427
+ function detectClaudeSessionId() {
428
+ try {
429
+ const cwd = process.cwd();
430
+ const projectDir = cwd.replace(/\//g, '-');
431
+ const sessionsDir = path.join(os.homedir(), '.claude', 'projects', projectDir);
432
+
433
+ if (!fs.existsSync(sessionsDir)) return null;
434
+
435
+ const files = fs
436
+ .readdirSync(sessionsDir)
437
+ .filter(f => f.endsWith('.jsonl'))
438
+ .map(f => ({
439
+ name: f,
440
+ mtime: fs.statSync(path.join(sessionsDir, f)).mtimeMs
441
+ }))
442
+ .sort((a, b) => b.mtime - a.mtime);
443
+
444
+ if (files.length === 0) return null;
445
+
446
+ const uuid = files[0].name.replace('.jsonl', '');
447
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(uuid)) {
448
+ return uuid;
449
+ }
450
+ return null;
451
+ } catch {
452
+ return null;
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Codex exposes the active resumable thread id directly in the runtime
458
+ * environment, so prefer that over filesystem heuristics.
459
+ */
460
+ function detectCodexSessionId() {
461
+ const sessionId = process.env.CODEX_THREAD_ID?.trim() || process.env.CODEX_SESSION_ID?.trim();
462
+ return sessionId || null;
463
+ }
464
+
465
+ /**
466
+ * Resolve the external session ID from flags, env, or auto-detection.
467
+ * Priority: explicit flag > env var > auto-detect.
468
+ */
469
+ function resolveExternalSessionId(flags) {
470
+ if (flags['external-session-id']) {
471
+ const val = String(flags['external-session-id']).trim();
472
+ return val.toLowerCase() === 'null' ? null : val;
473
+ }
474
+
475
+ const agentId = String(flags.agent ?? process.env.AGENT_IDENTIFIER ?? '').toLowerCase();
476
+ if (agentId.includes('codex')) {
477
+ const detected = detectCodexSessionId();
478
+ if (detected) return detected;
479
+ }
480
+
481
+ if (agentId.includes('claude') || agentId === '' || agentId === 'claude-code') {
482
+ const detected = detectClaudeSessionId();
483
+ if (detected) return detected;
484
+ }
485
+
486
+ return undefined; // undefined = omit from payload
487
+ }
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // attach
491
+ // ---------------------------------------------------------------------------
492
+
493
+ async function protocolAttach(args) {
494
+ const flags = parseFlags(args);
495
+ const ticketId = requireFlag(flags, 'ticket-id', 'TICKET_ID');
496
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
497
+ const timeoutMs = resolveTimeout(flags);
498
+
499
+ const externalSessionId = resolveExternalSessionId(flags);
500
+
501
+ const body = {
502
+ ticketId,
503
+ agentIdentifier: resolveProtocolAgentIdentifier(flags),
504
+ connectionMethod: String(flags.method ?? 'cli'),
505
+ ...(externalSessionId !== undefined ? { externalSessionId } : {}),
506
+ metadata: resolveProtocolMetadata(flags, { cwd: process.cwd() })
507
+ };
508
+
509
+ const data = await apiPost(
510
+ platformUrl,
511
+ bearerToken,
512
+ localSecret,
513
+ organizationId,
514
+ '/api/protocol/attach',
515
+ body,
516
+ timeoutMs
517
+ );
518
+
519
+ const sessionKey = data.session?.sessionKey;
520
+ console.log(JSON.stringify(data, null, 2));
521
+
522
+ if (sessionKey) {
523
+ // Emit a machine-readable line for easy shell capture:
524
+ // SESSION_KEY=$(ovld protocol attach --ticket-id ... | grep ^SESSION_KEY= | cut -d= -f2)
525
+ process.stderr.write(`\nSESSION_KEY=${sessionKey}\n`);
526
+ }
527
+ }
528
+
529
+ // ---------------------------------------------------------------------------
530
+ // update
531
+ // ---------------------------------------------------------------------------
532
+
533
+ async function protocolUpdate(args) {
534
+ const flags = parseFlags(args);
535
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
536
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
537
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
538
+ const summary = flags['summary-file']
539
+ ? readTextFile(String(flags['summary-file']), '--summary-file')
540
+ : requireFlag(flags, 'summary', undefined);
541
+
542
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
543
+ const timeoutMs = resolveTimeout(flags);
544
+ const changeRationales = await resolveChangeRationales(flags);
545
+ const externalSessionId = resolveExternalSessionId(flags);
546
+
547
+ const body = {
548
+ sessionKey,
549
+ ticketId,
550
+ summary,
551
+ ...(externalSessionId !== undefined ? { externalSessionId } : {}),
552
+ ...(flags['external-url']
553
+ ? {
554
+ externalUrl:
555
+ String(flags['external-url']).trim().toLowerCase() === 'null'
556
+ ? null
557
+ : String(flags['external-url'])
558
+ }
559
+ : {}),
560
+ ...(flags.phase ? { phase: String(flags.phase) } : {}),
561
+ ...(flags['event-type'] ? { eventType: String(flags['event-type']) } : {}),
562
+ ...(flags['payload-json']
563
+ ? { payload: parseJsonFlag('--payload-json', flags['payload-json']) }
564
+ : {}),
565
+ ...(changeRationales.length > 0 ? { changeRationales } : {})
566
+ };
567
+
568
+ const data = await apiPost(
569
+ platformUrl,
570
+ bearerToken,
571
+ localSecret,
572
+ organizationId,
573
+ '/api/protocol/update',
574
+ body,
575
+ timeoutMs
576
+ );
577
+ console.log(JSON.stringify(data, null, 2));
578
+ }
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // record-change-rationales
582
+ // ---------------------------------------------------------------------------
583
+
584
+ async function protocolRecordChangeRationales(args) {
585
+ const flags = parseFlags(args);
586
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
587
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
588
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
589
+
590
+ const changeRationales = await resolveChangeRationales(flags);
591
+ if (changeRationales.length === 0) {
592
+ throw new Error(
593
+ 'Provide at least one rationale with --change-rationales-json or --change-rationales-file'
594
+ );
595
+ }
596
+
597
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
598
+ const timeoutMs = resolveTimeout(flags);
599
+
600
+ const body = {
601
+ sessionKey,
602
+ ticketId,
603
+ changeRationales,
604
+ ...(flags['summary-file']
605
+ ? { summary: readTextFile(String(flags['summary-file']), '--summary-file') }
606
+ : flags.summary
607
+ ? { summary: String(flags.summary) }
608
+ : {}),
609
+ ...(flags.phase ? { phase: String(flags.phase) } : {})
610
+ };
611
+
612
+ const data = await apiPost(
613
+ platformUrl,
614
+ bearerToken,
615
+ localSecret,
616
+ organizationId,
617
+ '/api/protocol/record-change-rationales',
618
+ body,
619
+ timeoutMs
620
+ );
621
+ console.log(JSON.stringify(data, null, 2));
622
+ }
623
+
624
+ // ---------------------------------------------------------------------------
625
+ // ask
626
+ // ---------------------------------------------------------------------------
627
+
628
+ async function protocolAsk(args) {
629
+ const flags = parseFlags(args);
630
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
631
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
632
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
633
+ const question = flags['question-file']
634
+ ? readTextFile(String(flags['question-file']), '--question-file')
635
+ : requireFlag(flags, 'question', undefined);
636
+
637
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
638
+ const timeoutMs = resolveTimeout(flags);
639
+
640
+ const body = {
641
+ sessionKey,
642
+ ticketId,
643
+ question,
644
+ ...(flags.phase ? { phase: String(flags.phase) } : {}),
645
+ ...(flags['payload-json']
646
+ ? { payload: parseJsonFlag('--payload-json', flags['payload-json']) }
647
+ : {})
648
+ };
649
+
650
+ const data = await apiPost(
651
+ platformUrl,
652
+ bearerToken,
653
+ localSecret,
654
+ organizationId,
655
+ '/api/protocol/ask',
656
+ body,
657
+ timeoutMs
658
+ );
659
+ console.log(JSON.stringify(data, null, 2));
660
+ }
661
+
662
+ // ---------------------------------------------------------------------------
663
+ // permission-request
664
+ // ---------------------------------------------------------------------------
665
+
666
+ async function protocolPermissionRequest(args) {
667
+ const flags = parseFlags(args);
668
+ const ticketId = requireFlag(flags, 'ticket-id', 'TICKET_ID');
669
+ const payload = flags['payload-file']
670
+ ? await readJsonFileOrStdin(String(flags['payload-file']), '--payload-file')
671
+ : {};
672
+
673
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
674
+ const timeoutMs = resolveTimeout(flags);
675
+
676
+ const data = await apiPost(
677
+ platformUrl,
678
+ bearerToken,
679
+ localSecret,
680
+ organizationId,
681
+ `/api/protocol/permission-request?ticketId=${encodeURIComponent(ticketId)}`,
682
+ payload,
683
+ timeoutMs
684
+ );
685
+ console.log(JSON.stringify(data, null, 2));
686
+ }
687
+
688
+ // ---------------------------------------------------------------------------
689
+ // read-context
690
+ // ---------------------------------------------------------------------------
691
+
692
+ async function protocolReadContext(args) {
693
+ const flags = parseFlags(args);
694
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
695
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
696
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
697
+
698
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
699
+ const timeoutMs = resolveTimeout(flags);
700
+
701
+ const body = {
702
+ sessionKey,
703
+ ticketId,
704
+ ...(flags.query ? { query: String(flags.query) } : {}),
705
+ ...(flags.limit ? { limit: parseInt(String(flags.limit), 10) } : {})
706
+ };
707
+
708
+ const data = await apiPost(
709
+ platformUrl,
710
+ bearerToken,
711
+ localSecret,
712
+ organizationId,
713
+ '/api/protocol/read-context',
714
+ body,
715
+ timeoutMs
716
+ );
717
+ console.log(JSON.stringify(data, null, 2));
718
+ }
719
+
720
+ // ---------------------------------------------------------------------------
721
+ // write-context
722
+ // ---------------------------------------------------------------------------
723
+
724
+ async function protocolWriteContext(args) {
725
+ const flags = parseFlags(args);
726
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
727
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
728
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
729
+ const key = requireFlag(flags, 'key', undefined);
730
+
731
+ if (flags.value === undefined) {
732
+ throw new Error('--value is required');
733
+ }
734
+
735
+ let value;
736
+ try {
737
+ value = JSON.parse(String(flags.value));
738
+ } catch {
739
+ value = String(flags.value);
740
+ }
741
+
742
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
743
+ const timeoutMs = resolveTimeout(flags);
744
+
745
+ const body = {
746
+ sessionKey,
747
+ ticketId,
748
+ key,
749
+ value,
750
+ ...(flags.tags
751
+ ? {
752
+ tags: String(flags.tags)
753
+ .split(',')
754
+ .map(t => t.trim())
755
+ }
756
+ : {})
757
+ };
758
+
759
+ const data = await apiPost(
760
+ platformUrl,
761
+ bearerToken,
762
+ localSecret,
763
+ organizationId,
764
+ '/api/protocol/write-context',
765
+ body,
766
+ timeoutMs
767
+ );
768
+ console.log(JSON.stringify(data, null, 2));
769
+ }
770
+
771
+ // ---------------------------------------------------------------------------
772
+ // deliver
773
+ // ---------------------------------------------------------------------------
774
+
775
+ async function protocolDeliver(args) {
776
+ const flags = parseFlags(args);
777
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
778
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
779
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
780
+ const deliverPayload = flags['payload-file']
781
+ ? await readJsonFileOrStdin(String(flags['payload-file']), '--payload-file')
782
+ : null;
783
+ const summary =
784
+ deliverPayload?.summary ??
785
+ (flags['summary-file']
786
+ ? readTextFile(String(flags['summary-file']), '--summary-file')
787
+ : requireFlag(flags, 'summary', undefined));
788
+
789
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
790
+ const timeoutMs = resolveTimeout(flags);
791
+
792
+ let artifacts = deliverPayload?.artifacts ?? [];
793
+ if (deliverPayload && flags['artifacts-file']) {
794
+ throw new Error('Use either --payload-file or --artifacts-file, not both');
795
+ }
796
+ if (deliverPayload && flags['artifacts-json']) {
797
+ throw new Error('Use either --payload-file or --artifacts-json, not both');
798
+ }
799
+ if (flags['artifacts-file']) {
800
+ artifacts = await readJsonFileOrStdin(String(flags['artifacts-file']), '--artifacts-file');
801
+ } else if (flags['artifacts-json']) {
802
+ artifacts = parseJsonFlag('--artifacts-json', flags['artifacts-json']);
803
+ }
804
+
805
+ if (deliverPayload && (flags['change-rationales-file'] || flags['change-rationales-json'])) {
806
+ throw new Error('Use either --payload-file or change-rationale flags, not both');
807
+ }
808
+
809
+ const changeRationales =
810
+ deliverPayload?.changeRationales ?? (await resolveChangeRationales(flags));
811
+ validateDeliverFileChanges(flags, changeRationales);
812
+
813
+ const body = {
814
+ sessionKey,
815
+ ticketId,
816
+ summary,
817
+ artifacts,
818
+ ...(changeRationales.length > 0 ? { changeRationales } : {})
819
+ };
820
+
821
+ const data = await apiPost(
822
+ platformUrl,
823
+ bearerToken,
824
+ localSecret,
825
+ organizationId,
826
+ '/api/protocol/deliver',
827
+ body,
828
+ timeoutMs
829
+ );
830
+ console.log(JSON.stringify(data, null, 2));
831
+ }
832
+
833
+ // ---------------------------------------------------------------------------
834
+ // objective attachments
835
+ // ---------------------------------------------------------------------------
836
+
837
+ async function protocolAttachmentList(args) {
838
+ const flags = parseFlags(args);
839
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
840
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
841
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
842
+
843
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
844
+ const timeoutMs = resolveTimeout(flags);
845
+
846
+ const body = {
847
+ sessionKey,
848
+ ticketId,
849
+ ...(flags['objective-id'] ? { objectiveId: String(flags['objective-id']) } : {})
850
+ };
851
+
852
+ const data = await apiPost(
853
+ platformUrl,
854
+ bearerToken,
855
+ localSecret,
856
+ organizationId,
857
+ '/api/protocol/attachments/list',
858
+ body,
859
+ timeoutMs
860
+ );
861
+ console.log(JSON.stringify(data, null, 2));
862
+ }
863
+
864
+ async function protocolAttachmentPrepareUpload(args) {
865
+ const flags = parseFlags(args);
866
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
867
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
868
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
869
+ const objectiveId = requireFlag(flags, 'objective-id', undefined);
870
+ const fileName = requireFlag(flags, 'file-name', undefined);
871
+
872
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
873
+ const timeoutMs = resolveTimeout(flags);
874
+
875
+ const body = {
876
+ sessionKey,
877
+ ticketId,
878
+ objectiveId,
879
+ fileName,
880
+ ...(flags.label ? { label: String(flags.label) } : {}),
881
+ ...(flags['content-type'] ? { contentType: String(flags['content-type']) } : {}),
882
+ ...(flags['file-size'] ? { fileSize: parseInt(String(flags['file-size']), 10) } : {}),
883
+ ...(flags['metadata-json']
884
+ ? { metadata: parseJsonFlag('--metadata-json', flags['metadata-json']) }
885
+ : {})
886
+ };
887
+
888
+ const data = await apiPost(
889
+ platformUrl,
890
+ bearerToken,
891
+ localSecret,
892
+ organizationId,
893
+ '/api/protocol/attachments/prepare-upload',
894
+ body,
895
+ timeoutMs
896
+ );
897
+ console.log(JSON.stringify(data, null, 2));
898
+ }
899
+
900
+ async function protocolAttachmentFinalizeUpload(args) {
901
+ const flags = parseFlags(args);
902
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
903
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
904
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
905
+ const objectiveId = requireFlag(flags, 'objective-id', undefined);
906
+ const storagePath = requireFlag(flags, 'storage-path', undefined);
907
+ const label = requireFlag(flags, 'label', undefined);
908
+
909
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
910
+ const timeoutMs = resolveTimeout(flags);
911
+
912
+ const body = {
913
+ sessionKey,
914
+ ticketId,
915
+ objectiveId,
916
+ storagePath,
917
+ label,
918
+ ...(flags['content-type'] ? { contentType: String(flags['content-type']) } : {}),
919
+ ...(flags['file-size'] ? { fileSize: parseInt(String(flags['file-size']), 10) } : {}),
920
+ ...(flags['metadata-json']
921
+ ? { metadata: parseJsonFlag('--metadata-json', flags['metadata-json']) }
922
+ : {})
923
+ };
924
+
925
+ const data = await apiPost(
926
+ platformUrl,
927
+ bearerToken,
928
+ localSecret,
929
+ organizationId,
930
+ '/api/protocol/attachments/finalize-upload',
931
+ body,
932
+ timeoutMs
933
+ );
934
+ console.log(JSON.stringify(data, null, 2));
935
+ }
936
+
937
+ async function protocolAttachmentGetDownloadUrl(args) {
938
+ const flags = parseFlags(args);
939
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
940
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
941
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
942
+ if (!flags['attachment-id'] && !flags['storage-path']) {
943
+ throw new Error('--attachment-id or --storage-path is required');
944
+ }
945
+ if (flags['storage-path'] && !flags['objective-id']) {
946
+ throw new Error('--objective-id is required when using --storage-path');
947
+ }
948
+
949
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
950
+ const timeoutMs = resolveTimeout(flags);
951
+
952
+ const body = {
953
+ sessionKey,
954
+ ticketId,
955
+ ...(flags['objective-id'] ? { objectiveId: String(flags['objective-id']) } : {}),
956
+ ...(flags['attachment-id'] ? { attachmentId: String(flags['attachment-id']) } : {}),
957
+ ...(flags['storage-path'] ? { storagePath: String(flags['storage-path']) } : {}),
958
+ ...(flags['expires-in'] ? { expiresIn: parseInt(String(flags['expires-in']), 10) } : {})
959
+ };
960
+
961
+ const data = await apiPost(
962
+ platformUrl,
963
+ bearerToken,
964
+ localSecret,
965
+ organizationId,
966
+ '/api/protocol/attachments/get-download-url',
967
+ body,
968
+ timeoutMs
969
+ );
970
+ console.log(JSON.stringify(data, null, 2));
971
+ }
972
+
973
+ async function protocolAttachmentUploadFile(args) {
974
+ const flags = parseFlags(args);
975
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
976
+ if (!sessionKey) throw new Error('--session-key is required (or set SESSION_KEY)');
977
+ if (!ticketId) throw new Error('--ticket-id is required (or set TICKET_ID)');
978
+ const objectiveId = requireFlag(flags, 'objective-id', undefined);
979
+ const filePath = requireFlag(flags, 'file', undefined);
980
+
981
+ const { readFile, stat } = await import('node:fs/promises');
982
+ const path = await import('node:path');
983
+ const fileName = String(flags['file-name'] ?? path.basename(filePath));
984
+ const contentType = String(flags['content-type'] ?? 'application/octet-stream');
985
+ const label = String(flags.label ?? fileName);
986
+
987
+ const fileStats = await stat(filePath);
988
+ const fileBytes = await readFile(filePath);
989
+
990
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
991
+ const timeoutMs = resolveTimeout(flags);
992
+
993
+ const metadata = flags['metadata-json']
994
+ ? parseJsonFlag('--metadata-json', flags['metadata-json'])
995
+ : undefined;
996
+
997
+ const prepared = await apiPost(
998
+ platformUrl,
999
+ bearerToken,
1000
+ localSecret,
1001
+ organizationId,
1002
+ '/api/protocol/attachments/prepare-upload',
1003
+ {
1004
+ sessionKey,
1005
+ ticketId,
1006
+ objectiveId,
1007
+ fileName,
1008
+ label,
1009
+ contentType,
1010
+ fileSize: fileStats.size,
1011
+ ...(metadata ? { metadata } : {})
1012
+ },
1013
+ timeoutMs
1014
+ );
1015
+
1016
+ const uploadUrl = prepared?.upload?.url;
1017
+ const storagePath = prepared?.draft?.storagePath;
1018
+ if (!uploadUrl || !storagePath) {
1019
+ throw new Error('Prepare upload response missing upload URL or storagePath.');
1020
+ }
1021
+
1022
+ await uploadToSignedUrl(uploadUrl, fileBytes, contentType, timeoutMs);
1023
+
1024
+ const finalized = await apiPost(
1025
+ platformUrl,
1026
+ bearerToken,
1027
+ localSecret,
1028
+ organizationId,
1029
+ '/api/protocol/attachments/finalize-upload',
1030
+ {
1031
+ sessionKey,
1032
+ ticketId,
1033
+ objectiveId,
1034
+ storagePath,
1035
+ label,
1036
+ contentType,
1037
+ fileSize: fileStats.size,
1038
+ ...(metadata ? { metadata } : {})
1039
+ },
1040
+ timeoutMs
1041
+ );
1042
+
1043
+ console.log(JSON.stringify(finalized, null, 2));
1044
+ }
1045
+
1046
+ // ---------------------------------------------------------------------------
1047
+ // discover-project (resolve project from working directory)
1048
+ // ---------------------------------------------------------------------------
1049
+
1050
+ async function protocolDiscoverProject(args) {
1051
+ const flags = parseFlags(args);
1052
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
1053
+ const timeoutMs = resolveTimeout(flags);
1054
+
1055
+ const workingDirectory = String(flags['working-directory'] ?? process.cwd());
1056
+
1057
+ const data = await apiPost(
1058
+ platformUrl,
1059
+ bearerToken,
1060
+ localSecret,
1061
+ organizationId,
1062
+ '/api/protocol/discover-project',
1063
+ { workingDirectory },
1064
+ timeoutMs
1065
+ );
1066
+ console.log(JSON.stringify(data, null, 2));
1067
+
1068
+ if (data.project?.id) {
1069
+ process.stderr.write(`\nPROJECT_ID=${data.project.id}\n`);
1070
+ }
1071
+ }
1072
+
1073
+ // ---------------------------------------------------------------------------
1074
+ // connect (lightweight session, no context returned)
1075
+ // ---------------------------------------------------------------------------
1076
+
1077
+ async function protocolConnect(args) {
1078
+ const flags = parseFlags(args);
1079
+ const ticketId = requireFlag(flags, 'ticket-id', 'TICKET_ID');
1080
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
1081
+ const timeoutMs = resolveTimeout(flags);
1082
+
1083
+ const body = {
1084
+ ticketId,
1085
+ agentIdentifier: resolveProtocolAgentIdentifier(flags),
1086
+ connectionMethod: String(flags.method ?? 'cli'),
1087
+ metadata: resolveProtocolMetadata(flags, { cwd: process.cwd() })
1088
+ };
1089
+
1090
+ const data = await apiPost(
1091
+ platformUrl,
1092
+ bearerToken,
1093
+ localSecret,
1094
+ organizationId,
1095
+ '/api/protocol/connect',
1096
+ body,
1097
+ timeoutMs
1098
+ );
1099
+
1100
+ const sessionKey = data.session?.sessionKey;
1101
+ console.log(JSON.stringify(data, null, 2));
1102
+
1103
+ if (sessionKey) {
1104
+ process.stderr.write(`\nSESSION_KEY=${sessionKey}\n`);
1105
+ }
1106
+ }
1107
+
1108
+ // ---------------------------------------------------------------------------
1109
+ // load-context (read-only ticket fetch, no session)
1110
+ // ---------------------------------------------------------------------------
1111
+
1112
+ async function protocolLoadContext(args) {
1113
+ const flags = parseFlags(args);
1114
+ const ticketId = requireFlag(flags, 'ticket-id', 'TICKET_ID');
1115
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
1116
+ const timeoutMs = resolveTimeout(flags);
1117
+
1118
+ const body = { ticketId };
1119
+
1120
+ const data = await apiPost(
1121
+ platformUrl,
1122
+ bearerToken,
1123
+ localSecret,
1124
+ organizationId,
1125
+ '/api/protocol/load-context',
1126
+ body,
1127
+ timeoutMs
1128
+ );
1129
+ console.log(JSON.stringify(data, null, 2));
1130
+ }
1131
+
1132
+ // ---------------------------------------------------------------------------
1133
+ // prompt (create ticket + connect in one call)
1134
+ // ---------------------------------------------------------------------------
1135
+
1136
+ async function protocolPrompt(args) {
1137
+ const flags = parseFlags(args);
1138
+ const objective = requireFlag(flags, 'objective', undefined);
1139
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
1140
+ const timeoutMs = resolveTimeout(flags);
1141
+ const agentIdentifier = resolveProtocolAgentIdentifier(flags);
1142
+ const modelIdentifier = resolveProtocolModelIdentifier(flags);
1143
+
1144
+ // When --project-id is not provided, auto-send cwd as workingDirectory so
1145
+ // the server can resolve the project from the caller's project_user
1146
+ // local_working_directory setting.
1147
+ const personal = Boolean(flags.personal);
1148
+ const workingDirectory =
1149
+ flags['working-directory'] ?? (!flags['project-id'] && !personal ? process.cwd() : undefined);
1150
+
1151
+ const body = {
1152
+ objective,
1153
+ agentIdentifier,
1154
+ connectionMethod: String(flags.method ?? 'cli'),
1155
+ metadata: resolveProtocolMetadata(flags, { cwd: process.cwd() }),
1156
+ ...(flags.title ? { title: String(flags.title) } : {}),
1157
+ ...(flags.priority ? { priority: String(flags.priority) } : {}),
1158
+ ...(flags['project-id'] ? { projectId: String(flags['project-id']) } : {}),
1159
+ ...(personal ? { personal: true } : {}),
1160
+ ...(workingDirectory ? { workingDirectory: String(workingDirectory) } : {}),
1161
+ ...(flags['acceptance-criteria']
1162
+ ? { acceptanceCriteria: String(flags['acceptance-criteria']) }
1163
+ : {}),
1164
+ ...(flags['available-tools'] ? { availableTools: String(flags['available-tools']) } : {}),
1165
+ ...(flags['execution-target'] ? { executionTarget: String(flags['execution-target']) } : {}),
1166
+ delegate: resolveProtocolTicketDelegate(flags, modelIdentifier, agentIdentifier),
1167
+ ...(flags['parent-session-key']
1168
+ ? { parentSessionKey: String(flags['parent-session-key']) }
1169
+ : {}),
1170
+ ...(flags['parent-ticket-id']
1171
+ ? { parentTicketId: String(flags['parent-ticket-id'] ?? process.env.TICKET_ID ?? '') }
1172
+ : {})
1173
+ };
1174
+
1175
+ const data = await apiPost(
1176
+ platformUrl,
1177
+ bearerToken,
1178
+ localSecret,
1179
+ organizationId,
1180
+ '/api/protocol/prompt',
1181
+ body,
1182
+ timeoutMs
1183
+ );
1184
+
1185
+ const sessionKey = data.session?.sessionKey;
1186
+ const ticketId = data.ticket?.ticket_id ?? data.ticket?.id;
1187
+ console.log(JSON.stringify(data, null, 2));
1188
+
1189
+ if (sessionKey) {
1190
+ process.stderr.write(`\nSESSION_KEY=${sessionKey}\n`);
1191
+ }
1192
+ if (ticketId) {
1193
+ process.stderr.write(`TICKET_ID=${ticketId}\n`);
1194
+ }
1195
+ }
1196
+
1197
+ // ---------------------------------------------------------------------------
1198
+ // create (create follow-up ticket draft only)
1199
+ // ---------------------------------------------------------------------------
1200
+
1201
+ async function protocolCreateTicket(args) {
1202
+ const flags = parseFlags(args);
1203
+ const { sessionKey, ticketId } = resolveSessionFlags(flags);
1204
+ const objective = requireFlag(flags, 'objective', undefined);
1205
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
1206
+ const timeoutMs = resolveTimeout(flags);
1207
+ const agentIdentifier = resolveProtocolAgentIdentifier(flags);
1208
+ const modelIdentifier = resolveProtocolModelIdentifier(flags);
1209
+
1210
+ const hasSessionContext = Boolean(sessionKey && ticketId);
1211
+
1212
+ // Follow-up mode: create a draft ticket linked to the current session ticket.
1213
+ if (hasSessionContext) {
1214
+ const body = {
1215
+ sessionKey,
1216
+ ticketId,
1217
+ objective,
1218
+ ...(flags.title ? { title: String(flags.title) } : {}),
1219
+ ...(flags.priority ? { priority: String(flags.priority) } : {}),
1220
+ ...(flags['acceptance-criteria']
1221
+ ? { acceptanceCriteria: String(flags['acceptance-criteria']) }
1222
+ : {}),
1223
+ ...(flags['available-tools'] ? { availableTools: String(flags['available-tools']) } : {}),
1224
+ ...(flags['execution-target'] ? { executionTarget: String(flags['execution-target']) } : {}),
1225
+ delegate: resolveProtocolTicketDelegate(flags, modelIdentifier, agentIdentifier)
1226
+ };
1227
+
1228
+ const data = await apiPost(
1229
+ platformUrl,
1230
+ bearerToken,
1231
+ localSecret,
1232
+ organizationId,
1233
+ '/api/protocol/create-ticket',
1234
+ body,
1235
+ timeoutMs
1236
+ );
1237
+ console.log(JSON.stringify(data, null, 2));
1238
+ return;
1239
+ }
1240
+
1241
+ // Standalone mode: resolve project from cwd/--working-directory first, then create draft ticket.
1242
+ if (sessionKey || ticketId) {
1243
+ throw new Error(
1244
+ 'Provide both --session-key and --ticket-id for follow-up create, or provide neither for standalone create.'
1245
+ );
1246
+ }
1247
+
1248
+ const standaloneWorkingDirectory =
1249
+ !flags.personal && !flags['project-id']
1250
+ ? String(flags['working-directory'] ?? process.cwd())
1251
+ : undefined;
1252
+
1253
+ const standaloneBody = {
1254
+ objective,
1255
+ ...(flags.personal ? { personal: true } : {}),
1256
+ ...(!flags.personal && flags['project-id'] ? { projectId: String(flags['project-id']) } : {}),
1257
+ ...(standaloneWorkingDirectory ? { workingDirectory: standaloneWorkingDirectory } : {}),
1258
+ ...(flags.title ? { title: String(flags.title) } : {}),
1259
+ ...(flags.priority ? { priority: String(flags.priority) } : {}),
1260
+ ...(flags['acceptance-criteria']
1261
+ ? { acceptanceCriteria: String(flags['acceptance-criteria']) }
1262
+ : {}),
1263
+ ...(flags['available-tools'] ? { availableTools: String(flags['available-tools']) } : {}),
1264
+ ...(flags['execution-target'] ? { executionTarget: String(flags['execution-target']) } : {}),
1265
+ delegate: resolveProtocolTicketDelegate(flags, modelIdentifier, agentIdentifier)
1266
+ };
1267
+
1268
+ const data = await apiPost(
1269
+ platformUrl,
1270
+ bearerToken,
1271
+ localSecret,
1272
+ organizationId,
1273
+ '/api/protocol/tickets',
1274
+ standaloneBody,
1275
+ timeoutMs
1276
+ );
1277
+ console.log(JSON.stringify(data, null, 2));
1278
+ }
1279
+
1280
+ // ---------------------------------------------------------------------------
1281
+ // search-tickets (find tickets by query/status/project/created_by/dates)
1282
+ // ---------------------------------------------------------------------------
1283
+
1284
+ async function protocolSearchTickets(args) {
1285
+ const flags = parseFlags(args);
1286
+ const { platformUrl, bearerToken, localSecret, organizationId } = await resolveAuth();
1287
+ const timeoutMs = resolveTimeout(flags);
1288
+
1289
+ const statuses = flags.status
1290
+ ? String(flags.status)
1291
+ .split(',')
1292
+ .map(s => s.trim())
1293
+ .filter(Boolean)
1294
+ : undefined;
1295
+
1296
+ const body = {
1297
+ ...(flags.query ? { query: String(flags.query) } : {}),
1298
+ ...(statuses?.length ? { statuses } : {}),
1299
+ ...(flags['include-completed'] !== undefined
1300
+ ? {
1301
+ includeCompleted:
1302
+ flags['include-completed'] !== false && flags['include-completed'] !== 'false'
1303
+ }
1304
+ : {}),
1305
+ ...(flags.limit ? { limit: parseInt(String(flags.limit), 10) } : {}),
1306
+ ...(flags['project-id'] ? { projectId: String(flags['project-id']) } : {}),
1307
+ ...(flags['created-by'] ? { createdBy: String(flags['created-by']) } : {}),
1308
+ ...(flags['updated-after'] ? { updatedAfter: String(flags['updated-after']) } : {}),
1309
+ ...(flags['updated-before'] ? { updatedBefore: String(flags['updated-before']) } : {})
1310
+ };
1311
+
1312
+ const data = await apiPost(
1313
+ platformUrl,
1314
+ bearerToken,
1315
+ localSecret,
1316
+ organizationId,
1317
+ '/api/protocol/search-tickets',
1318
+ body,
1319
+ timeoutMs
1320
+ );
1321
+ console.log(JSON.stringify(data, null, 2));
1322
+ }
1323
+
1324
+ // ---------------------------------------------------------------------------
1325
+ // auth-status (agent-friendly auth diagnostics)
1326
+ // ---------------------------------------------------------------------------
1327
+
1328
+ async function protocolAuthStatus() {
1329
+ const status = await getAuthStatus();
1330
+
1331
+ console.log(
1332
+ JSON.stringify(
1333
+ {
1334
+ ok: status.isLoggedIn,
1335
+ authStatus: {
1336
+ isLoggedIn: status.isLoggedIn,
1337
+ platformUrl: status.platformUrl,
1338
+ platformUrlSource: status.platformUrlSource,
1339
+ tokenSource: status.tokenSource,
1340
+ tokenPresent: status.tokenPresent,
1341
+ organizationId: status.organizationId,
1342
+ authMode: status.authMode,
1343
+ error: status.error,
1344
+ hasLocalSecret: status.hasLocalSecret,
1345
+ credentialsFileExists: status.credentialsFileExists,
1346
+ electronCredentialsFileExists: status.electronCredentialsFileExists
1347
+ }
1348
+ },
1349
+ null,
1350
+ 2
1351
+ )
1352
+ );
1353
+ }
1354
+
1355
+ // ---------------------------------------------------------------------------
1356
+ // Router
1357
+ // ---------------------------------------------------------------------------
1358
+
1359
+ export async function runProtocolCommand(subcommand, args) {
1360
+ if (!subcommand || subcommand === 'help' || subcommand === '--help') {
1361
+ console.log(`ovld protocol <subcommand> [flags]
1362
+
1363
+ Use this for ticket lifecycle work from an agent runtime: create a standalone
1364
+ draft with \`ovld protocol create\`, create-and-attach with \`ovld protocol prompt\`,
1365
+ or attach to an existing ticket with \`ovld protocol attach --ticket-id <ticket_id>\`.
1366
+
1367
+ Project discovery:
1368
+ When prompting or creating tickets, the CLI automatically resolves the correct
1369
+ project by matching your current working directory against your configured
1370
+ "Local working directory" for that project (stored per user in Overlord).
1371
+ You can also discover the project explicitly:
1372
+
1373
+ ovld protocol discover-project
1374
+ ovld protocol discover-project --working-directory /path/to/repo
1375
+
1376
+ Use --project-id to override automatic resolution on prompt or ticket creation.
1377
+ Use --personal to create a private ticket without assigning any project.
1378
+
1379
+ Subcommands:
1380
+ auth-status Return machine-readable auth status for agent runtimes
1381
+ discover-project Resolve a project from the current working directory
1382
+ attach Start a ticket session and return full working context
1383
+ connect Start a lightweight session without full context
1384
+ load-context Read ticket context without creating a session
1385
+ search-tickets Find tickets by keyword, status, project, creator, or update date
1386
+ create Create a draft ticket without attaching (follow-up or standalone)
1387
+ prompt Create a ticket and attach to it immediately
1388
+ update Post progress, activity events, and optional change rationales
1389
+ record-change-rationales Persist structured change rationales without a progress update
1390
+ ask Post a blocking question and move the ticket to review
1391
+ permission-request Notify Overlord that the agent is requesting tool permission
1392
+ read-context Read shared persistent context for this ticket
1393
+ write-context Write shared persistent context for future sessions
1394
+ deliver Finish work, send artifacts, and move the ticket to review
1395
+ attachment-list List objective attachments visible to the current session
1396
+ attachment-prepare-upload Get a signed upload URL for an objective attachment
1397
+ attachment-finalize-upload Finalize an uploaded attachment row after storage upload
1398
+ attachment-download-url Get a signed download URL for an existing attachment
1399
+ attachment-upload-file Prepare, upload, and finalize a local file in one command
1400
+
1401
+ Environment fallback:
1402
+ --session-key <- SESSION_KEY
1403
+ --ticket-id <- TICKET_ID (human-readable ticket_id, e.g. 1:899)
1404
+ auth/host <- OVERLORD_URL, optional OVERLORD_ACCESS_TOKEN + OVERLORD_ORGANIZATION_ID, or shared OAuth credentials from ovld auth/Desktop login
1405
+ --timeout <- OVERLORD_TIMEOUT
1406
+
1407
+ Common flags:
1408
+ --timeout <ms> Request timeout in milliseconds (default: ${DEFAULT_TIMEOUT_MS})
1409
+ --ticket-id <ticket_id> Ticket identifier (e.g. 1:899) when the subcommand operates on an existing ticket
1410
+ --session-key <key> Session key returned by attach/connect/prompt
1411
+ --agent <identifier> Agent identifier sent to Overlord (default: AGENT_IDENTIFIER or claude-code)
1412
+ --model <identifier> Model identifier to snapshot on executing objectives
1413
+ --method <connectionMethod> Connection method sent to Overlord (default: cli)
1414
+
1415
+ auth-status:
1416
+ Purpose:
1417
+ Check whether the local runtime has usable Overlord credentials.
1418
+ Returns:
1419
+ JSON with ok=true|false plus authStatus fields describing token and host sources.
1420
+
1421
+ discover-project:
1422
+ Purpose:
1423
+ Resolve the Overlord project that corresponds to the current (or given) working directory.
1424
+ Uses the caller's configured "Local working directory" for matching.
1425
+ Optional:
1426
+ --working-directory <path> Directory to match (default: current working directory)
1427
+ Returns:
1428
+ Project JSON with id, name, organizationId. Prints PROJECT_ID=<id> on stderr.
1429
+ Notes:
1430
+ Set your local working directory for a project in the Overlord UI under Project Settings.
1431
+ When no match is found, returns a 404 with a hint.
1432
+
1433
+ attach:
1434
+ Purpose:
1435
+ Create the working session for an agent on an existing ticket. This is the normal first call.
1436
+ Required:
1437
+ --ticket-id <ticket_id> Human-readable identifier (e.g. 1:899). Also accepts UUID.
1438
+ Optional:
1439
+ --agent <identifier>
1440
+ --model <identifier>
1441
+ --method <connectionMethod>
1442
+ --external-session-id <id|null> Store the native agent thread/session id, or clear it with null
1443
+ --metadata-json <json> Extra session metadata object
1444
+ Returns:
1445
+ Full JSON including session.sessionKey, ticket, history, artifacts, sharedState, and promptContext
1446
+ Notes:
1447
+ If --external-session-id is omitted, the CLI may auto-detect Codex or Claude session ids
1448
+
1449
+ connect:
1450
+ Purpose:
1451
+ Create a lightweight session when you only need a session key and not the full ticket payload
1452
+ Required:
1453
+ --ticket-id <ticket_id> Human-readable identifier (e.g. 1:899). Also accepts UUID.
1454
+ Optional:
1455
+ --agent <identifier>
1456
+ --model <identifier>
1457
+ --method <connectionMethod>
1458
+ --metadata-json <json> Extra session metadata object
1459
+ Returns:
1460
+ Session JSON and SESSION_KEY on stderr when available
1461
+
1462
+ load-context:
1463
+ Purpose:
1464
+ Read ticket details without creating a session
1465
+ Required:
1466
+ --ticket-id <id>
1467
+
1468
+ search-tickets:
1469
+ Purpose:
1470
+ Find tickets in your organization by keyword, status, project, creator, or update window.
1471
+ Omit --query to list mode (most recently updated first).
1472
+ Optional:
1473
+ --query <text> Free-text search across the ticket search vector + title fallback
1474
+ --status <csv> Comma-separated statuses, e.g. "draft,next-up,execute"
1475
+ --include-completed <bool> Include completed tickets (default: false)
1476
+ --limit <n> Max results 1..50 (default: 8)
1477
+ --project-id <uuid> Restrict to a single project
1478
+ --created-by <uuid> Restrict to tickets created by this user
1479
+ --updated-after <iso> Updated_at >= ISO timestamp
1480
+ --updated-before <iso> Updated_at <= ISO timestamp
1481
+ Returns:
1482
+ JSON with { tickets, count }.
1483
+
1484
+ update:
1485
+ Purpose:
1486
+ Post progress or activity events during execution
1487
+ Required:
1488
+ --session-key <key>
1489
+ --ticket-id <id>
1490
+ --summary <text> or --summary-file <path>
1491
+ Optional:
1492
+ --phase <status> draft | execute | review | deliver | complete | blocked | cancelled
1493
+ --event-type <type> update | user_follow_up | alert
1494
+ --payload-json <json> Additional structured payload, for example notifications
1495
+ --external-url <url|null> Store or clear a deep link to the live agent session
1496
+ --external-session-id <id|null>
1497
+ --change-rationales-json <json>
1498
+ --change-rationales-file <path>
1499
+ Notes:
1500
+ Use phase=execute while actively working. user_follow_up is for verbatim human follow-up messages.
1501
+
1502
+ record-change-rationales:
1503
+ Purpose:
1504
+ Persist structured file-change rationale records without also posting a normal update
1505
+ Required:
1506
+ --session-key <key>
1507
+ --ticket-id <id>
1508
+ --change-rationales-json <json> or --change-rationales-file <path>
1509
+ Optional:
1510
+ --summary <text> or --summary-file <path>
1511
+ --phase <status>
1512
+
1513
+ ask:
1514
+ Purpose:
1515
+ Raise a blocking question for a human reviewer/PM
1516
+ Required:
1517
+ --session-key <key>
1518
+ --ticket-id <id>
1519
+ --question <text> or --question-file <path>
1520
+ Optional:
1521
+ --phase <status>
1522
+ --payload-json <json>
1523
+ Notes:
1524
+ After ask succeeds, stop working until the human responds
1525
+
1526
+ permission-request:
1527
+ Purpose:
1528
+ Notify Overlord that the local agent runtime is requesting tool permission.
1529
+ This is primarily used by installed permission hooks.
1530
+ Required:
1531
+ --ticket-id <id>
1532
+ Optional:
1533
+ --payload-file <path|-> Hook JSON payload, or stdin when "-"
1534
+
1535
+ read-context:
1536
+ Purpose:
1537
+ Read persistent shared context written by earlier sessions
1538
+ Required:
1539
+ --session-key <key>
1540
+ --ticket-id <id>
1541
+ Optional:
1542
+ --query <text> Filter by key substring
1543
+ --limit <n> Max entries to return
1544
+
1545
+ write-context:
1546
+ Purpose:
1547
+ Save shared facts for future sessions
1548
+ Required:
1549
+ --session-key <key>
1550
+ --ticket-id <id>
1551
+ --key <name>
1552
+ --value <json-or-string> Parsed as JSON first; stored as a string if JSON parsing fails
1553
+ Optional:
1554
+ --tags <csv>
1555
+
1556
+ deliver:
1557
+ Purpose:
1558
+ Conclude the session and submit the final narrative plus artifacts/change rationales
1559
+ Required:
1560
+ --session-key <key>
1561
+ --ticket-id <id>
1562
+ --summary <text> or --summary-file <path>
1563
+ or: --payload-file <path|-> containing { summary, artifacts, changeRationales }
1564
+ Optional:
1565
+ --artifacts-json <json>
1566
+ --artifacts-file <path|->
1567
+ --change-rationales-json <json>
1568
+ --change-rationales-file <path|->
1569
+ --skip-file-change-check Bypass local git vs changeRationales validation
1570
+ Notes:
1571
+ Use --payload-file - to read the full delivery JSON from stdin without creating a scratch file.
1572
+ Do not combine --payload-file with --artifacts-json/--artifacts-file or change-rationale flags.
1573
+ In a git workspace, deliver validates that changed files are represented by changeRationales unless skipped.
1574
+
1575
+ prompt:
1576
+ Purpose:
1577
+ Create a ticket and attach to it in one call.
1578
+ When --project-id is omitted, automatically resolves the project from the
1579
+ current working directory (matching against the caller's project_user.local_working_directory).
1580
+ Required:
1581
+ --objective <text>
1582
+ Optional:
1583
+ --title <text>
1584
+ --priority <level> low | medium | high | urgent
1585
+ --project-id <id> Explicit project; skips working-directory resolution
1586
+ --personal Create the ticket without assigning a project
1587
+ --working-directory <path> Override cwd for project resolution (default: cwd)
1588
+ --acceptance-criteria <text>
1589
+ --available-tools <text>
1590
+ --execution-target <t> agent | human
1591
+ --delegate <model> Model or delegate identifier that created the ticket
1592
+ --parent-session-key <key>
1593
+ --parent-ticket-id <id>
1594
+ --agent <identifier>
1595
+ --model <identifier>
1596
+ --method <connectionMethod>
1597
+ --metadata-json <json> Extra session metadata object
1598
+ Returns:
1599
+ New ticket/session JSON plus SESSION_KEY and TICKET_ID on stderr when available
1600
+
1601
+ create:
1602
+ Purpose:
1603
+ Create a draft ticket without attaching to it.
1604
+ If session flags are provided, creates a follow-up draft linked to the current ticket.
1605
+ If session flags are omitted, resolves project by working directory and creates a standalone draft.
1606
+ Required:
1607
+ --objective <text>
1608
+ Optional:
1609
+ --session-key <key>
1610
+ --ticket-id <id>
1611
+ --working-directory <path> Resolve project by your configured local working directory (default: cwd)
1612
+ --project-id <id> Explicit project for standalone draft creation
1613
+ --personal Create a private standalone draft without a project
1614
+ --title <text>
1615
+ --priority <level> low | medium | high | urgent
1616
+ --acceptance-criteria <text>
1617
+ --available-tools <text>
1618
+ --execution-target <t> agent | human
1619
+ --delegate <model> Model or delegate identifier that created the ticket
1620
+ --agent <identifier>
1621
+ --model <identifier>
1622
+ Returns:
1623
+ New draft ticket JSON (follow-up draft when session flags are provided)
1624
+ Notes:
1625
+ Standalone create auto-discovers the project from the current working directory unless --personal is set.
1626
+ Follow-up create requires both --session-key and --ticket-id.
1627
+
1628
+ attachment-list:
1629
+ Required:
1630
+ --session-key <key>
1631
+ --ticket-id <id>
1632
+ Optional:
1633
+ --objective-id <id> Filter to a single objective
1634
+ Returns:
1635
+ JSON array of { id, label, content_type, file_size, objective_id, storage_path, created_at }
1636
+
1637
+ attachment-prepare-upload:
1638
+ Required:
1639
+ --session-key <key>
1640
+ --ticket-id <id>
1641
+ --objective-id <id>
1642
+ --file-name <name>
1643
+ Optional:
1644
+ --label <text>
1645
+ --content-type <mime>
1646
+ --file-size <bytes>
1647
+ --metadata-json <json>
1648
+
1649
+ attachment-finalize-upload:
1650
+ Required:
1651
+ --session-key <key>
1652
+ --ticket-id <id>
1653
+ --objective-id <id>
1654
+ --storage-path <path>
1655
+ --label <text>
1656
+ Optional:
1657
+ --content-type <mime>
1658
+ --file-size <bytes>
1659
+ --metadata-json <json>
1660
+
1661
+ attachment-download-url:
1662
+ Required:
1663
+ --session-key <key>
1664
+ --ticket-id <id>
1665
+ one of: --attachment-id <id> | --storage-path <path>
1666
+ Optional:
1667
+ --objective-id <id> Required when using --storage-path
1668
+ --expires-in <seconds>
1669
+
1670
+ attachment-upload-file:
1671
+ Required:
1672
+ --session-key <key>
1673
+ --ticket-id <id>
1674
+ --objective-id <id>
1675
+ --file <path>
1676
+ Optional:
1677
+ --file-name <name> Defaults to basename of --file
1678
+ --label <text> Defaults to file name
1679
+ --content-type <mime> Defaults to application/octet-stream
1680
+ --metadata-json <json>
1681
+
1682
+ Examples:
1683
+ ovld protocol auth-status
1684
+ ovld protocol discover-project
1685
+ ovld protocol discover-project --working-directory /path/to/repo
1686
+ ovld protocol prompt --agent codex --objective "Implement feature X" # auto-resolves project from cwd
1687
+ ovld protocol attach --ticket-id abc-123
1688
+ ovld protocol attach --ticket-id abc-123 --external-session-id null
1689
+ ovld protocol connect --ticket-id abc-123
1690
+ ovld protocol load-context --ticket-id abc-123
1691
+ ovld protocol search-tickets --query "auth refactor" --status next-up,execute --limit 10
1692
+ ovld protocol create --agent codex --objective "Capture follow-up work from this repo"
1693
+ ovld protocol create --agent codex --session-key <key> --ticket-id <id> --objective "Capture follow-up work"
1694
+ ovld protocol prompt --agent codex --objective "Implement user auth" --priority high
1695
+ ovld protocol update --session-key <key> --ticket-id <id> --summary "Did X" --phase execute
1696
+ ovld protocol update --session-key <key> --ticket-id <id> --summary-file ./update.txt --event-type user_follow_up
1697
+ ovld protocol record-change-rationales --session-key <key> --ticket-id <id> --change-rationales-json '[{"label":"...","file_path":"...","summary":"...","why":"...","impact":"...","hunks":[{"header":"@@ ... @@"}]}]'
1698
+ ovld protocol ask --session-key <key> --ticket-id <id> --question-file ./question.txt
1699
+ ovld protocol read-context --session-key <key> --ticket-id <id> --query arch --limit 5
1700
+ ovld protocol write-context --session-key <key> --ticket-id <id> --key "arch" --value '"monorepo"' --tags repo,agent
1701
+ ovld protocol attachment-list --session-key <key> --ticket-id <id>
1702
+ ovld protocol attachment-prepare-upload --session-key <key> --ticket-id <id> --objective-id <objective-id> --file-name spec.pdf --content-type application/pdf
1703
+ ovld protocol attachment-upload-file --session-key <key> --ticket-id <id> --objective-id <objective-id> --file ./spec.pdf
1704
+ ovld protocol attachment-download-url --session-key <key> --ticket-id <id> --attachment-id <attachment-id>
1705
+ ovld protocol deliver --session-key <key> --ticket-id <id> --summary "Done"
1706
+ ovld protocol deliver --session-key <key> --ticket-id <id> --summary "Done" --artifacts-file ./artifacts.json
1707
+ ovld protocol deliver --session-key <key> --ticket-id <id> --payload-file ./deliver.json
1708
+ ovld protocol deliver --session-key <key> --ticket-id <id> --payload-file -
1709
+ ovld protocol deliver --session-key <key> --ticket-id <id> --summary "Done" --skip-file-change-check
1710
+ ovld protocol deliver --session-key <key> --ticket-id <id> --summary "Done" --timeout 60000
1711
+ `);
1712
+ return;
1713
+ }
1714
+
1715
+ if (subcommand === 'discover-project') {
1716
+ await protocolDiscoverProject(args);
1717
+ return;
1718
+ }
1719
+ if (subcommand === 'auth-status') {
1720
+ await protocolAuthStatus();
1721
+ return;
1722
+ }
1723
+ if (subcommand === 'attach') {
1724
+ await protocolAttach(args);
1725
+ return;
1726
+ }
1727
+ if (subcommand === 'connect') {
1728
+ await protocolConnect(args);
1729
+ return;
1730
+ }
1731
+ if (subcommand === 'load-context') {
1732
+ await protocolLoadContext(args);
1733
+ return;
1734
+ }
1735
+ if (subcommand === 'search-tickets') {
1736
+ await protocolSearchTickets(args);
1737
+ return;
1738
+ }
1739
+ if (subcommand === 'create' || subcommand === 'create-ticket') {
1740
+ await protocolCreateTicket(args);
1741
+ return;
1742
+ }
1743
+ if (subcommand === 'prompt' || subcommand === 'spawn') {
1744
+ await protocolPrompt(args);
1745
+ return;
1746
+ }
1747
+ if (subcommand === 'attachment-list') {
1748
+ await protocolAttachmentList(args);
1749
+ return;
1750
+ }
1751
+ if (subcommand === 'attachment-prepare-upload') {
1752
+ await protocolAttachmentPrepareUpload(args);
1753
+ return;
1754
+ }
1755
+ if (subcommand === 'attachment-finalize-upload') {
1756
+ await protocolAttachmentFinalizeUpload(args);
1757
+ return;
1758
+ }
1759
+ if (subcommand === 'attachment-download-url') {
1760
+ await protocolAttachmentGetDownloadUrl(args);
1761
+ return;
1762
+ }
1763
+ if (subcommand === 'attachment-upload-file') {
1764
+ await protocolAttachmentUploadFile(args);
1765
+ return;
1766
+ }
1767
+ if (subcommand === 'update') {
1768
+ await protocolUpdate(args);
1769
+ return;
1770
+ }
1771
+ if (subcommand === 'record-change-rationales') {
1772
+ await protocolRecordChangeRationales(args);
1773
+ return;
1774
+ }
1775
+ if (subcommand === 'ask') {
1776
+ await protocolAsk(args);
1777
+ return;
1778
+ }
1779
+ if (subcommand === 'permission-request') {
1780
+ await protocolPermissionRequest(args);
1781
+ return;
1782
+ }
1783
+ if (subcommand === 'read-context') {
1784
+ await protocolReadContext(args);
1785
+ return;
1786
+ }
1787
+ if (subcommand === 'write-context') {
1788
+ await protocolWriteContext(args);
1789
+ return;
1790
+ }
1791
+ if (subcommand === 'deliver') {
1792
+ await protocolDeliver(args);
1793
+ return;
1794
+ }
1795
+
1796
+ console.error(`Unknown protocol subcommand: ${subcommand}\n`);
1797
+ console.log('Run: ovld protocol help');
1798
+ process.exit(1);
1799
+ }