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.
- package/README.md +61 -0
- package/bin/_cli/attach.mjs +356 -0
- package/bin/_cli/auth.mjs +382 -0
- package/bin/_cli/credentials.mjs +267 -0
- package/bin/_cli/index.mjs +126 -0
- package/bin/_cli/launcher.mjs +196 -0
- package/bin/_cli/new-ticket.mjs +248 -0
- package/bin/_cli/protocol.mjs +1271 -0
- package/bin/_cli/setup.mjs +553 -0
- package/bin/_cli/ticket.mjs +55 -0
- package/bin/_cli/tickets.mjs +120 -0
- package/bin/ovld.mjs +8 -0
- package/package.json +30 -0
|
@@ -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
|
+
}
|