magic-spells 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1071 @@
1
+ import {
2
+ mapPyramidHttpError,
3
+ operationsByName,
4
+ pyramidError,
5
+ toPyramidError
6
+ } from "./chunk-PQFOO5A7.js";
7
+ import {
8
+ MagicError,
9
+ TtlCache,
10
+ readField,
11
+ readStr
12
+ } from "./chunk-JZ7XESF4.js";
13
+
14
+ // ../pyramid/src/cache/resolver.ts
15
+ var TTL_MS = 6e4;
16
+ var PROJECTS_KEY = "projects";
17
+ var Resolver = class {
18
+ constructor(client) {
19
+ this.client = client;
20
+ }
21
+ /** Per-project workflow cache (also the hydration join source). */
22
+ workflows = new TtlCache(TTL_MS);
23
+ /** Workspace-wide project list cache (backs project-name resolution). */
24
+ projects = new TtlCache(TTL_MS);
25
+ // ---------- Workflow fetch + 60s cache ----------
26
+ /**
27
+ * Cached workflow for a project (60s TTL). One fetch warms every kind of
28
+ * resolution for that project. Backs `get_project_workflow` and hydration.
29
+ */
30
+ async getWorkflow(projectId) {
31
+ const hit = this.workflows.get(projectId);
32
+ if (hit) return hit;
33
+ const [rawWf, rawLabels, rawMembers, rawSchema] = await Promise.all([
34
+ this.client.getWorkflow(projectId),
35
+ this.client.listLabels(projectId),
36
+ this.client.listMembers(projectId),
37
+ this.client.getTaskSchema(projectId)
38
+ ]);
39
+ const wf = normalizeWorkflow(rawWf);
40
+ wf.labels = (Array.isArray(rawLabels) ? rawLabels : []).map(normalizeLabel);
41
+ wf.members = (Array.isArray(rawMembers) ? rawMembers : []).map(normalizeMember);
42
+ wf.templates = normalizeTaskSchema(rawSchema);
43
+ this.workflows.set(projectId, wf);
44
+ return wf;
45
+ }
46
+ // ---------- Project ----------
47
+ /** handle (ci exact) -> name (ci exact) -> unique fuzzy contains. */
48
+ async resolveProject(nameOrHandle) {
49
+ const projects = await this.getProjects();
50
+ const needle = norm(nameOrHandle);
51
+ const byHandle = projects.filter((p) => norm(p.handle) === needle);
52
+ const byName = projects.filter((p) => norm(p.name) === needle);
53
+ const fuzzy = projects.filter((p) => contains(p.handle, needle) || contains(p.name, needle));
54
+ const match = pick(
55
+ "project",
56
+ "ambiguous_project_name",
57
+ nameOrHandle,
58
+ [byHandle, byName, fuzzy],
59
+ (p) => p.handle
60
+ );
61
+ if (match) return match;
62
+ throw pyramidError("project_not_found", `No project matches "${nameOrHandle}".`, {
63
+ candidates: shortlist(
64
+ nameOrHandle,
65
+ projects.map((p) => p.handle)
66
+ )
67
+ });
68
+ }
69
+ // ---------- Status ----------
70
+ /**
71
+ * key (ci) -> name (ci) -> category (ci) -> stage-name (first status of that
72
+ * stage by position). Returns the full WorkflowStatus (carries stage_id).
73
+ */
74
+ async resolveStatus(projectId, q) {
75
+ const wf = await this.getWorkflow(projectId);
76
+ const needle = norm(q);
77
+ const byKey = wf.statuses.filter((s) => norm(s.key) === needle);
78
+ const byName = wf.statuses.filter((s) => norm(s.name) === needle);
79
+ const byCategory = wf.statuses.filter(
80
+ (s) => s.category !== void 0 && norm(s.category) === needle
81
+ );
82
+ let match = pick(
83
+ "status",
84
+ "ambiguous_status_name",
85
+ q,
86
+ [byKey, byName, byCategory],
87
+ (s) => s.name
88
+ );
89
+ if (!match) {
90
+ const stage = wf.stages.find((s) => norm(s.key) === needle || norm(s.name) === needle);
91
+ if (stage) {
92
+ const first = firstStatusOfStage(wf, stage.id);
93
+ if (first) match = first;
94
+ }
95
+ }
96
+ if (!match) {
97
+ const fuzzy = wf.statuses.filter((s) => contains(s.key, needle) || contains(s.name, needle));
98
+ match = pick("status", "ambiguous_status_name", q, [fuzzy], (s) => s.name);
99
+ }
100
+ if (match) return match;
101
+ throw pyramidError("status_not_found", `No status matches "${q}".`, {
102
+ candidates: shortlist(
103
+ q,
104
+ wf.statuses.map((s) => s.name)
105
+ )
106
+ });
107
+ }
108
+ // ---------- Stage ----------
109
+ /** key (ci) -> name (ci) -> category (ci) -> unique fuzzy contains. */
110
+ async resolveStage(projectId, q) {
111
+ const wf = await this.getWorkflow(projectId);
112
+ const needle = norm(q);
113
+ const byKey = wf.stages.filter((s) => norm(s.key) === needle);
114
+ const byName = wf.stages.filter((s) => norm(s.name) === needle);
115
+ const byCategory = wf.stages.filter(
116
+ (s) => s.category !== void 0 && norm(s.category) === needle
117
+ );
118
+ const fuzzy = wf.stages.filter((s) => contains(s.key, needle) || contains(s.name, needle));
119
+ const match = pick(
120
+ "stage",
121
+ "ambiguous_stage_name",
122
+ q,
123
+ [byKey, byName, byCategory, fuzzy],
124
+ (s) => s.name
125
+ );
126
+ if (match) return match;
127
+ throw pyramidError("stage_not_found", `No stage matches "${q}".`, {
128
+ candidates: shortlist(
129
+ q,
130
+ wf.stages.map((s) => s.name)
131
+ )
132
+ });
133
+ }
134
+ // ---------- User ----------
135
+ /** email (ci exact) -> display_name (ci exact) -> unique fuzzy contains. */
136
+ async resolveUser(projectId, q) {
137
+ const wf = await this.getWorkflow(projectId);
138
+ const needle = norm(q);
139
+ const byEmail = wf.members.filter((m) => norm(m.email) === needle);
140
+ const byName = wf.members.filter((m) => norm(m.display_name) === needle);
141
+ const fuzzy = wf.members.filter(
142
+ (m) => contains(m.email, needle) || contains(m.display_name, needle)
143
+ );
144
+ const match = pick(
145
+ "user",
146
+ "ambiguous_user_name",
147
+ q,
148
+ [byEmail, byName, fuzzy],
149
+ (m) => m.display_name
150
+ );
151
+ if (match) return match;
152
+ throw pyramidError("user_not_found", `No user matches "${q}".`, {
153
+ candidates: shortlist(
154
+ q,
155
+ wf.members.map((m) => m.display_name)
156
+ )
157
+ });
158
+ }
159
+ // ---------- Label ----------
160
+ /** name (ci exact) -> unique fuzzy contains. */
161
+ async resolveLabel(projectId, q) {
162
+ const wf = await this.getWorkflow(projectId);
163
+ const needle = norm(q);
164
+ const byName = wf.labels.filter((l) => norm(l.name) === needle);
165
+ const fuzzy = wf.labels.filter((l) => contains(l.name, needle));
166
+ const match = pick("label", "ambiguous_label_name", q, [byName, fuzzy], (l) => l.name);
167
+ if (match) return { id: match.id, name: match.name };
168
+ throw pyramidError("label_not_found", `No label matches "${q}".`, {
169
+ candidates: shortlist(
170
+ q,
171
+ wf.labels.map((l) => l.name)
172
+ )
173
+ });
174
+ }
175
+ // ---------- Invalidation ----------
176
+ /**
177
+ * Drop cached data after a mutating op. One getWorkflow warms every per-project
178
+ * kind, so any per-project kind invalidates the whole project workflow entry;
179
+ * `project` also clears the workspace-wide project list. With no `kind`, clear
180
+ * everything cached for the project.
181
+ */
182
+ invalidate(projectId, kind) {
183
+ if (kind === "project") {
184
+ this.projects.clear();
185
+ return;
186
+ }
187
+ this.workflows.delete(projectId);
188
+ if (kind === void 0 && (projectId === "" || projectId === "*")) {
189
+ this.projects.clear();
190
+ }
191
+ }
192
+ // ---------- Internal: project-list cache ----------
193
+ async getProjects() {
194
+ return this.projects.getOrLoad(
195
+ PROJECTS_KEY,
196
+ async () => extractArray(await this.client.listProjects()).map(normalizeProject)
197
+ );
198
+ }
199
+ };
200
+ function pick(_kind, ambiguousCode, query, tiers, label) {
201
+ for (const tier of tiers) {
202
+ if (tier.length === 1) return tier[0];
203
+ if (tier.length > 1) {
204
+ throw pyramidError(ambiguousCode, `"${query}" is ambiguous \u2014 ${tier.length} matches.`, {
205
+ candidates: dedupeStrings(tier.map(label))
206
+ });
207
+ }
208
+ }
209
+ return void 0;
210
+ }
211
+ function extractArray(body) {
212
+ if (Array.isArray(body)) return body;
213
+ if (body && typeof body === "object") {
214
+ const obj = body;
215
+ for (const key of ["data", "items", "results", "projects"]) {
216
+ const val = obj[key];
217
+ if (Array.isArray(val)) return val;
218
+ }
219
+ }
220
+ return [];
221
+ }
222
+ function rec(v) {
223
+ return v && typeof v === "object" ? v : {};
224
+ }
225
+ function str(v, fallback = "") {
226
+ return typeof v === "string" ? v : fallback;
227
+ }
228
+ function optStr(v) {
229
+ return typeof v === "string" && v.length > 0 ? v : void 0;
230
+ }
231
+ var PROJECT_ROLES = ["admin", "pm", "member", "viewer", "guest"];
232
+ function projectRole(v) {
233
+ return PROJECT_ROLES.includes(str(v)) ? str(v) : "member";
234
+ }
235
+ function normalizeProject(raw) {
236
+ const p = rec(raw);
237
+ return {
238
+ id: str(p.id),
239
+ handle: str(p.handle),
240
+ name: str(p.name),
241
+ task_prefix: str(p.task_prefix),
242
+ role: projectRole(p.role),
243
+ archived: p.archived_at != null || p.archived === true
244
+ };
245
+ }
246
+ function normalizeStage(raw) {
247
+ const s = rec(raw);
248
+ const stage = {
249
+ id: str(s.id),
250
+ key: str(s.key),
251
+ name: str(s.name),
252
+ position: str(s.position)
253
+ };
254
+ const category = optStr(s.category);
255
+ if (category !== void 0) stage.category = category;
256
+ return stage;
257
+ }
258
+ function normalizeStatus(raw) {
259
+ const s = rec(raw);
260
+ const status = {
261
+ id: str(s.id),
262
+ key: str(s.key),
263
+ name: str(s.name),
264
+ stage_id: str(s.stage_id),
265
+ position: str(s.position)
266
+ };
267
+ const category = optStr(s.category);
268
+ if (category !== void 0) status.category = category;
269
+ return status;
270
+ }
271
+ function normalizeMember(raw) {
272
+ const m = rec(raw);
273
+ const u = rec(m.user);
274
+ const member = {
275
+ id: str(u.id) || str(m.user_id) || str(m.id),
276
+ display_name: str(u.display_name) || str(m.display_name),
277
+ email: str(u.email) || str(m.email),
278
+ role: str(m.role) || str(u.role)
279
+ };
280
+ const jobTitle = optStr(m.job_title) ?? optStr(u.job_title);
281
+ if (jobTitle !== void 0) member.job_title = jobTitle;
282
+ return member;
283
+ }
284
+ function normalizeTaskSchema(raw) {
285
+ const s = rec(raw);
286
+ const fieldsByTemplate = rec(s.fields_by_template);
287
+ const templates = Array.isArray(s.templates) ? s.templates : [];
288
+ return templates.map((t) => {
289
+ const tt = rec(t);
290
+ const id = str(tt.id);
291
+ const rawFields = fieldsByTemplate[id];
292
+ const fields = (Array.isArray(rawFields) ? rawFields : []).map(normalizeField);
293
+ return { id, name: str(tt.name), fields };
294
+ });
295
+ }
296
+ function normalizeLabel(raw) {
297
+ const l = rec(raw);
298
+ return { id: str(l.id), name: str(l.name), color: str(l.color) };
299
+ }
300
+ function normalizeField(raw) {
301
+ const f = rec(raw);
302
+ const field = {
303
+ id: str(f.id),
304
+ key: str(f.key),
305
+ name: str(f.name),
306
+ field_type: str(f.field_type)
307
+ };
308
+ if (Array.isArray(f.options)) {
309
+ field.options = f.options.filter((o) => typeof o === "string");
310
+ }
311
+ return field;
312
+ }
313
+ function normalizeWorkflow(raw) {
314
+ const w = rec(raw);
315
+ const rawStages = Array.isArray(w.stages) ? w.stages : [];
316
+ const stages = rawStages.map(normalizeStage);
317
+ let statuses;
318
+ if (Array.isArray(w.statuses)) {
319
+ statuses = w.statuses.map(normalizeStatus);
320
+ } else {
321
+ statuses = [];
322
+ for (const rawStage of rawStages) {
323
+ const s = rec(rawStage);
324
+ const nested = Array.isArray(s.statuses) ? s.statuses : [];
325
+ const stageId = str(s.id);
326
+ for (const ns of nested) {
327
+ const status = normalizeStatus(ns);
328
+ if (status.stage_id === "") status.stage_id = stageId;
329
+ statuses.push(status);
330
+ }
331
+ }
332
+ }
333
+ const labels = (Array.isArray(w.labels) ? w.labels : []).map(normalizeLabel);
334
+ const members = (Array.isArray(w.members) ? w.members : []).map(normalizeMember);
335
+ const templates = (Array.isArray(w.templates) ? w.templates : []).map((t) => {
336
+ const tt = rec(t);
337
+ return {
338
+ id: str(tt.id),
339
+ name: str(tt.name),
340
+ fields: (Array.isArray(tt.fields) ? tt.fields : []).map(normalizeField)
341
+ };
342
+ });
343
+ return {
344
+ project: normalizeProject(w.project),
345
+ stages,
346
+ statuses,
347
+ labels,
348
+ members,
349
+ templates
350
+ };
351
+ }
352
+ function firstStatusOfStage(wf, stageId) {
353
+ const inStage = wf.statuses.filter((s) => s.stage_id === stageId);
354
+ if (inStage.length === 0) return void 0;
355
+ return [...inStage].sort(
356
+ (a, b) => a.position < b.position ? -1 : a.position > b.position ? 1 : 0
357
+ )[0];
358
+ }
359
+ function norm(s) {
360
+ return s.trim().toLowerCase();
361
+ }
362
+ function contains(haystack, normNeedle) {
363
+ if (normNeedle.length === 0) return false;
364
+ return haystack.trim().toLowerCase().includes(normNeedle);
365
+ }
366
+ function dedupeStrings(xs) {
367
+ return [...new Set(xs.filter((x) => x.length > 0))];
368
+ }
369
+ function shortlist(query, candidates) {
370
+ const q = norm(query);
371
+ const pool = dedupeStrings(candidates);
372
+ if (pool.length === 0) return [];
373
+ const scored = pool.map((c) => {
374
+ const cn = norm(c);
375
+ const sub = cn.includes(q) || q.length > 0 && q.includes(cn) ? 0 : 1;
376
+ return { c, sub, dist: levenshtein(q, cn) };
377
+ });
378
+ scored.sort((a, b) => a.sub - b.sub || a.dist - b.dist || a.c.localeCompare(b.c));
379
+ return scored.slice(0, 8).map((s) => s.c);
380
+ }
381
+ function levenshtein(a, b) {
382
+ if (a === b) return 0;
383
+ if (a.length === 0) return b.length;
384
+ if (b.length === 0) return a.length;
385
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
386
+ let curr = new Array(b.length + 1);
387
+ for (let i = 1; i <= a.length; i++) {
388
+ curr[0] = i;
389
+ for (let j = 1; j <= b.length; j++) {
390
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
391
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
392
+ }
393
+ [prev, curr] = [curr, prev];
394
+ }
395
+ return prev[b.length];
396
+ }
397
+
398
+ // ../pyramid/src/client/pyramid-client.ts
399
+ import { request as undiciRequest } from "undici";
400
+ var PyramidClient = class {
401
+ baseUrl;
402
+ apiKey;
403
+ constructor(config) {
404
+ this.baseUrl = config.baseUrl.endsWith("/") ? config.baseUrl.slice(0, -1) : config.baseUrl;
405
+ this.apiKey = config.apiKey;
406
+ }
407
+ // ---- Phase-1 read-only surface (raw server JSON; hydration is elsewhere) ----
408
+ /**
409
+ * GET /v1/me — the authenticated user (raw `model.User`). The payload may carry
410
+ * a workspace in a future revision, so callers read it defensively; today it is
411
+ * just the user record. Returned raw.
412
+ */
413
+ getMe() {
414
+ return this.request("GET", "/v1/me");
415
+ }
416
+ /**
417
+ * GET /v1/workspaces — the caller's workspaces. The API key is workspace-pinned,
418
+ * so this is effectively a single-element list (the op layer takes `[0]` as the
419
+ * pinned workspace). Server shape is `{ data: Workspace[] }`; we unwrap to the
420
+ * array and tolerate a bare array.
421
+ */
422
+ listWorkspaces() {
423
+ return this.request("GET", "/v1/workspaces").then(unwrapData);
424
+ }
425
+ /**
426
+ * GET /v1/projects — accessible projects. Server shape is
427
+ * `{ data: Project[], cursor }`; we unwrap `data` (tolerating a bare array).
428
+ * First-page discovery only — Phase-1 needs no pagination here.
429
+ */
430
+ listProjects() {
431
+ return this.request("GET", "/v1/projects").then(unwrapData);
432
+ }
433
+ /**
434
+ * GET /v1/projects/{id}/workflow — the project's workflow (stages, statuses,
435
+ * labels, members, templates). Returned raw; the Resolver/hydrate layer joins
436
+ * it. `projectId` must already be a UUID (name->id resolution lives upstream).
437
+ */
438
+ getWorkflow(projectId) {
439
+ return this.request("GET", `/v1/projects/${encodeURIComponent(projectId)}/workflow`);
440
+ }
441
+ /**
442
+ * GET /v1/me/tasks — the tasks the caller OWNS or REVIEWS, across projects,
443
+ * newest first, with an opaque keyset cursor. Returns the raw `{ data, cursor }`
444
+ * envelope (cursor normalized to `string | null`). `limit` caps the page;
445
+ * `cursor` continues a prior page.
446
+ *
447
+ * There is deliberately NO `role` parameter: the server accepts `?role=` and
448
+ * IGNORES it (DOC-BACKEND-CONTRACT), so sending it would promise a narrowing
449
+ * that never happens.
450
+ */
451
+ async listMyTasks(opts) {
452
+ const envelope = await this.request("GET", "/v1/me/tasks", {
453
+ query: {
454
+ limit: opts?.limit,
455
+ cursor: opts?.cursor
456
+ }
457
+ });
458
+ return {
459
+ data: Array.isArray(envelope?.data) ? envelope.data : [],
460
+ cursor: typeof envelope?.cursor === "string" ? envelope.cursor : null
461
+ };
462
+ }
463
+ // ---- Phase-2 task read surface (raw server JSON; hydration is elsewhere) ----
464
+ /**
465
+ * GET /v1/projects/{id}/tasks — a project's tasks, newest first, with an opaque
466
+ * keyset cursor. Returns the raw `{ data, cursor }` envelope (cursor normalized
467
+ * to `string | null`). All filter values are already-resolved UUIDs (`status` is
468
+ * a status UUID, plus `stage_id`/`owner_id`/`reviewer_id`/`label_id`) or free
469
+ * text (`q`); `expand` opts relations into the rows. `projectId` must already be
470
+ * a UUID. (Real query names per DOC-BACKEND-CONTRACT — no `assignee_id`.)
471
+ */
472
+ async listTasks(projectId, opts) {
473
+ const envelope = await this.request(
474
+ "GET",
475
+ `/v1/projects/${encodeURIComponent(projectId)}/tasks`,
476
+ {
477
+ query: {
478
+ status: opts?.status,
479
+ stage_id: opts?.stage_id,
480
+ owner_id: opts?.owner_id,
481
+ reviewer_id: opts?.reviewer_id,
482
+ label_id: opts?.label_id,
483
+ q: opts?.q,
484
+ limit: opts?.limit,
485
+ cursor: opts?.cursor,
486
+ expand: opts?.expand
487
+ }
488
+ }
489
+ );
490
+ return normalizeCursorEnvelope(envelope);
491
+ }
492
+ /**
493
+ * GET /v1/projects/{id}/tasks/archived — a project's ARCHIVED tasks (a separate
494
+ * route from `listTasks`). Returns the raw `{ data, cursor }` envelope. `opts`
495
+ * carries `archived_after`/`limit`/`cursor`. `projectId` must already be a UUID.
496
+ */
497
+ async listArchived(projectId, opts) {
498
+ const envelope = await this.request(
499
+ "GET",
500
+ `/v1/projects/${encodeURIComponent(projectId)}/tasks/archived`,
501
+ {
502
+ query: {
503
+ archived_after: opts?.archived_after,
504
+ limit: opts?.limit,
505
+ cursor: opts?.cursor
506
+ }
507
+ }
508
+ );
509
+ return normalizeCursorEnvelope(envelope);
510
+ }
511
+ /**
512
+ * GET /v1/projects/{id}/labels — a project's labels (separate from /workflow).
513
+ * Returns a bare array of raw label rows (tolerating a `{ data }` envelope).
514
+ */
515
+ listLabels(projectId) {
516
+ return this.request(
517
+ "GET",
518
+ `/v1/projects/${encodeURIComponent(projectId)}/labels`
519
+ ).then(unwrapData);
520
+ }
521
+ /**
522
+ * GET /v1/projects/{id}/members — a project's members (separate from /workflow).
523
+ * Returns a bare array of raw `ProjectMember` rows (each carries `user`).
524
+ */
525
+ listMembers(projectId) {
526
+ return this.request(
527
+ "GET",
528
+ `/v1/projects/${encodeURIComponent(projectId)}/members`
529
+ ).then(unwrapData);
530
+ }
531
+ /**
532
+ * GET /v1/projects/{id}/task-schema — the project's custom-field schema. Returns
533
+ * the raw `{ templates, fields_by_template }` payload (the Resolver merges it
534
+ * into the cached Workflow shape).
535
+ */
536
+ getTaskSchema(projectId) {
537
+ return this.request(
538
+ "GET",
539
+ `/v1/projects/${encodeURIComponent(projectId)}/task-schema`
540
+ );
541
+ }
542
+ /**
543
+ * GET /v1/tasks/{id}/timeline — one page of a task's history, **oldest first**
544
+ * (the reverse of every other list here). Returns the raw `{ data, cursor }`
545
+ * envelope. `limit` defaults to 50 server-side and is capped at 200;
546
+ * `event_type` narrows to one kind (e.g. `owner_changed`). No If-Match.
547
+ */
548
+ async getTaskTimeline(taskId, opts) {
549
+ const envelope = await this.request(
550
+ "GET",
551
+ `/v1/tasks/${encodeURIComponent(taskId)}/timeline`,
552
+ {
553
+ query: {
554
+ limit: opts?.limit,
555
+ cursor: opts?.cursor,
556
+ event_type: opts?.event_type
557
+ }
558
+ }
559
+ );
560
+ return normalizeCursorEnvelope(envelope);
561
+ }
562
+ /**
563
+ * GET /v1/tasks/{id} — one task's full detail (raw `RawTaskDetail`). A non-empty
564
+ * `expand` enables relations (editor/timeline/comments/estimates/followers/…);
565
+ * omit it for the lean shape. `taskId` must already be a UUID.
566
+ */
567
+ getTask(taskId, expand) {
568
+ return this.request("GET", `/v1/tasks/${encodeURIComponent(taskId)}`, {
569
+ query: { expand }
570
+ });
571
+ }
572
+ /**
573
+ * GET /v1/search/tasks — full-text task search across the workspace. Returns the
574
+ * raw `{ data }` envelope (no cursor per DOC-BACKEND-CONTRACT; normalized to a
575
+ * null cursor). `q` is the query; the optional filters are already-resolved user
576
+ * UUIDs.
577
+ */
578
+ async searchTasks(opts) {
579
+ const envelope = await this.request("GET", "/v1/search/tasks", {
580
+ query: {
581
+ q: opts.q,
582
+ owner_id: opts.owner_id,
583
+ reviewer_id: opts.reviewer_id,
584
+ limit: opts.limit
585
+ }
586
+ });
587
+ return normalizeCursorEnvelope(envelope);
588
+ }
589
+ // ---- Phase-2 task write surface (raw server JSON; resolution is elsewhere) ----
590
+ /**
591
+ * POST /v1/projects/{id}/tasks — create one task. `body` is the already-resolved
592
+ * create payload (status_id/owner_id/label_ids/…); this client does no name
593
+ * resolution. Returns the raw `RawTaskDetail` of the created task.
594
+ */
595
+ createTask(projectId, body) {
596
+ return this.request("POST", `/v1/projects/${encodeURIComponent(projectId)}/tasks`, {
597
+ body
598
+ });
599
+ }
600
+ /**
601
+ * POST /v1/tasks/bulk — create many tasks. `body` is the resolved
602
+ * `{ project_id, template_id, tasks: [...] }` batch. The server replies
603
+ * `{ created: [Task], errors: [] }`; this returns the RAW envelope (the op
604
+ * hydrates `created`). No If-Match.
605
+ */
606
+ bulkCreate(body) {
607
+ return this.request("POST", "/v1/tasks/bulk", { body });
608
+ }
609
+ /**
610
+ * PATCH /v1/tasks/{id} — sparse update of a task's content (title/description/
611
+ * priority/dates/estimate/guest_visible) AND its `owner_id`/`reviewer_id` (an explicit
612
+ * null clears one). `body` still carries NO labels and NO field_values — those
613
+ * keep their dedicated endpoints. Requires If-Match
614
+ * (DOC-CONCURRENCY): GET first to capture the ETag, send it, retry ONCE on 409.
615
+ * Returns the raw updated `RawTaskDetail`.
616
+ */
617
+ updateTask(taskId, body) {
618
+ return this.withPrecondition(
619
+ taskId,
620
+ (etag) => this.request("PATCH", `/v1/tasks/${encodeURIComponent(taskId)}`, {
621
+ body,
622
+ headers: { "if-match": etag }
623
+ })
624
+ );
625
+ }
626
+ /**
627
+ * PATCH /v1/tasks/{id}/move — change a task's status (which carries its stage)
628
+ * and/or its position via neighbor ids. `body` is
629
+ * `{ status_id, before_id?, after_id? }` — already-resolved UUIDs; positions are
630
+ * server-generated, never a fractional key. The server replies an ENVELOPE
631
+ * `{ task, previous }`; this returns it raw (the op hydrates `raw.task`). No
632
+ * If-Match.
633
+ */
634
+ moveTask(taskId, body) {
635
+ return this.request("PATCH", `/v1/tasks/${encodeURIComponent(taskId)}/move`, { body });
636
+ }
637
+ /**
638
+ * POST /v1/tasks/{id}/labels — add a label to an existing task. `labelId` is an
639
+ * already-resolved label UUID. No If-Match. Returns the raw response.
640
+ */
641
+ addTaskLabel(taskId, labelId) {
642
+ return this.request("POST", `/v1/tasks/${encodeURIComponent(taskId)}/labels`, {
643
+ body: { label_id: labelId }
644
+ });
645
+ }
646
+ /**
647
+ * DELETE /v1/tasks/{id}/labels/{labelId} — remove a label from a task. Both ids
648
+ * are already-resolved UUIDs. No If-Match. Resolves to void (204).
649
+ */
650
+ async removeTaskLabel(taskId, labelId) {
651
+ await this.request(
652
+ "DELETE",
653
+ `/v1/tasks/${encodeURIComponent(taskId)}/labels/${encodeURIComponent(labelId)}`
654
+ );
655
+ }
656
+ /**
657
+ * PATCH /v1/tasks/{id}/field-values — bulk-set a task's custom-field values.
658
+ * `body` is `{ field_values: { <fieldId>: value } }` keyed by field UUID. No
659
+ * If-Match. Returns the raw response.
660
+ */
661
+ setFieldValues(taskId, body) {
662
+ return this.request("PATCH", `/v1/tasks/${encodeURIComponent(taskId)}/field-values`, {
663
+ body
664
+ });
665
+ }
666
+ /**
667
+ * POST /v1/tasks/{id}/archive — soft-archive a task (reversible via the op's
668
+ * `unarchiveTask`). Returns the raw updated `RawTaskDetail`.
669
+ */
670
+ archiveTask(taskId) {
671
+ return this.request("POST", `/v1/tasks/${encodeURIComponent(taskId)}/archive`);
672
+ }
673
+ /**
674
+ * POST /v1/tasks/{id}/unarchive — restore a soft-archived task. Returns the raw
675
+ * updated `RawTaskDetail`. (The `task.archive` op picks archive vs unarchive.)
676
+ */
677
+ unarchiveTask(taskId) {
678
+ return this.request("POST", `/v1/tasks/${encodeURIComponent(taskId)}/unarchive`);
679
+ }
680
+ /**
681
+ * DELETE /v1/tasks/{id} — delete a task. `hard=true` requests a hard delete
682
+ * (the destructive op gates this upstream behind MAGIC_ALLOW_DESTRUCTIVE).
683
+ * Requires If-Match (DOC-CONCURRENCY): GET first to capture the ETag, send it,
684
+ * retry ONCE on 409. The server replies 204; resolves to void.
685
+ */
686
+ async deleteTask(taskId, hard) {
687
+ await this.withPrecondition(
688
+ taskId,
689
+ (etag) => this.request("DELETE", `/v1/tasks/${encodeURIComponent(taskId)}`, {
690
+ query: { hard },
691
+ headers: { "if-match": etag }
692
+ })
693
+ );
694
+ }
695
+ // ---- Phase-2 comment surface (raw server JSON; resolution is elsewhere) ----
696
+ /**
697
+ * GET /v1/tasks/{id}/comments — a task's comments, stage-scoped. Returns the raw
698
+ * `{ data, cursor }` envelope. `stage_id` (already a UUID) narrows to one stage;
699
+ * omit it to read across stages.
700
+ */
701
+ async listComments(taskId, opts) {
702
+ const envelope = await this.request(
703
+ "GET",
704
+ `/v1/tasks/${encodeURIComponent(taskId)}/comments`,
705
+ {
706
+ query: {
707
+ stage_id: opts?.stage_id,
708
+ limit: opts?.limit,
709
+ cursor: opts?.cursor
710
+ }
711
+ }
712
+ );
713
+ return normalizeCursorEnvelope(envelope);
714
+ }
715
+ /**
716
+ * POST /v1/tasks/{id}/comments — add a root (stage-scoped) comment. `body` is
717
+ * `{ content, stage_id?, mention_user_ids? }` with already-resolved mention
718
+ * UUIDs; `stage_id` defaults to the task's current stage upstream. Returns the
719
+ * raw `RawComment`.
720
+ */
721
+ addComment(taskId, body) {
722
+ return this.request("POST", `/v1/tasks/${encodeURIComponent(taskId)}/comments`, {
723
+ body
724
+ });
725
+ }
726
+ /**
727
+ * GET /v1/comments/{id} — one comment (raw `RawComment`). The `comment.reply`
728
+ * op fetches the target to verify it is a ROOT comment (`parent_id` /
729
+ * `thread_root_id` null) BEFORE replying, enforcing the one-level rule. Returned
730
+ * raw; resolution/hydration is elsewhere. `commentId` must already be a UUID.
731
+ */
732
+ getComment(commentId) {
733
+ return this.request("GET", `/v1/comments/${encodeURIComponent(commentId)}`);
734
+ }
735
+ /**
736
+ * POST /v1/comments/{id}/replies — reply to a ROOT comment (one level only; the
737
+ * op rejects reply-to-reply before this call). `body` is
738
+ * `{ content, mention_user_ids? }` with already-resolved mention UUIDs. Returns
739
+ * the raw `RawComment`.
740
+ */
741
+ replyComment(commentId, body) {
742
+ return this.request("POST", `/v1/comments/${encodeURIComponent(commentId)}/replies`, {
743
+ body
744
+ });
745
+ }
746
+ // ---------------------------- core request ----------------------------
747
+ /**
748
+ * Issue one authenticated JSON request and return the parsed body as `T`.
749
+ *
750
+ * - Sets Bearer auth + `Accept: application/json` (and `Content-Type` for a body).
751
+ * - 2xx: returns the parsed JSON (an empty/204 body parses to `undefined`).
752
+ * - non-2xx: parses the `{ error: { code, message } }` envelope (best effort)
753
+ * and throws `mapPyramidHttpError(status, body)`.
754
+ * - undici/transport failure (DNS, connect, reset, timeout): throws
755
+ * `MagicError('network', ...)` via `toPyramidError`.
756
+ *
757
+ * The API key is on the header only — never logged, never in the URL.
758
+ */
759
+ async request(method, path, opts) {
760
+ return (await this.requestWithETag(method, path, opts)).body;
761
+ }
762
+ /**
763
+ * The transport core, also returning the response's `ETag` header (null when
764
+ * absent). `request<T>()` is the body-only sugar over this; the If-Match
765
+ * read-first flow reads the etag here (DOC-CONCURRENCY).
766
+ */
767
+ async requestWithETag(method, path, opts) {
768
+ const url = this.buildUrl(path, opts?.query);
769
+ const headers = {
770
+ authorization: `Bearer ${this.apiKey}`,
771
+ accept: "application/json"
772
+ };
773
+ let bodyText;
774
+ if (opts?.body !== void 0) {
775
+ bodyText = JSON.stringify(opts.body);
776
+ headers["content-type"] = "application/json";
777
+ }
778
+ if (opts?.headers) {
779
+ for (const [k, v] of Object.entries(opts.headers)) headers[k] = v;
780
+ }
781
+ let status;
782
+ let raw;
783
+ let etag;
784
+ try {
785
+ const res = await undiciRequest(url, {
786
+ method,
787
+ headers,
788
+ body: bodyText
789
+ });
790
+ status = res.statusCode;
791
+ etag = readETagHeader(res.headers);
792
+ raw = await res.body.text();
793
+ } catch (err) {
794
+ const mapped = toPyramidError(err);
795
+ throw mapped.code === "network" ? mapped : pyramidError("network", mapped.message, {
796
+ hint: "Check MAGIC_PYRAMID_URL and your network connection."
797
+ });
798
+ }
799
+ const parsed = safeJsonParse(raw);
800
+ if (status < 200 || status >= 300) {
801
+ throw mapPyramidHttpError(status, parsed);
802
+ }
803
+ return { body: parsed, etag };
804
+ }
805
+ /**
806
+ * Capture the current ETag for a task (DOC-CONCURRENCY): GET /v1/tasks/{id} and
807
+ * read its `ETag` response header, falling back to the body's `updated_at` when
808
+ * the header is absent. Used by the If-Match read-first flow.
809
+ */
810
+ async getTaskETag(taskId) {
811
+ const res = await this.requestWithETag(
812
+ "GET",
813
+ `/v1/tasks/${encodeURIComponent(taskId)}`
814
+ );
815
+ if (res.etag) return res.etag;
816
+ const body = res.body;
817
+ if (body && typeof body === "object") {
818
+ const updatedAt = body.updated_at;
819
+ if (typeof updatedAt === "string" && updatedAt.length > 0) return updatedAt;
820
+ }
821
+ return "";
822
+ }
823
+ /**
824
+ * Run an If-Match-guarded mutation (PATCH update / DELETE) with the read-first
825
+ * flow (DOC-CONCURRENCY): GET to capture the ETag, run `fn(etag)`, and on a 409
826
+ * `conflict` refetch the ETag and retry ONCE before surfacing the conflict.
827
+ */
828
+ async withPrecondition(taskId, fn) {
829
+ const etag = await this.getTaskETag(taskId);
830
+ try {
831
+ return await fn(etag);
832
+ } catch (err) {
833
+ if (err instanceof MagicError && err.code === "conflict") {
834
+ const fresh = await this.getTaskETag(taskId);
835
+ return fn(fresh);
836
+ }
837
+ throw err;
838
+ }
839
+ }
840
+ /** Build an absolute URL, appending defined query params (skips null/undefined). */
841
+ buildUrl(path, query) {
842
+ const url = new URL(`${this.baseUrl}${path}`);
843
+ if (query) {
844
+ for (const [key, value] of Object.entries(query)) {
845
+ if (value === void 0 || value === null) continue;
846
+ url.searchParams.set(key, String(value));
847
+ }
848
+ }
849
+ return url.toString();
850
+ }
851
+ };
852
+ function readETagHeader(headers) {
853
+ const value = headers.etag ?? headers.ETag;
854
+ if (Array.isArray(value)) return value[0] ?? null;
855
+ return typeof value === "string" ? value : null;
856
+ }
857
+ function unwrapData(body) {
858
+ if (Array.isArray(body)) return body;
859
+ if (body && typeof body === "object" && "data" in body) {
860
+ const data = body.data;
861
+ if (Array.isArray(data)) return data;
862
+ }
863
+ return [];
864
+ }
865
+ function normalizeCursorEnvelope(envelope) {
866
+ return {
867
+ data: Array.isArray(envelope?.data) ? envelope.data : [],
868
+ cursor: typeof envelope?.cursor === "string" ? envelope.cursor : null
869
+ };
870
+ }
871
+ function safeJsonParse(text) {
872
+ if (text.trim() === "") return void 0;
873
+ try {
874
+ return JSON.parse(text);
875
+ } catch {
876
+ return void 0;
877
+ }
878
+ }
879
+
880
+ // ../pyramid/src/credential.ts
881
+ var PYRAMID_CREDENTIAL = {
882
+ id: "pyramid",
883
+ title: "Pyramid",
884
+ credential: "pyramid_",
885
+ keyHelp: "Pyramid \u2192 Settings \u2192 API Keys"
886
+ };
887
+ var DEFAULT_API_URL = "https://api.pyramid.magicspells.io";
888
+
889
+ // ../pyramid/src/doctor.ts
890
+ var whoamiOp = async () => (await import("./operations-GGMLIYEW.js")).operationsByName.get("pyramid.whoami");
891
+ var doctorChecks = [
892
+ {
893
+ name: "pyramid auth",
894
+ async run(ctx) {
895
+ const me = await (await whoamiOp()).run({}, ctx);
896
+ const name = me.user.display_name || "(unknown user)";
897
+ const user = me.user.email ? `${name} <${me.user.email}>` : name;
898
+ const workspace = me.workspace.name || "(workspace)";
899
+ const names = me.projects.map((p) => p.name || p.handle).filter((n) => n.length > 0);
900
+ return {
901
+ ok: true,
902
+ detail: `Authenticated as ${user} \xB7 workspace ${workspace} \xB7 ${names.length} project${names.length === 1 ? "" : "s"}` + (names.length ? `: ${names.join(", ")}` : ""),
903
+ data: {
904
+ user: { name, email: me.user.email || null },
905
+ workspace: { name: workspace },
906
+ projects: names
907
+ }
908
+ };
909
+ }
910
+ },
911
+ {
912
+ name: "pyramid workflow",
913
+ async run(ctx) {
914
+ const projects = await ctx.client.api.listProjects();
915
+ const id = readStr(readField(projects[0], "id"));
916
+ if (!id) return { ok: true, detail: "No projects; workflow endpoint not checked." };
917
+ await ctx.client.resolver.getWorkflow(id);
918
+ return { ok: true, detail: "Workflow endpoint: OK." };
919
+ }
920
+ }
921
+ ];
922
+
923
+ // ../pyramid/src/mcp/prompts.ts
924
+ var DOCTOR_DESCRIPTION = "Check your Pyramid setup: confirm the API key authenticates and show the user, workspace, and accessible projects.";
925
+ var DOCTOR_INSTRUCTION = [
926
+ "Run a Pyramid setup check.",
927
+ "",
928
+ "Call the `pyramid_whoami` tool, then report back, in plain language:",
929
+ " \u2022 the authenticated user (display name and email),",
930
+ " \u2022 the workspace (name) you are connected to, and",
931
+ " \u2022 the accessible projects (names).",
932
+ "",
933
+ "Finish by confirming the setup looks good. If `pyramid_whoami` returns an error,",
934
+ "act on its `code`: for an `auth_*` code, tell the user to regenerate their",
935
+ "key in Pyramid \u2192 Settings \u2192 API Keys. Never print raw JSON or an HTTP status."
936
+ ].join("\n");
937
+ function registerPrompts(server) {
938
+ server.registerPrompt("pyramid_doctor", { description: DOCTOR_DESCRIPTION }, () => ({
939
+ messages: [
940
+ {
941
+ role: "user",
942
+ content: { type: "text", text: DOCTOR_INSTRUCTION }
943
+ }
944
+ ]
945
+ }));
946
+ }
947
+
948
+ // ../pyramid/src/mcp/resources.ts
949
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
950
+ function op(name) {
951
+ const found = operationsByName.get(name);
952
+ if (!found) throw new Error(`missing operation ${name}`);
953
+ return found;
954
+ }
955
+ async function renderOp(op2, context) {
956
+ try {
957
+ const input = op2.input.parse({});
958
+ const result = await op2.run(input, await context());
959
+ return JSON.stringify(result, null, 2);
960
+ } catch (err) {
961
+ return JSON.stringify({ error: toPyramidError(err).toJSON() }, null, 2);
962
+ }
963
+ }
964
+ function registerResources(server, context) {
965
+ server.registerResource(
966
+ "pyramid_me",
967
+ "pyramid://me",
968
+ {
969
+ title: "Pyramid identity",
970
+ description: "The authenticated user, their workspace, and accessible projects (whoami).",
971
+ mimeType: "application/json"
972
+ },
973
+ async (uri) => {
974
+ const text = await renderOp(op("pyramid.whoami"), context);
975
+ return {
976
+ contents: [{ uri: uri.href, mimeType: "application/json", text }]
977
+ };
978
+ }
979
+ );
980
+ server.registerResource(
981
+ "pyramid_projects",
982
+ "pyramid://projects",
983
+ {
984
+ title: "Pyramid projects",
985
+ description: "The projects accessible to the authenticated user.",
986
+ mimeType: "application/json"
987
+ },
988
+ async (uri) => {
989
+ const text = await renderOp(op("pyramid.project.list"), context);
990
+ return {
991
+ contents: [{ uri: uri.href, mimeType: "application/json", text }]
992
+ };
993
+ }
994
+ );
995
+ server.registerResource(
996
+ "pyramid_project_workflow",
997
+ new ResourceTemplate("pyramid://projects/{handle}/workflow", { list: void 0 }),
998
+ {
999
+ title: "Project workflow",
1000
+ description: "A project's stages, statuses, labels, members, and field templates, by handle.",
1001
+ mimeType: "application/json"
1002
+ },
1003
+ async (uri, variables) => {
1004
+ const raw = variables.handle;
1005
+ const handle = Array.isArray(raw) ? raw[0] : raw;
1006
+ const workflow = op("pyramid.project.workflow");
1007
+ let text;
1008
+ if (handle) {
1009
+ try {
1010
+ const result = await workflow.run(
1011
+ workflow.input.parse({ project: handle }),
1012
+ await context()
1013
+ );
1014
+ text = JSON.stringify(result, null, 2);
1015
+ } catch (err) {
1016
+ text = JSON.stringify({ error: toPyramidError(err).toJSON() }, null, 2);
1017
+ }
1018
+ } else {
1019
+ text = JSON.stringify(
1020
+ {
1021
+ error: {
1022
+ code: "validation_failed",
1023
+ message: "missing project handle",
1024
+ product: "pyramid"
1025
+ }
1026
+ },
1027
+ null,
1028
+ 2
1029
+ );
1030
+ }
1031
+ return {
1032
+ contents: [{ uri: uri.href, mimeType: "application/json", text }]
1033
+ };
1034
+ }
1035
+ );
1036
+ }
1037
+
1038
+ // ../pyramid/src/mcp/index.ts
1039
+ var instructions = (version) => `Pyramid MCP v${version} \u2014 drive the Pyramid project tracker. Rules: (1) You act as one user in ONE workspace (the key is workspace-pinned). (2) Inputs accept names/keys, not UUIDs; outputs are already hydrated to names. (3) RENDER tasks as a left-rail card, one field per line, no JSON: \`\u2502 <KEY>\` / \`\u2502 NAME: <title>\` / \`\u2502 DESCRIPTION: <one line; truncate in lists>\` / \`\u2502 <Stage>: <Status>\` \u2014 always show stage and status together as \`Stage: Status\`. (4) For a LIST ("get my work"), render one card per task SEPARATED BY A BLANK LINE so each reads as a distinct ticket \u2014 never let cards run together \u2014 with one-line descriptions; show the page, then say "showing N of M \u2014 ask for more"; NEVER auto-paginate. Full description + drill-in only for a single task. (5) Drill-in (comments, git PRs/commits) only when asked, for the named task. (6) Dates are UTC \u2014 render UTC-labeled or relative-with-label, never bare local. (7) On error, act on the \`code\` (don't parse the message): \`ambiguous_*\` \u2192 show candidates and ask; \`auth_*\` \u2192 tell the user to regenerate their key in Pyramid \u2192 Settings \u2192 API Keys. Never print raw JSON or an HTTP status to the user.`;
1040
+ function registerPyramidMcp(server, context) {
1041
+ registerResources(server, context);
1042
+ registerPrompts(server);
1043
+ }
1044
+
1045
+ // ../pyramid/src/index.ts
1046
+ var pyramid = {
1047
+ ...PYRAMID_CREDENTIAL,
1048
+ apiUrl: DEFAULT_API_URL,
1049
+ // `magic login` mints workspace keys here from the Account grant (API-PYRAMID-CLI-SESSIONS).
1050
+ sessions: { path: "/v1/cli/sessions" },
1051
+ // The operations graph loads only when a Pyramid command runs.
1052
+ load: async () => (await import("./operations-GGMLIYEW.js")).operations,
1053
+ client(session, config) {
1054
+ const api = new PyramidClient({
1055
+ apiKey: session.key,
1056
+ baseUrl: config.products.pyramid.apiUrl
1057
+ });
1058
+ return { api, resolver: new Resolver(api) };
1059
+ },
1060
+ // `--project` stays a Pyramid flag, usable anywhere in argv (pyramid-cli's global).
1061
+ globalFlags: ["project"],
1062
+ doctor: doctorChecks,
1063
+ mcp: { instructions, register: registerPyramidMcp }
1064
+ };
1065
+ var src_default = pyramid;
1066
+ export {
1067
+ DEFAULT_API_URL,
1068
+ PYRAMID_CREDENTIAL,
1069
+ src_default as default,
1070
+ pyramid
1071
+ };