tines 0.0.1

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 (2) hide show
  1. package/dist/index.js +2454 -0
  2. package/package.json +35 -0
package/dist/index.js ADDED
@@ -0,0 +1,2454 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "node:fs";
5
+ import { hostname as hostname2 } from "node:os";
6
+ import { dirname as dirname3, join as join3 } from "node:path";
7
+ import { createInterface } from "node:readline/promises";
8
+
9
+ // src/daemon/daemon.ts
10
+ import { spawn } from "node:child_process";
11
+ import { mkdirSync as mkdirSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
12
+ import { hostname, platform, arch } from "node:os";
13
+ import { dirname as dirname2, join as join2 } from "node:path";
14
+
15
+ // ../shared/src/types.ts
16
+ function actorLabel(actor) {
17
+ if (actor.run) {
18
+ const ref = actor.run.issue_ref ? `run on ${actor.run.issue_ref.project_name}/${actor.run.issue_ref.number}` : `run ${actor.run.run_id}`;
19
+ return `${actor.user_name} via ${actor.run.runner_name} \xB7 ${ref}`;
20
+ }
21
+ return actor.api_key_name ? `${actor.user_name} via ${actor.api_key_name}` : actor.user_name;
22
+ }
23
+ var PROMPT_MAX_BYTES = 32 * 1024;
24
+ var SKILL_MAX_TOTAL_BYTES = 100 * 1024;
25
+ var AGENT_GUIDELINES_NAME = "agent-guidelines";
26
+ var JOURNAL_NAME = "journal";
27
+ var AGENT_GUIDELINES_BODY = `You are an agent working on a Tines issue over its HTTP API / CLI. Beyond doing the work, leave the workspace smarter than you found it. Four places to write, chosen by who should inherit what you learned:
28
+
29
+ - **Issue comments** \u2014 all prose about this issue: progress, findings, dead ends, questions, and instructions for whoever picks it up next. \`tines issues comment <project>/<number> "<markdown>"\`
30
+ - **Issue context (artifacts)** \u2014 things this issue needs *attached*, not said: a skill, a repo/branch pin, or an override of a broader item (reuse its name): \`tines context create --kind <k> --name <n> --issue <project>/<number> \u2026\`. Never notes \u2014 notes are comments.
31
+ - **Your journal** \u2014 shared notes for anyone doing this stage of work in this project. Append a dated bullet whenever you learn something they would want: commands that actually work, gotchas, where things live (see "Journal" at the end of this prompt for the exact commands). If an entry is wrong or stale, rewrite the journal to fix it \u2014 do not append a correction on top. Keep it short; prune when you touch it.
32
+ - **Context change requests** \u2014 never edit shared context (project-, state-, or global-scoped items) directly. Propose instead: file an issue in the project you are working in, titled \`Context change: <scope label>\`, naming the item (kind, name, scope) with the full proposed text in the description. A human reviews and applies it.
33
+
34
+ When in doubt: comment. If the lesson outlives this issue, journal it. Only file a context change when a shared rule is wrong or missing.`;
35
+ var AGENT_GUIDELINES_DESCRIPTION = "How agents should use comments, artifacts, the journal, and context change requests";
36
+ function repoDirFromUrl(url) {
37
+ const stripped = url.replace(/[?#].*$/, "").replace(/\/+$/, "");
38
+ const lastSlash = Math.max(stripped.lastIndexOf("/"), stripped.lastIndexOf(":"));
39
+ const base = stripped.slice(lastSlash + 1).replace(/\.git$/, "");
40
+ if (!base || base === "." || base === ".." || base.includes("\\") || base.includes("=")) return "repo";
41
+ return base;
42
+ }
43
+ var MODEL_TIERS = ["smartest", "balanced", "cheapest"];
44
+ var RUNNER_ONLINE_WINDOW_MS = 2 * 60 * 1e3;
45
+ var RUNNER_OFFLINE_FAIL_MS = 5 * 60 * 1e3;
46
+ var LAUNCH_STALL_MS = 5 * 60 * 1e3;
47
+ var RUN_KEY_SLACK_MS = 10 * 60 * 1e3;
48
+ var RUN_LOG_MAX_BYTES = 256 * 1024;
49
+ function runDurationLabel(run, now = Date.now()) {
50
+ if (!run.started_at) return "\u2014";
51
+ const seconds = Math.max(0, Math.round(((run.ended_at ?? now) - run.started_at) / 1e3));
52
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`;
53
+ }
54
+ function utilizationLabel(quota2, activeRuns, stateName = (id) => id) {
55
+ if (quota2.type === "global_cap") {
56
+ return `${activeRuns.length}/${quota2.limit} global slot${quota2.limit === 1 ? "" : "s"} in use`;
57
+ }
58
+ const counts = /* @__PURE__ */ new Map();
59
+ for (const run of activeRuns) {
60
+ const entry = counts.get(run.state_id_at_start) ?? {
61
+ name: run.state_at_start_name ?? stateName(run.state_id_at_start),
62
+ n: 0
63
+ };
64
+ entry.n += 1;
65
+ counts.set(run.state_id_at_start, entry);
66
+ }
67
+ for (const stateId of Object.keys(quota2.overrides)) {
68
+ if (!counts.has(stateId)) counts.set(stateId, { name: stateName(stateId), n: 0 });
69
+ }
70
+ if (counts.size === 0) return `no active runs (roster default ${quota2.default_limit} per state)`;
71
+ return [...counts.entries()].map(([stateId, { name, n }]) => `${name} ${n}/${quota2.overrides[stateId] ?? quota2.default_limit}`).join(" \xB7 ");
72
+ }
73
+
74
+ // ../shared/src/client.ts
75
+ var ApiError = class extends Error {
76
+ status;
77
+ code;
78
+ details;
79
+ constructor(status, body, fallback) {
80
+ super(body?.message ?? fallback);
81
+ this.name = "ApiError";
82
+ this.status = status;
83
+ this.code = body?.code ?? "unknown";
84
+ this.details = body?.details;
85
+ }
86
+ };
87
+ function query(params) {
88
+ const search = new URLSearchParams();
89
+ for (const [key, value] of Object.entries(params)) {
90
+ if (value !== void 0 && value !== "" && value !== null) search.set(key, String(value));
91
+ }
92
+ const s = search.toString();
93
+ return s ? `?${s}` : "";
94
+ }
95
+ function createApiClient(options) {
96
+ const base = options.baseUrl.replace(/\/+$/, "");
97
+ const fetchFn = options.fetch ?? globalThis.fetch;
98
+ async function request(method, path, body) {
99
+ const headers = { accept: "application/json" };
100
+ if (options.apiKey) headers.authorization = `Bearer ${options.apiKey}`;
101
+ if (body !== void 0) headers["content-type"] = "application/json";
102
+ const res = await fetchFn(`${base}${path}`, {
103
+ method,
104
+ headers,
105
+ body: body === void 0 ? void 0 : JSON.stringify(body)
106
+ });
107
+ if (!res.ok) {
108
+ let parsed = null;
109
+ try {
110
+ parsed = (await res.json()).error ?? null;
111
+ } catch {
112
+ }
113
+ throw new ApiError(res.status, parsed, `${method} ${path} failed: ${res.status}`);
114
+ }
115
+ if (res.status === 204) return void 0;
116
+ return await res.json();
117
+ }
118
+ const get = (path) => request("GET", path);
119
+ return {
120
+ getTime: () => get("/api/time"),
121
+ // Projects
122
+ listProjects: (page = {}) => get(`/api/v1/projects${query(page)}`),
123
+ createProject: (body) => request("POST", "/api/v1/projects", body),
124
+ getProject: (id) => get(`/api/v1/projects/${id}`),
125
+ updateProject: (id, body) => request("PATCH", `/api/v1/projects/${id}`, body),
126
+ deleteProject: (id, body) => request("DELETE", `/api/v1/projects/${id}`, body),
127
+ // Workflows
128
+ listWorkflows: (page = {}) => get(`/api/v1/workflows${query(page)}`),
129
+ createWorkflow: (body) => request("POST", "/api/v1/workflows", body),
130
+ getWorkflow: (id) => get(`/api/v1/workflows/${id}`),
131
+ updateWorkflow: (id, body) => request("PATCH", `/api/v1/workflows/${id}`, body),
132
+ deleteWorkflow: (id, body) => request("DELETE", `/api/v1/workflows/${id}`, body),
133
+ // Issues
134
+ listIssues: (filters = {}) => get(`/api/v1/issues${query(filters)}`),
135
+ listProjectIssues: (projectId, filters = {}) => get(`/api/v1/projects/${projectId}/issues${query(filters)}`),
136
+ createIssue: (projectId, body) => request("POST", `/api/v1/projects/${projectId}/issues`, body),
137
+ getIssue: (id) => get(`/api/v1/issues/${id}`),
138
+ getIssueByNumber: (projectId, number) => get(`/api/v1/projects/${projectId}/issues/${number}`),
139
+ updateIssue: (id, body) => request("PATCH", `/api/v1/issues/${id}`, body),
140
+ transitionIssue: (id, body) => request("POST", `/api/v1/issues/${id}/transition`, body),
141
+ /** Un-park: clears needs_attention, resets the attempt count. */
142
+ resumeIssue: (id) => request("POST", `/api/v1/issues/${id}/resume`),
143
+ /** The dispatch explainer: "why isn't this running?". */
144
+ getIssueDispatch: (id) => get(`/api/v1/issues/${id}/dispatch`),
145
+ // Issue links (dependencies & duplicates)
146
+ addIssueLink: (issueId, body) => request("POST", `/api/v1/issues/${issueId}/links`, body),
147
+ removeIssueLink: (issueId, linkId) => request("DELETE", `/api/v1/issues/${issueId}/links/${linkId}`),
148
+ // Comments
149
+ listComments: (issueId, page = {}) => get(`/api/v1/issues/${issueId}/comments${query(page)}`),
150
+ createComment: (issueId, body) => request("POST", `/api/v1/issues/${issueId}/comments`, body),
151
+ // Scheduled tasks
152
+ listSchedules: (filters = {}) => get(`/api/v1/schedules${query(filters)}`),
153
+ listProjectSchedules: (projectId, page = {}) => get(`/api/v1/projects/${projectId}/schedules${query(page)}`),
154
+ getSchedule: (id) => get(`/api/v1/schedules/${id}`),
155
+ updateSchedule: (id, body) => request("PATCH", `/api/v1/schedules/${id}`, body),
156
+ deleteSchedule: (id) => request("DELETE", `/api/v1/schedules/${id}`),
157
+ /** Run now: creates an instance immediately (gate-respecting; 422 when blocked). */
158
+ runSchedule: (id) => request("POST", `/api/v1/schedules/${id}/run`),
159
+ // Context items
160
+ listContext: (filters = {}) => get(`/api/v1/context${query(filters)}`),
161
+ createContextItem: (body) => request("POST", "/api/v1/context", body),
162
+ getContextItem: (id) => get(`/api/v1/context/${id}`),
163
+ updateContextItem: (id, body) => request("PATCH", `/api/v1/context/${id}`, body),
164
+ deleteContextItem: (id) => request("DELETE", `/api/v1/context/${id}`),
165
+ /** Atomic append to a prompt item's body (blank-line separated). */
166
+ appendContextItem: (id, body) => request("POST", `/api/v1/context/${id}/append`, body),
167
+ /** Effective context for an issue: the assembled bundle. */
168
+ getIssueContext: (issueId) => get(`/api/v1/issues/${issueId}/context`),
169
+ /** Launch prompt: stitched context plus the generated issue block. */
170
+ getIssuePrompt: (issueId) => get(`/api/v1/issues/${issueId}/prompt`),
171
+ // Events
172
+ listEvents: (filters = {}) => get(`/api/v1/events${query(filters)}`),
173
+ // Runners
174
+ listRunners: () => get("/api/v1/runners"),
175
+ createRunner: (body) => request("POST", "/api/v1/runners", body),
176
+ getRunner: (id) => get(`/api/v1/runners/${id}`),
177
+ updateRunner: (id, body) => request("PATCH", `/api/v1/runners/${id}`, body),
178
+ /** Reject-by-default: 422 names referencing rules/pins unless `force`. */
179
+ deleteRunner: (id, body) => request("DELETE", `/api/v1/runners/${id}`, body),
180
+ /** Create/reconnect a local runner; the response's token is shown once. */
181
+ registerRunner: (body) => request("POST", "/api/v1/runners/register", body),
182
+ /** Invalidate the runner token and mint a fresh one (shown once). */
183
+ rotateRunnerToken: (id) => request("POST", `/api/v1/runners/${id}/rotate-token`),
184
+ // Local runner protocol (runner-token auth: construct the client with
185
+ // the runner token as `apiKey`)
186
+ pollRunner: (id, body) => request("POST", `/api/v1/runners/${id}/poll`, body),
187
+ appendRunLog: (runId, body) => request("POST", `/api/v1/runs/${runId}/logs`, body),
188
+ finishRun: (runId, body) => request("POST", `/api/v1/runs/${runId}/finish`, body),
189
+ // Agent runs
190
+ listRuns: (filters = {}) => get(`/api/v1/runs${query(filters)}`),
191
+ getRun: (id) => get(`/api/v1/runs/${id}`),
192
+ cancelRun: (id) => request("POST", `/api/v1/runs/${id}/cancel`),
193
+ // Routing rules (one per exact scope; responses carry shadow hints)
194
+ listRoutingRules: () => get("/api/v1/routing-rules"),
195
+ createRoutingRule: (body) => request("POST", "/api/v1/routing-rules", body),
196
+ updateRoutingRule: (id, body) => request("PATCH", `/api/v1/routing-rules/${id}`, body),
197
+ deleteRoutingRule: (id) => request("DELETE", `/api/v1/routing-rules/${id}`),
198
+ // Supervisor settings
199
+ getSupervisorSettings: () => get("/api/v1/supervisor/settings"),
200
+ updateSupervisorSettings: (body) => request("PUT", "/api/v1/supervisor/settings", body),
201
+ // API keys (create/revoke require a browser session, not a key)
202
+ listApiKeys: () => get("/api/v1/api-keys"),
203
+ createApiKey: (body) => request("POST", "/api/v1/api-keys", body),
204
+ revokeApiKey: (id) => request("DELETE", `/api/v1/api-keys/${id}`)
205
+ };
206
+ }
207
+
208
+ // ../shared/src/schedule.ts
209
+ var WEEKDAY_NAMES = [
210
+ "Sunday",
211
+ "Monday",
212
+ "Tuesday",
213
+ "Wednesday",
214
+ "Thursday",
215
+ "Friday",
216
+ "Saturday"
217
+ ];
218
+ function describeRecurrence(preset, cron) {
219
+ if (preset) {
220
+ switch (preset.kind) {
221
+ case "hourly": {
222
+ const every = preset.every_hours ?? 1;
223
+ const at = preset.minute ? ` at :${String(preset.minute).padStart(2, "0")}` : "";
224
+ return every === 1 ? `Every hour${at}` : `Every ${every} hours${at}`;
225
+ }
226
+ case "daily":
227
+ return `Every day at ${preset.time}`;
228
+ case "weekly":
229
+ return `Every ${WEEKDAY_NAMES[preset.weekday ?? 0]} at ${preset.time}`;
230
+ case "monthly":
231
+ return `Monthly on day ${preset.day_of_month} at ${preset.time}`;
232
+ }
233
+ }
234
+ return `Cron \u201C${cron}\u201D`;
235
+ }
236
+ var PLACEHOLDER_DESCRIPTIONS = {
237
+ date: "run date, e.g. 2026-08-23",
238
+ time: "run time, e.g. 09:30",
239
+ datetime: "date and time together",
240
+ schedule_name: "the schedule's name",
241
+ count: "how many instances it has created, starting at 1"
242
+ };
243
+ var TEMPLATE_PLACEHOLDERS = Object.keys(PLACEHOLDER_DESCRIPTIONS).map((key) => ({ key, description: PLACEHOLDER_DESCRIPTIONS[key] }));
244
+
245
+ // src/daemon/store.ts
246
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
247
+ import { homedir } from "node:os";
248
+ import { dirname, join } from "node:path";
249
+ function defaultConfigDir() {
250
+ return process.env.TINES_CONFIG_DIR ?? join(homedir(), ".config", "tines");
251
+ }
252
+ function readJsonFile(path) {
253
+ if (!existsSync(path)) return null;
254
+ try {
255
+ return JSON.parse(readFileSync(path, "utf8"));
256
+ } catch {
257
+ return null;
258
+ }
259
+ }
260
+ function writeJsonFile(path, value, { secret = false } = {}) {
261
+ mkdirSync(dirname(path), { recursive: true });
262
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}
263
+ `, secret ? { mode: 384 } : {});
264
+ }
265
+ function credentialsKey(url, name) {
266
+ return `${url.replace(/\/+$/, "")}#${name}`;
267
+ }
268
+ function credentialsPath(dir) {
269
+ return join(dir, "runners.json");
270
+ }
271
+ function loadRunnerCredentials(dir, url, name) {
272
+ const all = readJsonFile(credentialsPath(dir));
273
+ const entry = all?.[credentialsKey(url, name)];
274
+ return entry && typeof entry.runner_id === "string" && typeof entry.token === "string" ? entry : null;
275
+ }
276
+ function saveRunnerCredentials(dir, url, name, creds) {
277
+ const path = credentialsPath(dir);
278
+ const all = readJsonFile(path) ?? {};
279
+ all[credentialsKey(url, name)] = creds;
280
+ writeJsonFile(path, all, { secret: true });
281
+ }
282
+ function hasRunnerCredentials(dir, url, name) {
283
+ return loadRunnerCredentials(dir, url, name) !== null;
284
+ }
285
+ function clearRunnerCredentials(dir, url, name) {
286
+ const path = credentialsPath(dir);
287
+ const all = readJsonFile(path) ?? {};
288
+ delete all[credentialsKey(url, name)];
289
+ writeJsonFile(path, all, { secret: true });
290
+ }
291
+ function daemonStatePath(dir, runnerId) {
292
+ return join(dir, `daemon-state-${runnerId}.json`);
293
+ }
294
+ function loadDaemonState(path) {
295
+ const state = readJsonFile(path);
296
+ if (!state || !Array.isArray(state.runs)) return [];
297
+ return state.runs.filter(
298
+ (e) => typeof e === "object" && e !== null && typeof e.run_id === "string" && typeof e.pid === "number" && typeof e.workspace === "string"
299
+ );
300
+ }
301
+ function saveDaemonState(path, runs) {
302
+ writeJsonFile(path, { runs });
303
+ }
304
+ function processStartTimeMs(pid) {
305
+ try {
306
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
307
+ const afterComm = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
308
+ const startTicks = Number(afterComm[19]);
309
+ const btimeLine = readFileSync("/proc/stat", "utf8").split("\n").find((line) => line.startsWith("btime "));
310
+ const btime = Number(btimeLine?.slice("btime ".length));
311
+ if (!Number.isFinite(startTicks) || !Number.isFinite(btime)) return null;
312
+ return btime * 1e3 + startTicks / 100 * 1e3;
313
+ } catch {
314
+ return null;
315
+ }
316
+ }
317
+
318
+ // src/daemon/support.ts
319
+ var HARNESS_KINDS = ["claude_code", "codex", "custom"];
320
+ function shellQuote(value) {
321
+ return `'${value.replaceAll("'", `'\\''`)}'`;
322
+ }
323
+ function expandCommandTemplate(template, input) {
324
+ return template.replaceAll("{prompt_file}", shellQuote(input.promptFile)).replaceAll("{workspace}", shellQuote(input.workspace)).replaceAll("{model}", input.model ? shellQuote(input.model) : "''");
325
+ }
326
+ function buildHarnessInvocation(spec, input) {
327
+ switch (spec.harness) {
328
+ case "claude_code":
329
+ return {
330
+ file: "sh",
331
+ args: [
332
+ "-c",
333
+ `claude -p${input.model ? ` --model ${shellQuote(input.model)}` : ""} < ${shellQuote(input.promptFile)}`
334
+ ]
335
+ };
336
+ case "codex":
337
+ return {
338
+ file: "codex",
339
+ args: ["exec", ...input.model ? ["--model", input.model] : [], input.prompt]
340
+ };
341
+ case "custom": {
342
+ if (!spec.command) throw new Error("the custom harness needs a --command template");
343
+ return { file: "sh", args: ["-c", expandCommandTemplate(spec.command, input)] };
344
+ }
345
+ }
346
+ }
347
+ var RunTable = class {
348
+ constructor(effects) {
349
+ this.effects = effects;
350
+ }
351
+ effects;
352
+ runs = /* @__PURE__ */ new Map();
353
+ get size() {
354
+ return this.runs.size;
355
+ }
356
+ ids() {
357
+ return [...this.runs.keys()];
358
+ }
359
+ values() {
360
+ return [...this.runs.values()];
361
+ }
362
+ has(runId) {
363
+ return this.runs.has(runId);
364
+ }
365
+ track(run) {
366
+ this.runs.set(run.runId, run);
367
+ this.effects.persist();
368
+ }
369
+ /** Call after setting a run's pid so the state file reflects it. */
370
+ persist() {
371
+ this.effects.persist();
372
+ }
373
+ /** Removes the run and releases its local traces. Safe to call twice. */
374
+ cleanup(run) {
375
+ this.runs.delete(run.runId);
376
+ this.effects.persist();
377
+ this.effects.release(run);
378
+ }
379
+ /**
380
+ * Ends a run: flush logs, finish-report, clean up. On a run someone
381
+ * already settled (a poll-cancel racing a clone failure or spawn error)
382
+ * this still cleans up — the slot, workspace, and state entry must never
383
+ * outlive the run — but reports nothing.
384
+ */
385
+ async finishAndCleanup(run, status, error) {
386
+ if (run.settled) return this.cleanup(run);
387
+ run.settled = true;
388
+ await run.flush?.();
389
+ try {
390
+ await this.effects.finish(run, status, error);
391
+ this.effects.log(`run ${run.runId} finished: ${status}${error ? ` (${error})` : ""}`);
392
+ } catch (err) {
393
+ this.effects.log(
394
+ `finish report for run ${run.runId} not accepted: ${err instanceof Error ? err.message : String(err)}`
395
+ );
396
+ }
397
+ this.cleanup(run);
398
+ }
399
+ /**
400
+ * Marks a supervisor-settled run (`cancels`): kill, do NOT finish-report.
401
+ * Returns the run for the caller to kill when it was live; null when
402
+ * unknown or already settled. A run with no pid yet (still materializing)
403
+ * is cleaned up by the launch path's next settled check.
404
+ */
405
+ markCanceled(runId) {
406
+ const run = this.runs.get(runId);
407
+ if (!run || run.settled) return null;
408
+ run.canceled = true;
409
+ run.settled = true;
410
+ return run;
411
+ }
412
+ };
413
+ var LogBatcher = class {
414
+ constructor(send, opts = {}) {
415
+ this.send = send;
416
+ this.opts = opts;
417
+ }
418
+ send;
419
+ opts;
420
+ buffer = "";
421
+ timer = null;
422
+ sending = Promise.resolve();
423
+ append(text) {
424
+ if (!text) return;
425
+ this.buffer += text;
426
+ if (Buffer.byteLength(this.buffer, "utf8") >= (this.opts.maxBytes ?? 8 * 1024)) {
427
+ void this.flush();
428
+ } else if (!this.timer) {
429
+ this.timer = setTimeout(() => void this.flush(), this.opts.intervalMs ?? 2e3);
430
+ this.timer.unref?.();
431
+ }
432
+ }
433
+ /** Sends whatever is buffered now; resolves when every send so far settled. */
434
+ flush() {
435
+ if (this.timer) {
436
+ clearTimeout(this.timer);
437
+ this.timer = null;
438
+ }
439
+ const chunk = this.buffer;
440
+ this.buffer = "";
441
+ if (chunk) {
442
+ this.sending = this.sending.then(() => this.send(chunk)).catch((err) => this.opts.onError?.(err));
443
+ }
444
+ return this.sending;
445
+ }
446
+ };
447
+
448
+ // src/daemon/daemon.ts
449
+ var log = (message2) => console.log(`[${(/* @__PURE__ */ new Date()).toISOString().slice(11, 19)}] ${message2}`);
450
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
451
+ function killTree(pid, signal) {
452
+ try {
453
+ process.kill(-pid, signal);
454
+ } catch {
455
+ try {
456
+ process.kill(pid, signal);
457
+ } catch {
458
+ }
459
+ }
460
+ }
461
+ function pidAlive(pid) {
462
+ try {
463
+ process.kill(pid, 0);
464
+ return true;
465
+ } catch {
466
+ return false;
467
+ }
468
+ }
469
+ async function runDaemon(opts) {
470
+ mkdirSync2(opts.configDir, { recursive: true });
471
+ let creds = loadRunnerCredentials(opts.configDir, opts.url, opts.name);
472
+ if (creds) {
473
+ log(`reconnecting as runner "${opts.name}" (${creds.runner_id}) \u2014 token from ${opts.configDir}`);
474
+ } else {
475
+ if (!opts.apiKey) {
476
+ throw new Error(
477
+ `no stored runner token for "${opts.name}" at ${opts.url} \u2014 set TINES_API_KEY (a user API key) to register`
478
+ );
479
+ }
480
+ const userClient = createApiClient({ baseUrl: opts.url, apiKey: opts.apiKey });
481
+ const registered = await userClient.registerRunner({
482
+ name: opts.name,
483
+ harness: opts.harness,
484
+ ...opts.command !== void 0 ? { command: opts.command } : {},
485
+ max_concurrent: opts.maxConcurrent,
486
+ hostname: hostname(),
487
+ platform: `${platform()} ${arch()}`
488
+ });
489
+ creds = { runner_id: registered.runner.id, token: registered.runner_token };
490
+ saveRunnerCredentials(opts.configDir, opts.url, opts.name, creds);
491
+ log(`registered runner "${opts.name}" (${creds.runner_id}); token stored in ${opts.configDir}`);
492
+ }
493
+ const client2 = createApiClient({ baseUrl: opts.url, apiKey: creds.token });
494
+ const statePath = daemonStatePath(opts.configDir, creds.runner_id);
495
+ let shuttingDown = false;
496
+ const table2 = new RunTable({
497
+ finish: async (run, status, error) => {
498
+ await client2.finishRun(run.runId, { status, ...error ? { error } : {} });
499
+ },
500
+ release: (run) => {
501
+ if (run.timeout) clearTimeout(run.timeout);
502
+ rmSync(run.workspace, { recursive: true, force: true });
503
+ },
504
+ persist: () => {
505
+ const entries = table2.values().filter((run) => run.child?.pid !== void 0).map((run) => ({
506
+ run_id: run.runId,
507
+ pid: run.child.pid,
508
+ workspace: run.workspace,
509
+ key_fingerprint: run.keyFingerprint,
510
+ started_at: run.spawnedAt
511
+ }));
512
+ saveDaemonState(statePath, entries);
513
+ },
514
+ log
515
+ });
516
+ for (const orphan of loadDaemonState(statePath)) {
517
+ if (pidAlive(orphan.pid)) {
518
+ const processStart = processStartTimeMs(orphan.pid);
519
+ const reused = processStart !== null && orphan.started_at !== void 0 && processStart > orphan.started_at + 6e4;
520
+ if (reused) {
521
+ log(`state-file pid ${orphan.pid} (run ${orphan.run_id}) was recycled; not killing it`);
522
+ } else {
523
+ log(`killing orphaned harness from a previous life: run ${orphan.run_id} (pid ${orphan.pid})`);
524
+ killTree(orphan.pid, "SIGKILL");
525
+ }
526
+ }
527
+ try {
528
+ await client2.finishRun(orphan.run_id, {
529
+ status: "failed",
530
+ error: "daemon restarted; orphaned harness killed"
531
+ });
532
+ } catch {
533
+ }
534
+ rmSync(orphan.workspace, { recursive: true, force: true });
535
+ }
536
+ saveDaemonState(statePath, []);
537
+ const killWithoutFinish = (runId) => {
538
+ const run = table2.markCanceled(runId);
539
+ if (!run) return;
540
+ log(`supervisor canceled run ${runId}; killing without finish-reporting`);
541
+ if (run.child?.pid) {
542
+ const pid = run.child.pid;
543
+ killTree(pid, "SIGTERM");
544
+ setTimeout(() => killTree(pid, "SIGKILL"), 5e3).unref?.();
545
+ }
546
+ };
547
+ const launch = async (assignment) => {
548
+ const runId = assignment.run.id;
549
+ if (table2.has(runId)) return;
550
+ const workspace = join2(opts.configDir, "workspaces", runId);
551
+ const run = {
552
+ runId,
553
+ workspace,
554
+ canceled: false,
555
+ timedOut: false,
556
+ settled: false,
557
+ keyFingerprint: assignment.run_key.slice(0, 14),
558
+ batcher: new LogBatcher((chunk) => client2.appendRunLog(runId, { chunk }).then(() => {
559
+ }), {
560
+ onError: (err) => log(`log append for run ${runId} failed: ${message(err)}`)
561
+ })
562
+ };
563
+ run.flush = () => run.batcher.flush();
564
+ table2.track(run);
565
+ log(`run ${runId} assigned (issue ${assignment.run.issue_ref ? `${assignment.run.issue_ref.project_name}/${assignment.run.issue_ref.number}` : assignment.run.issue_id}); materializing workspace`);
566
+ try {
567
+ rmSync(workspace, { recursive: true, force: true });
568
+ mkdirSync2(workspace, { recursive: true });
569
+ writeFileSync2(join2(workspace, "prompt.md"), `${assignment.prompt}
570
+ `);
571
+ for (const skill of assignment.bundle.skills) {
572
+ for (const file of skill.files) {
573
+ const target = join2(workspace, "skills", skill.name, file.path);
574
+ mkdirSync2(dirname2(target), { recursive: true });
575
+ writeFileSync2(target, file.content);
576
+ }
577
+ }
578
+ writeFileSync2(
579
+ join2(workspace, "repos.json"),
580
+ `${JSON.stringify(assignment.bundle.repos, null, 2)}
581
+ `
582
+ );
583
+ for (const repo of assignment.bundle.repos) {
584
+ if (run.settled) return table2.cleanup(run);
585
+ const args = ["clone", ...repo.branch ? ["--branch", repo.branch] : [], repo.url, repo.dir];
586
+ run.batcher.append(`$ git ${args.join(" ")}
587
+ `);
588
+ const result = await runGit(args, workspace, run.batcher);
589
+ if (result !== 0) {
590
+ return table2.finishAndCleanup(run, "failed", `git clone failed for ${repo.url} (exit ${result})`);
591
+ }
592
+ }
593
+ if (run.settled) return table2.cleanup(run);
594
+ const invocation = buildHarnessInvocation(
595
+ { harness: opts.harness, command: opts.command },
596
+ {
597
+ workspace,
598
+ promptFile: join2(workspace, "prompt.md"),
599
+ prompt: assignment.prompt,
600
+ model: assignment.run.model
601
+ }
602
+ );
603
+ const child = spawn(invocation.file, invocation.args, {
604
+ cwd: workspace,
605
+ env: { ...process.env, TINES_API_KEY: assignment.run_key, TINES_API_URL: opts.url },
606
+ stdio: ["ignore", "pipe", "pipe"],
607
+ detached: true
608
+ });
609
+ run.child = child;
610
+ run.spawnedAt = Date.now();
611
+ table2.persist();
612
+ log(`run ${runId}: launched ${invocation.file} (pid ${child.pid})`);
613
+ child.stdout?.on("data", (data) => run.batcher.append(data.toString("utf8")));
614
+ child.stderr?.on("data", (data) => run.batcher.append(data.toString("utf8")));
615
+ run.timeout = setTimeout(
616
+ () => {
617
+ if (run.settled) return;
618
+ log(`run ${runId} hit its ${assignment.timeout_minutes}m timeout; killing`);
619
+ run.timedOut = true;
620
+ if (child.pid) killTree(child.pid, "SIGTERM");
621
+ if (child.pid) setTimeout(() => killTree(child.pid, "SIGKILL"), 5e3).unref?.();
622
+ },
623
+ assignment.timeout_minutes * 6e4
624
+ );
625
+ child.on("error", (err) => {
626
+ void table2.finishAndCleanup(run, "failed", `failed to launch harness: ${message(err)}`);
627
+ });
628
+ child.on("exit", (code, signal) => {
629
+ if (run.timedOut) {
630
+ void table2.finishAndCleanup(
631
+ run,
632
+ "failed",
633
+ `run exceeded the ${assignment.timeout_minutes}m timeout; harness killed`
634
+ );
635
+ } else if (code === 0) {
636
+ void table2.finishAndCleanup(run, "completed");
637
+ } else {
638
+ void table2.finishAndCleanup(
639
+ run,
640
+ "failed",
641
+ signal ? `harness killed by ${signal}` : `harness exited with code ${code}`
642
+ );
643
+ }
644
+ });
645
+ } catch (err) {
646
+ void table2.finishAndCleanup(run, "failed", `workspace setup failed: ${message(err)}`);
647
+ }
648
+ };
649
+ const shutdown = async () => {
650
+ if (shuttingDown) return;
651
+ shuttingDown = true;
652
+ log("shutting down; failing in-flight runs");
653
+ await Promise.all(
654
+ table2.values().map(async (run) => {
655
+ if (run.child?.pid) killTree(run.child.pid, "SIGTERM");
656
+ await table2.finishAndCleanup(run, "failed", "daemon shut down");
657
+ })
658
+ );
659
+ process.exit(0);
660
+ };
661
+ process.on("SIGINT", () => void shutdown());
662
+ process.on("SIGTERM", () => void shutdown());
663
+ log(
664
+ `polling ${opts.url} every ${Math.round(opts.pollIntervalMs / 1e3)}s (harness ${opts.harness}, max ${opts.maxConcurrent} concurrent) \u2014 Ctrl-C to stop`
665
+ );
666
+ let failures = 0;
667
+ while (!shuttingDown) {
668
+ try {
669
+ const res = await client2.pollRunner(creds.runner_id, {
670
+ owned_runs: table2.ids(),
671
+ max_concurrent: opts.maxConcurrent
672
+ });
673
+ failures = 0;
674
+ for (const runId of res.cancels) killWithoutFinish(runId);
675
+ for (const assignment of res.assignments) {
676
+ if (table2.size >= opts.maxConcurrent) {
677
+ log(
678
+ `warning: supervisor delivered ${assignment.run.id} beyond --max-concurrent ${opts.maxConcurrent}; launching anyway (the server cap governs)`
679
+ );
680
+ }
681
+ void launch(assignment);
682
+ }
683
+ } catch (err) {
684
+ if (err instanceof ApiError && err.status === 401) {
685
+ for (const run of table2.values()) {
686
+ if (run.child?.pid) killTree(run.child.pid, "SIGKILL");
687
+ rmSync(run.workspace, { recursive: true, force: true });
688
+ }
689
+ saveDaemonState(statePath, []);
690
+ clearRunnerCredentials(opts.configDir, opts.url, opts.name);
691
+ throw new Error(
692
+ `the supervisor rejected this runner's token (was it rotated?) \u2014 the stored token was dropped; restart with the new token via \`tines runners rotate-token ${opts.name}\` on this machine, or with TINES_API_KEY set to re-register`
693
+ );
694
+ }
695
+ failures += 1;
696
+ log(`poll failed (${message(err)}); retrying with backoff`);
697
+ }
698
+ const backoff = Math.min(
699
+ opts.pollIntervalMs * 2 ** Math.min(failures, 3),
700
+ Math.max(6e4, opts.pollIntervalMs * 5)
701
+ );
702
+ await sleep(failures > 0 ? backoff : opts.pollIntervalMs);
703
+ }
704
+ }
705
+ function message(err) {
706
+ return err instanceof Error ? err.message : String(err);
707
+ }
708
+ function runGit(args, cwd, batcher) {
709
+ return new Promise((resolve) => {
710
+ const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
711
+ child.stdout?.on("data", (data) => batcher.append(data.toString("utf8")));
712
+ child.stderr?.on("data", (data) => batcher.append(data.toString("utf8")));
713
+ child.on("error", () => resolve(127));
714
+ child.on("exit", (code) => resolve(code ?? 1));
715
+ });
716
+ }
717
+
718
+ // src/help-guard.ts
719
+ function helpGuard(command, markdown) {
720
+ if (markdown === "--help" || markdown === "-h") {
721
+ command.help();
722
+ return true;
723
+ }
724
+ return false;
725
+ }
726
+
727
+ // src/index.ts
728
+ import { Command } from "commander";
729
+ var DEFAULT_URL = "http://localhost:5173";
730
+ function withCommon(cmd, { baseUrlFlag = true } = {}) {
731
+ if (baseUrlFlag) {
732
+ cmd.option(
733
+ "-u, --url <url>",
734
+ `base URL of the Tines API (or set TINES_API_URL; default ${DEFAULT_URL})`
735
+ );
736
+ }
737
+ return cmd.option("--api-key <key>", "API key (or set TINES_API_KEY)").option("--json", "output the raw JSON response");
738
+ }
739
+ function withList(cmd) {
740
+ return withCommon(
741
+ cmd.option("--limit <n>", "maximum items to return", (v) => Number.parseInt(v, 10)).option("--cursor <cursor>", "resume from the next_cursor of a previous page")
742
+ );
743
+ }
744
+ function resolveUrl(opts) {
745
+ return opts.url ?? process.env.TINES_API_URL ?? DEFAULT_URL;
746
+ }
747
+ function resolveApiKey(opts) {
748
+ return opts.apiKey ?? process.env.TINES_API_KEY;
749
+ }
750
+ function client(opts) {
751
+ return createApiClient({ baseUrl: resolveUrl(opts), apiKey: resolveApiKey(opts) });
752
+ }
753
+ function die(message2) {
754
+ console.error(`error: ${message2}`);
755
+ process.exit(1);
756
+ }
757
+ function reportError(err) {
758
+ if (err instanceof ApiError) {
759
+ let message2 = `${err.message} (${err.code})`;
760
+ const allowed = err.details?.allowed_transitions;
761
+ if (Array.isArray(allowed)) {
762
+ const actions = allowed.map((t) => {
763
+ const at = t;
764
+ return at.to_state ? `"${at.name}" \u2192 ${at.to_state.name}` : `"${at.name}"`;
765
+ });
766
+ message2 += actions.length > 0 ? `
767
+ allowed actions: ${actions.join(", ")}` : "\nallowed actions: none (terminal state)";
768
+ }
769
+ die(message2);
770
+ }
771
+ die(err instanceof Error ? err.message : String(err));
772
+ }
773
+ function printJson(value) {
774
+ console.log(JSON.stringify(value, null, 2));
775
+ }
776
+ function printList(res, opts, render) {
777
+ if (opts.json) return printJson(res);
778
+ render(res.items);
779
+ if (res.next_cursor) console.log(`
780
+ more results: rerun with --cursor ${res.next_cursor}`);
781
+ }
782
+ function table(rows) {
783
+ if (rows.length === 0) return;
784
+ const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)));
785
+ for (const row of rows) {
786
+ console.log(row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd());
787
+ }
788
+ }
789
+ function timestamp(ms) {
790
+ return new Date(ms).toISOString().replace("T", " ").slice(0, 19);
791
+ }
792
+ function parseJsonObject(raw, source) {
793
+ let value;
794
+ try {
795
+ value = JSON.parse(raw);
796
+ } catch (err) {
797
+ die(`invalid JSON from ${source}: ${err instanceof Error ? err.message : String(err)}`);
798
+ }
799
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
800
+ die(`expected a JSON object from ${source}`);
801
+ }
802
+ return value;
803
+ }
804
+ function readJsonBody(inline, file) {
805
+ if (inline !== void 0 && file !== void 0) {
806
+ die("pass the JSON inline or with --file, not both");
807
+ }
808
+ if (inline !== void 0) return parseJsonObject(inline, "the argument");
809
+ if (file !== void 0 && file !== "-") {
810
+ let raw;
811
+ try {
812
+ raw = readFileSync2(file, "utf8");
813
+ } catch (err) {
814
+ die(`cannot read ${file}: ${err instanceof Error ? err.message : String(err)}`);
815
+ }
816
+ return parseJsonObject(raw, file);
817
+ }
818
+ if (file === "-" || !process.stdin.isTTY) {
819
+ const raw = readFileSync2(0, "utf8");
820
+ if (raw.trim() === "") {
821
+ if (file === "-") die("no JSON on stdin");
822
+ return void 0;
823
+ }
824
+ return parseJsonObject(raw, "stdin");
825
+ }
826
+ return void 0;
827
+ }
828
+ function assertNewStatesHavePrompts(states, prompts) {
829
+ if (prompts === false || !Array.isArray(states)) return;
830
+ const missing = states.filter((s) => typeof s === "object" && s !== null).filter((s) => s.id === void 0 && !String(s.prompt ?? "").trim()).map((s) => typeof s.name === "string" ? s.name : "?");
831
+ if (missing.length > 0) {
832
+ die(
833
+ `new state${missing.length === 1 ? "" : "s"} ${missing.map((n) => `"${n}"`).join(", ")} ${missing.length === 1 ? "has" : "have"} no initial prompt \u2014 issues sitting in a state inherit its context
834
+ add "prompt": "<markdown>" to each new state in the JSON (its stage instructions), or pass --no-prompts to skip`
835
+ );
836
+ }
837
+ }
838
+ var WORKFLOW_JSON_HELP = `
839
+ The JSON body may be passed inline, via --file <path>, --file - (stdin), or
840
+ piped on stdin. Shape:
841
+
842
+ {
843
+ "name": "Review",
844
+ "description": "Two-step review",
845
+ "initial_state": "Draft",
846
+ "states": [
847
+ { "name": "Draft", "category": "active", "prompt": "Drafting means\u2026" },
848
+ { "name": "In review", "category": "awaiting_human", "prompt": "Review checklist\u2026" },
849
+ { "name": "Done", "category": "done", "prompt": "\u2026" }
850
+ ],
851
+ "transitions": [
852
+ { "name": "submit", "from": "Draft", "to": "In review" },
853
+ { "name": "approve", "from": "In review", "to": "Done" },
854
+ { "name": "send back", "from": "In review", "to": "Draft" }
855
+ ]
856
+ }
857
+
858
+ Categories: backlog, active, awaiting_human, done. On edit, a state with an
859
+ "id" updates that existing state; states/transitions arrays replace the
860
+ existing sets wholesale when present.
861
+
862
+ Each NEW state should carry a "prompt" \u2014 its initial stage instructions,
863
+ created as a state-scoped context item \u2014 or pass --no-prompts to skip.
864
+ `;
865
+ async function resolveProject(api, ref) {
866
+ const { items } = await api.listProjects();
867
+ const byId = items.find((p) => p.id === ref);
868
+ if (byId) return byId;
869
+ const byName = items.filter((p) => p.name === ref);
870
+ if (byName.length === 1) return byName[0];
871
+ if (byName.length > 1) {
872
+ die(`project name "${ref}" is ambiguous; use an id: ${byName.map((p) => p.id).join(", ")}`);
873
+ }
874
+ die(`no project named "${ref}" (have: ${items.map((p) => p.name).join(", ") || "none"})`);
875
+ }
876
+ async function resolveWorkflow(api, ref) {
877
+ const { items } = await api.listWorkflows();
878
+ const found = items.find((w) => w.id === ref) ?? (items.filter((w) => w.name === ref).length === 1 ? items.find((w) => w.name === ref) : void 0);
879
+ if (found) return found;
880
+ if (items.filter((w) => w.name === ref).length > 1) {
881
+ die(`workflow name "${ref}" is ambiguous; use an id`);
882
+ }
883
+ die(`no workflow "${ref}" (have: ${items.map((w) => `${w.name} [${w.id}]`).join(", ")})`);
884
+ }
885
+ function parseScheduleRef(ref) {
886
+ const sep = ref.indexOf("/");
887
+ if (sep < 1 || sep === ref.length - 1) {
888
+ die(`schedule reference must look like <project>/<name>, got "${ref}"`);
889
+ }
890
+ return { project: ref.slice(0, sep), name: ref.slice(sep + 1) };
891
+ }
892
+ async function resolveSchedule(api, ref) {
893
+ const { project, name } = parseScheduleRef(ref);
894
+ const proj = await resolveProject(api, project);
895
+ const { items } = await api.listProjectSchedules(proj.id, { limit: 100 });
896
+ const found = items.find((s) => s.name === name) ?? items.find((s) => s.id === name);
897
+ if (!found) {
898
+ die(
899
+ `no schedule "${name}" in project "${proj.name}" (have: ${items.map((s) => s.name).join(", ") || "none"})`
900
+ );
901
+ }
902
+ return found;
903
+ }
904
+ function parseIssueRef(ref) {
905
+ const match = ref.match(/^(.+)\/(\d+)$/);
906
+ if (!match) die(`issue reference must look like <project>/<number>, got "${ref}"`);
907
+ return { project: match[1], number: Number.parseInt(match[2], 10) };
908
+ }
909
+ async function resolveIssue(api, ref) {
910
+ const { project, number } = parseIssueRef(ref);
911
+ const proj = await resolveProject(api, project);
912
+ return api.getIssueByNumber(proj.id, number);
913
+ }
914
+ async function resolveStateFlag(api, ref) {
915
+ const sep = ref.indexOf("/");
916
+ if (sep < 1 || sep === ref.length - 1) {
917
+ die(`--state must look like <workflow>/<state>, got "${ref}"`);
918
+ }
919
+ const workflow = await resolveWorkflow(api, ref.slice(0, sep));
920
+ const stateRef = ref.slice(sep + 1);
921
+ const state = workflow.states.find((s) => s.name === stateRef) ?? workflow.states.find((s) => s.id === stateRef);
922
+ if (!state) {
923
+ die(
924
+ `workflow "${workflow.name}" has no state "${stateRef}" (have: ${workflow.states.map((s) => s.name).join(", ")})`
925
+ );
926
+ }
927
+ return { workflow, state };
928
+ }
929
+ async function resolveScopeFlags(api, opts) {
930
+ const scope = {};
931
+ if (opts.project !== void 0) scope.project_id = (await resolveProject(api, opts.project)).id;
932
+ if (opts.state !== void 0) scope.workflow_state_id = (await resolveStateFlag(api, opts.state)).state.id;
933
+ if (opts.issue !== void 0) scope.issue_id = (await resolveIssue(api, opts.issue)).id;
934
+ return scope;
935
+ }
936
+ function readBodyValue(value) {
937
+ if (value.startsWith("@@")) return value.slice(1);
938
+ if (value.startsWith("@")) {
939
+ const file = value.slice(1);
940
+ try {
941
+ return readFileSync2(file, "utf8");
942
+ } catch (err) {
943
+ die(`cannot read ${file}: ${err instanceof Error ? err.message : String(err)}`);
944
+ }
945
+ }
946
+ return value;
947
+ }
948
+ function parseFileSpec(spec) {
949
+ const sep = spec.indexOf("=");
950
+ if (sep < 1 || sep === spec.length - 1) {
951
+ die(`--file must look like <path>=@<local-file>, got "${spec}"`);
952
+ }
953
+ const path = spec.slice(0, sep);
954
+ const source = spec.slice(sep + 1);
955
+ if (!source.startsWith("@")) {
956
+ die(`skill file content always comes from a local file: --file ${path}=@<local-file>`);
957
+ }
958
+ const file = source.slice(1);
959
+ try {
960
+ return { path, content: readFileSync2(file, "utf8") };
961
+ } catch (err) {
962
+ die(`cannot read ${file}: ${err instanceof Error ? err.message : String(err)}`);
963
+ }
964
+ }
965
+ var collect = (value, previous) => [...previous, value];
966
+ function contextItemSummary(item) {
967
+ switch (item.kind) {
968
+ case "prompt":
969
+ return `${Buffer.byteLength(item.body ?? "", "utf8")} bytes`;
970
+ case "skill":
971
+ return `${item.file_count ?? item.files?.length ?? 0} file${(item.file_count ?? item.files?.length ?? 0) === 1 ? "" : "s"}`;
972
+ case "repo":
973
+ return `${item.repo_url}${item.repo_branch ? `#${item.repo_branch}` : ""}`;
974
+ }
975
+ }
976
+ function printContextItem(item) {
977
+ console.log(`${item.kind} "${item.name}" [${item.id}] v${item.version}`);
978
+ if (item.description) console.log(item.description);
979
+ console.log(`scope: ${item.scope.label}`);
980
+ console.log(`updated: ${timestamp(item.updated_at)} created: ${timestamp(item.created_at)}`);
981
+ if (item.kind === "prompt") {
982
+ console.log(`
983
+ ${item.body}`);
984
+ } else if (item.kind === "skill") {
985
+ console.log(`
986
+ files (seeded at skills/${item.name}/):`);
987
+ for (const f of item.files ?? []) {
988
+ console.log(` ${f.path} (${Buffer.byteLength(f.content, "utf8")} bytes)`);
989
+ }
990
+ if ((item.files ?? []).length === 0) console.log(" (none)");
991
+ } else {
992
+ console.log(`
993
+ url: ${item.repo_url}`);
994
+ if (item.repo_branch) console.log(`branch: ${item.repo_branch}`);
995
+ console.log(`dir: ${item.repo_dir ?? `${repoDirFromUrl(item.repo_url ?? "")} (derived from the URL)`}`);
996
+ }
997
+ }
998
+ var systemTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
999
+ function parseWeekday(value) {
1000
+ const trimmed = value.trim().toLowerCase();
1001
+ if (/^\d+$/.test(trimmed)) {
1002
+ const n = Number.parseInt(trimmed, 10);
1003
+ if (n <= 7) return n % 7;
1004
+ die(`--on weekday must be 0-7 or a name, got "${value}"`);
1005
+ }
1006
+ if (trimmed.length >= 3) {
1007
+ const idx = WEEKDAY_NAMES.findIndex((w) => w.toLowerCase().startsWith(trimmed));
1008
+ if (idx !== -1) return idx;
1009
+ }
1010
+ die(`unknown weekday "${value}" (use e.g. monday, tue, or 0-6 with 0 = Sunday)`);
1011
+ }
1012
+ function buildRecurrence(opts) {
1013
+ const hasPresetFlags = opts.every !== void 0 || opts.at !== void 0 || opts.on !== void 0;
1014
+ if (opts.cron !== void 0 && hasPresetFlags) {
1015
+ die("pass --cron or --every/--at/--on, not both");
1016
+ }
1017
+ if (opts.cron !== void 0) return { cron: opts.cron };
1018
+ if (!hasPresetFlags) return void 0;
1019
+ if (opts.every === void 0) {
1020
+ die("--at/--on set a preset time; add --every <hourly|Nh|daily|weekly|monthly>");
1021
+ }
1022
+ const hourly = opts.every === "hourly" ? 1 : opts.every.match(/^(\d+)h$/)?.[1];
1023
+ if (hourly !== void 0) {
1024
+ if (opts.on !== void 0) die("an hourly recurrence does not take --on");
1025
+ const every = typeof hourly === "number" ? hourly : Number.parseInt(hourly, 10);
1026
+ if (every < 1 || every > 23) die(`--every <N>h needs N between 1 and 23, got "${opts.every}"`);
1027
+ let minute = 0;
1028
+ if (opts.at !== void 0) {
1029
+ const m = opts.at.match(/^:?(\d{1,2})$/);
1030
+ if (!m || Number.parseInt(m[1], 10) > 59) {
1031
+ die(`with an hourly recurrence, --at is the minute past the hour (0-59 or :MM), got "${opts.at}"`);
1032
+ }
1033
+ minute = Number.parseInt(m[1], 10);
1034
+ }
1035
+ return { preset: { kind: "hourly", every_hours: every, minute } };
1036
+ }
1037
+ const time = opts.at ?? "09:00";
1038
+ switch (opts.every) {
1039
+ case "daily": {
1040
+ if (opts.on !== void 0) die("--every daily does not take --on");
1041
+ return { preset: { kind: "daily", time } };
1042
+ }
1043
+ case "weekly": {
1044
+ if (opts.on === void 0) die("--every weekly needs --on <weekday>");
1045
+ return { preset: { kind: "weekly", time, weekday: parseWeekday(opts.on) } };
1046
+ }
1047
+ case "monthly": {
1048
+ if (opts.on === void 0) die("--every monthly needs --on <day-of-month>");
1049
+ const day = Number.parseInt(opts.on, 10);
1050
+ if (!/^\d+$/.test(opts.on.trim()) || day < 1 || day > 31) {
1051
+ die(`--on day-of-month must be 1-31, got "${opts.on}"`);
1052
+ }
1053
+ return { preset: { kind: "monthly", time, day_of_month: day } };
1054
+ }
1055
+ default:
1056
+ die(`--every must be hourly, <N>h, daily, weekly, or monthly, got "${opts.every}"`);
1057
+ }
1058
+ }
1059
+ function recurrenceLabel(schedule) {
1060
+ return `${describeRecurrence(schedule.preset, schedule.cron)}, ${schedule.timezone}`;
1061
+ }
1062
+ function issueRef(ref) {
1063
+ return `${ref.project_name}/${ref.number}`;
1064
+ }
1065
+ function linkRows(entries, note = () => "") {
1066
+ return entries.map((e) => [
1067
+ ` ${issueRef(e)}`,
1068
+ e.title,
1069
+ `${e.effective_state.name} (${e.effective_state.category})`,
1070
+ note(e)
1071
+ ]);
1072
+ }
1073
+ function printIssueLinks(links) {
1074
+ if (links.blocked_by.length > 0) {
1075
+ console.log("\nblocked by:");
1076
+ table(linkRows(links.blocked_by, (e) => e.effective_state.category === "done" ? "" : "(open)"));
1077
+ }
1078
+ if (links.blocks.length > 0) {
1079
+ console.log("\nblocks:");
1080
+ table(linkRows(links.blocks));
1081
+ }
1082
+ if (links.duplicate_of) {
1083
+ console.log("\nduplicate of:");
1084
+ table(linkRows([links.duplicate_of], () => "(the state shown above follows it)"));
1085
+ }
1086
+ if (links.duplicated_by.length > 0) {
1087
+ console.log("\nduplicates:");
1088
+ table(linkRows(links.duplicated_by));
1089
+ }
1090
+ }
1091
+ function printIssueDetail(issue) {
1092
+ console.log(`${issue.project_name}/#${issue.number} ${issue.title}`);
1093
+ const dup = issue.duplicate_of;
1094
+ console.log(
1095
+ `state: ${issue.effective_state.name} (${issue.effective_state.category})${dup ? ` (via ${issueRef(dup)} \u2014 duplicate)` : ""} workflow: ${issue.workflow.name} updated: ${timestamp(issue.updated_at)}`
1096
+ );
1097
+ if (dup) {
1098
+ console.log(`own state: ${issue.state.name} (${issue.state.category}) \u2014 dormant while this is a duplicate`);
1099
+ }
1100
+ console.log(`id: ${issue.id}`);
1101
+ printIssueLinks(issue.links);
1102
+ if (issue.description) {
1103
+ console.log(`
1104
+ ${issue.description}`);
1105
+ }
1106
+ const allowed = issue.allowed_transitions.map((t) => `"${t.name}" \u2192 ${t.to_state.name}`);
1107
+ console.log(`
1108
+ allowed actions: ${allowed.length ? allowed.join(", ") : "none (terminal state)"}`);
1109
+ if (issue.comments.length > 0) {
1110
+ console.log(`
1111
+ comments (${issue.comments.length}):`);
1112
+ for (const c of issue.comments) {
1113
+ console.log(`
1114
+ [${timestamp(c.created_at)}] ${actorLabel(c.actor)}:`);
1115
+ for (const line of c.body.split("\n")) console.log(` ${line}`);
1116
+ }
1117
+ }
1118
+ }
1119
+ function printWorkflowDetail(wf) {
1120
+ console.log(`${wf.name}${wf.is_system ? " (standard, read-only)" : ""} [${wf.id}]`);
1121
+ if (wf.description) console.log(wf.description);
1122
+ console.log("\nstates:");
1123
+ const byId = new Map(wf.states.map((s) => [s.id, s]));
1124
+ table(
1125
+ wf.states.map((s) => [
1126
+ ` ${s.name}`,
1127
+ s.category,
1128
+ s.id === wf.initial_state_id ? "(initial)" : ""
1129
+ ])
1130
+ );
1131
+ console.log("\ntransitions:");
1132
+ for (const t of wf.transitions) {
1133
+ console.log(
1134
+ ` "${t.name}": ${byId.get(t.from_state_id)?.name} \u2192 ${byId.get(t.to_state_id)?.name}`
1135
+ );
1136
+ }
1137
+ for (const w of wf.warnings ?? []) console.log(`
1138
+ warning: ${w}`);
1139
+ }
1140
+ function eventSummary(ev) {
1141
+ const p = ev.payload;
1142
+ const issue = ev.issue_ref ? `${ev.issue_ref.project_name}/#${ev.issue_ref.number}` : null;
1143
+ switch (ev.type) {
1144
+ case "issue.created":
1145
+ return `created ${issue}: ${p.title}${p.scheduled_task_name ? ` (via schedule "${p.scheduled_task_name}")` : ""}`;
1146
+ case "issue.updated":
1147
+ return `updated ${issue} (${p.changed?.join(", ")})`;
1148
+ case "issue.transitioned":
1149
+ return `${p.action ? `"${p.action}" on` : "moved"} ${issue}: ${p.from_state_name} \u2192 ${p.to_state_name}`;
1150
+ case "issue.commented":
1151
+ return `commented on ${issue}`;
1152
+ case "project.created":
1153
+ case "project.updated":
1154
+ case "project.deleted":
1155
+ return `${ev.type.split(".")[1]} project "${p.name ?? ev.project_name}"`;
1156
+ case "workflow.created":
1157
+ case "workflow.updated":
1158
+ case "workflow.deleted":
1159
+ return `${ev.type.split(".")[1]} workflow "${p.name}"`;
1160
+ case "api_key.created":
1161
+ return `created API key "${p.name}"`;
1162
+ case "api_key.revoked":
1163
+ return `revoked API key "${p.name}"`;
1164
+ case "scheduled_task.created":
1165
+ case "scheduled_task.updated":
1166
+ case "scheduled_task.deleted":
1167
+ return `${ev.type.split(".")[1]} schedule "${p.name}"`;
1168
+ case "context.created":
1169
+ case "context.updated":
1170
+ case "context.deleted": {
1171
+ const scope = p.scope;
1172
+ const verb = ev.type.split(".")[1];
1173
+ return `${verb} ${p.kind} "${p.name}"${scope?.label ? ` [${scope.label}]` : ""}`;
1174
+ }
1175
+ case "scheduled_task.skipped": {
1176
+ const blocking = Array.isArray(p.blocking) ? p.blocking.length : 0;
1177
+ return `skipped schedule "${p.name}" (${blocking} open instance${blocking === 1 ? "" : "s"})`;
1178
+ }
1179
+ case "runner.registered":
1180
+ case "runner.updated":
1181
+ case "runner.removed":
1182
+ return `${ev.type.split(".")[1]} runner "${p.name}"`;
1183
+ case "runner.errored":
1184
+ return `runner "${p.runner_name}" failed to launch (${p.consecutive_failures} consecutive): ${p.error}`;
1185
+ case "agent_run.started":
1186
+ return `run started on ${issue} via ${p.runner_name} (${p.tier}${p.model ? ` \u2192 ${p.model}` : ""})`;
1187
+ case "agent_run.ended":
1188
+ return `run ${p.status} on ${issue} via ${p.runner_name}${p.outcome ? ` \u2014 ${p.outcome}` : ""}`;
1189
+ case "issue.parked":
1190
+ return `parked ${issue} after ${p.attempt_count} strikes \u2014 needs attention`;
1191
+ case "issue.resumed":
1192
+ return `resumed ${issue} (attempt count reset)`;
1193
+ case "routing_rule.created":
1194
+ case "routing_rule.updated":
1195
+ case "routing_rule.deleted":
1196
+ return `${ev.type.split(".")[1]} the ${p.scope_label} routing rule`;
1197
+ case "settings.updated":
1198
+ return `updated supervisor settings (${p.changed?.join(", ") || "no changes"})`;
1199
+ default:
1200
+ return ev.type;
1201
+ }
1202
+ }
1203
+ var program = new Command();
1204
+ program.name("tines").description("CLI for Tines").version("0.0.1").enablePositionalOptions();
1205
+ withCommon(program.command("time").description("Fetch the current time from the Tines API")).action(
1206
+ async (opts) => {
1207
+ const result = await client(opts).getTime();
1208
+ if (opts.json) printJson(result);
1209
+ else console.log(`Server time: ${result.time} (unix ${result.unix})`);
1210
+ }
1211
+ );
1212
+ var projects = program.command("projects").description("Manage projects");
1213
+ withList(projects.command("list").description("List projects")).action(async (opts) => {
1214
+ const res = await client(opts).listProjects({ limit: opts.limit, cursor: opts.cursor });
1215
+ printList(res, opts, (items) => {
1216
+ if (items.length === 0) return console.log("no projects");
1217
+ table([
1218
+ ["NAME", "ISSUES", "ID", "CREATED"],
1219
+ ...items.map((p) => [p.name, String(p.issue_count), p.id, timestamp(p.created_at)])
1220
+ ]);
1221
+ });
1222
+ });
1223
+ withCommon(
1224
+ projects.command("create <name>").description("Create a project (with its initial context prompt)").option("-d, --description <text>", "project description").option("-w, --default-workflow <id-or-name>", "default workflow for new issues").option("--prompt <md>", "initial conventions prompt, stitched into every issue's agent prompt: inline Markdown or @file").option("--no-prompt", "create without an initial prompt")
1225
+ ).action(
1226
+ async (name, opts) => {
1227
+ if (opts.prompt === void 0 || opts.prompt === true) {
1228
+ die(
1229
+ 'every issue in a project inherits its context \u2014 give the project an initial prompt:\n --prompt "<markdown>" house conventions, inline or @file\n --no-prompt create without one (add later: tines context create -k prompt -n conventions -p <name> --body \u2026)'
1230
+ );
1231
+ }
1232
+ const api = client(opts);
1233
+ const workflowId = opts.defaultWorkflow ? (await resolveWorkflow(api, opts.defaultWorkflow)).id : void 0;
1234
+ const project = await api.createProject({
1235
+ name,
1236
+ description: opts.description,
1237
+ default_workflow_id: workflowId,
1238
+ initial_prompt: typeof opts.prompt === "string" ? readBodyValue(opts.prompt) : void 0
1239
+ });
1240
+ if (opts.json) return printJson(project);
1241
+ console.log(
1242
+ `created project "${project.name}" (${project.id})${typeof opts.prompt === "string" ? ' with its "conventions" prompt' : ""}`
1243
+ );
1244
+ }
1245
+ );
1246
+ withCommon(projects.command("show <id-or-name>").description("Show a project")).action(
1247
+ async (ref, opts) => {
1248
+ const api = client(opts);
1249
+ const project = await resolveProject(api, ref);
1250
+ if (opts.json) return printJson(project);
1251
+ console.log(`${project.name} [${project.id}]`);
1252
+ if (project.description) console.log(project.description);
1253
+ const defaultWorkflow = project.default_workflow_id ? (await api.getWorkflow(project.default_workflow_id)).name : "(standard)";
1254
+ console.log(`
1255
+ default workflow: ${defaultWorkflow}`);
1256
+ console.log(
1257
+ `issues: ${project.issue_count} created: ${timestamp(project.created_at)} updated: ${timestamp(project.updated_at)}`
1258
+ );
1259
+ }
1260
+ );
1261
+ withCommon(
1262
+ projects.command("edit <id-or-name>").description("Edit a project").option("-n, --name <name>", "rename the project").option("-d, --description <text>", "set the description").option("-w, --default-workflow <id-or-name>", "set the default workflow for new issues").option("--no-default-workflow", "clear the default workflow (fall back to standard)")
1263
+ ).action(
1264
+ async (ref, opts) => {
1265
+ const api = client(opts);
1266
+ const project = await resolveProject(api, ref);
1267
+ const body = {};
1268
+ if (opts.name !== void 0) body.name = opts.name;
1269
+ if (opts.description !== void 0) body.description = opts.description;
1270
+ if (opts.defaultWorkflow === false) body.default_workflow_id = null;
1271
+ else if (opts.defaultWorkflow !== void 0) {
1272
+ body.default_workflow_id = (await resolveWorkflow(api, opts.defaultWorkflow)).id;
1273
+ }
1274
+ if (Object.keys(body).length === 0) {
1275
+ die("nothing to update: pass --name, --description, or --[no-]default-workflow");
1276
+ }
1277
+ const updated = await api.updateProject(project.id, body);
1278
+ if (opts.json) return printJson(updated);
1279
+ console.log(`updated project "${updated.name}" (${updated.id})`);
1280
+ }
1281
+ );
1282
+ withCommon(
1283
+ projects.command("delete <id-or-name>").description("Delete a project (refused while it still contains issues)")
1284
+ ).action(async (ref, opts) => {
1285
+ const api = client(opts);
1286
+ const project = await resolveProject(api, ref);
1287
+ await api.deleteProject(project.id);
1288
+ console.log(`deleted project "${project.name}" (${project.id})`);
1289
+ });
1290
+ var workflows = program.command("workflows").description("Manage the workflow library");
1291
+ withList(workflows.command("list").description("List the workflow library")).action(
1292
+ async (opts) => {
1293
+ const res = await client(opts).listWorkflows({ limit: opts.limit, cursor: opts.cursor });
1294
+ printList(res, opts, (items) => {
1295
+ if (items.length === 0) return console.log("no workflows");
1296
+ table([
1297
+ ["NAME", "STATES", "ISSUES", "ID", ""],
1298
+ ...items.map((w) => [
1299
+ w.name,
1300
+ String(w.states.length),
1301
+ String(w.issue_count),
1302
+ w.id,
1303
+ w.is_system ? "(standard, read-only)" : ""
1304
+ ])
1305
+ ]);
1306
+ });
1307
+ }
1308
+ );
1309
+ withCommon(
1310
+ workflows.command("show <id-or-name>").description("Show a workflow with states and transitions")
1311
+ ).action(async (ref, opts) => {
1312
+ const api = client(opts);
1313
+ const wf = await resolveWorkflow(api, ref);
1314
+ if (opts.json) return printJson(wf);
1315
+ printWorkflowDetail(wf);
1316
+ });
1317
+ withCommon(
1318
+ workflows.command("create [json]").description('Create a workflow from a JSON definition (states carry initial "prompt" instructions)').option("-f, --file <path>", 'read the JSON definition from a file ("-" for stdin)').option("--no-prompts", 'allow states without initial "prompt" instructions').addHelpText("after", WORKFLOW_JSON_HELP)
1319
+ ).action(async (inline, opts) => {
1320
+ const body = readJsonBody(inline, opts.file);
1321
+ if (!body) {
1322
+ die(
1323
+ `missing workflow JSON: pass it inline, with --file <path>, or pipe it on stdin
1324
+ see \`tines workflows create --help\` for the expected shape`
1325
+ );
1326
+ }
1327
+ assertNewStatesHavePrompts(body.states, opts.prompts);
1328
+ const wf = await client(opts).createWorkflow(body);
1329
+ if (opts.json) return printJson(wf);
1330
+ console.log(`created workflow "${wf.name}" (${wf.id})
1331
+ `);
1332
+ printWorkflowDetail(wf);
1333
+ });
1334
+ withCommon(
1335
+ workflows.command("edit <id-or-name> [json]").description("Update a workflow from a JSON definition and/or flags").option("-f, --file <path>", 'read the JSON definition from a file ("-" for stdin)').option("-n, --name <name>", "rename the workflow").option("-d, --description <text>", "set the description").option("--initial-state <id-or-name>", "set the initial state").option("--no-prompts", 'allow new states without initial "prompt" instructions').addHelpText("after", WORKFLOW_JSON_HELP)
1336
+ ).action(
1337
+ async (ref, inline, opts) => {
1338
+ const api = client(opts);
1339
+ const wf = await resolveWorkflow(api, ref);
1340
+ const body = readJsonBody(inline, opts.file) ?? {};
1341
+ assertNewStatesHavePrompts(body.states, opts.prompts);
1342
+ if (opts.name !== void 0) body.name = opts.name;
1343
+ if (opts.description !== void 0) body.description = opts.description;
1344
+ if (opts.initialState !== void 0) body.initial_state = opts.initialState;
1345
+ if (Object.keys(body).length === 0) {
1346
+ die("nothing to update: pass JSON and/or --name/--description/--initial-state");
1347
+ }
1348
+ const updated = await api.updateWorkflow(wf.id, body);
1349
+ if (opts.json) return printJson(updated);
1350
+ console.log(`updated workflow "${updated.name}" (${updated.id})
1351
+ `);
1352
+ printWorkflowDetail(updated);
1353
+ }
1354
+ );
1355
+ withCommon(
1356
+ workflows.command("delete <id-or-name>").description("Delete a workflow (refused while issues still reference it)")
1357
+ ).action(async (ref, opts) => {
1358
+ const api = client(opts);
1359
+ const wf = await resolveWorkflow(api, ref);
1360
+ await api.deleteWorkflow(wf.id);
1361
+ console.log(`deleted workflow "${wf.name}" (${wf.id})`);
1362
+ });
1363
+ var issues = program.command("issues").description("Work with issues");
1364
+ withList(
1365
+ issues.command("list").description("List issues across projects (hides done issues unless --all)").option("-p, --project <name>", "filter by project name or id").option("-s, --state <name>", "filter by state name or id").option("-c, --category <cat>", "filter by state category").option("-w, --workflow <id-or-name>", "filter by workflow").option("-a, --all", "include issues in done states").option("--ready", "only issues that are actionable now (not done, not a duplicate, no open blockers)")
1366
+ ).action(
1367
+ async (opts) => {
1368
+ const res = await client(opts).listIssues({
1369
+ project: opts.project,
1370
+ state: opts.state,
1371
+ category: opts.category,
1372
+ workflow: opts.workflow,
1373
+ hide_done: !opts.all,
1374
+ ready: opts.ready,
1375
+ limit: opts.limit,
1376
+ cursor: opts.cursor
1377
+ });
1378
+ printList(res, opts, (items) => {
1379
+ if (items.length === 0) return console.log(opts.ready ? "no ready issues" : "no issues");
1380
+ table([
1381
+ ["REF", "TITLE", "STATE", "CATEGORY", "LAST ACTIVITY", ""],
1382
+ ...items.map((i) => [
1383
+ `${i.project_name}/${i.number}`,
1384
+ i.title,
1385
+ // Duplicates display their canonical issue's state, so lists
1386
+ // (and the filters above) go by the effective state.
1387
+ i.effective_state.name,
1388
+ i.effective_state.category,
1389
+ timestamp(i.last_activity_at),
1390
+ [i.open_blockers.length > 0 ? "blocked" : "", i.duplicate_of ? "dup" : ""].filter(Boolean).join(" ")
1391
+ ])
1392
+ ]);
1393
+ });
1394
+ }
1395
+ );
1396
+ withCommon(
1397
+ issues.command("create <project>").description("Create an issue in a project, optionally with a recurrence (a scheduled task)").requiredOption("-t, --title <title>", "issue title (doubles as the title template with a recurrence)").option("-d, --description <markdown>", "issue description (Markdown)").option("-w, --workflow <id-or-name>", "workflow (defaults to project default, else standard)").option("-s, --state <name>", "starting state (defaults to the workflow's initial state)").option("--every <preset>", 'repeat hourly (or every N hours: "6h"), daily, weekly, or monthly').option("--at <when>", "preset time of day HH:MM (default 09:00); for hourly, the minute past the hour :MM (default :00)").option("--on <when>", "weekday (weekly) or day of month (monthly)").option("--cron <expr>", "5-field cron expression (alternative to --every/--at/--on)").option("--tz <iana>", "schedule timezone (defaults to the system timezone)").option("--if-closed", "only create a new instance when all previous instances are closed").option("--schedule-name <name>", "schedule name, unique per project (defaults to the title)")
1398
+ ).action(
1399
+ async (projectRef, opts) => {
1400
+ const api = client(opts);
1401
+ const project = await resolveProject(api, projectRef);
1402
+ const workflowId = opts.workflow ? (await resolveWorkflow(api, opts.workflow)).id : void 0;
1403
+ const recurrence = buildRecurrence(opts);
1404
+ if (!recurrence && (opts.ifClosed !== void 0 || opts.scheduleName !== void 0)) {
1405
+ die('--if-closed/--schedule-name need a recurrence: add --every \u2026 or --cron "<expr>"');
1406
+ }
1407
+ const schedule = recurrence ? {
1408
+ ...recurrence,
1409
+ name: opts.scheduleName,
1410
+ timezone: opts.tz ?? systemTimezone(),
1411
+ require_all_closed: opts.ifClosed ?? false
1412
+ } : void 0;
1413
+ const issue = await api.createIssue(project.id, {
1414
+ title: opts.title,
1415
+ description: opts.description,
1416
+ workflow_id: workflowId,
1417
+ state: opts.state,
1418
+ schedule
1419
+ });
1420
+ if (opts.json) return printJson(issue);
1421
+ console.log(
1422
+ `created ${issue.project_name}/#${issue.number} "${issue.title}" in state "${issue.state.name}"`
1423
+ );
1424
+ if (issue.schedule) {
1425
+ console.log(
1426
+ `created schedule "${issue.project_name}/${issue.schedule.name}": ${recurrenceLabel(issue.schedule)} \u2014 next run ${timestamp(issue.schedule.next_run_at)}`
1427
+ );
1428
+ }
1429
+ }
1430
+ );
1431
+ withCommon(
1432
+ issues.command("show <ref>").description("Show an issue (<project>/<number>), including allowed transitions")
1433
+ ).action(async (ref, opts) => {
1434
+ const issue = await resolveIssue(client(opts), ref);
1435
+ if (opts.json) return printJson(issue);
1436
+ printIssueDetail(issue);
1437
+ });
1438
+ withCommon(
1439
+ issues.command("edit <ref>").description("Edit an issue: title, description, workflow, or force-set state").option("-t, --title <title>", "set the title").option("-d, --description <markdown>", "set the description (Markdown)").option(
1440
+ "-s, --state <name>",
1441
+ "force-set the state, bypassing the workflow's transitions (records a forced move)"
1442
+ ).option("-w, --workflow <id-or-name>", "move the issue onto another workflow")
1443
+ ).action(
1444
+ async (ref, opts) => {
1445
+ const api = client(opts);
1446
+ const issue = await resolveIssue(api, ref);
1447
+ const body = {};
1448
+ if (opts.title !== void 0) body.title = opts.title;
1449
+ if (opts.description !== void 0) body.description = opts.description;
1450
+ if (opts.state !== void 0) body.state = opts.state;
1451
+ if (opts.workflow !== void 0) body.workflow_id = (await resolveWorkflow(api, opts.workflow)).id;
1452
+ if (Object.keys(body).length === 0) {
1453
+ die("nothing to update: pass --title, --description, --state, and/or --workflow");
1454
+ }
1455
+ const updated = await api.updateIssue(issue.id, body);
1456
+ if (opts.json) return printJson(updated);
1457
+ const notes = [];
1458
+ if (body.title !== void 0) notes.push(`title "${updated.title}"`);
1459
+ if (body.description !== void 0) notes.push("description");
1460
+ if (body.workflow_id !== void 0) notes.push(`workflow "${updated.workflow.name}"`);
1461
+ if (updated.state.id !== issue.state.id) {
1462
+ notes.push(`state ${issue.state.name} \u2192 ${updated.state.name}`);
1463
+ }
1464
+ console.log(`updated ${updated.project_name}/#${updated.number}: ${notes.join(", ")}`);
1465
+ }
1466
+ );
1467
+ withCommon(
1468
+ issues.command("move <ref> <action>").description('Take a transition on an issue by its action name (e.g. "approve")')
1469
+ ).action(async (ref, action, opts) => {
1470
+ const api = client(opts);
1471
+ const issue = await resolveIssue(api, ref);
1472
+ const moved = await api.transitionIssue(issue.id, { action });
1473
+ if (opts.json) return printJson(moved);
1474
+ console.log(
1475
+ `${moved.project_name}/#${moved.number}: ${issue.state.name} \u2192 ${moved.state.name} ("${action}")`
1476
+ );
1477
+ });
1478
+ withCommon(
1479
+ issues.command("comment <ref> <markdown>").description("Comment on an issue (Markdown body)").passThroughOptions()
1480
+ ).action(async (ref, markdown, opts, command) => {
1481
+ if (helpGuard(command, markdown)) return;
1482
+ const api = client(opts);
1483
+ const issue = await resolveIssue(api, ref);
1484
+ const comment = await api.createComment(issue.id, { body: markdown });
1485
+ if (opts.json) return printJson(comment);
1486
+ console.log(`commented on ${issue.project_name}/#${issue.number} as ${actorLabel(comment.actor)}`);
1487
+ });
1488
+ withCommon(
1489
+ issues.command("block <blocker> <blocked>").description("Record that <blocker> blocks <blocked> (advisory: transitions stay allowed)")
1490
+ ).action(async (blockerRef, blockedRef, opts) => {
1491
+ const api = client(opts);
1492
+ const blocker = await resolveIssue(api, blockerRef);
1493
+ const blocked = await resolveIssue(api, blockedRef);
1494
+ const link = await api.addIssueLink(blocked.id, { kind: "blocked_by", issue_id: blocker.id });
1495
+ if (opts.json) return printJson(link);
1496
+ console.log(
1497
+ `${blocker.project_name}/#${blocker.number} now blocks ${blocked.project_name}/#${blocked.number} "${blocked.title}"`
1498
+ );
1499
+ });
1500
+ withCommon(
1501
+ issues.command("unblock <blocker> <blocked>").description("Remove the link making <blocker> block <blocked>")
1502
+ ).action(async (blockerRef, blockedRef, opts) => {
1503
+ const api = client(opts);
1504
+ const blocker = await resolveIssue(api, blockerRef);
1505
+ const blocked = await resolveIssue(api, blockedRef);
1506
+ const link = blocked.links.blocked_by.find((l) => l.issue_id === blocker.id);
1507
+ if (!link) {
1508
+ die(
1509
+ `${blocked.project_name}/#${blocked.number} is not blocked by ${blocker.project_name}/#${blocker.number} (blocked by: ${blocked.links.blocked_by.map(issueRef).join(", ") || "nothing"})`
1510
+ );
1511
+ }
1512
+ await api.removeIssueLink(blocked.id, link.link_id);
1513
+ if (opts.json) return printJson({ removed: link });
1514
+ console.log(
1515
+ `${blocker.project_name}/#${blocker.number} no longer blocks ${blocked.project_name}/#${blocked.number}`
1516
+ );
1517
+ });
1518
+ withCommon(
1519
+ issues.command("duplicate <ref> <canonical>").alias("dupe").description("Mark <ref> as a duplicate of <canonical> (its state then follows <canonical>)")
1520
+ ).action(async (ref, canonicalRef, opts) => {
1521
+ const api = client(opts);
1522
+ const issue = await resolveIssue(api, ref);
1523
+ const canonical = await resolveIssue(api, canonicalRef);
1524
+ const link = await api.addIssueLink(issue.id, { kind: "duplicate_of", issue_id: canonical.id });
1525
+ if (opts.json) return printJson(link);
1526
+ console.log(
1527
+ `${issue.project_name}/#${issue.number} is now a duplicate of ${canonical.project_name}/#${canonical.number} "${canonical.title}" \u2014 showing its state (${canonical.effective_state.name})`
1528
+ );
1529
+ });
1530
+ withCommon(
1531
+ issues.command("context <ref>").description("Print an issue's effective context (the assembled bundle for its current state)").option("--out <dir>", "write the bundle to a directory: prompt.md, skills/<name>/\u2026, repos.json").option("--force", "allow --out into a non-empty directory")
1532
+ ).action(async (ref, opts) => {
1533
+ const api = client(opts);
1534
+ const issue = await resolveIssue(api, ref);
1535
+ const context2 = await api.getIssueContext(issue.id);
1536
+ if (opts.json && !opts.out) return printJson(context2);
1537
+ if (opts.out === void 0) {
1538
+ if (context2.prompt.text) console.log(context2.prompt.text);
1539
+ if (context2.skills.length > 0) {
1540
+ console.log(`
1541
+ skills: ${context2.skills.map((s) => s.name).join(", ")}`);
1542
+ }
1543
+ for (const repo of context2.repos) {
1544
+ console.log(`repo: ${repo.name} ${repo.url}${repo.branch ? `#${repo.branch}` : ""} \u2192 ${repo.dir}/`);
1545
+ }
1546
+ for (const o of context2.overridden) {
1547
+ console.log(`overridden: ${o.kind} "${o.name}" [${o.scope.label}] (overridden by ${o.overridden_by})`);
1548
+ }
1549
+ for (const c of context2.conflicts) {
1550
+ console.log(`conflict: repos ${c.item_ids.join(", ")} all resolve to checkout dir "${c.dir}"`);
1551
+ }
1552
+ return;
1553
+ }
1554
+ if (context2.conflicts.length > 0) {
1555
+ die(
1556
+ `refusing to write: checkout-directory conflict${context2.conflicts.length === 1 ? "" : "s"} among the effective repos (${context2.conflicts.map((c) => `"${c.dir}": ${c.item_ids.join(", ")}`).join("; ")}); rename or re-dir the items first`
1557
+ );
1558
+ }
1559
+ if (existsSync2(opts.out) && readdirSync(opts.out).length > 0 && !opts.force) {
1560
+ die(`refusing to write into non-empty directory ${opts.out} (pass --force to override)`);
1561
+ }
1562
+ mkdirSync3(opts.out, { recursive: true });
1563
+ writeFileSync3(join3(opts.out, "prompt.md"), context2.prompt.text ? `${context2.prompt.text}
1564
+ ` : "");
1565
+ for (const skill of context2.skills) {
1566
+ for (const file of skill.files) {
1567
+ const target = join3(opts.out, "skills", skill.name, file.path);
1568
+ mkdirSync3(dirname3(target), { recursive: true });
1569
+ writeFileSync3(target, file.content);
1570
+ }
1571
+ }
1572
+ writeFileSync3(join3(opts.out, "repos.json"), `${JSON.stringify(context2.repos, null, 2)}
1573
+ `);
1574
+ console.log(
1575
+ `wrote ${opts.out}/prompt.md, ${context2.skills.length} skill${context2.skills.length === 1 ? "" : "s"}, repos.json (${context2.repos.length} repo${context2.repos.length === 1 ? "" : "s"})`
1576
+ );
1577
+ });
1578
+ withCommon(
1579
+ issues.command("unduplicate <ref>").alias("undupe").description("Unmark a duplicate (its own state was never changed, so it simply reappears)")
1580
+ ).action(async (ref, opts) => {
1581
+ const api = client(opts);
1582
+ const issue = await resolveIssue(api, ref);
1583
+ const link = issue.links.duplicate_of;
1584
+ if (!link) die(`${issue.project_name}/#${issue.number} is not marked as a duplicate`);
1585
+ await api.removeIssueLink(issue.id, link.link_id);
1586
+ if (opts.json) return printJson({ removed: link });
1587
+ console.log(
1588
+ `${issue.project_name}/#${issue.number} is no longer a duplicate of ${issueRef(link)} \u2014 state ${issue.state.name} (${issue.state.category})`
1589
+ );
1590
+ });
1591
+ withCommon(
1592
+ issues.command("prompt <ref>").description("Print the launch prompt: the stitched context followed by the issue block")
1593
+ ).action(async (ref, opts) => {
1594
+ const api = client(opts);
1595
+ const issue = await resolveIssue(api, ref);
1596
+ const prompt = await api.getIssuePrompt(issue.id);
1597
+ if (opts.json) return printJson(prompt);
1598
+ console.log(prompt.text);
1599
+ });
1600
+ withCommon(
1601
+ issues.command("assign <ref> [runner]").description("Pin an issue to a runner (<runner>[:tier]) \u2014 replaces routing rules for it; --clear unpins").option("--clear", "remove the pin")
1602
+ ).action(async (ref, runnerSpec, opts) => {
1603
+ const api = client(opts);
1604
+ const issue = await resolveIssue(api, ref);
1605
+ if (opts.clear) {
1606
+ if (runnerSpec !== void 0) die("--clear does not take a runner");
1607
+ const updated2 = await api.updateIssue(issue.id, { pinned_runner_id: null });
1608
+ if (opts.json) return printJson(updated2);
1609
+ return console.log(`unpinned ${updated2.project_name}/#${updated2.number} \u2014 routing rules apply again`);
1610
+ }
1611
+ if (runnerSpec === void 0) die("pass <runner>[:tier] to pin, or --clear to unpin");
1612
+ const { name, tier } = parseTargetSpec(runnerSpec);
1613
+ const runner = await resolveRunner(api, name);
1614
+ const updated = await api.updateIssue(issue.id, {
1615
+ pinned_runner_id: runner.id,
1616
+ pinned_tier: tier ?? null
1617
+ });
1618
+ if (opts.json) return printJson(updated);
1619
+ console.log(
1620
+ `pinned ${updated.project_name}/#${updated.number} to ${runner.name}${tier ? ` (tier ${tier})` : ""} \u2014 only this runner will take it`
1621
+ );
1622
+ });
1623
+ withCommon(
1624
+ issues.command("dispatch <ref>").description("Explain why an issue is (not) dispatching: eligibility, routing, per-runner verdicts")
1625
+ ).action(async (ref, opts) => {
1626
+ const api = client(opts);
1627
+ const issue = await resolveIssue(api, ref);
1628
+ const ex = await api.getIssueDispatch(issue.id);
1629
+ if (opts.json) return printJson(ex);
1630
+ printExplainer(issue, ex);
1631
+ });
1632
+ withCommon(
1633
+ issues.command("resume <ref>").description("Un-park an issue: clear needs-attention and reset the attempt count")
1634
+ ).action(async (ref, opts) => {
1635
+ const api = client(opts);
1636
+ const issue = await resolveIssue(api, ref);
1637
+ const updated = await api.resumeIssue(issue.id);
1638
+ if (opts.json) return printJson(updated);
1639
+ console.log(
1640
+ issue.needs_attention || issue.attempt_count > 0 ? `resumed ${updated.project_name}/#${updated.number} \u2014 attempt count reset, back in the pool` : `${updated.project_name}/#${updated.number} was not parked \u2014 nothing to do`
1641
+ );
1642
+ });
1643
+ function printExplainer(issue, ex) {
1644
+ console.log(`${issue.project_name}/#${issue.number} ${issue.title}`);
1645
+ console.log(`
1646
+ ${ex.verdict}
1647
+ `);
1648
+ table(ex.checks.map((c) => [` ${c.ok ? "ok" : "FAIL"}`, c.name.replaceAll("_", " "), c.detail]));
1649
+ if (ex.pin) {
1650
+ console.log(
1651
+ `
1652
+ pinned to ${ex.pin.runner_name ?? ex.pin.runner_id}${ex.pin.tier ? `:${ex.pin.tier}` : ""} (replaces rule matching)`
1653
+ );
1654
+ } else if (ex.matched_rule) {
1655
+ console.log(`
1656
+ matched rule: ${ex.matched_rule.scope_label}`);
1657
+ }
1658
+ if (ex.targets.length > 0) {
1659
+ console.log("targets (preference order):");
1660
+ table(
1661
+ ex.targets.map((t) => [
1662
+ ` ${t.runner_name}`,
1663
+ `${t.tier} \u2192 ${t.model ?? "(model n/a)"}`,
1664
+ t.verdict === "ok" ? "available" : t.verdict.replaceAll("_", " "),
1665
+ t.verdict === "ok" ? "" : t.detail
1666
+ ])
1667
+ );
1668
+ }
1669
+ if (ex.queue_position !== null && ex.queue_position > 0) {
1670
+ console.log(`queue: ${ex.queue_position} eligible issue${ex.queue_position === 1 ? "" : "s"} ahead of this one`);
1671
+ }
1672
+ if (ex.active_run) {
1673
+ console.log(
1674
+ `active run: ${ex.active_run.id} on ${ex.active_run.runner_name} (${ex.active_run.status})`
1675
+ );
1676
+ }
1677
+ if (ex.parked) {
1678
+ console.log(
1679
+ `parked after ${ex.attempt_count}/${ex.attempt_limit} strikes \u2014 \`tines issues resume\` (or any manual transition) revives it`
1680
+ );
1681
+ }
1682
+ }
1683
+ var context = program.command("context").description("Manage context items (prompts, skills, repo pointers) scoped to projects, states, and issues");
1684
+ var SCOPE_FLAGS_HELP = `
1685
+ Scope flags (combinable \u2014 an item applies where ALL of its set dimensions match):
1686
+ --project <name> only for issues in this project
1687
+ --state <workflow>/<state> only for issues currently in this state
1688
+ --issue <project>/<number> only for this issue
1689
+ `;
1690
+ function withScopeFlags(cmd) {
1691
+ return cmd.option("-p, --project <name>", "scope: project name or id").option("-s, --state <workflow/state>", "scope: workflow-qualified state").option("-i, --issue <ref>", "scope: issue (<project>/<number>)").addHelpText("after", SCOPE_FLAGS_HELP);
1692
+ }
1693
+ withList(
1694
+ withScopeFlags(
1695
+ context.command("list").description("List context items (scope filters match every item whose scope includes the element)").option("-k, --kind <kind>", "filter by kind: prompt, skill, or repo").option("--exact", "only items whose scope sets exactly the given dimensions").option("-q, --search <text>", "search names and descriptions")
1696
+ )
1697
+ ).action(async (opts) => {
1698
+ const api = client(opts);
1699
+ const scope = await resolveScopeFlags(api, opts);
1700
+ const res = await api.listContext({
1701
+ kind: opts.kind,
1702
+ project: scope.project_id ?? void 0,
1703
+ state: scope.workflow_state_id ?? void 0,
1704
+ issue: scope.issue_id ?? void 0,
1705
+ q: opts.search,
1706
+ exact: opts.exact ? true : void 0,
1707
+ limit: opts.limit,
1708
+ cursor: opts.cursor
1709
+ });
1710
+ printList(res, opts, (items) => {
1711
+ if (items.length === 0) return console.log("no context items");
1712
+ table([
1713
+ ["KIND", "NAME", "SCOPE", "PAYLOAD", "UPDATED", "ID"],
1714
+ ...items.map((i) => [
1715
+ i.kind,
1716
+ i.name,
1717
+ i.scope.label,
1718
+ contextItemSummary(i),
1719
+ timestamp(i.updated_at),
1720
+ i.id
1721
+ ])
1722
+ ]);
1723
+ });
1724
+ });
1725
+ withCommon(context.command("show <id>").description("Show a context item (skills include their files)")).action(
1726
+ async (id, opts) => {
1727
+ const item = await client(opts).getContextItem(id);
1728
+ if (opts.json) return printJson(item);
1729
+ printContextItem(item);
1730
+ }
1731
+ );
1732
+ withCommon(
1733
+ withScopeFlags(
1734
+ context.command("create").description("Create a context item scoped to a project, state, and/or issue").requiredOption("-k, --kind <kind>", "prompt, skill, or repo").requiredOption("-n, --name <name>", "item name (slug-like for skills; the dedup/override key)").option("-d, --description <text>", "one-liner shown in lists").option("--body <md>", "prompt body: inline Markdown or @file (escape a literal @ as @@)").option("--file <path>=@<local>", "skill file: workspace path = local file (repeatable)", collect, []).option("--url <url>", "repo: clone URL").option("--branch <branch>", "repo: branch to check out").option("--dir <dir>", "repo: checkout directory (defaults to the URL's basename)")
1735
+ ),
1736
+ // --url is the repo pointer here; the API base comes from TINES_API_URL.
1737
+ { baseUrlFlag: false }
1738
+ ).action(
1739
+ async (opts) => {
1740
+ const api = client({ apiKey: opts.apiKey, json: opts.json });
1741
+ const scope = await resolveScopeFlags(api, opts);
1742
+ const body = {
1743
+ kind: opts.kind,
1744
+ name: opts.name,
1745
+ description: opts.description,
1746
+ ...scope
1747
+ };
1748
+ if (opts.body !== void 0) body.body = readBodyValue(opts.body);
1749
+ if (opts.file.length > 0) body.files = opts.file.map(parseFileSpec);
1750
+ if (opts.kind === "skill" && body.files === void 0) body.files = [];
1751
+ if (opts.url !== void 0) body.repo_url = opts.url;
1752
+ if (opts.branch !== void 0) body.repo_branch = opts.branch;
1753
+ if (opts.dir !== void 0) body.repo_dir = opts.dir;
1754
+ const item = await api.createContextItem(body);
1755
+ if (opts.json) return printJson(item);
1756
+ console.log(`created ${item.kind} "${item.name}" (${item.id}) \u2014 scope: ${item.scope.label}`);
1757
+ }
1758
+ );
1759
+ withCommon(
1760
+ withScopeFlags(
1761
+ context.command("edit <id>").description("Edit a context item: payload, name, description, or scope").option("-n, --name <name>", "rename the item").option("-d, --description <text>", "set the description").option("--body <md>", "prompt body: inline Markdown or @file (escape a literal @ as @@)").option("--file <path>=@<local>", "add or replace a skill file (repeatable)", collect, []).option("--remove-file <path>", "remove a skill file (repeatable)", collect, []).option("--url <url>", "repo: clone URL").option("--branch <branch>", "repo: branch (empty string clears it)").option("--dir <dir>", "repo: checkout directory (empty string restores the URL default)").option("--unset <dimension>", "drop a scope dimension: project, state, or issue (repeatable)", collect, []).option(
1762
+ "--expect-version <n>",
1763
+ "fail (409) unless the item is still at this version",
1764
+ (v) => Number.parseInt(v, 10)
1765
+ )
1766
+ ),
1767
+ // --url is the repo pointer here; the API base comes from TINES_API_URL.
1768
+ { baseUrlFlag: false }
1769
+ ).action(
1770
+ async (id, opts) => {
1771
+ const api = client({ apiKey: opts.apiKey, json: opts.json });
1772
+ const body = {};
1773
+ if (opts.expectVersion !== void 0) body.expected_version = opts.expectVersion;
1774
+ if (opts.name !== void 0) body.name = opts.name;
1775
+ if (opts.description !== void 0) body.description = opts.description;
1776
+ const scope = await resolveScopeFlags(api, opts);
1777
+ Object.assign(body, scope);
1778
+ for (const dim of opts.unset) {
1779
+ if (dim === "project") body.project_id = null;
1780
+ else if (dim === "state") body.workflow_state_id = null;
1781
+ else if (dim === "issue") body.issue_id = null;
1782
+ else die(`--unset takes project, state, or issue, got "${dim}"`);
1783
+ }
1784
+ if (opts.body !== void 0) body.body = readBodyValue(opts.body);
1785
+ if (opts.file.length > 0 || opts.removeFile.length > 0) {
1786
+ const current = await api.getContextItem(id);
1787
+ if (current.kind !== "skill") die(`--file/--remove-file only apply to skills (this is a ${current.kind})`);
1788
+ if (body.expected_version === void 0) body.expected_version = current.version;
1789
+ const files = new Map((current.files ?? []).map((f) => [f.path, f.content]));
1790
+ for (const path of opts.removeFile) {
1791
+ if (!files.delete(path)) {
1792
+ die(`no file "${path}" in skill "${current.name}" (have: ${[...files.keys()].join(", ") || "none"})`);
1793
+ }
1794
+ }
1795
+ for (const spec of opts.file) {
1796
+ const f = parseFileSpec(spec);
1797
+ files.set(f.path, f.content);
1798
+ }
1799
+ body.files = [...files.entries()].map(([path, content]) => ({ path, content }));
1800
+ }
1801
+ if (opts.url !== void 0) body.repo_url = opts.url;
1802
+ if (opts.branch !== void 0) body.repo_branch = opts.branch === "" ? null : opts.branch;
1803
+ if (opts.dir !== void 0) body.repo_dir = opts.dir === "" ? null : opts.dir;
1804
+ if (Object.keys(body).length === 0) {
1805
+ die("nothing to update: pass payload flags, --name/--description, scope flags, and/or --unset");
1806
+ }
1807
+ const item = await api.updateContextItem(id, body);
1808
+ if (opts.json) return printJson(item);
1809
+ console.log(`updated ${item.kind} "${item.name}" (${item.id}) \u2014 scope: ${item.scope.label}`);
1810
+ }
1811
+ );
1812
+ withCommon(context.command("delete <id>").description("Delete a context item")).action(
1813
+ async (id, opts) => {
1814
+ const api = client(opts);
1815
+ const item = await api.getContextItem(id);
1816
+ await api.deleteContextItem(id);
1817
+ console.log(`deleted ${item.kind} "${item.name}" (${item.id}) \u2014 scope: ${item.scope.label}`);
1818
+ }
1819
+ );
1820
+ withCommon(
1821
+ context.command("init").description('Seed the global "agent-guidelines" prompt (a no-op if it already exists)')
1822
+ ).action(async (opts) => {
1823
+ const api = client(opts);
1824
+ const { items } = await api.listContext({ kind: "prompt", exact: true, limit: 100 });
1825
+ const existing = items.find((i) => i.name === AGENT_GUIDELINES_NAME);
1826
+ if (existing) {
1827
+ if (opts.json) return printJson(existing);
1828
+ return console.log(
1829
+ `"${AGENT_GUIDELINES_NAME}" already exists (${existing.id}, v${existing.version}) \u2014 left untouched`
1830
+ );
1831
+ }
1832
+ const created = await api.createContextItem({
1833
+ kind: "prompt",
1834
+ name: AGENT_GUIDELINES_NAME,
1835
+ description: AGENT_GUIDELINES_DESCRIPTION,
1836
+ body: AGENT_GUIDELINES_BODY
1837
+ });
1838
+ if (opts.json) return printJson(created);
1839
+ console.log(
1840
+ `seeded global "${AGENT_GUIDELINES_NAME}" (${created.id}) \u2014 it now opens every launch prompt; edit it freely`
1841
+ );
1842
+ });
1843
+ var journal = program.command("journal").description("An issue's stage journal: shared notes for its project + current state");
1844
+ async function resolveJournal(api, ref) {
1845
+ const issue = await resolveIssue(api, ref);
1846
+ const { items } = await api.listContext({
1847
+ kind: "prompt",
1848
+ project: issue.project_id,
1849
+ state: issue.state.id,
1850
+ exact: true,
1851
+ limit: 100
1852
+ });
1853
+ return { issue, item: items.find((i) => i.name === JOURNAL_NAME) ?? null };
1854
+ }
1855
+ withCommon(
1856
+ journal.command("show <ref>").description("Print the journal for the issue's project and current state")
1857
+ ).action(async (ref, opts) => {
1858
+ const api = client(opts);
1859
+ const { issue, item } = await resolveJournal(api, ref);
1860
+ if (!item) {
1861
+ die(
1862
+ `no journal exists yet for project ${issue.project_name} \xB7 state ${issue.state.name}
1863
+ start one: tines journal append ${issue.project_name}/${issue.number} "- <date>: <lesson>"`
1864
+ );
1865
+ }
1866
+ const full = await api.getContextItem(item.id);
1867
+ if (opts.json) return printJson(full);
1868
+ console.log(`journal for project ${issue.project_name} \xB7 state ${issue.state.name} (v${full.version})`);
1869
+ console.log("");
1870
+ console.log(full.body ?? "");
1871
+ });
1872
+ withCommon(
1873
+ journal.command("append <ref> <markdown>").description("Append a lesson (creates the journal on first use)").passThroughOptions()
1874
+ ).action(async (ref, markdown, opts, command) => {
1875
+ if (helpGuard(command, markdown)) return;
1876
+ const api = client(opts);
1877
+ const { issue, item } = await resolveJournal(api, ref);
1878
+ const scopeLabel = `project ${issue.project_name} \xB7 state ${issue.state.name}`;
1879
+ if (item) {
1880
+ const updated = await api.appendContextItem(item.id, { text: markdown });
1881
+ if (opts.json) return printJson(updated);
1882
+ return console.log(`appended to the ${scopeLabel} journal (now v${updated.version})`);
1883
+ }
1884
+ try {
1885
+ const created = await api.createContextItem({
1886
+ kind: "prompt",
1887
+ name: JOURNAL_NAME,
1888
+ project_id: issue.project_id,
1889
+ workflow_state_id: issue.state.id,
1890
+ body: markdown.trim()
1891
+ });
1892
+ if (opts.json) return printJson(created);
1893
+ console.log(`started the ${scopeLabel} journal (${created.id})`);
1894
+ } catch (err) {
1895
+ if (!(err instanceof ApiError) || err.code !== "duplicate_context_name") throw err;
1896
+ const { item: fresh } = await resolveJournal(api, ref);
1897
+ if (!fresh) throw err;
1898
+ const updated = await api.appendContextItem(fresh.id, { text: markdown });
1899
+ if (opts.json) return printJson(updated);
1900
+ console.log(`appended to the ${scopeLabel} journal (now v${updated.version})`);
1901
+ }
1902
+ });
1903
+ withCommon(
1904
+ journal.command("rewrite <ref>").description("Replace the journal body (to fix or prune entries) \u2014 version-checked").requiredOption("--body <md>", "the full new body: inline Markdown or @file").requiredOption(
1905
+ "--expect-version <n>",
1906
+ "the version being replaced (from the prompt or journal show)",
1907
+ (v) => Number.parseInt(v, 10)
1908
+ )
1909
+ ).action(async (ref, opts) => {
1910
+ const api = client(opts);
1911
+ const { issue, item } = await resolveJournal(api, ref);
1912
+ if (!item) {
1913
+ die(
1914
+ `no journal exists yet for project ${issue.project_name} \xB7 state ${issue.state.name}; nothing to rewrite`
1915
+ );
1916
+ }
1917
+ const updated = await api.updateContextItem(item.id, {
1918
+ body: readBodyValue(opts.body),
1919
+ expected_version: opts.expectVersion
1920
+ });
1921
+ if (opts.json) return printJson(updated);
1922
+ console.log(
1923
+ `rewrote the project ${issue.project_name} \xB7 state ${issue.state.name} journal (now v${updated.version})`
1924
+ );
1925
+ });
1926
+ var schedules = program.command("schedules").description("Manage scheduled tasks (addressed as <project>/<name>)");
1927
+ function scheduleRef(s) {
1928
+ return `${s.project_name}/${s.name}`;
1929
+ }
1930
+ function printScheduleDetail(s) {
1931
+ console.log(`${scheduleRef(s)} [${s.id}]${s.enabled ? "" : " (paused)"}`);
1932
+ console.log(`${recurrenceLabel(s)} (cron "${s.cron}")`);
1933
+ console.log(
1934
+ `gate: ${s.require_all_closed ? "only create when previous instances are closed" : "off"} workflow: ${s.workflow_name} start state: ${s.state_name ?? "(initial)"}`
1935
+ );
1936
+ console.log(
1937
+ `next run: ${s.enabled ? timestamp(s.next_run_at) : "(paused)"} last run: ${s.last_run_at ? timestamp(s.last_run_at) : "never"} runs: ${s.run_count} open instances: ${s.open_instances}`
1938
+ );
1939
+ console.log(`
1940
+ title template: ${s.title_template}`);
1941
+ if (s.description_template) {
1942
+ console.log("description template:");
1943
+ for (const line of s.description_template.split("\n")) console.log(` ${line}`);
1944
+ }
1945
+ }
1946
+ withList(
1947
+ schedules.command("list").description("List scheduled tasks (hides paused schedules unless --all)").option("-p, --project <name>", "filter by project name or id").option("-a, --all", "include paused schedules")
1948
+ ).action(async (opts) => {
1949
+ const res = await client(opts).listSchedules({
1950
+ project: opts.project,
1951
+ enabled: opts.all ? void 0 : true,
1952
+ limit: opts.limit,
1953
+ cursor: opts.cursor
1954
+ });
1955
+ printList(res, opts, (items) => {
1956
+ if (items.length === 0) return console.log("no schedules");
1957
+ table([
1958
+ ["NAME", "RECURRENCE", "NEXT RUN", "LAST RUN", "OPEN", ""],
1959
+ ...items.map((s) => [
1960
+ scheduleRef(s),
1961
+ recurrenceLabel(s),
1962
+ s.enabled ? timestamp(s.next_run_at) : "\u2014",
1963
+ s.last_run_at ? timestamp(s.last_run_at) : "never",
1964
+ String(s.open_instances),
1965
+ s.enabled ? "" : "(paused)"
1966
+ ])
1967
+ ]);
1968
+ });
1969
+ });
1970
+ withCommon(
1971
+ schedules.command("show <ref>").description("Show a schedule (<project>/<name>): config, next/last run, recent instances")
1972
+ ).action(async (ref, opts) => {
1973
+ const api = client(opts);
1974
+ const schedule = await resolveSchedule(api, ref);
1975
+ if (opts.json) return printJson(schedule);
1976
+ printScheduleDetail(schedule);
1977
+ const { items } = await api.listIssues({ schedule: schedule.id, limit: 10 });
1978
+ if (items.length > 0) {
1979
+ console.log(`
1980
+ recent instances:`);
1981
+ table(
1982
+ items.map((i) => [
1983
+ ` ${i.project_name}/${i.number}`,
1984
+ i.title,
1985
+ i.state.name,
1986
+ timestamp(i.created_at)
1987
+ ])
1988
+ );
1989
+ }
1990
+ });
1991
+ withCommon(
1992
+ schedules.command("edit <ref>").description("Edit a schedule: templates, workflow, start state, recurrence, timezone, gate, or name").option("-t, --title <template>", "set the title template").option("-d, --description <markdown>", "set the description template (Markdown)").option(
1993
+ "-w, --workflow <id-or-name>",
1994
+ "move future instances onto another workflow (resets the start state to its initial state unless --state is also given)"
1995
+ ).option(
1996
+ "-s, --state <id-or-name>",
1997
+ "start state for future instances (the workflow's initial state = the default)"
1998
+ ).option("--every <preset>", 'repeat hourly (or every N hours: "6h"), daily, weekly, or monthly').option("--at <when>", "preset time of day HH:MM (default 09:00); for hourly, the minute past the hour :MM (default :00)").option("--on <when>", "weekday (weekly) or day of month (monthly)").option("--cron <expr>", "5-field cron expression (alternative to --every/--at/--on)").option("--tz <iana>", "set the schedule timezone").option("--if-closed", "only create a new instance when all previous instances are closed").option("--no-if-closed", "clear the only-when-closed gate").option("--name <new-name>", "rename the schedule")
1999
+ ).action(
2000
+ async (ref, opts) => {
2001
+ const api = client(opts);
2002
+ const schedule = await resolveSchedule(api, ref);
2003
+ const body = {};
2004
+ if (opts.title !== void 0) body.title_template = opts.title;
2005
+ if (opts.description !== void 0) body.description_template = opts.description;
2006
+ if (opts.workflow !== void 0) body.workflow_id = (await resolveWorkflow(api, opts.workflow)).id;
2007
+ if (opts.state !== void 0) body.state = opts.state;
2008
+ const recurrence = buildRecurrence(opts);
2009
+ if (recurrence?.preset) body.preset = recurrence.preset;
2010
+ if (recurrence?.cron !== void 0) body.cron = recurrence.cron;
2011
+ if (opts.tz !== void 0) body.timezone = opts.tz;
2012
+ if (opts.ifClosed !== void 0) body.require_all_closed = opts.ifClosed;
2013
+ if (opts.name !== void 0) body.name = opts.name;
2014
+ if (Object.keys(body).length === 0) {
2015
+ die(
2016
+ "nothing to update: pass --title, --description, --workflow, --state, --every/--at/--on, --cron, --tz, --[no-]if-closed, and/or --name"
2017
+ );
2018
+ }
2019
+ const updated = await api.updateSchedule(schedule.id, body);
2020
+ if (opts.json) return printJson(updated);
2021
+ console.log(`updated schedule "${scheduleRef(updated)}"
2022
+ `);
2023
+ printScheduleDetail(updated);
2024
+ }
2025
+ );
2026
+ withCommon(schedules.command("pause <ref>").description("Pause a schedule (keeps config and history)")).action(
2027
+ async (ref, opts) => {
2028
+ const api = client(opts);
2029
+ const schedule = await resolveSchedule(api, ref);
2030
+ const updated = await api.updateSchedule(schedule.id, { enabled: false });
2031
+ if (opts.json) return printJson(updated);
2032
+ console.log(`paused schedule "${scheduleRef(updated)}"`);
2033
+ }
2034
+ );
2035
+ withCommon(
2036
+ schedules.command("resume <ref>").description("Resume a paused schedule (recomputes the next occurrence from now)")
2037
+ ).action(async (ref, opts) => {
2038
+ const api = client(opts);
2039
+ const schedule = await resolveSchedule(api, ref);
2040
+ const updated = await api.updateSchedule(schedule.id, { enabled: true });
2041
+ if (opts.json) return printJson(updated);
2042
+ console.log(`resumed schedule "${scheduleRef(updated)}" \u2014 next run ${timestamp(updated.next_run_at)}`);
2043
+ });
2044
+ withCommon(
2045
+ schedules.command("run <ref>").description("Create an instance now (respects the only-when-closed gate)")
2046
+ ).action(async (ref, opts) => {
2047
+ const api = client(opts);
2048
+ const schedule = await resolveSchedule(api, ref);
2049
+ const issue = await api.runSchedule(schedule.id);
2050
+ if (opts.json) return printJson(issue);
2051
+ console.log(
2052
+ `created ${issue.project_name}/#${issue.number} "${issue.title}" in state "${issue.state.name}"`
2053
+ );
2054
+ });
2055
+ withCommon(
2056
+ schedules.command("delete <ref>").description("Delete a schedule (existing issues are kept)").option("-y, --yes", "skip the confirmation prompt")
2057
+ ).action(async (ref, opts) => {
2058
+ const api = client(opts);
2059
+ const schedule = await resolveSchedule(api, ref);
2060
+ if (!opts.yes) {
2061
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2062
+ const answer = await rl.question(
2063
+ `Delete schedule "${scheduleRef(schedule)}"? Its ${schedule.run_count} existing issue${schedule.run_count === 1 ? "" : "s"} will be kept. [y/N] `
2064
+ );
2065
+ rl.close();
2066
+ if (!/^y(es)?$/i.test(answer.trim())) die("aborted");
2067
+ }
2068
+ await api.deleteSchedule(schedule.id);
2069
+ console.log(`deleted schedule "${scheduleRef(schedule)}" (issues kept)`);
2070
+ });
2071
+ async function resolveRunner(api, ref) {
2072
+ const { items } = await api.listRunners();
2073
+ const found = items.find((r) => r.name === ref) ?? items.find((r) => r.id === ref);
2074
+ if (!found) {
2075
+ die(`no runner named "${ref}" (have: ${items.map((r) => r.name).join(", ") || "none"})`);
2076
+ }
2077
+ return found;
2078
+ }
2079
+ function parseTargetSpec(spec) {
2080
+ const sep = spec.lastIndexOf(":");
2081
+ if (sep === -1) return { name: spec };
2082
+ const name = spec.slice(0, sep);
2083
+ const tier = spec.slice(sep + 1);
2084
+ if (!name) die(`target must look like <runner>[:tier], got "${spec}"`);
2085
+ if (!MODEL_TIERS.includes(tier)) {
2086
+ die(`unknown tier "${tier}" in "${spec}" (tiers: ${MODEL_TIERS.join(", ")})`);
2087
+ }
2088
+ return { name, tier };
2089
+ }
2090
+ function runnerStatusLabel(runner) {
2091
+ if (runner.status === "paused") return "paused";
2092
+ return runner.online ? "online" : "offline";
2093
+ }
2094
+ var runners = program.command("runners").description("Manage the runner registry");
2095
+ withCommon(runners.command("list").description("List runners")).action(async (opts) => {
2096
+ const res = await client(opts).listRunners();
2097
+ if (opts.json) return printJson(res);
2098
+ if (res.items.length === 0) return console.log("no runners");
2099
+ table([
2100
+ ["NAME", "TYPE", "STATUS", "RUNS", "TIER", "LAST SEEN"],
2101
+ ...res.items.map((r) => [
2102
+ r.name,
2103
+ r.type,
2104
+ runnerStatusLabel(r),
2105
+ `${r.active_runs}/${r.max_concurrent}`,
2106
+ r.default_tier,
2107
+ r.last_seen_at ? timestamp(r.last_seen_at) : "\u2014"
2108
+ ])
2109
+ ]);
2110
+ });
2111
+ withCommon(runners.command("show <name>").description("Show a runner")).action(
2112
+ async (ref, opts) => {
2113
+ const runner = await resolveRunner(client(opts), ref);
2114
+ if (opts.json) return printJson(runner);
2115
+ console.log(`${runner.name} (${runner.type}) [${runner.id}] ${runnerStatusLabel(runner)}`);
2116
+ console.log(
2117
+ `active runs: ${runner.active_runs}/${runner.max_concurrent} timeout: ${runner.max_run_minutes}m default tier: ${runner.default_tier}`
2118
+ );
2119
+ if (runner.last_seen_at) console.log(`last seen: ${timestamp(runner.last_seen_at)}`);
2120
+ if (runner.launch_failures > 0) {
2121
+ console.log(
2122
+ `launch failures: ${runner.launch_failures}${runner.backoff_until ? ` (backing off until ${timestamp(runner.backoff_until)})` : ""}`
2123
+ );
2124
+ }
2125
+ const harness = runner.config.harness;
2126
+ if (typeof harness === "string") console.log(`harness: ${harness}`);
2127
+ }
2128
+ );
2129
+ withCommon(
2130
+ runners.command("pause <name>").description("Pause a runner (stops new assignments; identity and rules stay)")
2131
+ ).action(async (ref, opts) => {
2132
+ const api = client(opts);
2133
+ const runner = await resolveRunner(api, ref);
2134
+ const updated = await api.updateRunner(runner.id, { status: "paused" });
2135
+ if (opts.json) return printJson(updated);
2136
+ console.log(`paused runner "${updated.name}"`);
2137
+ });
2138
+ withCommon(runners.command("resume <name>").description("Resume a paused runner")).action(
2139
+ async (ref, opts) => {
2140
+ const api = client(opts);
2141
+ const runner = await resolveRunner(api, ref);
2142
+ const updated = await api.updateRunner(runner.id, { status: "active" });
2143
+ if (opts.json) return printJson(updated);
2144
+ console.log(`resumed runner "${updated.name}"`);
2145
+ }
2146
+ );
2147
+ withCommon(
2148
+ runners.command("remove <name>").description("Remove a runner (refused while routing rules or pins reference it, unless --force)").option("--force", "strip the runner from routing rules and clear issue pins (emptied rules are kept, flagged)")
2149
+ ).action(async (ref, opts) => {
2150
+ const api = client(opts);
2151
+ const runner = await resolveRunner(api, ref);
2152
+ await api.deleteRunner(runner.id, opts.force ? { force: true } : void 0);
2153
+ console.log(`removed runner "${runner.name}"${opts.force ? " (references stripped)" : ""}`);
2154
+ });
2155
+ withCommon(
2156
+ runners.command("rotate-token <name>").description("Invalidate a local runner's token and mint a fresh one (shown once)")
2157
+ ).action(async (ref, opts) => {
2158
+ const api = client(opts);
2159
+ const runner = await resolveRunner(api, ref);
2160
+ const rotated = await api.rotateRunnerToken(runner.id);
2161
+ if (opts.json) return printJson(rotated);
2162
+ const url = resolveUrl(opts);
2163
+ console.log(`rotated the token for runner "${rotated.runner.name}" \u2014 the old token is dead.`);
2164
+ console.log(`new token (shown once): ${rotated.runner_token}`);
2165
+ if (hasRunnerCredentials(defaultConfigDir(), url, rotated.runner.name)) {
2166
+ saveRunnerCredentials(defaultConfigDir(), url, rotated.runner.name, {
2167
+ runner_id: rotated.runner.id,
2168
+ token: rotated.runner_token
2169
+ });
2170
+ console.log(`stored it for the daemon on this machine (${defaultConfigDir()}); restart the daemon to adopt it.`);
2171
+ } else {
2172
+ console.log("drop it into the daemon machine's config \u2014 its next poll gets a 401 until it adopts the new token.");
2173
+ }
2174
+ });
2175
+ var runnerCmd = program.command("runner").description("The local runner daemon");
2176
+ withCommon(
2177
+ runnerCmd.command("daemon").description("Run the local runner daemon: register/reconnect, poll for assigned runs, execute them").option("--name <name>", "runner name, unique per user (default: this hostname)").option("--harness <harness>", "claude-code | codex | custom", "claude-code").option("--command <template>", "custom harness command template ({prompt_file}, {workspace}, {model})").option("--max-concurrent <n>", "maximum simultaneous runs", (v) => Number.parseInt(v, 10), 1).option("--poll-interval <seconds>", "seconds between polls", (v) => Number.parseInt(v, 10), 15)
2178
+ ).action(
2179
+ async (opts) => {
2180
+ const harness = opts.harness.replaceAll("-", "_");
2181
+ if (!HARNESS_KINDS.includes(harness)) {
2182
+ die(`--harness must be claude-code, codex, or custom, got "${opts.harness}"`);
2183
+ }
2184
+ if (harness === "custom" && !opts.command) {
2185
+ die('the custom harness needs --command "<template>" ({prompt_file}, {workspace}, {model})');
2186
+ }
2187
+ if (harness !== "custom" && opts.command) die("--command only applies to --harness custom");
2188
+ if (!Number.isInteger(opts.maxConcurrent) || opts.maxConcurrent < 1 || opts.maxConcurrent > 100) {
2189
+ die("--max-concurrent must be an integer between 1 and 100");
2190
+ }
2191
+ if (!Number.isInteger(opts.pollInterval) || opts.pollInterval < 1) {
2192
+ die("--poll-interval must be a positive number of seconds");
2193
+ }
2194
+ await runDaemon({
2195
+ url: resolveUrl(opts).replace(/\/+$/, ""),
2196
+ apiKey: resolveApiKey(opts),
2197
+ name: opts.name ?? hostname2(),
2198
+ harness,
2199
+ command: opts.command,
2200
+ maxConcurrent: opts.maxConcurrent,
2201
+ pollIntervalMs: opts.pollInterval * 1e3,
2202
+ configDir: defaultConfigDir()
2203
+ });
2204
+ }
2205
+ );
2206
+ var runsCmd = program.command("runs").description("Agent runs: attempts at issues by runners");
2207
+ function runRow(run) {
2208
+ return [
2209
+ run.id,
2210
+ run.issue_ref ? issueRef(run.issue_ref) : run.issue_id,
2211
+ run.runner_name,
2212
+ `${run.tier}${run.model ? ` (${run.model})` : ""}`,
2213
+ run.status,
2214
+ runDurationLabel(run),
2215
+ timestamp(run.created_at)
2216
+ ];
2217
+ }
2218
+ withList(
2219
+ runsCmd.command("list").description("List runs, newest first").option("-i, --issue <ref>", "filter to one issue (<project>/<number>)").option("-r, --runner <name>", "filter by runner name").option("--active", "only runs holding a claim (assigned/launching/running)")
2220
+ ).action(async (opts) => {
2221
+ const api = client(opts);
2222
+ const issueId = opts.issue ? (await resolveIssue(api, opts.issue)).id : void 0;
2223
+ const runnerId = opts.runner ? (await resolveRunner(api, opts.runner)).id : void 0;
2224
+ const res = await api.listRuns({
2225
+ issue: issueId,
2226
+ runner: runnerId,
2227
+ active: opts.active ? true : void 0,
2228
+ limit: opts.limit,
2229
+ cursor: opts.cursor
2230
+ });
2231
+ printList(res, opts, (items) => {
2232
+ if (items.length === 0) return console.log(opts.active ? "no active runs" : "no runs");
2233
+ table([["ID", "ISSUE", "RUNNER", "TIER", "STATUS", "DURATION", "CREATED"], ...items.map(runRow)]);
2234
+ });
2235
+ });
2236
+ withCommon(
2237
+ runsCmd.command("show <id>").description("Show a run; --logs prints the captured log tail").option("--logs", "print the log tail")
2238
+ ).action(async (id, opts) => {
2239
+ const api = client(opts);
2240
+ const run = await api.getRun(id);
2241
+ if (opts.json) return printJson(run);
2242
+ console.log(`${run.id} ${run.status} on ${run.runner_name}`);
2243
+ if (run.issue_ref) console.log(`issue: ${issueRef(run.issue_ref)} \u2014 ${run.issue_ref.title}`);
2244
+ console.log(`tier: ${run.tier} model: ${run.model ?? "(n/a)"}`);
2245
+ console.log(
2246
+ `states: ${run.state_at_start_name ?? run.state_id_at_start} \u2192 ${run.state_at_end_name ?? run.state_id_at_end ?? "\u2026"}`
2247
+ );
2248
+ console.log(
2249
+ `created: ${timestamp(run.created_at)} started: ${run.started_at ? timestamp(run.started_at) : "\u2014"} ended: ${run.ended_at ? timestamp(run.ended_at) : "\u2014"} duration: ${runDurationLabel(run)}`
2250
+ );
2251
+ if (run.provider_session_id) console.log(`provider session: ${run.provider_session_id}`);
2252
+ if (run.provider_url) console.log(`provider console: ${run.provider_url}`);
2253
+ if (run.error) console.log(`error: ${run.error}`);
2254
+ if (opts.logs) {
2255
+ console.log("");
2256
+ if (run.log_bytes_dropped > 0) {
2257
+ console.log(`[${Math.round(run.log_bytes_dropped / 1024)} KB truncated from the head]`);
2258
+ }
2259
+ console.log(run.log || "(no log output captured)");
2260
+ }
2261
+ });
2262
+ withCommon(
2263
+ runsCmd.command("cancel <id>").description("Cancel a run (judged like any other end: usually a strike)")
2264
+ ).action(async (id, opts) => {
2265
+ const api = client(opts);
2266
+ const run = await api.cancelRun(id);
2267
+ if (opts.json) return printJson(run);
2268
+ console.log(
2269
+ `canceled ${run.id}${run.issue_ref ? ` on ${issueRef(run.issue_ref)}` : ""} (was on ${run.runner_name})`
2270
+ );
2271
+ });
2272
+ var routing = program.command("routing").description("Scoped routing rules: which runner takes which issues (most specific scope wins)");
2273
+ async function resolveRoutingScope(api, opts) {
2274
+ const projectId = opts.project !== void 0 ? (await resolveProject(api, opts.project)).id : null;
2275
+ const stateId = opts.state !== void 0 ? (await resolveStateFlag(api, opts.state)).state.id : null;
2276
+ const parts = [];
2277
+ if (opts.project) parts.push(`project ${opts.project}`);
2278
+ if (opts.state) parts.push(`state ${opts.state}`);
2279
+ return { projectId, stateId, label: parts.length > 0 ? parts.join(" \xB7 ") : "global" };
2280
+ }
2281
+ function ruleTargetsLabel(rule) {
2282
+ if (rule.targets.length === 0) return "(no targets)";
2283
+ return rule.targets.map((t) => `${t.runner_name}${t.tier ? `:${t.tier}` : ""}${t.runner_status === "paused" ? " (paused)" : ""}`).join(" \u2192 ");
2284
+ }
2285
+ withCommon(routing.command("list").description("List routing rules, most specific first")).action(
2286
+ async (opts) => {
2287
+ const res = await client(opts).listRoutingRules();
2288
+ if (opts.json) return printJson(res);
2289
+ if (res.items.length === 0) return console.log("no routing rules \u2014 nothing will dispatch");
2290
+ table([
2291
+ ["SCOPE", "TARGETS", "ID"],
2292
+ ...res.items.map((r) => [r.scope.label, ruleTargetsLabel(r), r.id])
2293
+ ]);
2294
+ }
2295
+ );
2296
+ withCommon(
2297
+ routing.command("set <target...>").description("Create or replace the rule at a scope: an ordered list of <runner>[:tier] targets").option("-p, --project <name>", "scope: project name or id").option("-s, --state <workflow/state>", "scope: workflow-qualified state")
2298
+ ).action(async (targetSpecs, opts) => {
2299
+ const api = client(opts);
2300
+ const scope = await resolveRoutingScope(api, opts);
2301
+ const targets = [];
2302
+ for (const spec of targetSpecs) {
2303
+ const { name, tier } = parseTargetSpec(spec);
2304
+ const runner = await resolveRunner(api, name);
2305
+ targets.push(tier ? { runner_id: runner.id, tier } : { runner_id: runner.id });
2306
+ }
2307
+ const { items } = await api.listRoutingRules();
2308
+ const existing = items.find(
2309
+ (r) => r.scope.project_id === scope.projectId && r.scope.workflow_state_id === scope.stateId
2310
+ );
2311
+ const rule = existing ? await api.updateRoutingRule(existing.id, { targets }) : await api.createRoutingRule({ project_id: scope.projectId, workflow_state_id: scope.stateId, targets });
2312
+ if (opts.json) return printJson(rule);
2313
+ console.log(
2314
+ `${existing ? "updated" : "created"} the ${rule.scope.label} rule: ${ruleTargetsLabel(rule)}`
2315
+ );
2316
+ for (const warning of rule.warnings) console.log(`warning: ${warning.message}`);
2317
+ });
2318
+ withCommon(
2319
+ routing.command("clear").description("Delete the rule at a scope (issues it matched stop dispatching)").option("-p, --project <name>", "scope: project name or id").option("-s, --state <workflow/state>", "scope: workflow-qualified state")
2320
+ ).action(async (opts) => {
2321
+ const api = client(opts);
2322
+ const scope = await resolveRoutingScope(api, opts);
2323
+ const { items } = await api.listRoutingRules();
2324
+ const existing = items.find(
2325
+ (r) => r.scope.project_id === scope.projectId && r.scope.workflow_state_id === scope.stateId
2326
+ );
2327
+ if (!existing) die(`no routing rule at scope ${scope.label}`);
2328
+ await api.deleteRoutingRule(existing.id);
2329
+ console.log(`cleared the ${existing.scope.label} rule`);
2330
+ });
2331
+ var supervisor = program.command("supervisor").description("The automation kill switch, quota policy, and attempt limit");
2332
+ function quotaLabel(quota2, stateName) {
2333
+ if (quota2.type === "global_cap") return `global cap: at most ${quota2.limit} concurrent runs`;
2334
+ const overrides = Object.entries(quota2.overrides).map(
2335
+ ([id, limit]) => `${stateName ? stateName(id) : id}=${limit}`
2336
+ );
2337
+ return `state roster: default ${quota2.default_limit} per state${overrides.length > 0 ? `, overrides: ${overrides.join(", ")}` : ""}`;
2338
+ }
2339
+ withCommon(supervisor.command("status").description("One-screen overview: kill switch, quota, utilization, runners")).action(
2340
+ async (opts) => {
2341
+ const api = client(opts);
2342
+ const [settings, runnersRes, workflows2, activeRuns] = await Promise.all([
2343
+ api.getSupervisorSettings(),
2344
+ api.listRunners(),
2345
+ api.listWorkflows({ limit: 100 }),
2346
+ api.listRuns({ active: true, limit: 100 })
2347
+ ]);
2348
+ if (opts.json) {
2349
+ return printJson({ settings, runners: runnersRes.items, active_runs: activeRuns.items });
2350
+ }
2351
+ const stateNames = /* @__PURE__ */ new Map();
2352
+ for (const wf of workflows2.items) {
2353
+ for (const s of wf.states) stateNames.set(s.id, `${wf.name}/${s.name}`);
2354
+ }
2355
+ console.log(`automation: ${settings.enabled ? "ON" : "OFF (kill switch \u2014 nothing dispatches)"}`);
2356
+ console.log(quotaLabel(settings.quota, (id) => stateNames.get(id) ?? id));
2357
+ console.log(`utilization: ${utilizationLabel(settings.quota, activeRuns.items, (id) => stateNames.get(id) ?? id)}`);
2358
+ console.log(`attempt limit: ${settings.attempt_limit} strikes, then the issue parks`);
2359
+ if (runnersRes.items.length === 0) {
2360
+ console.log("runners: none");
2361
+ } else {
2362
+ console.log("runners:");
2363
+ table(
2364
+ runnersRes.items.map((r) => [
2365
+ ` ${r.name}`,
2366
+ r.type,
2367
+ runnerStatusLabel(r),
2368
+ `${r.active_runs}/${r.max_concurrent}`
2369
+ ])
2370
+ );
2371
+ }
2372
+ }
2373
+ );
2374
+ withCommon(supervisor.command("enable").description("Arm automation (the kill switch on)")).action(
2375
+ async (opts) => {
2376
+ const settings = await client(opts).updateSupervisorSettings({ enabled: true });
2377
+ if (opts.json) return printJson(settings);
2378
+ console.log("automation is ON \u2014 eligible issues with a matching rule will dispatch");
2379
+ }
2380
+ );
2381
+ withCommon(supervisor.command("disable").description("Pause all automation at once (the kill switch off)")).action(
2382
+ async (opts) => {
2383
+ const settings = await client(opts).updateSupervisorSettings({ enabled: false });
2384
+ if (opts.json) return printJson(settings);
2385
+ console.log("automation is OFF \u2014 nothing new dispatches until re-enabled");
2386
+ }
2387
+ );
2388
+ var quota = supervisor.command("quota").description("Pick and configure the quota policy");
2389
+ withCommon(
2390
+ quota.command("global <n>").description("Use the global cap: at most <n> concurrent runs in total")
2391
+ ).action(async (n, opts) => {
2392
+ const limit = Number.parseInt(n, 10);
2393
+ const settings = await client(opts).updateSupervisorSettings({
2394
+ quota: { type: "global_cap", limit }
2395
+ });
2396
+ if (opts.json) return printJson(settings);
2397
+ console.log(quotaLabel(settings.quota));
2398
+ });
2399
+ withCommon(
2400
+ quota.command("roster").description("Use the per-state roster: at most N concurrent runs per workflow state").requiredOption("--default <n>", "limit for states without an override", (v) => Number.parseInt(v, 10)).option(
2401
+ "--state <workflow/state=n>",
2402
+ "per-state override (repeatable), counted by the state a run started in",
2403
+ collect,
2404
+ []
2405
+ )
2406
+ ).action(async (opts) => {
2407
+ const api = client(opts);
2408
+ const overrides = {};
2409
+ for (const spec of opts.state) {
2410
+ const sep = spec.lastIndexOf("=");
2411
+ if (sep < 1 || sep === spec.length - 1) {
2412
+ die(`--state must look like <workflow>/<state>=<n>, got "${spec}"`);
2413
+ }
2414
+ const limit = Number.parseInt(spec.slice(sep + 1), 10);
2415
+ const { state } = await resolveStateFlag(api, spec.slice(0, sep));
2416
+ overrides[state.id] = limit;
2417
+ }
2418
+ const settings = await api.updateSupervisorSettings({
2419
+ quota: { type: "state_roster", default_limit: opts.default, overrides }
2420
+ });
2421
+ if (opts.json) return printJson(settings);
2422
+ const workflows2 = await api.listWorkflows({ limit: 100 });
2423
+ const stateNames = /* @__PURE__ */ new Map();
2424
+ for (const wf of workflows2.items) {
2425
+ for (const s of wf.states) stateNames.set(s.id, `${wf.name}/${s.name}`);
2426
+ }
2427
+ console.log(quotaLabel(settings.quota, (id) => stateNames.get(id) ?? id));
2428
+ });
2429
+ var events = program.command("events").description("Read the activity log");
2430
+ withList(
2431
+ events.command("list").description("List activity events, newest first").option("-i, --issue <ref>", "filter to one issue (<project>/<number>)").option("-p, --project <name>", "filter by project name or id").option("-t, --type <type>", "filter by event type (e.g. issue.transitioned)")
2432
+ ).action(async (opts) => {
2433
+ const api = client(opts);
2434
+ const issueId = opts.issue ? (await resolveIssue(api, opts.issue)).id : void 0;
2435
+ const res = await api.listEvents({
2436
+ issue: issueId,
2437
+ project: opts.project,
2438
+ type: opts.type,
2439
+ limit: opts.limit,
2440
+ cursor: opts.cursor
2441
+ });
2442
+ printList(res, opts, (items) => {
2443
+ if (items.length === 0) return console.log("no events");
2444
+ table([
2445
+ ["WHEN", "ACTOR", "EVENT"],
2446
+ ...items.map((ev) => [timestamp(ev.created_at), actorLabel(ev.actor), eventSummary(ev)])
2447
+ ]);
2448
+ });
2449
+ });
2450
+ try {
2451
+ await program.parseAsync();
2452
+ } catch (err) {
2453
+ reportError(err);
2454
+ }