@opsee/cli 0.11.9

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 (85) hide show
  1. package/README.md +1962 -0
  2. package/bin/opsee.js +28 -0
  3. package/package.json +40 -0
  4. package/skills/README.md +3 -0
  5. package/skills/to-issues/SKILL.md +92 -0
  6. package/skills/to-issues/agents/openai.yaml +5 -0
  7. package/skills/to-spec/SKILL.md +79 -0
  8. package/skills/to-spec/agents/openai.yaml +5 -0
  9. package/skills/wayfinder/SKILL.md +138 -0
  10. package/skills/wayfinder/agents/openai.yaml +5 -0
  11. package/src/args.ts +676 -0
  12. package/src/cli.ts +341 -0
  13. package/src/commands/account.ts +121 -0
  14. package/src/commands/deps.ts +11 -0
  15. package/src/commands/foreman-control.ts +242 -0
  16. package/src/commands/foreman-debug.ts +131 -0
  17. package/src/commands/foreman-plan.ts +213 -0
  18. package/src/commands/foreman-service.ts +186 -0
  19. package/src/commands/foreman-up.ts +165 -0
  20. package/src/commands/foreman-views.ts +398 -0
  21. package/src/commands/foreman.ts +465 -0
  22. package/src/commands/init.ts +176 -0
  23. package/src/commands/initiative.ts +192 -0
  24. package/src/commands/login.ts +24 -0
  25. package/src/commands/whoami.ts +15 -0
  26. package/src/foreman/account-store.ts +96 -0
  27. package/src/foreman/account.ts +474 -0
  28. package/src/foreman/claude-worker-adapter.ts +412 -0
  29. package/src/foreman/codex-worker-adapter.ts +472 -0
  30. package/src/foreman/completion-report.ts +153 -0
  31. package/src/foreman/core/context.ts +169 -0
  32. package/src/foreman/core/defects.ts +280 -0
  33. package/src/foreman/core/exec.ts +20 -0
  34. package/src/foreman/core/gates.ts +493 -0
  35. package/src/foreman/core/handoff.ts +163 -0
  36. package/src/foreman/core/install.ts +109 -0
  37. package/src/foreman/core/learnings.ts +368 -0
  38. package/src/foreman/core/outbox-tracker.ts +192 -0
  39. package/src/foreman/core/pin.ts +226 -0
  40. package/src/foreman/core/plan-context.ts +238 -0
  41. package/src/foreman/core/process-table.ts +535 -0
  42. package/src/foreman/core/reconcile.ts +227 -0
  43. package/src/foreman/core/report.ts +60 -0
  44. package/src/foreman/core/run.ts +2836 -0
  45. package/src/foreman/core/scheduler.ts +244 -0
  46. package/src/foreman/core/summary.ts +166 -0
  47. package/src/foreman/core/text.ts +97 -0
  48. package/src/foreman/core/transcripts.ts +38 -0
  49. package/src/foreman/core/triage.ts +138 -0
  50. package/src/foreman/core/verifier.ts +800 -0
  51. package/src/foreman/core/views.ts +940 -0
  52. package/src/foreman/core/work-contract.ts +152 -0
  53. package/src/foreman/core/workspace.ts +335 -0
  54. package/src/foreman/fake-handoff.ts +33 -0
  55. package/src/foreman/fake-learnings.ts +26 -0
  56. package/src/foreman/fake-remote-api.ts +70 -0
  57. package/src/foreman/fake-tracker-adapter.ts +355 -0
  58. package/src/foreman/fake-worker-adapter.ts +221 -0
  59. package/src/foreman/host.ts +75 -0
  60. package/src/foreman/local-dir.ts +28 -0
  61. package/src/foreman/opsee-tracker-adapter.ts +612 -0
  62. package/src/foreman/process-group.ts +160 -0
  63. package/src/foreman/remote-api.ts +283 -0
  64. package/src/foreman/run-recipe.ts +274 -0
  65. package/src/foreman/service-unit.ts +257 -0
  66. package/src/foreman/tracker-adapter.ts +298 -0
  67. package/src/foreman/triage-draft.ts +40 -0
  68. package/src/foreman/vendor.ts +23 -0
  69. package/src/foreman/verdict.ts +120 -0
  70. package/src/foreman/worker-adapter.ts +177 -0
  71. package/src/foreman/worker-process.ts +488 -0
  72. package/src/identity.ts +49 -0
  73. package/src/index.ts +3 -0
  74. package/src/init/managed.ts +84 -0
  75. package/src/init/mcp-config.ts +77 -0
  76. package/src/init/paths.ts +16 -0
  77. package/src/init/pointer-block.ts +45 -0
  78. package/src/init/project.ts +22 -0
  79. package/src/init/prompt.ts +45 -0
  80. package/src/init/run-recipe-config.ts +133 -0
  81. package/src/init/skills.ts +38 -0
  82. package/src/init/text.ts +22 -0
  83. package/src/init/tracker-doc.ts +106 -0
  84. package/src/opsee-config.ts +116 -0
  85. package/templates/issue-tracker.md +162 -0
@@ -0,0 +1,612 @@
1
+ /**
2
+ * The Opsee Tracker Adapter: TrackerAdapter over the generated Connect-RPC client (ADR-0010).
3
+ *
4
+ * It calls the same backend RPCs the MCP tools do, directly, and mirrors their traps: EditTask is
5
+ * read-modify-write (its validators want the whole Task back), list RPCs need explicit pagination,
6
+ * DeleteTaskLabel takes the join-row id, AddComment needs the caller's user id.
7
+ */
8
+ import { timestampDate } from "@bufbuild/protobuf/wkt";
9
+ import { Code, ConnectError } from "@connectrpc/connect";
10
+ import type { Run, RunEvent, RunEventInput } from "@opsee/mcp-server/gen/api/v1/initiative_pb.js";
11
+ import {
12
+ TaskDependencyType,
13
+ type BoardColumn,
14
+ type Comment,
15
+ type InitiativeMemoryEntry,
16
+ type Label,
17
+ type Task,
18
+ type TaskDependency,
19
+ type TaskPullRequest,
20
+ } from "@opsee/mcp-server/gen/api/v1/models_pb.js";
21
+ import { defaultPagination, overridePagination, type ApiClients } from "@opsee/mcp-server/src/client/api.js";
22
+ import { blockNoteToMarkdown, isBlockNoteJSON } from "@opsee/mcp-server/src/utils/format/blocknote.js";
23
+ // One implementation, shared with `opsee_file_defect`: the two filers must resolve the same label,
24
+ // the same task type and the same priority, or the Tasks they file differ on the board (OPS-284).
25
+ import { sameName } from "@opsee/mcp-server/src/utils/text.js";
26
+ import {
27
+ pickColumn,
28
+ TrackerError,
29
+ type DependencyType,
30
+ type TrackerErrorCode,
31
+ type InitiativeContext,
32
+ type Lifecycle,
33
+ type TrackerEdge,
34
+ type TrackerPullRequest,
35
+ MemoryEntry,
36
+ MemoryKind,
37
+ MemoryQuery,
38
+ MemoryRecord,
39
+ NewTask,
40
+ ProjectRepository,
41
+ PullRequestLink,
42
+ ReadyTasks,
43
+ RunRecord,
44
+ RunRecordQuery,
45
+ TaskWithContext,
46
+ TrackerAdapter,
47
+ TrackerColumn,
48
+ TrackerComment,
49
+ TrackerDependency,
50
+ TrackerTask,
51
+ } from "./tracker-adapter.js";
52
+
53
+ /** The generated clients the adapter talks to; tests hand in a set pointed at the harness. */
54
+ export type TrackerClients = Pick<
55
+ ApiClients,
56
+ | "tasks"
57
+ | "boards"
58
+ | "boardColumns"
59
+ | "taskTypes"
60
+ | "taskPriorities"
61
+ | "users"
62
+ | "labels"
63
+ | "taskLabels"
64
+ | "comments"
65
+ | "taskDependencies"
66
+ | "initiatives"
67
+ | "vcsIntegrations"
68
+ >;
69
+
70
+ /** A Tracker-side refusal the Foreman can act on: a missing label or column, an unknown Task. */
71
+ export { TrackerError };
72
+
73
+ /** Events fetched per GetRunEvents call while walking a Run Record. */
74
+ const RUN_RECORD_PAGE = 500;
75
+ /** Entries fetched per GetInitiativeMemory call while walking the memory log. */
76
+ const MEMORY_PAGE = 500;
77
+
78
+ /** The colour a Status Label gets when the Foreman has to create it. */
79
+ const STATUS_LABEL_COLOR = "#6b7280";
80
+
81
+ const DEPENDENCY_TYPES: Record<number, DependencyType | undefined> = {
82
+ [TaskDependencyType.BLOCKS]: "blocks",
83
+ [TaskDependencyType.BLOCKED_BY]: "blocked_by",
84
+ [TaskDependencyType.DUPLICATES]: "duplicates",
85
+ [TaskDependencyType.RELATES_TO]: "relates_to",
86
+ };
87
+
88
+ function byDisplayOrder(a: BoardColumn, b: BoardColumn): number {
89
+ return a.displayOrder - b.displayOrder;
90
+ }
91
+
92
+ export function toColumn(c: BoardColumn): TrackerColumn {
93
+ return { id: c.id, name: c.name, lifecycle: c.lifecycleState };
94
+ }
95
+
96
+ /** The label names on TaskLabel join rows (rows whose label was not loaded are dropped). */
97
+ function labelNames(rows: { label?: { name: string } }[]): string[] {
98
+ return rows.map((row) => row.label?.name).filter((name): name is string => !!name);
99
+ }
100
+
101
+ /** The web app the Task pages live on; the MCP server's OAuth flow reads the same variable. */
102
+ export function appUrl(): string {
103
+ return (process.env.OPSEE_APP_URL || "https://opsee.ai").replace(/\/+$/, "");
104
+ }
105
+
106
+ /** The Task's canonical page (`/<company>/<project>/<IDENTIFIER>`, the backend's TaskCanonicalURL
107
+ * without the decorative title slug), when the Task came with its project and company. */
108
+ export function taskUrl(t: Task): string | undefined {
109
+ const company = t.project?.company?.slug;
110
+ const project = t.project?.slug;
111
+ if (!company || !project || !t.identifier) return undefined;
112
+ return `${appUrl()}/${company}/${project}/${t.identifier.toUpperCase()}`;
113
+ }
114
+
115
+ /** A Task description as the Foreman reads it: markdown. Opsee stores what the web UI saved after an
116
+ * edit as BlockNote JSON, so that is converted here, once, for every consumer of a TrackerTask. */
117
+ function markdownOf(description: string | undefined): string {
118
+ if (!description) return "";
119
+ return isBlockNoteJSON(description) ? blockNoteToMarkdown(description) : description;
120
+ }
121
+
122
+ /** Maps a generated Task; `labels` overrides what the Task embeds when the caller fetched them. */
123
+ export function toTask(t: Task, labels?: string[]): TrackerTask {
124
+ return {
125
+ url: taskUrl(t),
126
+ id: t.id,
127
+ identifier: t.identifier,
128
+ title: t.title,
129
+ description: markdownOf(t.description),
130
+ projectId: t.project?.id ?? t.projectId,
131
+ initiativeId: t.initiativeId,
132
+ parentTaskId: t.parentTaskId,
133
+ column: t.boardColumn ? toColumn(t.boardColumn) : undefined,
134
+ labels: labels ?? labelNames(t.taskLabels),
135
+ type: t.taskType?.name,
136
+ priority: t.taskPriority?.name,
137
+ // The scheduler's sort keys (core/scheduler.ts), both already on the Task the RPCs hand out:
138
+ // GetReadyTasks preloads TaskPriority, and created_at is a column of the Task itself.
139
+ priorityLevel: t.taskPriority?.level,
140
+ createdAt: t.createdAt ? timestampDate(t.createdAt) : undefined,
141
+ };
142
+ }
143
+
144
+ export function toComment(c: Comment): TrackerComment {
145
+ return {
146
+ id: c.id,
147
+ // A comment edited in the web UI comes back as BlockNote JSON, like a description.
148
+ body: markdownOf(c.content),
149
+ authorUserId: c.userId,
150
+ createdAt: c.createdAt ? timestampDate(c.createdAt) : undefined,
151
+ };
152
+ }
153
+
154
+ function toDependency(d: TaskDependency): TrackerDependency | undefined {
155
+ const type = DEPENDENCY_TYPES[d.type];
156
+ return type ? { id: d.id, fromTaskId: d.fromTaskId, toTaskId: d.toTaskId, type } : undefined;
157
+ }
158
+
159
+ /** A graph edge as GetInitiativeContext reports it: the type is the lowercase name (`blocks`). */
160
+ function toEdge(e: { fromTaskId: number; toTaskId: number; type: string }): TrackerEdge | undefined {
161
+ const type = e.type.toLowerCase() as DependencyType;
162
+ return type === "blocks" || type === "blocked_by" || type === "duplicates" || type === "relates_to"
163
+ ? { fromTaskId: e.fromTaskId, toTaskId: e.toTaskId, type }
164
+ : undefined;
165
+ }
166
+
167
+ export function toPullRequest(pr: TaskPullRequest): TrackerPullRequest {
168
+ return {
169
+ taskId: pr.taskId,
170
+ number: pr.prNumber,
171
+ title: pr.title,
172
+ url: pr.url,
173
+ state: pr.state,
174
+ repository: pr.projectRepository?.repositoryFullName || undefined,
175
+ };
176
+ }
177
+
178
+ function toMemoryRecord(e: InitiativeMemoryEntry): MemoryRecord {
179
+ return {
180
+ id: e.id,
181
+ kind: e.kind as MemoryKind,
182
+ body: e.body,
183
+ sourceTaskId: e.sourceType === "task" ? e.sourceId : undefined,
184
+ sourceUrl: e.sourceUrl,
185
+ isAgent: e.isAgent,
186
+ isSystem: e.isSystem,
187
+ createdAt: e.createdAt ? timestampDate(e.createdAt) : undefined,
188
+ };
189
+ }
190
+
191
+ interface ProjectDefaults {
192
+ boardId: number;
193
+ /** Where a new Task lands: the `todo` column, else the board's first. */
194
+ newTaskColumnId: number;
195
+ types: { id: number; name: string }[];
196
+ priorities: { id: number; name: string }[];
197
+ }
198
+
199
+ export class OpseeTrackerAdapter implements TrackerAdapter {
200
+ private userId?: number;
201
+ private readonly columnsByBoard = new Map<number, BoardColumn[]>();
202
+ private readonly defaultsByProject = new Map<number, ProjectDefaults>();
203
+
204
+ constructor(private readonly clients: TrackerClients) {}
205
+
206
+ async listReadyTasks(initiativeId: number): Promise<ReadyTasks> {
207
+ let res;
208
+ try {
209
+ res = await this.clients.initiatives.getReadyTasks({ initiativeId });
210
+ } catch (error) {
211
+ // The likeliest way to get here is `foreman run <taskId>`: say so instead of echoing the RPC.
212
+ if (error instanceof ConnectError && error.code === Code.NotFound) {
213
+ throw new TrackerError(`Initiative ${initiativeId} not found. foreman run takes an Initiative id, not a Task id.`);
214
+ }
215
+ throw error;
216
+ }
217
+ // GetReadyTasks does not preload labels; a Ready Task's labels are what routes it (vendor and
218
+ // Account pins live there), so fetch them rather than report none.
219
+ const tasks = await Promise.all(res.tasks.map(async (t) => toTask(t, await this.labelsOf(t.id))));
220
+ return { tasks, dispatchLabel: res.dispatchLabel, dispatchLabelExists: res.dispatchLabelExists };
221
+ }
222
+
223
+ async getTask(taskId: number): Promise<TaskWithContext> {
224
+ const task = await this.rawTask(taskId);
225
+ const page = overridePagination();
226
+ const [labels, comments, dependencies, parent] = await Promise.all([
227
+ this.labelsOf(taskId),
228
+ this.clients.comments.getComments({ taskId, pagination: page }),
229
+ this.clients.taskDependencies.getTaskDependencies({ taskId, pagination: page }),
230
+ task.parentTaskId ? this.rawTask(task.parentTaskId) : undefined,
231
+ ]);
232
+ return {
233
+ task: toTask(task, labels),
234
+ comments: comments.comments.map(toComment),
235
+ dependencies: dependencies.taskDependencies
236
+ .map(toDependency)
237
+ .filter((d): d is TrackerDependency => !!d),
238
+ parent: parent ? toTask(parent) : undefined,
239
+ };
240
+ }
241
+
242
+ async addComment(taskId: number, body: string): Promise<TrackerComment> {
243
+ const res = await this.clients.comments.addComment({
244
+ content: body,
245
+ isInternal: false,
246
+ taskId,
247
+ userId: await this.me(),
248
+ });
249
+ if (!res.comment) throw new TrackerError(`Comment on task ${taskId} came back empty`);
250
+ return toComment(res.comment);
251
+ }
252
+
253
+ /** The Initiative's discussion thread. The same RPC a Task comment uses — it already takes an
254
+ * `initiative_id` instead of a `task_id` — so this needed nothing of the backend. */
255
+ async addInitiativeComment(initiativeId: number, body: string): Promise<TrackerComment> {
256
+ const res = await this.clients.comments.addComment({
257
+ content: body,
258
+ isInternal: false,
259
+ initiativeId,
260
+ userId: await this.me(),
261
+ });
262
+ if (!res.comment) throw new TrackerError(`Comment on Initiative ${initiativeId} came back empty`);
263
+ return toComment(res.comment);
264
+ }
265
+
266
+ async moveTask(taskId: number, lifecycle: Lifecycle): Promise<TrackerColumn> {
267
+ const task = await this.rawTask(taskId);
268
+ const column = await this.columnFor(task.board?.id ?? task.boardId, lifecycle);
269
+ if (column.id !== (task.boardColumn?.id ?? task.boardColumnId)) {
270
+ await this.editTask(task, { boardColumnId: column.id });
271
+ }
272
+ return toColumn(column);
273
+ }
274
+
275
+ async attachLabel(taskId: number, label: string): Promise<void> {
276
+ const task = await this.rawTask(taskId);
277
+ const existing = await this.taskLabelRow(taskId, label);
278
+ if (existing) return;
279
+ const found = await this.labelByName(task.project?.id ?? task.projectId, label);
280
+ await this.clients.taskLabels.addTaskLabel({ taskId, labelId: found.id });
281
+ }
282
+
283
+ async detachLabel(taskId: number, label: string): Promise<void> {
284
+ const existing = await this.taskLabelRow(taskId, label);
285
+ if (!existing) return;
286
+ await this.clients.taskLabels.deleteTaskLabel({ id: existing.id });
287
+ }
288
+
289
+ async ensureLabel(projectId: number, label: string): Promise<void> {
290
+ const res = await this.clients.labels.getLabels({ projectId, pagination: overridePagination() });
291
+ if (res.labels.some((l) => l.isActive && sameName(l.name, label))) return;
292
+ await this.clients.labels.addLabel({ name: label, projectId, color: STATUS_LABEL_COLOR, isActive: true });
293
+ }
294
+
295
+ async createTask(input: NewTask): Promise<TrackerTask> {
296
+ const res = await this.clients.initiatives.getInitiative({ id: input.initiativeId });
297
+ if (!res.initiative) throw new TrackerError(`Initiative ${input.initiativeId} not found`);
298
+ const projectId = res.initiative.projectId;
299
+ const defaults = await this.projectDefaults(projectId);
300
+ const labels = await Promise.all(
301
+ (input.labels ?? []).map((name) => this.labelByName(projectId, name)),
302
+ );
303
+
304
+ const created = await this.clients.tasks.addTask({
305
+ title: input.title,
306
+ description: input.description,
307
+ displayOrder: "",
308
+ aiModeEnabled: false,
309
+ boardId: defaults.boardId,
310
+ boardColumnId: defaults.newTaskColumnId,
311
+ projectId,
312
+ taskTypeId: pickNamed(defaults.types, input.type, "task type", "unknown_task_type").id,
313
+ taskPriorityId: pickNamed(defaults.priorities, input.priority, "priority", "unknown_priority").id,
314
+ reporterUserId: await this.me(),
315
+ parentTaskId: input.parentTaskId,
316
+ labelIds: labels.map((l) => l.id),
317
+ });
318
+ const task = created.task;
319
+ if (!task) throw new TrackerError(`Creating "${input.title}" returned no task`);
320
+
321
+ // AddTask cannot place a Task in an Initiative (only EditTask carries initiative_id), and edges
322
+ // need the new id, so this is three RPCs. If a later one fails the Task is deleted again, so the
323
+ // caller never sees a half-wired Task; if even that fails, the error names what was left.
324
+ try {
325
+ await this.editTask(task, { initiativeId: input.initiativeId });
326
+ for (const other of input.relatesTo ?? []) {
327
+ await this.clients.taskDependencies.addTaskDependency({
328
+ fromTaskId: task.id,
329
+ toTaskId: other,
330
+ type: TaskDependencyType.RELATES_TO,
331
+ });
332
+ }
333
+ for (const blocker of input.blockedBy ?? []) {
334
+ await this.clients.taskDependencies.addTaskDependency({
335
+ fromTaskId: blocker,
336
+ toTaskId: task.id,
337
+ type: TaskDependencyType.BLOCKS,
338
+ });
339
+ }
340
+ } catch (error) {
341
+ const why = `placing task "${input.title}" in initiative ${input.initiativeId} or wiring its edges failed: ${errorMessage(error)}`;
342
+ try {
343
+ await this.clients.tasks.deleteTask({ id: task.id });
344
+ } catch (undoError) {
345
+ throw new TrackerError(
346
+ `${why}; removing the half-wired task ${task.identifier} (${task.id}) failed too: ${errorMessage(undoError)}`,
347
+ );
348
+ }
349
+ throw new TrackerError(`${why} (the task was removed again)`);
350
+ }
351
+ return { ...toTask(task, labels.map((l) => l.name)), initiativeId: input.initiativeId };
352
+ }
353
+
354
+ async projectRepositories(projectId: number): Promise<ProjectRepository[]> {
355
+ const res = await this.clients.vcsIntegrations.getVCSIntegrations({
356
+ projectId,
357
+ pagination: overridePagination(),
358
+ });
359
+ return res.projectRepositories.map((r) => ({
360
+ id: r.id,
361
+ fullName: r.repositoryFullName,
362
+ url: r.url,
363
+ }));
364
+ }
365
+
366
+ async linkPullRequest(taskId: number, pr: PullRequestLink): Promise<void> {
367
+ await this.clients.tasks.linkTaskPullRequest({
368
+ taskId,
369
+ projectRepositoryId: pr.projectRepositoryId,
370
+ prNumber: pr.number,
371
+ url: pr.url,
372
+ title: pr.title,
373
+ headBranch: pr.headBranch,
374
+ headSha: pr.headSha,
375
+ });
376
+ }
377
+
378
+ async addMemory(initiativeId: number, entry: MemoryEntry): Promise<{ id: number }> {
379
+ const res = await this.clients.initiatives.addInitiativeMemoryEntry({
380
+ initiativeId,
381
+ kind: entry.kind,
382
+ body: entry.body,
383
+ contentType: "text",
384
+ sourceType: entry.sourceTaskId ? "task" : entry.sourceUrl ? "pull_request" : "none",
385
+ sourceId: entry.sourceTaskId,
386
+ sourceUrl: entry.sourceUrl,
387
+ });
388
+ if (!res.memoryEntry) throw new TrackerError(`Memory entry on initiative ${initiativeId} came back empty`);
389
+ return { id: res.memoryEntry.id };
390
+ }
391
+
392
+ /** Walks GetInitiativeMemory page by page: like GetRunEvents it windows to 50 entries and does
393
+ * not honour override_pagination, so one call would silently drop a long Run's memory. The
394
+ * filters are sent to the server and applied again on the way out, so what Context Assembly sees
395
+ * does not depend on which filters the server implements. Newest first. */
396
+ async listMemory(initiativeId: number, query: MemoryQuery = {}): Promise<MemoryRecord[]> {
397
+ const entries: InitiativeMemoryEntry[] = [];
398
+ for (let page = 1; ; page++) {
399
+ const res = await this.clients.initiatives.getInitiativeMemory({
400
+ initiativeId,
401
+ pagination: defaultPagination(page, MEMORY_PAGE),
402
+ kinds: query.kinds ?? [],
403
+ sourceType: query.sourceTaskId === undefined ? "" : "task",
404
+ sourceId: query.sourceTaskId,
405
+ });
406
+ entries.push(...res.memoryEntries);
407
+ if (res.memoryEntries.length < MEMORY_PAGE) break;
408
+ }
409
+ return entries
410
+ .filter(
411
+ (e) =>
412
+ (!query.kinds || (query.kinds as string[]).includes(e.kind)) &&
413
+ (query.sourceTaskId === undefined || (e.sourceType === "task" && e.sourceId === query.sourceTaskId)),
414
+ )
415
+ .map(toMemoryRecord)
416
+ .sort((a, b) => b.id - a.id);
417
+ }
418
+
419
+ /** GetInitiativeContext, the RPC behind the MCP's `opsee_get_initiative_context`, for the
420
+ * Initiative, its graph and its pull requests; the memory log is walked through `listMemory`
421
+ * instead of taken from the response, whose `memory_limit` windows it, so a planning session
422
+ * sees the whole log the way Context Assembly does. */
423
+ async getInitiativeContext(initiativeId: number): Promise<InitiativeContext> {
424
+ let res;
425
+ try {
426
+ res = await this.clients.initiatives.getInitiativeContext({ initiativeId, memoryLimit: 1 });
427
+ } catch (error) {
428
+ if (error instanceof ConnectError && error.code === Code.NotFound) throw new TrackerError(`Initiative ${initiativeId} not found`);
429
+ throw error;
430
+ }
431
+ const ini = res.initiative;
432
+ if (!ini) throw new TrackerError(`Initiative ${initiativeId} not found`);
433
+ const memory = await this.listMemory(initiativeId);
434
+ return {
435
+ initiative: {
436
+ id: ini.id,
437
+ title: ini.title,
438
+ summary: ini.summary || undefined,
439
+ status: ini.status,
440
+ coreIdea: markdownOf(ini.coreIdea),
441
+ projectId: ini.projectId,
442
+ },
443
+ tasks: (res.graph?.tasks ?? []).map((t) => toTask(t)),
444
+ edges: (res.graph?.edges ?? []).map(toEdge).filter((e): e is TrackerEdge => !!e),
445
+ slices: (res.graph?.slices ?? []).map((s) => ({ layer: s.layer, taskIds: [...s.taskIds] })),
446
+ memory,
447
+ pullRequests: res.pullRequests.map(toPullRequest),
448
+ };
449
+ }
450
+
451
+ async appendRunEvents(initiativeId: number, events: RunEventInput[]): Promise<RunEvent[]> {
452
+ const res = await this.clients.initiatives.appendRunEvents({ initiativeId, events });
453
+ return res.events;
454
+ }
455
+
456
+ /** Walks the Run Record with the server's `afterId` cursor: GetRunEvents windows to 50 events by
457
+ * default and does not honour override_pagination, so one call would silently truncate a Run. */
458
+ async readRunRecord(initiativeId: number, query: RunRecordQuery = {}): Promise<RunRecord> {
459
+ const events: RunEvent[] = [];
460
+ let run: Run | undefined;
461
+ let afterId = query.afterId;
462
+ for (;;) {
463
+ const res = await this.clients.initiatives.getRunEvents({
464
+ initiativeId,
465
+ pagination: defaultPagination(1, RUN_RECORD_PAGE),
466
+ kinds: query.kinds ?? [],
467
+ taskId: query.taskId,
468
+ afterId,
469
+ });
470
+ run ??= res.run;
471
+ events.push(...res.events);
472
+ if (res.events.length < RUN_RECORD_PAGE) return { run, events };
473
+ afterId = res.events[res.events.length - 1].id;
474
+ }
475
+ }
476
+
477
+ // ---- internals ----
478
+
479
+ private async me(): Promise<number> {
480
+ if (this.userId !== undefined) return this.userId;
481
+ const res = await this.clients.users.getMe({});
482
+ if (!res.user) throw new TrackerError("Backend returned no user for this token");
483
+ this.userId = res.user.id;
484
+ return res.user.id;
485
+ }
486
+
487
+ private async rawTask(id: number): Promise<Task> {
488
+ const res = await this.clients.tasks.getTask({ id });
489
+ if (!res.task) throw new TrackerError(`Task ${id} not found`);
490
+ return res.task;
491
+ }
492
+
493
+ /** EditTask validates the whole Task, so every edit resends what the backend already has. Only
494
+ * the fields in `patch` change; labels and repository links are left untouched by omission. */
495
+ private async editTask(task: Task, patch: { boardColumnId?: number; initiativeId?: number }): Promise<void> {
496
+ await this.clients.tasks.editTask({
497
+ initiativeId: patch.initiativeId,
498
+ id: task.id,
499
+ identifier: task.identifier,
500
+ title: task.title,
501
+ description: task.description,
502
+ displayOrder: task.displayOrder,
503
+ storyPoints: task.storyPoints,
504
+ estimatedHours: task.estimatedHours,
505
+ actualHours: task.actualHours,
506
+ dueDate: task.dueDate,
507
+ aiModeEnabled: task.aiModeEnabled,
508
+ metadataJson: task.metadataJson,
509
+ boardId: task.board?.id ?? task.boardId,
510
+ boardColumnId: patch.boardColumnId ?? task.boardColumn?.id ?? task.boardColumnId,
511
+ projectId: task.project?.id ?? task.projectId,
512
+ taskTypeId: task.taskType?.id ?? task.taskTypeId,
513
+ taskPriorityId: task.taskPriority?.id ?? task.taskPriorityId,
514
+ reporterUserId: task.reporterUser?.id ?? task.reporterUserId,
515
+ assignedUserId: task.assignedUser?.id ?? task.assignedUserId,
516
+ cycleId: task.cycle?.id ?? task.cycleId,
517
+ milestoneId: task.milestoneId,
518
+ parentTaskId: task.parentTaskId,
519
+ });
520
+ }
521
+
522
+ private async columns(boardId: number, refresh = false): Promise<BoardColumn[]> {
523
+ let columns = refresh ? undefined : this.columnsByBoard.get(boardId);
524
+ if (!columns) {
525
+ const res = await this.clients.boardColumns.getBoardColumns({
526
+ boardId,
527
+ pagination: overridePagination(),
528
+ });
529
+ columns = res.boardColumns.filter((c) => c.isActive).sort(byDisplayOrder);
530
+ this.columnsByBoard.set(boardId, columns);
531
+ }
532
+ return columns;
533
+ }
534
+
535
+ /** The first active column (by display order) for `lifecycle` (tracker-adapter.ts `pickColumn`:
536
+ * by state, or for `in_review` by name or the lone `active` column); refetches once on a miss so
537
+ * a column added since the cache was filled is still found. */
538
+ private async columnFor(boardId: number, lifecycle: Lifecycle): Promise<BoardColumn> {
539
+ const pick = (columns: BoardColumn[]) => {
540
+ const hit = pickColumn(columns.map(toColumn), lifecycle);
541
+ return hit ? columns.find((c) => c.id === hit.id) : undefined;
542
+ };
543
+ let column = pick(await this.columns(boardId));
544
+ if (!column) column = pick(await this.columns(boardId, true));
545
+ if (!column) {
546
+ const states = (await this.columns(boardId))
547
+ .map((c) => `${c.name}=${c.lifecycleState ?? "(none)"}`)
548
+ .join(", ");
549
+ throw new TrackerError(`Board ${boardId} has no column with lifecycle "${lifecycle}" (columns: ${states})`);
550
+ }
551
+ return column;
552
+ }
553
+
554
+ private async labelByName(projectId: number, name: string): Promise<Label> {
555
+ const res = await this.clients.labels.getLabels({ projectId, pagination: overridePagination() });
556
+ const label = res.labels.find((l) => l.isActive && sameName(l.name, name));
557
+ if (!label) throw new TrackerError(`Project ${projectId} has no active label "${name}"`);
558
+ return label;
559
+ }
560
+
561
+ private async taskLabelRows(taskId: number) {
562
+ const res = await this.clients.taskLabels.getTaskLabels({ taskId, pagination: overridePagination() });
563
+ return res.taskLabels;
564
+ }
565
+
566
+ private async labelsOf(taskId: number): Promise<string[]> {
567
+ return labelNames(await this.taskLabelRows(taskId));
568
+ }
569
+
570
+ private async taskLabelRow(taskId: number, name: string) {
571
+ return (await this.taskLabelRows(taskId)).find((row) => row.label && sameName(row.label.name, name));
572
+ }
573
+
574
+ private async projectDefaults(projectId: number): Promise<ProjectDefaults> {
575
+ let defaults = this.defaultsByProject.get(projectId);
576
+ if (defaults) return defaults;
577
+ const page = overridePagination();
578
+ const [boards, types, priorities] = await Promise.all([
579
+ this.clients.boards.getBoards({ projectId, pagination: page }),
580
+ this.clients.taskTypes.getTaskTypes({ projectId, pagination: page }),
581
+ this.clients.taskPriorities.getTaskPriorities({ projectId, pagination: page }),
582
+ ]);
583
+ const board = boards.boards.find((b) => b.isActive) ?? boards.boards[0];
584
+ if (!board) throw new TrackerError(`Project ${projectId} has no board`);
585
+ const columns = await this.columns(board.id);
586
+ const landing = columns.find((c) => c.lifecycleState === "todo") ?? columns[0];
587
+ if (!landing) throw new TrackerError(`Board ${board.id} has no columns`);
588
+ if (types.taskTypes.length === 0) throw new TrackerError(`Project ${projectId} has no task types`);
589
+ if (priorities.taskPriorities.length === 0) throw new TrackerError(`Project ${projectId} has no priorities`);
590
+ defaults = {
591
+ boardId: board.id,
592
+ newTaskColumnId: landing.id,
593
+ types: types.taskTypes.map((t) => ({ id: t.id, name: t.name })),
594
+ priorities: priorities.taskPriorities.map((p) => ({ id: p.id, name: p.name })),
595
+ };
596
+ this.defaultsByProject.set(projectId, defaults);
597
+ return defaults;
598
+ }
599
+ }
600
+
601
+ function pickNamed<T extends { name: string }>(options: T[], name: string | undefined, what: string, code?: TrackerErrorCode): T {
602
+ if (!name) return options[0];
603
+ const found = options.find((o) => sameName(o.name, name));
604
+ if (!found) {
605
+ throw new TrackerError(`No ${what} named "${name}" (have: ${options.map((o) => o.name).join(", ")})`, code);
606
+ }
607
+ return found;
608
+ }
609
+
610
+ function errorMessage(error: unknown): string {
611
+ return error instanceof Error ? error.message : String(error);
612
+ }