@thehammer/danx-dashboard-mcp 0.1.162 → 0.1.164

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/handlers.js CHANGED
@@ -21,886 +21,169 @@
21
21
  * decide its next move; swallowing any of it would force the agent to
22
22
  * guess.
23
23
  */
24
- import { readFile } from "node:fs/promises";
25
24
  import { basename, extname, isAbsolute } from "node:path";
26
- import { resolvePriority } from "./priority.js";
27
- /**
28
- * `filter`/`fields`/`sort` are JSON-encoded onto the query string (the route
29
- * parses `?filter=<JSON>`, `?fields=<JSON>`, `?sort=<JSON>` — see
30
- * `src/issues/read/reader.ts#parseEnvelope` + `handleGetIssues`); each is
31
- * omitted entirely when empty so the wire carries no `{}`/`[]` noise. `board`
32
- * stays a flat top-level param, resolved by the HTTP client exactly as
33
- * before.
34
- */
35
- export async function issueList(client, args) {
36
- const query = {};
37
- if (args.filter !== undefined && Object.keys(args.filter).length > 0) {
38
- query.filter = JSON.stringify(args.filter);
39
- }
40
- // DX-3427 — a JSON-encoded field tree, mirroring `issue_get` below.
41
- if (args.fields !== undefined && Object.keys(args.fields).length > 0) {
42
- query.fields = JSON.stringify(args.fields);
43
- }
44
- if (args.sort !== undefined && args.sort.length > 0) {
45
- query.sort = JSON.stringify(args.sort);
46
- }
47
- if (args.limit !== undefined)
48
- query.limit = args.limit;
49
- if (args.offset !== undefined)
50
- query.offset = args.offset;
51
- // DX-1171 — board-only: forward the qualified board id, no repo.
52
- return client.request({ method: "GET", path: "", query, board: args.board });
53
- }
54
- // ---------------- issue_get ----------------
55
- /**
56
- * The most distinct ids one batch `issue_get` may name — mirrors the server's
57
- * `src/issues/list-page.ts#ISSUE_BATCH_GET_MAX` (the batch route 400s above it);
58
- * `__tests__/handlers.test.ts` asserts the two agree.
59
- */
60
- export const ISSUE_BATCH_GET_MAX = 100;
61
- /**
62
- * The largest page any paged list read accepts (`issue_list`'s `limit`,
63
- * `plan_get`'s `cards_limit`) — mirrors the server's
64
- * `src/issues/list-page.ts#MAX_LIMIT` (it 400s above it);
65
- * `__tests__/handlers.test.ts` asserts the two agree.
66
- */
67
- export const LIST_PAGE_MAX_LIMIT = 1000;
25
+ import { readFile } from "node:fs/promises";
26
+ // ---------------- plan_connect (DX-2683) ----------------
27
+ // DX-3465 (PLN-11 R-25) — every other per-operation handler that used to
28
+ // live in this file (issue_*, plan_* except plan_connect, brief_*,
29
+ // repo_knowledge_*, failure_category_*, dispatch_transcript_search,
30
+ // quality_gate_instruction) is deleted: `danxbotApi`/`danxbotApiSpec` below
31
+ // are the generic replacement. `plan_connect` stays a dedicated tool because
32
+ // it is session-bound (see `index.ts`'s own comment on this).
33
+ const PLAN_SESSIONS_BASE_PATH = "/api/plan-sessions";
68
34
  /**
69
- * Fetch one card via `GET /api/issues/:id`, or many via
70
- * `GET /api/issues/batch?ids=...` (DX-2727) — a real batch read that keeps
71
- * the single-id route's global, cross-board resolution rather than routing
72
- * through `issue_list` (board-scoped per call, and its filter has no `id`
73
- * property). Exactly one of `id` / `ids` must be given; the batch form
74
- * reports a per-id `not_found` list rather than failing the whole call. The
75
- * batch form is a global read with no board scope, so `ids` together with
76
- * `board` is REFUSED here rather than `board` being silently dropped.
35
+ * Connect THIS session to a plan — the same write the operator's Connect
36
+ * action performs, reaching the same server-side code path. `me` in the URL
37
+ * is resolved from the forwarded session id, so this can only ever bind the
38
+ * caller's own session.
39
+ *
40
+ * A session already on another plan is MOVED, and the response says which
41
+ * plan it left (`movedFrom`).
42
+ *
43
+ * IT DOES NOTHING ABOUT HEARING THE PLAN'S EVENTS, deliberately. Delivery belongs
44
+ * to the danxbot Claude Code plugin's plan event bridge, a background process
45
+ * that holds the session's ONE listener ticket and starts when this tool
46
+ * succeeds. The dashboard keeps one ticket per session, so a ticket minted here
47
+ * would end the bridge's stream.
48
+ *
49
+ * `title` (DX-2816) rides here, not on a header, because the AGENT — not this
50
+ * server — is the one thing able to read Claude's own session title (via a
51
+ * Desktop-side `get_session` tool no MCP server subprocess can call). Sent
52
+ * only when the caller supplied one; the server leaves an unset title alone.
77
53
  */
78
- export async function issueGet(client, args) {
79
- if (args.id !== undefined && args.ids !== undefined) {
80
- throw new Error("issue_get: pass exactly one of id or ids, not both");
81
- }
82
- if (args.ids !== undefined && args.board !== undefined) {
83
- throw new Error("issue_get: board does not apply to the ids batch form — it resolves every id globally; drop board");
84
- }
85
- const query = {};
86
- // DX-3426 — the tree travels as ONE JSON-encoded query param (mirrors how
87
- // `issue_list`'s `filter`/`sort` envelopes already ride the wire above),
88
- // rather than the old CSV-of-group-names. Sent on both the single and
89
- // batch forms — comment paging (or any other relation argument) now lives
90
- // INSIDE the tree, so there is nothing left for the old ids+comments_*
91
- // refusal to guard against.
92
- if (args.fields !== undefined && Object.keys(args.fields).length > 0) {
93
- query.fields = JSON.stringify(args.fields);
94
- }
95
- if (args.ids !== undefined) {
96
- if (args.ids.length === 0) {
97
- throw new Error("issue_get: ids must be a non-empty array");
98
- }
99
- query.ids = args.ids.join(",");
100
- // DX-2727 — the batch route resolves globally, exactly like the
101
- // single-id path (see its own doc comment): no `board` is forwarded.
102
- return client.request({ method: "GET", path: "/batch", query });
103
- }
104
- if (args.id === undefined) {
105
- throw new Error("issue_get: pass exactly one of id or ids");
106
- }
107
- return client.request({
108
- method: "GET",
109
- path: `/${encodeURIComponent(args.id)}`,
110
- query,
111
- board: args.board,
112
- });
113
- }
114
- export async function issueCreate(client, args, defaultBoard) {
115
- // Resolve the target board ONCE: per-call `args.board` override (a
116
- // qualified `<repo>:<slug>` id) wins, else the dispatch's env-derived
117
- // board. `board` lives in BOTH the query string (via http-client) AND
118
- // the body (the create route reads it from the JSON envelope, as
119
- // `repo` did pre-DX-1171). Sending only one fails-loud — the
120
- // duplication is intentional. The dashboard owns board→repo and 404s
121
- // an unknown board.
122
- const board = args.board ?? defaultBoard;
123
- const body = {
124
- board,
125
- type: args.type,
126
- title: args.title,
127
- description: args.description,
128
- // DX-3006 — forwarded UNCONDITIONALLY, alongside the other required
129
- // fields, never behind an `if (… !== undefined)` like the optional ones
130
- // below. Zod has already refused a call that omitted it, so there is
131
- // nothing to coalesce here: whatever the caller decided, including an
132
- // explicit null, is what the route receives.
133
- plan: args.plan,
134
- };
135
- if (args.summary !== undefined)
136
- body.summary = args.summary;
137
- if (args.parent_id !== undefined)
138
- body.parent_id = args.parent_id;
139
- if (args.ac !== undefined)
140
- body.ac = args.ac;
141
- if (args.effort_level !== undefined)
142
- body.effort_level = args.effort_level;
143
- // DX-3238 — same tier-word → numeric midpoint resolution `issueEdit` does;
144
- // the route only ever accepts a number (`parsePriority`, `create.ts:545`).
145
- if (args.priority !== undefined)
146
- body.priority = resolvePriority(args.priority);
147
- if (args.list_id !== undefined)
148
- body.list_id = args.list_id;
149
- if (args.quality_gates !== undefined)
150
- body.quality_gates = args.quality_gates;
151
- // DX-1895 (explicit-only) — forward the caller's explicit auto-triage
152
- // decision; the server stamps false when absent (never auto-triaged).
153
- // Per-child flags ride along inside phase_children entries verbatim.
154
- if (args.triage_enabled !== undefined)
155
- body.triage_enabled = args.triage_enabled;
156
- if (args.phase_children !== undefined)
157
- body.phase_children = args.phase_children;
158
- return client.request({ method: "POST", path: "", body, board });
159
- }
160
- export async function issueEdit(client, args) {
161
- // `board` is a transport concern (query), NOT a patch field — pull it
162
- // out of `rest` so it never leaks into the `/edit` body (the server
163
- // 400s on non-allowlisted keys). `priority` is pulled out separately so it
164
- // can be resolved (tier word → numeric) before it reaches the body.
165
- const { id, board, priority, ...rest } = args;
166
- // Strip undefined so we don't send `{title: undefined}` — JSON.stringify
167
- // drops them anyway but explicit is clearer.
168
- const body = {};
169
- for (const [k, v] of Object.entries(rest)) {
170
- if (v !== undefined)
171
- body[k] = v;
172
- }
173
- // DX-1532 — resolve a tier WORD to its numeric midpoint (inverse of
174
- // priorityTier); a raw number passes through for the route to range-validate.
175
- // Omitted (undefined) → never sent, so a prose-only edit is unchanged.
176
- if (priority !== undefined) {
177
- body.priority = resolvePriority(priority);
178
- }
179
- return client.request({
180
- method: "PATCH",
181
- path: `/${encodeURIComponent(id)}/edit`,
182
- body,
183
- board,
184
- });
185
- }
186
- export async function issueTransition(client, args) {
187
- const { id, board, ...body } = args;
188
- // DX-2735 / DX-2830: no reminder on block. A block is a dispatch HOLD only —
189
- // it never puts the card in front of a human — so there is no question to
190
- // list options for. A card that needs a human uses issue_problem add, which
191
- // carries its own reminder.
192
- return client.request({
193
- method: "POST",
194
- path: `/${encodeURIComponent(id)}/transition`,
195
- body,
196
- board,
197
- });
198
- }
199
- export async function issueTriage(client, args) {
200
- const { id, board, ...body } = args;
54
+ export async function planConnect(client, args) {
201
55
  return client.request({
202
56
  method: "POST",
203
- path: `/${encodeURIComponent(id)}/triage`,
204
- body,
205
- board,
206
- });
207
- }
208
- /**
209
- * DX-2735 — the ONE boundary check every action-dispatched handler uses for its
210
- * required args. The MCP schema already validates, but a handler can be called
211
- * without it (tests, the composition E2E, a future caller), so each required arg
212
- * is checked by TYPE, not merely for presence: `null`, a number where text
213
- * belongs, or a string where a row id belongs all throw here, before any request
214
- * is built, as `<tool> <mode> requires <arg> (<what it must be>)`.
215
- *
216
- * Returns the checkers rather than checking anything itself. `mode` is the
217
- * condition the args are required under — `action=<action>` for the
218
- * action-dispatched tools — so every tool refuses in the same wording.
219
- */
220
- function argCheckers(tool, mode) {
221
- const fail = (name, expected) => {
222
- throw new Error(`${tool} ${mode} requires ${name} (${expected})`);
223
- };
224
- return {
225
- /** Text, a content hash, a name, an enum value. */
226
- string(value, name) {
227
- if (typeof value !== "string")
228
- fail(name, "a string");
229
- return value;
230
- },
231
- /** A database row id. */
232
- id(value, name) {
233
- if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0)
234
- fail(name, "a positive integer id");
235
- return value;
57
+ path: "/me/plan",
58
+ basePath: PLAN_SESSIONS_BASE_PATH,
59
+ body: {
60
+ plan_id: args.plan_id,
61
+ ...(args.title === undefined ? {} : { title: args.title }),
236
62
  },
237
- };
238
- }
239
- /**
240
- * Every action-dispatched tool's args carry these three regardless of
241
- * `action` — never action-specific, so every per-action allow-list passed to
242
- * `refuseInapplicableFields` is unioned with this rather than repeating it.
243
- */
244
- const STRUCTURAL_ACTION_FIELDS = ["id", "action", "board"];
245
- /**
246
- * DX-3310 (code-review nit) — a field the caller sent that means nothing for
247
- * the chosen `action` used to be silently ignored: `issue_solution`'s `add`
248
- * built its `content` object from `title`/`body`/`pro`/`con`/`recommended`
249
- * regardless of action, then simply never read it on `remove`/`add_step`/etc,
250
- * and `issue_problem`'s `remove` never looked at `statement`/`context`/
251
- * `solutions` at all. Either way the caller got a 200 with the field quietly
252
- * discarded — no different from a typo. Refused loud instead, mirroring
253
- * `argCheckers`' own wording: every ACTUALLY-SUPPLIED (`!== undefined`) key
254
- * outside the action's own allow-list (plus `STRUCTURAL_ACTION_FIELDS`,
255
- * always allowed) is named in one error, so a caller fixes every offending
256
- * field in one round trip rather than one at a time.
257
- */
258
- // A9 (code-review fix) — takes `object` rather than `Record<string, unknown>`
259
- // so every caller (each with its own specific `*Args` interface, no index
260
- // signature) passes `args` directly, with no `as unknown as Record<...>`
261
- // bridge cast at the call site. `Object.entries` reads the key/value pairs
262
- // without needing an index signature on the input type either.
263
- function refuseInapplicableFields(tool, mode, args, allowed) {
264
- const offending = Object.entries(args)
265
- .filter(([key, value]) => value !== undefined && !STRUCTURAL_ACTION_FIELDS.includes(key) && !allowed.has(key))
266
- .map(([key]) => key)
267
- .sort();
268
- if (offending.length > 0) {
269
- throw new Error(`${tool} ${mode} refuses fields that do not apply to this action: ${offending.join(", ")}`);
270
- }
271
- }
272
- export async function issueComment(client, args) {
273
- const idEnc = encodeURIComponent(args.id);
274
- const board = args.board;
275
- const need = argCheckers("issue_comment", `action=${args.action}`);
276
- if (args.action === "add") {
277
- const text = need.string(args.text, "text");
278
- return client.request({
279
- method: "POST",
280
- path: `/${idEnc}/comments`,
281
- body: {
282
- text,
283
- ...(args.metadata !== undefined ? { metadata: args.metadata } : {}),
284
- ...(args.problem_id !== undefined ? { problem_id: args.problem_id } : {}),
285
- },
286
- board,
287
- });
288
- }
289
- if (args.action === "edit") {
290
- const commentId = need.id(args.comment_id, "comment_id");
291
- const text = need.string(args.text, "text");
292
- return client.request({
293
- method: "PATCH",
294
- path: `/${idEnc}/comments/${commentId}`,
295
- body: { text },
296
- board,
297
- });
298
- }
299
- // delete
300
- const commentId = need.id(args.comment_id, "comment_id");
301
- return client.request({
302
- method: "DELETE",
303
- path: `/${idEnc}/comments/${commentId}`,
304
- board,
305
- });
306
- }
307
- export async function issueDependency(client, args) {
308
- const idEnc = encodeURIComponent(args.id);
309
- const board = args.board;
310
- const need = argCheckers("issue_dependency", `action=${args.action}`);
311
- if (args.action === "add") {
312
- const kind = need.string(args.kind, "kind");
313
- const targetId = need.string(args.target_id, "target_id");
314
- return client.request({
315
- method: "POST",
316
- path: `/${idEnc}/dependencies`,
317
- body: {
318
- kind,
319
- target_id: targetId,
320
- reason: args.reason ?? "",
321
- },
322
- board,
323
- });
324
- }
325
- // remove
326
- const dependencyId = need.id(args.dependency_id, "dependency_id");
327
- return client.request({
328
- method: "DELETE",
329
- path: `/${idEnc}/dependencies/${dependencyId}`,
330
- // Server demands literal "recorded_in_error" — any other value is
331
- // a 400. The MCP boundary hardcodes it to remove a footgun: an
332
- // agent that types the wrong reason gets a refusal here at MCP
333
- // arg-validation, not a confusing 400 envelope.
334
- body: { reason: "recorded_in_error" },
335
- board,
336
63
  });
337
64
  }
65
+ // ---------------- resource_fields (DX-3426) ----------------
66
+ const RESOURCES_BASE_PATH = "/api/resources";
338
67
  /**
339
- * DX-2845 — mark a card's own `card/<id>` branch RETIRED (unsafe to merge)
340
- * via POST /api/issues/:id/card-branch-retire {reason}. NO status/terminal
341
- * gate on the server side — settable the moment a branch is judged unsafe,
342
- * whether the card is ToDo, In Progress, or anything else (see
343
- * `retireCardBranch`'s doc comment, `src/issues/write/card-branch-retire.ts`).
344
- * `by` is server-stamped from the resolved writing identity, never
345
- * client-supplied. Idempotent: retiring an already-retired branch just
346
- * re-stamps reason/actor/timestamp.
68
+ * `GET /api/resources/:resource/fields` — what a field TREE (the `fields`
69
+ * arg on `GET /api/issues/:id`, and any later route built on the same shape)
70
+ * may name for ONE resource: `{resource, description, always, hashes,
71
+ * fields, relations}`. Install-global (no `board` — the failure-category
72
+ * list route is board-less the same way: nothing server-side would read one).
73
+ * Unknown resource → 404 `unknown_resource`.
347
74
  */
348
- export async function issueRetireBranch(client, args) {
75
+ export async function resourceFields(client, args) {
349
76
  return client.request({
350
- method: "POST",
351
- path: `/${encodeURIComponent(args.id)}/card-branch-retire`,
352
- body: { reason: args.reason },
353
- board: args.board,
77
+ method: "GET",
78
+ path: `/${encodeURIComponent(args.resource)}/fields`,
79
+ basePath: RESOURCES_BASE_PATH,
354
80
  });
355
81
  }
356
- /**
357
- * DX-1362 — targeted checklist CUD. Action-dispatched onto the
358
- * `/api/issues/:id/checklists[/:cid[/items[/:iid]]]` route family so an agent
359
- * mutates ONE checklist or item without the wholesale `issue_edit({checklists})`
360
- * replace (which silently drops any omitted checklist + churns every item id).
361
- * A missing required arg for the chosen action throws at this boundary (no
362
- * round-trip). The dashboard's structured error envelope passes through verbatim.
363
- */
364
- export async function issueChecklist(client, args) {
365
- const idEnc = encodeURIComponent(args.id);
366
- const board = args.board;
367
- const need = argCheckers("issue_checklist", `action=${args.action}`);
368
- switch (args.action) {
369
- case "add_list": {
370
- const body = { name: need.string(args.name, "name") };
371
- if (args.items !== undefined)
372
- body.items = args.items;
373
- return client.request({
374
- method: "POST",
375
- path: `/${idEnc}/checklists`,
376
- body,
377
- board,
378
- });
379
- }
380
- case "update_list": {
381
- const checklistId = need.id(args.checklist_id, "checklist_id");
382
- const name = need.string(args.name, "name");
383
- return client.request({
384
- method: "PATCH",
385
- path: `/${idEnc}/checklists/${checklistId}`,
386
- body: { name },
387
- board,
388
- });
389
- }
390
- case "remove_list": {
391
- const checklistId = need.id(args.checklist_id, "checklist_id");
392
- return client.request({
393
- method: "DELETE",
394
- path: `/${idEnc}/checklists/${checklistId}`,
395
- board,
396
- });
397
- }
398
- case "add_item": {
399
- const checklistId = need.id(args.checklist_id, "checklist_id");
400
- const body = { label: need.string(args.label, "label") };
401
- if (args.detail !== undefined)
402
- body.detail = args.detail;
403
- if (args.status !== undefined)
404
- body.status = args.status;
405
- return client.request({
406
- method: "POST",
407
- path: `/${idEnc}/checklists/${checklistId}/items`,
408
- body,
409
- board,
410
- });
411
- }
412
- case "update_item": {
413
- const checklistId = need.id(args.checklist_id, "checklist_id");
414
- const itemId = need.id(args.item_id, "item_id");
415
- const body = {};
416
- if (args.label !== undefined)
417
- body.label = args.label;
418
- if (args.detail !== undefined)
419
- body.detail = args.detail;
420
- if (args.status !== undefined)
421
- body.status = args.status;
422
- return client.request({
423
- method: "PATCH",
424
- path: `/${idEnc}/checklists/${checklistId}/items/${itemId}`,
425
- body,
426
- board,
427
- });
428
- }
429
- case "remove_item": {
430
- const checklistId = need.id(args.checklist_id, "checklist_id");
431
- const itemId = need.id(args.item_id, "item_id");
432
- return client.request({
433
- method: "DELETE",
434
- path: `/${idEnc}/checklists/${checklistId}/items/${itemId}`,
435
- board,
436
- });
437
- }
438
- }
439
- }
440
- /**
441
- * A card's PROBLEMS via `/api/issues/:id/problems[/:pid]` (DX-2735),
442
- * action-dispatched like `issue_checklist`. A problem is one statement the
443
- * operator must resolve — a question or a flaw in the plan — owning its own
444
- * solutions and its own decisions. A missing required arg throws at this
445
- * boundary (no round-trip); the server's refusals (`stale_problem` with the
446
- * current row) pass through verbatim.
447
- *
448
- * `add` carries `solutions[]` in the SAME request so a problem and its options
449
- * land in one server transaction — never a problem briefly visible to the
450
- * operator with none of the options it was created with. DX-2830 — a card
451
- * needs a human exactly when it has an open problem, so `add` IS the moment a
452
- * card is put in front of one; the reminder that used to ride the retired
453
- * `issue_requires_human({set: true})` now rides here instead.
454
- *
455
- * DX-2942 — `statement` is capped at 200 characters server-side (a 400 names
456
- * the actual length otherwise); `context` is the markdown detail behind it,
457
- * sent only when given on `add`, and on `edit` `undefined` keeps the stored
458
- * context while `null` clears it — the same omit/null semantics
459
- * `plan_update_record` already gives a plan record's context.
460
- *
461
- * DX-3309 — `add` also takes `type`: `question` (the operator decides) or
462
- * `action` (only a person can carry it out). Sent only when given — the
463
- * server itself defaults an omitted value to `question`, so a caller that
464
- * predates this field keeps creating exactly what it always created. It is
465
- * set once, at creation, and never editable afterward. The FULL teaching
466
- * text (the could-I-do-this-myself test, and what `statement`/`context`/
467
- * `solutions[]` mean under each type) lives on the tool's own `type` field
468
- * `.describe()` in `index.ts` — that description, not this comment, is what
469
- * a calling agent actually reads.
470
- *
471
- * DX-3471 — `answer` {solution_id, note?} | {freeform} → POST :pid/answer;
472
- * `change_answer` {base_hash, solution_id, note?} | {base_hash, freeform} →
473
- * POST :pid/change-answer (retracts the live decision and records a new one
474
- * in ONE server transaction); `unanswer` {base_hash} → POST :pid/unanswer
475
- * (retracts with no new answer, reopening the problem). `base_hash` on these
476
- * two names the live DECISION (`decisionContentHash`), NOT the problem —
477
- * read it off a problem's `decisions[].content_hash` (from `list`), never
478
- * off the problem's own `content_hash`. A stale one → 409 `stale_decision`
479
- * with `currentHash` + `currentProblem`: merge, then retry.
480
- *
481
- * ALL THREE ARE OPERATOR-SESSION ONLY. The server's route policy admits only
482
- * a `human` principal on these three routes (`route-policies/index.ts`) — a
483
- * dispatched agent's machine credential is refused before it ever reaches a
484
- * handler, for the DX-2830 reason: an agent that could answer its own
485
- * question could release the very human gate it set to stop and wait for.
486
- * An operator-session agent (running under the OPERATOR's own dashboard
487
- * token, human-authenticated) CAN call these — this is what turns "the
488
- * operator decided in chat" into a durable, correctly-attributed decision
489
- * instead of a comment or a raw curl call. The decision records the human
490
- * decider (`decided_by`) and, when this call carries the session id
491
- * `@thehammer/danx-dashboard-mcp` already stamps on every request
492
- * (`x-danx-session-id`, DX-2683 — no separate parameter needed here),
493
- * which working session relayed it (`relayed_by_session` on the resulting
494
- * decision, DX-3471) — so a decision entered from the dashboard's own UI
495
- * (no header) stays distinguishable from one an agent relayed in chat.
496
- */
497
- /**
498
- * DX-3310 (nit) — per-action allow-list for `refuseInapplicableFields`.
499
- * `id`/`action`/`board` are structural and never listed (every action takes
500
- * them). `type` is listed under `edit` even though it can never actually be
501
- * SENT there (see the dedicated throw below) — that keeps the generic
502
- * refusal from firing first with a less specific message than the one that
503
- * already names the real reason.
504
- */
505
- const PROBLEM_ACTION_FIELDS = {
506
- list: [],
507
- add: ["statement", "context", "type", "summary", "solutions"],
508
- edit: ["problem_id", "base_hash", "statement", "context", "summary", "type"],
509
- remove: ["problem_id", "base_hash"],
510
- answer: ["problem_id", "solution_id", "note", "freeform"],
511
- change_answer: ["problem_id", "base_hash", "solution_id", "note", "freeform"],
512
- unanswer: ["problem_id", "base_hash"],
513
- };
514
- export async function issueProblem(client, args) {
515
- const idEnc = encodeURIComponent(args.id);
516
- const board = args.board;
517
- const need = argCheckers("issue_problem", `action=${args.action}`);
518
- refuseInapplicableFields("issue_problem", `action=${args.action}`, args, new Set(PROBLEM_ACTION_FIELDS[args.action]));
519
- switch (args.action) {
520
- case "list":
521
- return client.request({ method: "GET", path: `/${idEnc}/problems`, board });
522
- case "add": {
523
- const body = { statement: need.string(args.statement, "statement") };
524
- if (args.context !== undefined)
525
- body.context = args.context;
526
- // DX-3309 — sent only when given; the server itself defaults an omitted
527
- // value to "question" (today's behavior, unchanged for any caller that
528
- // doesn't yet know about this field).
529
- if (args.type !== undefined)
530
- body.type = args.type;
531
- // DX-3309 (follow-up) — sent only when given; the server itself refuses
532
- // an `action` add with no summary (400 naming the field), and defaults
533
- // an omitted `question` summary to absent (`null`).
534
- if (args.summary !== undefined)
535
- body.summary = args.summary;
536
- if (args.solutions !== undefined)
537
- body.solutions = args.solutions;
538
- // DX-3365 — the server's own response now carries the reminder (a
539
- // `reminders: [{key, text}]` field appended by the registry
540
- // middleware, `src/issues/reminders/middleware.ts`); this used to be an
541
- // EXTRA client-side GET (`withProblemsReminder`, deleted) computing the
542
- // identical text a request round-trip later. Passed through verbatim —
543
- // no dual path.
544
- return client.request({ method: "POST", path: `/${idEnc}/problems`, body, board });
545
- }
546
- case "edit": {
547
- const problemId = need.id(args.problem_id, "problem_id");
548
- const baseHash = need.string(args.base_hash, "base_hash");
549
- const statement = need.string(args.statement, "statement");
550
- // DX-3309 (test-review follow-up) — `type` is set once, at creation,
551
- // and never editable afterward; the HTTP edit route already refuses it
552
- // as an unknown key. This call used to silently DROP `type` on edit
553
- // (it was never read from `args` here at all), so an agent
554
- // "changing" question -> action got a 200 back with the type
555
- // unchanged, no error, no signal anything was ignored. Fail loud
556
- // instead: the fix for a wrong type is remove + re-add, never edit.
557
- if (args.type !== undefined) {
558
- throw new Error("issue_problem edit: type cannot be changed after creation — remove this problem and add a new one with the correct type");
559
- }
560
- return client.request({
561
- method: "PATCH",
562
- path: `/${idEnc}/problems/${problemId}`,
563
- body: {
564
- base_hash: baseHash,
565
- statement,
566
- ...(args.context === undefined ? {} : { context: args.context }),
567
- ...(args.summary === undefined ? {} : { summary: args.summary }),
568
- },
569
- board,
570
- });
571
- }
572
- case "remove": {
573
- const problemId = need.id(args.problem_id, "problem_id");
574
- const baseHash = need.string(args.base_hash, "base_hash");
575
- return client.request({
576
- method: "DELETE",
577
- path: `/${idEnc}/problems/${problemId}`,
578
- body: { base_hash: baseHash },
579
- board,
580
- });
581
- }
582
- // DX-3471 — answer/change_answer share the exact-one-of solution_id/freeform
583
- // shape the server's own `validateAnswer` enforces (`write/problem-answer.ts`);
584
- // checked here too so a caller gets the same clear boundary error every
585
- // OTHER missing/conflicting-arg case in this tool already gets, rather than
586
- // a round trip to the server for a 400 the client could have caught.
587
- case "answer": {
588
- const problemId = need.id(args.problem_id, "problem_id");
589
- return client.request({
590
- method: "POST",
591
- path: `/${idEnc}/problems/${problemId}/answer`,
592
- body: answerChoiceBody(args, "answer"),
593
- board,
594
- });
595
- }
596
- case "change_answer": {
597
- const problemId = need.id(args.problem_id, "problem_id");
598
- const baseHash = need.string(args.base_hash, "base_hash");
599
- return client.request({
600
- method: "POST",
601
- path: `/${idEnc}/problems/${problemId}/change-answer`,
602
- body: { base_hash: baseHash, ...answerChoiceBody(args, "change_answer") },
603
- board,
604
- });
605
- }
606
- case "unanswer": {
607
- const problemId = need.id(args.problem_id, "problem_id");
608
- const baseHash = need.string(args.base_hash, "base_hash");
609
- return client.request({
610
- method: "POST",
611
- path: `/${idEnc}/problems/${problemId}/unanswer`,
612
- body: { base_hash: baseHash },
613
- board,
614
- });
615
- }
616
- }
617
- }
618
- /**
619
- * DX-3471 — the either/or answer shape `answer`/`change_answer` both send:
620
- * `{solution_id, note?}` XOR `{freeform}`, mirroring the server's own
621
- * `validateAnswer` (`write/problem-answer.ts`) so a caller gets the SAME
622
- * refusal at this client boundary that the server would otherwise 400 on,
623
- * with no round trip.
624
- */
625
- function answerChoiceBody(args, mode) {
626
- const hasSolution = args.solution_id !== undefined;
627
- const hasFreeform = args.freeform !== undefined;
628
- if (hasSolution === hasFreeform) {
629
- throw new Error(`issue_problem ${mode} requires either solution_id or freeform (never both, never neither)`);
630
- }
631
- if (hasFreeform) {
632
- if (args.note !== undefined) {
633
- throw new Error(`issue_problem ${mode}: note qualifies a chosen solution — it is not valid with freeform`);
634
- }
635
- return { freeform: args.freeform };
636
- }
637
- const body = { solution_id: args.solution_id };
638
- if (args.note !== undefined)
639
- body.note = args.note;
640
- return body;
641
- }
642
- /**
643
- * One problem's candidate solutions, AND (DX-3310) one solution's individual
644
- * procedure steps, via `/api/issues/:id/problems/:pid/solutions[/:sid][/steps[/:stepId]]`
645
- * — action-dispatched like `issue_checklist`. A missing required arg for the
646
- * chosen action throws at this boundary (no round-trip); the server's
647
- * refusal envelopes (`stale_solution`/`stale_step` with the current row, a
648
- * second recommendation on the same problem, editing a chosen option, a
649
- * solution/step id from the wrong scope → 404, nesting past depth 3 → 400)
650
- * pass through verbatim.
651
- *
652
- * There is deliberately NO answer action on THIS tool — answering releases the
653
- * human gate on a card, and this tool has no notion of "who is calling".
654
- * `issue_problem`'s `answer` / `change_answer` / `unanswer` actions (DX-3471)
655
- * are where that lives instead: server-side human-principal-only (a
656
- * dispatched agent's machine credential is refused 403), so an operator
657
- * session can record a decision but a dispatched worker cannot release its
658
- * own gate.
659
- */
660
- const SOLUTION_ACTION_FIELDS = {
661
- add: ["title", "body", "pro", "con", "recommended", "steps"],
662
- edit: ["solution_id", "base_hash", "title", "body", "pro", "con", "recommended", "steps", "steps_base_hash"],
663
- remove: ["solution_id", "base_hash"],
664
- add_step: ["solution_id", "title", "description", "parent_step_id", "position"],
665
- edit_step: ["solution_id", "step_id", "base_hash", "title", "description"],
666
- remove_step: ["solution_id", "step_id", "base_hash"],
667
- };
668
- export async function issueSolution(client, args) {
669
- const need = argCheckers("issue_solution", `action=${args.action}`);
670
- // DX-2735: checked at runtime too, not only by the schema — a caller that skips
671
- // the MCP boundary must never build `/problems/undefined/solutions`.
672
- const problemId = need.id(args.problem_id, "problem_id");
673
- // DX-3310 (nit) — `problem_id` is unioned in here rather than listed in
674
- // every `SOLUTION_ACTION_FIELDS` entry: unlike `issue_problem` (where it
675
- // only applies to edit/remove), `issue_solution` requires it for every
676
- // action (checked above), so it is structural FOR THIS TOOL specifically.
677
- refuseInapplicableFields("issue_solution", `action=${args.action}`, args, new Set(["problem_id", ...SOLUTION_ACTION_FIELDS[args.action]]));
678
- const base = `/${encodeURIComponent(args.id)}/problems/${problemId}/solutions`;
679
- const board = args.board;
680
- const content = {};
681
- for (const key of ["title", "body", "pro", "con", "recommended"]) {
682
- if (args[key] !== undefined)
683
- content[key] = args[key];
684
- }
685
- switch (args.action) {
686
- case "add": {
687
- // DX-2735: the POST carries the CHECKED title, not a re-read of args.title.
688
- const title = need.string(args.title, "title");
689
- // DX-3310 — omitted `steps` on add means "no steps", not "leave
690
- // untouched" (there is nothing yet to leave untouched on a brand new
691
- // solution) — sent through only when the caller actually supplied it,
692
- // so an existing add-only caller that never heard of `steps` keeps
693
- // creating exactly what it always created.
694
- if (args.steps !== undefined)
695
- content.steps = args.steps;
696
- return client.request({ method: "POST", path: base, body: { ...content, title }, board });
697
- }
698
- case "edit": {
699
- const solutionId = need.id(args.solution_id, "solution_id");
700
- const baseHash = need.string(args.base_hash, "base_hash");
701
- // DX-3310 — omitted keeps the stored steps untouched; an explicit
702
- // (including empty) array replaces them via the server's diff.
703
- if (args.steps !== undefined) {
704
- content.steps = args.steps;
705
- // B2 (code-review fix) — REQUIRED alongside `steps`, checked here
706
- // (not only by the server) so a caller gets the teaching error at
707
- // this boundary rather than a round trip for something this client
708
- // already knows is missing.
709
- content.steps_base_hash = need.string(args.steps_base_hash, "steps_base_hash");
710
- }
711
- return client.request({
712
- method: "PATCH",
713
- path: `${base}/${solutionId}`,
714
- body: { base_hash: baseHash, ...content },
715
- board,
716
- });
717
- }
718
- case "remove": {
719
- const solutionId = need.id(args.solution_id, "solution_id");
720
- const baseHash = need.string(args.base_hash, "base_hash");
721
- return client.request({
722
- method: "DELETE",
723
- path: `${base}/${solutionId}`,
724
- body: { base_hash: baseHash },
725
- board,
726
- });
727
- }
728
- // DX-3310 (AC 35250) — granular single-step CUD, so changing one step
729
- // never requires resending the whole `steps` tree.
730
- case "add_step": {
731
- const solutionId = need.id(args.solution_id, "solution_id");
732
- const title = need.string(args.title, "title");
733
- const body = { title };
734
- if (args.description !== undefined)
735
- body.description = args.description;
736
- if (args.parent_step_id !== undefined)
737
- body.parent_step_id = args.parent_step_id;
738
- if (args.position !== undefined)
739
- body.position = args.position;
740
- return client.request({ method: "POST", path: `${base}/${solutionId}/steps`, body, board });
741
- }
742
- case "edit_step": {
743
- const solutionId = need.id(args.solution_id, "solution_id");
744
- const stepId = need.id(args.step_id, "step_id");
745
- const baseHash = need.string(args.base_hash, "base_hash");
746
- const body = { base_hash: baseHash };
747
- if (args.title !== undefined)
748
- body.title = args.title;
749
- if (args.description !== undefined)
750
- body.description = args.description;
751
- return client.request({ method: "PATCH", path: `${base}/${solutionId}/steps/${stepId}`, body, board });
752
- }
753
- case "remove_step": {
754
- const solutionId = need.id(args.solution_id, "solution_id");
755
- const stepId = need.id(args.step_id, "step_id");
756
- const baseHash = need.string(args.base_hash, "base_hash");
757
- return client.request({
758
- method: "DELETE",
759
- path: `${base}/${solutionId}/steps/${stepId}`,
760
- body: { base_hash: baseHash },
761
- board,
762
- });
763
- }
764
- }
765
- }
766
- /**
767
- * Put ONE quality gate on a card, or take it off, via
768
- * POST /api/issues/:id/quality-gates/:gate {action} — the same write the
769
- * dashboard drawer's Quality Gates tab performs (DX-1181). This is the ONLY
770
- * post-create path to change a card's gates: `issue_create` names them at
771
- * birth via `quality_gates`, and `issue_edit` rejects gate keys.
772
- *
773
- * `gate` is a registry name (`plan-dependency` | `plan-architecture` |
774
- * `plan-tdd` | `code-test-quality` | `code-architecture` | `code-quality`).
775
- * An unknown gate is a 400 from the server (never a silent no-op), as is a
776
- * card whose type is never gated (`Epic` / `Feature` / `Task`).
777
- *
778
- * DX-3015 — there is no board tri-state to reason about any more. A gate is on
779
- * the card or it is not, and every gate on a card is required, so `add` always
780
- * means "this gate now runs on this card" and `remove` always means it does
781
- * not. What the board still owns is the DEFAULT SET a NEW card is born with;
782
- * it has no say over a card that already exists.
783
- *
784
- * Because of that, the response is plain `{issue}`. The old body carried
785
- * `{applied, effective, reason}` because the per-card flag was only one of
786
- * three inputs and the board could silently make the write a no-op — with the
787
- * tri-state gone, the write IS the outcome and those fields would be constants.
788
- *
789
- * `note` records why the gate is on the card; `effort_level` (DX-1760) sets
790
- * `card_quality_gates.effort_level` (`null` clears a prior override). Both are
791
- * `add`-only — the server 400s either one alongside `remove`, since a removed
792
- * gate has no row to carry them.
793
- *
794
- * Re-adding a gate the card already carries updates `note`/`effort_level` and
795
- * leaves any recorded verdict untouched, so `add` is never a way to quietly
796
- * clear a `fail`. `remove` DOES discard the row and its verdict.
797
- */
798
- export async function issueQualityGate(client, args) {
799
- const body = { action: args.action };
800
- if (args.note !== undefined)
801
- body.note = args.note;
802
- if (args.effort_level !== undefined)
803
- body.effort_level = args.effort_level;
804
- return client.request({
805
- method: "POST",
806
- path: `/${encodeURIComponent(args.id)}/quality-gates/${encodeURIComponent(args.gate)}`,
807
- body,
82
+ // ---------------- danxbot_api (DX-3465, PLN-11 R-25) ----------------
83
+ export const DANXBOT_API_METHODS = ["GET", "POST", "PATCH", "PUT", "DELETE"];
84
+ export function isDanxbotApiMethod(value) {
85
+ return typeof value === "string" && DANXBOT_API_METHODS.includes(value);
86
+ }
87
+ /**
88
+ * `danxbot_api({method, path, query, body})` — the ONE generic tool that
89
+ * reaches every dashboard API route this credential is allowed to call
90
+ * (R-25, DX-3465). Replaces the ~45 per-operation tools this package used to
91
+ * ship one-for-one against each route: a new route needs a permission and a
92
+ * spec entry (`danxbot_api_spec`), never a new tool.
93
+ *
94
+ * The credential lives inside `client` (resolved once at boot — the
95
+ * operator's declared token file for a session, the per-dispatch
96
+ * `DANXBOT_DISPATCH_TOKEN` for a worker) and never appears in this
97
+ * function's arguments or return value — nothing in `DanxbotApiArgs` can
98
+ * carry one. The session id (`x-danx-session-id`) rides every call the SAME
99
+ * way it already does for every other tool on this server
100
+ * (`http-client.ts#request` stamps it from `config.session`); this tool adds
101
+ * no special-casing for that.
102
+ *
103
+ * `path` MUST start with `/api/` — this is a guardrail against a caller
104
+ * accidentally reaching for something outside the dashboard's own API
105
+ * surface (there is nothing else this credential could reach through
106
+ * `client`'s configured `baseUrl` that would make sense to call this way),
107
+ * not a route allowlist: the dashboard's own route policy (`authorize()`,
108
+ * `src/dashboard/route-policy.ts`) is the actual authorization boundary —
109
+ * an unknown or unauthorized route 404s/401s/403s exactly as it would to any
110
+ * other caller, and that refusal body (which names the fix — CP1) is
111
+ * returned verbatim, unmodified, like every other route's response.
112
+ *
113
+ * The response is the target route's JSON body, VERBATIM — including its
114
+ * own `reminders[]` field when the route embeds one (every reminder-
115
+ * registry row is keyed on `{method, route}` now, DX-3465 item 5, so a
116
+ * route that used to attach a reminder to a deleted per-operation tool's
117
+ * response still attaches it here, with zero special MCP-side plumbing).
118
+ */
119
+ export async function danxbotApi(client, args) {
120
+ return client.request({
121
+ method: args.method,
122
+ // `basePath: ""` — `path` here is the FULL absolute path already (see
123
+ // the tool's own docs), unlike every other handler in this file, which
124
+ // passes a path RELATIVE to a fixed `basePath` (`/api/issues`,
125
+ // `/api/plans`, ...). This tool has no fixed base; the caller names the
126
+ // whole route.
127
+ basePath: "",
128
+ path: args.path,
129
+ query: args.query,
130
+ body: args.body,
808
131
  board: args.board,
809
132
  });
810
133
  }
134
+ // ---------------- danxbot_api_list (DX-3465 A5, PLN-11 R-25) ----------------
135
+ const ROUTES_BASE_PATH = "/api/routes";
811
136
  /**
812
- * Stamp an operator MANUAL quality-gate verdict via
813
- * PATCH /api/issues/:id/quality-gates/:gate {status, message} — the same write
814
- * the dashboard Gates-tab Pass / Fail / Revert controls perform (DX-1373).
815
- *
816
- * SIBLING, NOT A REPLACEMENT, of `issueQualityGate` above: they are two
817
- * distinct operations on one resource, mirroring the server's own verb split.
818
- * POST flips the per-card `required` FLAG (does this gate run at all); PATCH
819
- * stamps the VERDICT (did it pass). Neither substitutes for the other, which
820
- * is why this is a separate tool rather than an overloaded mode on the
821
- * existing one (CLAUDE.md Core Principle 2 — no dual-shape).
822
- *
823
- * DX-2222 — why this tool has to exist. A card cannot reach Done while any
824
- * required POST gate row is not `pass` (`issue_transition complete` refuses
825
- * 409 with `failed_gate: "quality_gate_post"`). Until now the ONLY way to
826
- * write a verdict was the worker route `src/worker/quality-gate-route.ts`,
827
- * authed by the per-dispatch `DANX_AGENT_TOKEN` that exists only INSIDE a
828
- * spawned dispatch. An operator/host session (`issue_transition pickup
829
- * {manual:true}`, DX-946) has no such token, so it could pick a card up and
830
- * do the work but never close it — every manually-claimed card stranded In
831
- * Progress, and each stranded card then projects `conflict_on` / `waiting_on`
832
- * gates onto the ToDo queue, stalling dispatch for cards nobody is working.
833
- *
834
- * This route is the RIGHT one to expose for that: its handler
835
- * (`src/issues/write/quality-gate-verdict.ts`) is a pure, dispatch-less row
836
- * write authed by the ordinary per-user dashboard bearer — explicitly "NOT
837
- * the dispatch token — there is no dispatch behind a manual stamp". It does
838
- * NOT fire the worker route's per-dispatch side effects (`gate_release` on
839
- * pass, `block` on fail), so a manual verdict can never release or block a
840
- * dispatch that does not exist.
841
- *
842
- * Fail-loud (CP1), all server-side: unknown `gate` → 400; `status` outside
843
- * pass/fail/pending → 400; a pass/fail whose `message` is under 20 characters
844
- * → 400, because the note IS the accountability record for a human override.
845
- * `pending` reverts a prior verdict and clears the message.
137
+ * `danxbot_api_list()` — the routes YOUR credential may currently call,
138
+ * `[{method, path, summary, permission}]`, one line each. Backed by DX-3528's
139
+ * `GET /api/routes`, which filters the served route table through the SAME
140
+ * `isRouteAuthorizedForPrincipal` check `authorize()` enforces on every
141
+ * request — a route listed here is always callable, and a route this
142
+ * credential cannot call never appears. This is how you find the route for
143
+ * something you need to do: call this first when you don't already know the
144
+ * path, then `danxbot_api_spec({endpoints})` for the exact contract of the
145
+ * one(s) you picked, then `danxbot_api` to call it. No arguments — the
146
+ * server-side route table has no filter to pass.
846
147
  */
847
- export async function issueQualityGateVerdict(client, args) {
848
- const body = { status: args.status };
849
- if (args.message !== undefined)
850
- body.message = args.message;
148
+ export async function danxbotApiList(client) {
851
149
  return client.request({
852
- method: "PATCH",
853
- path: `/${encodeURIComponent(args.id)}/quality-gates/${encodeURIComponent(args.gate)}`,
854
- body,
855
- board: args.board,
150
+ method: "GET",
151
+ path: "",
152
+ basePath: ROUTES_BASE_PATH,
856
153
  });
857
154
  }
858
- // ---------------- quality_gate_instruction ----------------
859
- const QUALITY_GATE_INSTRUCTION_BASE_PATH = "/api/quality-gates";
155
+ // ---------------- danxbot_api_spec (DX-3465, PLN-11 R-25) ----------------
156
+ const OPENAPI_BASE_PATH = "/api/openapi";
860
157
  /**
861
- * DX-3341 (PLN-11 R-20) — fetch a named gate's CURRENT instruction via
862
- * GET /api/quality-gates/:gate/instruction. NOT card-scoped (unlike
863
- * `issue_quality_gate`/`issue_quality_gate_verdict` above) — this reads the
864
- * gate's DB-catalog SKILL PROSE for the calling session's board/repo, byte-
865
- * identical to what a dispatched gate reviewer's clean-room materializes at
866
- * `.claude/skills/<segment>/SKILL.md`. No second source: the dashboard route
867
- * behind this call reuses the exact same catalog-read + materialize-transform
868
- * chain a real gate dispatch's clean-room assembly uses.
158
+ * `danxbot_api_spec({endpoints})` — the exact OpenAPI 3.1 contract (DX-3527)
159
+ * for the named routes: request/response shapes, required permission/
160
+ * consumer action, and every documented error envelope. Call this before a
161
+ * `danxbot_api` call whose shape you are not already sure of — "a 4xx body
162
+ * names the fix; check the spec" (the MCP server's own shared instructions).
869
163
  *
870
- * Use this to run a quality gate from an operator session (R-19/R-20): fetch
871
- * the gate's instruction with this tool, dispatch a `danxbot:worker-*` tier
872
- * chosen by effort with that instruction, review, then record the verdict
873
- * via `issue_quality_gate_verdict` — never a hand-written review brief.
874
- *
875
- * `gate` is a registry name (same enum `issue_quality_gate` accepts). Unknown
876
- * gate -> 400; a registered gate with no resolvable catalog skill artifact
877
- * for this repo (a boot-seed/catalog-drift defect) -> 500.
164
+ * Backed by DX-3528's permission-aware `GET /api/openapi` — the SAME
165
+ * `isRouteAuthorizedForPrincipal` check `danxbot_api_list` uses, so an
166
+ * endpoint this credential may not call refuses loud (400
167
+ * `invalid_endpoints`, naming it under `unknown` or `disallowed`) rather
168
+ * than silently documenting a route you cannot actually reach.
878
169
  */
879
- export async function qualityGateInstruction(client, args) {
170
+ export async function danxbotApiSpec(client, args) {
880
171
  return client.request({
881
172
  method: "GET",
882
- path: `/${encodeURIComponent(args.gate)}/instruction`,
883
- basePath: QUALITY_GATE_INSTRUCTION_BASE_PATH,
884
- board: args.board,
885
- });
886
- }
887
- export async function issueRetro(client, args) {
888
- const { id, board, ...body } = args;
889
- return client.request({
890
- method: "PUT",
891
- path: `/${encodeURIComponent(id)}/retro`,
892
- body,
893
- board,
173
+ path: "",
174
+ basePath: OPENAPI_BASE_PATH,
175
+ query: { endpoints: args.endpoints.join(",") },
894
176
  });
895
177
  }
896
178
  /**
897
179
  * Extension → MIME map for the agent-attached file types that matter for
898
- * downstream rendering (image previews in the dashboard drawer, Trello card,
899
- * and Slack thread all key on the persisted `mime`). This package is a
900
- * standalone published artifact and cannot import the backend's helpers, so
901
- * the small map lives here. An unknown extension resolves to the generic
902
- * octet-stream — a sensible default for "we don't know the type", NOT a
903
- * silenced error (the bytes still upload + the row is still written).
180
+ * downstream rendering (image previews in the dashboard drawer, Trello
181
+ * card, and Slack thread all key on the persisted `mime`). This package is
182
+ * a standalone published artifact and cannot import the dashboard
183
+ * backend's own MIME table, so the small map lives here. An unknown
184
+ * extension resolves to the generic octet-stream — a sensible default for
185
+ * "we don't know the type", NOT a silenced error (the bytes still upload +
186
+ * the row is still written).
904
187
  */
905
188
  const MIME_BY_EXT = {
906
189
  ".png": "image/png",
@@ -921,700 +204,49 @@ const MIME_BY_EXT = {
921
204
  ".tgz": "application/gzip",
922
205
  };
923
206
  export function inferAttachmentMime(filename) {
924
- return (MIME_BY_EXT[extname(filename).toLowerCase()] ?? "application/octet-stream");
925
- }
926
- /**
927
- * Attach a local file to an issue card. Reads the bytes from the dispatch's
928
- * shared filesystem (this MCP server runs co-located with the agent), then
929
- * forwards to the dashboard's `POST /api/issues/:id/attachments` route — the
930
- * SAME ingest the dashboard UI upload uses, which uploads to S3, inserts ONE
931
- * danxbot-origin `issue_attachments` row (`source_type=''`), and publishes
932
- * `issue:updated` so the DX-1122 outbound projection mirrors the file to both
933
- * the linked Trello card and the Slack card-view thread.
934
- *
935
- * Fail-loud (`base:fail-loudly`): the path is validated (non-empty + absolute)
936
- * and the file is read BEFORE any HTTP call, so a bad/missing/unreadable path
937
- * throws here and NEVER reaches S3, the DB row, or the projection. An S3 /
938
- * server fault on the route surfaces as a 5xx, which the HTTP client throws.
939
- */
940
- export async function issueAttach(client, args, deps = {}) {
941
- if (typeof args.file_path !== "string" || args.file_path.trim() === "") {
942
- throw new Error("issue_attach: file_path must be a non-empty string");
943
- }
944
- if (!isAbsolute(args.file_path)) {
945
- throw new Error(`issue_attach: file_path must be an absolute path (got "${args.file_path}")`);
207
+ return MIME_BY_EXT[extname(filename).toLowerCase()] ?? "application/octet-stream";
208
+ }
209
+ /**
210
+ * Attach a local file to a card. Reads the bytes from the dispatch's
211
+ * shared filesystem (this MCP server runs co-located with the agent that
212
+ * calls it), then forwards to the dashboard's `POST /api/issues/:id/attachments`
213
+ * route — the SAME ingest the dashboard UI's own upload uses: stores the
214
+ * bytes in S3, inserts one attachment row, and (via the dashboard's
215
+ * existing outbound projection) auto-mirrors the file to the card's
216
+ * linked Trello card and Slack thread. Board-scoped; see `board`.
217
+ *
218
+ * Fail-loud (`base:fail-loudly`): `path` is validated (non-empty +
219
+ * absolute) and the file is read BEFORE any HTTP call, so a bad/missing/
220
+ * unreadable path throws here and NEVER reaches S3, the DB row, or the
221
+ * projection. THE FILE'S BYTES NEVER APPEAR IN THIS FUNCTION'S ARGUMENTS
222
+ * OR IN THE AGENT'S OWN TRANSCRIPT — they are read directly off disk into
223
+ * this process's memory and sent straight to the dashboard, the same
224
+ * privacy property `danxbot_api`'s credential handling has for tokens. An
225
+ * S3 / server fault on the route surfaces as a 5xx, which the HTTP client
226
+ * throws.
227
+ */
228
+ export async function attachFile(client, args, deps = {}) {
229
+ if (typeof args.path !== "string" || args.path.trim() === "") {
230
+ throw new Error("attach_file: path must be a non-empty string");
231
+ }
232
+ if (!isAbsolute(args.path)) {
233
+ throw new Error(`attach_file: path must be an absolute path (got "${args.path}")`);
946
234
  }
947
235
  const read = deps.readFile ?? readFile;
948
236
  // A missing / unreadable file rejects here — no HTTP call is made, so no
949
237
  // S3 upload, no row insert, and no projection are ever triggered.
950
- const bytes = await read(args.file_path);
951
- const filename = basename(args.file_path);
238
+ const bytes = await read(args.path);
239
+ const filename = args.filename?.trim() ? args.filename.trim() : basename(args.path);
240
+ const contentType = args.content_type?.trim() ? args.content_type.trim() : inferAttachmentMime(filename);
952
241
  return client.request({
953
242
  method: "POST",
954
- path: `/${encodeURIComponent(args.id)}/attachments`,
243
+ basePath: "/api/issues",
244
+ path: `/${encodeURIComponent(args.card_id)}/attachments`,
955
245
  body: {
956
246
  filename,
957
- contentType: inferAttachmentMime(filename),
247
+ contentType,
958
248
  base64: bytes.toString("base64"),
959
249
  },
960
250
  board: args.board,
961
251
  });
962
252
  }
963
- /* ── Plans: what a connected session may read and add to (DX-2683) ────────── */
964
- const PLANS_BASE_PATH = "/api/plans";
965
- const PLAN_SESSIONS_BASE_PATH = "/api/plan-sessions";
966
- /**
967
- * DX-3428 — every kind the plan resource's `events` relation accepts as a
968
- * `kinds` filter (`{"events": {"kinds": [...]}}`). The ONE copy in this
969
- * package (the `resource_fields`-describable `events` relation validates the
970
- * same set server-side), duplicated from the server's
971
- * `src/issues/db/plan-events.ts#PLAN_EVENT_KINDS` because the published
972
- * package cannot import server source at runtime — kept here purely for a
973
- * CONSUMER'S typed convenience (`PlanEventKind`); the MCP tool schema itself
974
- * no longer enumerates it (DX-3428 moved `plan_get` onto the generic,
975
- * resource-agnostic `fieldTreeSchema` every field-tree tool shares — see
976
- * `field-tree.ts`). `__tests__/handlers.test.ts` asserts the two are equal,
977
- * so drift fails a test rather than a live call.
978
- */
979
- export const PLAN_EVENT_KINDS = [
980
- "comment_added",
981
- "solution_answered",
982
- "problem_added",
983
- "problem_unanswered",
984
- "problem_answer_changed",
985
- "blocked",
986
- "unblocked",
987
- "transitioned",
988
- "edited",
989
- "dependency_added",
990
- "dependency_removed",
991
- "card_created",
992
- "plan_created",
993
- "plan_renamed",
994
- "card_added_to_plan",
995
- "card_removed_from_plan",
996
- "record_added",
997
- "record_updated",
998
- "record_removed",
999
- "note_added",
1000
- "note_updated",
1001
- "note_removed",
1002
- "architecture_section_added",
1003
- "architecture_section_updated",
1004
- "architecture_section_removed",
1005
- "architecture_sections_reordered",
1006
- "session_connected",
1007
- "session_switched_away",
1008
- "idle_nudge_sent",
1009
- // DX-3519 — plan sign-off.
1010
- "plan_signed_off",
1011
- "plan_sign_off_cleared",
1012
- "plan_auto_sign_off_changed",
1013
- ];
1014
- /**
1015
- * DX-3428 — every origin the `events` relation's `origin` filter accepts.
1016
- * Mirrors the server's `src/issues/db/plan-events.ts#PlanEventOrigin`
1017
- * (itself `ActivityOrigin`) — same drift protection as `PLAN_EVENT_KINDS`
1018
- * above, and likewise kept only for a consumer's typed convenience (not read
1019
- * by the MCP tool schema any more).
1020
- */
1021
- export const PLAN_EVENT_ORIGINS = ["operator", "agent", "machine"];
1022
- /**
1023
- * DX-2834 / DX-3519 — the plan-status taxonomy, mirroring `PLAN_FIELD_GROUPS`
1024
- * just above: the ONE copy in this package (the `plan_list` zod enum in
1025
- * `index.ts` reads this const), duplicated from the server's
1026
- * `src/issues/db/plans.ts#PLAN_STATUS_IDS` because the published package
1027
- * cannot import server source at runtime. `__tests__/handlers.test.ts`
1028
- * asserts the two are equal, so drift fails a test rather than a live call.
1029
- */
1030
- export const PLAN_STATUSES = ["awaiting-session", "planning", "building", "awaiting-sign-off", "complete"];
1031
- /** Every plan, plus which one THIS session is connected to. */
1032
- export async function planList(client, args = {}) {
1033
- return client.request({
1034
- method: "GET",
1035
- path: "",
1036
- basePath: PLANS_BASE_PATH,
1037
- query: { status: args.status },
1038
- });
1039
- }
1040
- /**
1041
- * One plan — its cheap scalars by default, or opt into its cards, its goals
1042
- * + rules + caveats, its architecture sections, its sessions and its event
1043
- * ledger via a field TREE (DX-3428, replacing the retired flat field-GROUP
1044
- * list). One call rather than several, which is what keeps this tool
1045
- * surface small enough to be worth an agent's context even once every part
1046
- * of a plan is opt-in rather than always-on.
1047
- */
1048
- export async function planGet(client, args = {}) {
1049
- const query = {};
1050
- // DX-3428 — the tree travels as ONE JSON-encoded query param, exactly like
1051
- // `issue_get`/`issue_list`'s own `fields` (see `issueGet` above).
1052
- if (args.fields !== undefined && Object.keys(args.fields).length > 0) {
1053
- query.fields = JSON.stringify(args.fields);
1054
- }
1055
- return client.request({
1056
- method: "GET",
1057
- path: args.plan_id === undefined ? "/mine" : `/${args.plan_id}/full`,
1058
- basePath: PLANS_BASE_PATH,
1059
- query,
1060
- });
1061
- }
1062
- /**
1063
- * Connect THIS session to a plan — the same write the operator's Connect
1064
- * action performs, reaching the same server-side code path. `me` in the URL
1065
- * is resolved from the forwarded session id, so this can only ever bind the
1066
- * caller's own session.
1067
- *
1068
- * A session already on another plan is MOVED, and the response says which
1069
- * plan it left (`movedFrom`).
1070
- *
1071
- * IT DOES NOTHING ABOUT HEARING THE PLAN'S EVENTS, deliberately. Delivery belongs
1072
- * to the danxbot Claude Code plugin's plan event bridge, a background process
1073
- * that holds the session's ONE listener ticket and starts when this tool
1074
- * succeeds. The dashboard keeps one ticket per session, so a ticket minted here
1075
- * would end the bridge's stream.
1076
- *
1077
- * `title` (DX-2816) rides here, not on a header, because the AGENT — not this
1078
- * server — is the one thing able to read Claude's own session title (via a
1079
- * Desktop-side `get_session` tool no MCP server subprocess can call). Sent
1080
- * only when the caller supplied one; the server leaves an unset title alone.
1081
- */
1082
- export async function planConnect(client, args) {
1083
- return client.request({
1084
- method: "POST",
1085
- path: "/me/plan",
1086
- basePath: PLAN_SESSIONS_BASE_PATH,
1087
- body: {
1088
- plan_id: args.plan_id,
1089
- ...(args.title === undefined ? {} : { title: args.title }),
1090
- },
1091
- });
1092
- }
1093
- /** Add a goal, rule or caveat to the connected plan. `context` is sent only when given. */
1094
- export async function planAddRecord(client, args) {
1095
- return client.request({
1096
- method: "POST",
1097
- path: "/mine/records",
1098
- basePath: PLANS_BASE_PATH,
1099
- body: {
1100
- kind: args.kind,
1101
- body: args.body,
1102
- ...(args.context === undefined ? {} : { context: args.context }),
1103
- },
1104
- });
1105
- }
1106
- /** Add an existing card to the connected plan. */
1107
- export async function planAddCard(client, args) {
1108
- return client.request({
1109
- method: "POST",
1110
- path: "/mine/cards",
1111
- basePath: PLANS_BASE_PATH,
1112
- body: { card_id: args.card_id },
1113
- });
1114
- }
1115
- /**
1116
- * Remove a card from a plan, via `DELETE /api/plans/:plan_id/cards/:card_id`
1117
- * — the existing route `plan_add_card`'s sibling. TAKES AN EXPLICIT
1118
- * `plan_id`, unlike `plan_add_card`: this is a membership toggle on a named
1119
- * plan (like `issue_dependency` add/remove), not a write scoped to the
1120
- * caller's connected session, so there is no `/mine` form of it. Idempotent
1121
- * — removing a card that was never a member is a no-op, not an error.
1122
- * Returns the plan's remaining member list.
1123
- */
1124
- export async function planRemoveCard(client, args) {
1125
- return client.request({
1126
- method: "DELETE",
1127
- path: `/${args.plan_id}/cards/${encodeURIComponent(args.card_id)}`,
1128
- basePath: PLANS_BASE_PATH,
1129
- });
1130
- }
1131
- /**
1132
- * Rename a plan, via `PATCH /api/plans/:plan_id` `{name}`. TAKES AN
1133
- * EXPLICIT `plan_id` for the same reason `plan_remove_card` does — renaming
1134
- * a plan is not a write scoped to the caller's connected session, it names
1135
- * the plan directly. The new name shows up immediately in `plan_list`.
1136
- */
1137
- export async function planRename(client, args) {
1138
- return client.request({
1139
- method: "PATCH",
1140
- path: `/${args.plan_id}`,
1141
- basePath: PLANS_BASE_PATH,
1142
- body: { name: args.name },
1143
- });
1144
- }
1145
- /**
1146
- * Read ONE section of the plan this session is connected to, via
1147
- * `GET /api/plans/mine/architecture/sections/:sid`. TAKES NO PLAN ID: the
1148
- * plan is resolved from your connected session, same as `plan_add_record`.
1149
- * Useful for a targeted read (skip pulling the whole plan via `plan_get`)
1150
- * and for conflict recovery after a 409 `stale_plan_architecture_section` —
1151
- * though that refusal already carries `currentTitle`/`currentContent`, so a
1152
- * second read is rarely needed for that specific case. Not connected →
1153
- * `{error: "session_not_connected"}`. Unknown/foreign section id → 404.
1154
- */
1155
- export async function planGetArchitectureSection(client, args) {
1156
- return client.request({
1157
- method: "GET",
1158
- path: `/mine/architecture/sections/${args.section_id}`,
1159
- basePath: PLANS_BASE_PATH,
1160
- });
1161
- }
1162
- /**
1163
- * Append a section to the connected plan's architecture, via
1164
- * `POST /api/plans/mine/architecture/sections`. TAKES NO PLAN ID, same as
1165
- * `plan_add_record`. The new section sorts after every existing live
1166
- * section — see `plan_reorder_architecture_section` to move it.
1167
- */
1168
- export async function planAddArchitectureSection(client, args) {
1169
- return client.request({
1170
- method: "POST",
1171
- path: "/mine/architecture/sections",
1172
- basePath: PLANS_BASE_PATH,
1173
- body: { title: args.title, content: args.content },
1174
- });
1175
- }
1176
- /**
1177
- * Edit a section's title and/or content, via
1178
- * `PATCH /api/plans/mine/architecture/sections/:sid`. Both `title` and
1179
- * `content` are OPTIONAL — send only whichever changed. `base_hash` MUST be
1180
- * the section's `contentHash` from the immediately-prior `plan_get`/
1181
- * `plan_get_architecture_section`; the server compares it against the row's
1182
- * current hash and, on a mismatch, refuses the write ENTIRELY and fails loud
1183
- * with `{ok: false, status: 409, body: {error:
1184
- * "stale_plan_architecture_section", currentHash, currentTitle,
1185
- * currentContent}}` rather than overwriting whoever wrote in between —
1186
- * `currentTitle`/`currentContent` ride the SAME refusal so you can merge and
1187
- * retry in ONE round trip. TAKES NO PLAN ID: the plan is resolved from your
1188
- * connected session, same as `plan_add_record`.
1189
- */
1190
- export async function planUpdateArchitectureSection(client, args) {
1191
- return client.request({
1192
- method: "PATCH",
1193
- path: `/mine/architecture/sections/${args.section_id}`,
1194
- basePath: PLANS_BASE_PATH,
1195
- body: {
1196
- base_hash: args.base_hash,
1197
- ...(args.title === undefined ? {} : { title: args.title }),
1198
- ...(args.content === undefined ? {} : { content: args.content }),
1199
- },
1200
- });
1201
- }
1202
- /**
1203
- * Soft-delete a section of the connected plan's architecture, via
1204
- * `DELETE /api/plans/mine/architecture/sections/:sid`. `base_hash` MUST be
1205
- * the section's `contentHash` from the immediately-prior read; a stale hash
1206
- * refuses the delete ENTIRELY (nothing is removed) with the same
1207
- * `{ok: false, status: 409, body: {error: "stale_plan_architecture_section",
1208
- * currentHash, currentTitle, currentContent}}` shape
1209
- * `plan_update_architecture_section` uses — on a mismatch, re-fetch and
1210
- * confirm this is still the section you meant to remove before retrying,
1211
- * never blindly re-send with the fresh hash. TAKES NO PLAN ID. Unknown/
1212
- * foreign/already-deleted section id → 404.
1213
- */
1214
- export async function planDeleteArchitectureSection(client, args) {
1215
- return client.request({
1216
- method: "DELETE",
1217
- path: `/mine/architecture/sections/${args.section_id}`,
1218
- basePath: PLANS_BASE_PATH,
1219
- body: { base_hash: args.base_hash },
1220
- });
1221
- }
1222
- /**
1223
- * Reassign the connected plan's section display order, via
1224
- * `PUT /api/plans/mine/architecture/sections/reorder`. UNGUARDED by content
1225
- * hash, by design: moving a section never changes its (or any other
1226
- * section's) `contentHash`. `order` must name exactly the plan's current
1227
- * live section ids, each once — a partial or foreign list is refused with a
1228
- * 400 rather than silently reordering a subset or dropping a section from
1229
- * view. TAKES NO PLAN ID.
1230
- */
1231
- export async function planReorderArchitectureSection(client, args) {
1232
- return client.request({
1233
- method: "PUT",
1234
- path: "/mine/architecture/sections/reorder",
1235
- basePath: PLANS_BASE_PATH,
1236
- body: { order: args.order },
1237
- });
1238
- }
1239
- /**
1240
- * Create a new, empty plan via `POST /api/plans`. GLOBAL — a plan is not
1241
- * board-scoped, so this takes no board and creates no membership; the caller
1242
- * still owns zero cards, zero records and zero architecture sections until
1243
- * it adds them. This does NOT connect any session to the new plan — call
1244
- * `plan_connect` separately (mirroring how creating a card does not add it to
1245
- * a plan; these are two deliberately separate steps, same as everywhere else
1246
- * in this tool surface).
1247
- */
1248
- export async function planCreate(client, args) {
1249
- return client.request({
1250
- method: "POST",
1251
- path: "",
1252
- basePath: PLANS_BASE_PATH,
1253
- body: { name: args.name },
1254
- });
1255
- }
1256
- /**
1257
- * Read ONE goal/rule/caveat of the plan this session is connected to, via
1258
- * `GET /api/plans/mine/records/:rid`. TAKES NO PLAN ID: the plan is resolved
1259
- * from your connected session, same as `plan_add_record`. Useful both for an
1260
- * ordinary targeted read (skip pulling the whole plan via `plan_get` just to
1261
- * see one record) and for conflict recovery after a 409 `stale_plan_record`
1262
- * — though that refusal already carries `currentBody`, so a second read is
1263
- * rarely needed for that specific case. Not connected →
1264
- * `{error: "session_not_connected"}`. Unknown/foreign record id → 404.
1265
- */
1266
- export async function planGetRecord(client, args) {
1267
- return client.request({
1268
- method: "GET",
1269
- path: `/mine/records/${args.record_id}`,
1270
- basePath: PLANS_BASE_PATH,
1271
- });
1272
- }
1273
- /**
1274
- * Edit a goal/rule/caveat of the plan this session is connected to, via
1275
- * `PATCH /api/plans/mine/records/:rid`. The reference (`G-1`, `R-4`,
1276
- * `CAV-12`) NEVER moves — only the text changes. `content_hash` MUST be the
1277
- * record's `contentHash` from the immediately-prior `plan_get`/
1278
- * `plan_get_record`; the server compares it against the row's current hash
1279
- * and, on a mismatch, refuses the write ENTIRELY and fails loud with
1280
- * `{ok: false, status: 409, body: {error: "stale_plan_record", currentHash,
1281
- * currentBody, currentContext}}` rather than overwriting whoever wrote in
1282
- * between. DX-2734: the hash covers body AND context, and `context` is sent
1283
- * only when given (omitted keeps the stored context, `null` clears it).
1284
- * `currentBody` rides the SAME refusal — the same richer shape
1285
- * `stale_plan_architecture_section` carries for a section — so you can
1286
- * merge and retry in ONE round trip without a second `plan_get_record` call.
1287
- * TAKES NO PLAN ID: the plan is resolved from your connected session, same as
1288
- * `plan_add_record`. Not connected → `{error: "session_not_connected"}`.
1289
- */
1290
- export async function planUpdateRecord(client, args) {
1291
- return client.request({
1292
- method: "PATCH",
1293
- path: `/mine/records/${args.record_id}`,
1294
- basePath: PLANS_BASE_PATH,
1295
- body: {
1296
- body: args.body,
1297
- content_hash: args.content_hash,
1298
- ...(args.context === undefined ? {} : { context: args.context }),
1299
- },
1300
- });
1301
- }
1302
- /**
1303
- * Soft-delete a goal/rule/caveat of the plan this session is connected to,
1304
- * via `DELETE /api/plans/mine/records/:rid`. Its reference (`G-1`, `R-4`,
1305
- * `CAV-12`) is retired PERMANENTLY and never reused by a later record of the
1306
- * same kind. `content_hash` MUST be the record's `contentHash` from the
1307
- * immediately-prior `plan_get`/`plan_get_record`; a stale hash refuses the
1308
- * delete ENTIRELY (nothing is removed) with the same
1309
- * `{ok: false, status: 409, body: {error: "stale_plan_record", currentHash,
1310
- * currentBody}}` shape `plan_update_record` uses. TAKES NO PLAN ID: the plan
1311
- * is resolved from your connected session, same as `plan_add_record`. Not
1312
- * connected → `{error: "session_not_connected"}`. Unknown/foreign/already-
1313
- * deleted record id → 404.
1314
- */
1315
- export async function planDeleteRecord(client, args) {
1316
- return client.request({
1317
- method: "DELETE",
1318
- path: `/mine/records/${args.record_id}`,
1319
- basePath: PLANS_BASE_PATH,
1320
- body: { content_hash: args.content_hash },
1321
- });
1322
- }
1323
- /**
1324
- * Write a milestone note to a plan, via `POST /api/plans/:plan_id/notes`.
1325
- * TAKES AN EXPLICIT `plan_id`, unlike `plan_add_record`/`plan_add_architecture_section`
1326
- * — mirrors `plan_add_card`'s sibling `plan_remove_card`/`plan_rename`: a
1327
- * dispatched worker has no plan connection when it finishes a card, and a
1328
- * card can sit on several plans, so the writer names which one the note
1329
- * belongs to. A note is a MILESTONE, not a log — write one when a card (or a
1330
- * related group of cards) finishes, an important decision lands, or a
1331
- * goal/rule/caveat/architecture section changes meaningfully; routine step
1332
- * progress stays a card comment. Links resolve on read into what they point
1333
- * at (a card's title, a record's ref + body, a section's title) — an unknown
1334
- * card id, an unparseable or foreign record ref, or an unknown or foreign
1335
- * section id is refused 400 naming exactly which one. A card link does NOT
1336
- * require the card to be a member of this plan; a record/section link MUST
1337
- * belong to THIS plan. `author` is stamped server-side from your identity,
1338
- * never sent by you. Unknown plan → 404. Returns the new note plus the
1339
- * plan's latest notes page.
1340
- */
1341
- export async function planAddNote(client, args) {
1342
- return client.request({
1343
- method: "POST",
1344
- path: `/${args.plan_id}/notes`,
1345
- basePath: PLANS_BASE_PATH,
1346
- body: {
1347
- title: args.title,
1348
- body: args.body,
1349
- ...(args.card_ids === undefined ? {} : { card_ids: args.card_ids }),
1350
- ...(args.record_refs === undefined ? {} : { record_refs: args.record_refs }),
1351
- ...(args.section_ids === undefined ? {} : { section_ids: args.section_ids }),
1352
- },
1353
- });
1354
- }
1355
- /**
1356
- * Edit a plan note, via `PATCH /api/plans/:plan_id/notes/:note_id`.
1357
- * `content_hash` MUST be the note's `contentHash` from your last read; on a
1358
- * mismatch nothing is written and you get `{error: "stale_plan_note",
1359
- * currentHash, currentTitle, currentBody, currentLinks}` — merge into those
1360
- * and retry with `content_hash: currentHash`, never blindly. `title`/`body`
1361
- * are each optional and keep their stored value when omitted. The LINK
1362
- * fields are all-or-nothing as a GROUP: omit all three to keep the stored
1363
- * link set untouched; send ANY one of them to REPLACE THE WHOLE SET (never a
1364
- * per-link add/remove — the set is the unit of change). The hash covers
1365
- * title, body AND the link set, so a stale read of any of the three is
1366
- * refused. TAKES AN EXPLICIT `plan_id`, same reason as `plan_add_note`.
1367
- * Unknown plan or note id → 404. Returns the edited note plus the plan's
1368
- * latest notes page.
1369
- */
1370
- export async function planUpdateNote(client, args) {
1371
- return client.request({
1372
- method: "PATCH",
1373
- path: `/${args.plan_id}/notes/${args.note_id}`,
1374
- basePath: PLANS_BASE_PATH,
1375
- body: {
1376
- content_hash: args.content_hash,
1377
- ...(args.title === undefined ? {} : { title: args.title }),
1378
- ...(args.body === undefined ? {} : { body: args.body }),
1379
- ...(args.card_ids === undefined ? {} : { card_ids: args.card_ids }),
1380
- ...(args.record_refs === undefined ? {} : { record_refs: args.record_refs }),
1381
- ...(args.section_ids === undefined ? {} : { section_ids: args.section_ids }),
1382
- },
1383
- });
1384
- }
1385
- /**
1386
- * Soft-delete a plan note, via `DELETE /api/plans/:plan_id/notes/:note_id`.
1387
- * `content_hash` MUST be the note's `contentHash` from your last read; a
1388
- * stale hash deletes nothing and returns the same `{error:
1389
- * "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}`
1390
- * shape `plan_update_note` uses. TAKES AN EXPLICIT `plan_id`, same reason as
1391
- * `plan_add_note`. Unknown plan, unknown note, or an already-deleted note →
1392
- * 404. Returns the plan's remaining latest notes page.
1393
- */
1394
- export async function planDeleteNote(client, args) {
1395
- return client.request({
1396
- method: "DELETE",
1397
- path: `/${args.plan_id}/notes/${args.note_id}`,
1398
- basePath: PLANS_BASE_PATH,
1399
- body: { content_hash: args.content_hash },
1400
- });
1401
- }
1402
- // ---------------- failure_category_list / _create / _update (DX-2792) ----------------
1403
- /**
1404
- * DX-2792 (Failure evaluation 3/4) — wraps `src/dashboard/failure-categories-routes.ts`,
1405
- * the DX-2791 (Failure evaluation 2/4) category registry's REST API. INSTALL-
1406
- * GLOBAL, not board-scoped: `failure_categories` carries no `board_id`
1407
- * column (every category applies across the whole install), so — unlike
1408
- * every `/api/issues/*`-backed tool above — these three never send a
1409
- * `board` query param and the `board` override field is simply absent from
1410
- * their schemas (mirrors the `plan_*` family's own board-less rationale in
1411
- * `index.ts`'s `boardField` comment, for the same underlying reason: nothing
1412
- * server-side would read it).
1413
- */
1414
- const FAILURE_CATEGORIES_BASE_PATH = "/api/failure-categories";
1415
- /**
1416
- * List every failure category with its live matched-occurrence count and
1417
- * last-seen time, via `GET /api/failure-categories`. Returns
1418
- * `{categories: [{id, name, description, matchers, ignore, ignoreReason,
1419
- * expectedRate, matchedCount, lastSeenMs, ...}]}`. A fresh install returns
1420
- * `{categories: []}`.
1421
- */
1422
- export async function failureCategoryList(client) {
1423
- return client.request({
1424
- method: "GET",
1425
- path: "",
1426
- basePath: FAILURE_CATEGORIES_BASE_PATH,
1427
- });
1428
- }
1429
- /**
1430
- * Create a new failure category via `POST /api/failure-categories`. At
1431
- * least one matcher, each with at least one of `sourceKind`/`tool`/
1432
- * `regexPattern` set, is required (400 otherwise). `ignore: true` REQUIRES a
1433
- * non-empty `ignoreReason` (400 otherwise). A matcher set that would overlap
1434
- * an EXISTING category's matchers is refused 400 naming the conflicting
1435
- * category — expand that category instead of creating a near-duplicate. On
1436
- * success, the dashboard re-matches every existing uncategorized occurrence
1437
- * against the new category before responding, so `matchedCount` in the
1438
- * response already reflects any newly-covered signatures.
1439
- */
1440
- export async function failureCategoryCreate(client, args) {
1441
- return client.request({
1442
- method: "POST",
1443
- path: "",
1444
- basePath: FAILURE_CATEGORIES_BASE_PATH,
1445
- body: {
1446
- name: args.name,
1447
- description: args.description ?? "",
1448
- matchers: args.matchers,
1449
- ignore: args.ignore ?? false,
1450
- ignoreReason: args.ignoreReason ?? null,
1451
- expectedRate: args.expectedRate ?? null,
1452
- },
1453
- });
1454
- }
1455
- /**
1456
- * Patch an existing failure category via `PATCH /api/failure-categories/:id`
1457
- * — the tool for BOTH "expand an existing category's matchers" (send the
1458
- * full replacement `matchers` array) and "mark a category ignored" (send
1459
- * `ignore: true` + a non-empty `ignoreReason`). At least one field is
1460
- * required (400 otherwise). Same overlap refusal as create (excluding this
1461
- * category's own prior matchers). On success, re-matches every
1462
- * uncategorized occurrence against the updated matcher set before
1463
- * responding.
1464
- */
1465
- export async function failureCategoryUpdate(client, args) {
1466
- const { id, ...patch } = args;
1467
- return client.request({
1468
- method: "PATCH",
1469
- path: `/${id}`,
1470
- basePath: FAILURE_CATEGORIES_BASE_PATH,
1471
- body: patch,
1472
- });
1473
- }
1474
- // ---------------- dispatch_transcript_search (DX-3221) ----------------
1475
- /**
1476
- * DX-3221 — search or tail ANOTHER dispatch's stored JSONL transcript
1477
- * without ever touching the filesystem. Wraps the existing durable
1478
- * `GET /api/dispatches/:id/logs` route (DX-1682, reading the `dispatch_logs`
1479
- * sink every worker already streams its raw JSONL lines into per DX-1484 —
1480
- * no dashboard-side change needed, that data is already captured today for
1481
- * every worker dispatch).
1482
- *
1483
- * Why this exists instead of a filesystem path: the transcript lives in
1484
- * ANOTHER dispatch's storage, not this one's container, so there is no local
1485
- * path to point Read/Grep at — an ordinary HTTP call authenticated the same
1486
- * way every other `mcp__danx-dashboard__*` tool already is, is how a
1487
- * dispatch reaches it at all.
1488
- *
1489
- * Why this ALSO fixes the underlying complaint `Read` can't: `Read`
1490
- * paginates by LINE, and a persisted JSONL entry can itself be one giant
1491
- * line (a large MCP tool-result payload, escaped-newline JSON) that exceeds
1492
- * the 25000-token cap with no way to sub-page inside it (`offset`/`limit`
1493
- * are line-granular). This handler does the string/regex search ITSELF,
1494
- * server-side of the model (inside this Node process), and only ever hands
1495
- * back a bounded excerpt around a match — never the whole line — so a
1496
- * single oversized line is no longer un-searchable.
1497
- */
1498
- const DISPATCHES_BASE_PATH = "/api/dispatches";
1499
- /** Tail lines returned when no `pattern` is given. */
1500
- const DEFAULT_TAIL_LINES = 20;
1501
- /** Hard ceiling on `tail` — keeps a mistaken huge request bounded. */
1502
- const MAX_TAIL_LINES = 200;
1503
- /** Characters of context returned around (or up to, for tail) each line. */
1504
- const DEFAULT_CONTEXT_CHARS = 1000;
1505
- /** Hard ceiling on `contextChars` — this is what keeps a single giant line from blowing the model's context the way `Read` does today. */
1506
- const MAX_CONTEXT_CHARS = 4000;
1507
- /** Matches returned before search stops scanning further lines. */
1508
- const DEFAULT_MAX_MATCHES = 10;
1509
- /** Hard ceiling on `maxMatches`. */
1510
- const MAX_MATCHES_CEILING = 50;
1511
- function clampPositiveInt(value, fallback, ceiling) {
1512
- const n = value ?? fallback;
1513
- if (!Number.isFinite(n) || n <= 0)
1514
- return fallback;
1515
- return Math.min(Math.floor(n), ceiling);
1516
- }
1517
- /**
1518
- * `GET /api/dispatches/:id/logs` (DX-1682) → search or tail, in-process.
1519
- * Proxies the SAME durable sink `handleGetDispatchLogs` already serves —
1520
- * this tool adds no new dashboard route, only client-side windowing.
1521
- */
1522
- export async function dispatchTranscriptSearch(client, args) {
1523
- const hasPattern = args.pattern !== undefined && args.pattern !== "";
1524
- // Validate the regex BEFORE the network call — a malformed `pattern` is a
1525
- // caller input error, not a reason to spend an HTTP round-trip first.
1526
- let re = null;
1527
- if (hasPattern) {
1528
- try {
1529
- re = new RegExp(args.pattern, "gi");
1530
- }
1531
- catch (err) {
1532
- return {
1533
- ok: false,
1534
- status: 400,
1535
- body: {
1536
- error: `dispatch_transcript_search: invalid \`pattern\` regex: ${err instanceof Error ? err.message : String(err)}`,
1537
- },
1538
- };
1539
- }
1540
- }
1541
- const logsResult = await client.request({
1542
- method: "GET",
1543
- path: `/${encodeURIComponent(args.dispatchId)}/logs`,
1544
- basePath: DISPATCHES_BASE_PATH,
1545
- });
1546
- if (!logsResult.ok) {
1547
- return logsResult;
1548
- }
1549
- const body = logsResult.body;
1550
- const lines = body?.lines ?? [];
1551
- const contextChars = clampPositiveInt(args.contextChars, DEFAULT_CONTEXT_CHARS, MAX_CONTEXT_CHARS);
1552
- if (re) {
1553
- const maxMatches = clampPositiveInt(args.maxMatches, DEFAULT_MAX_MATCHES, MAX_MATCHES_CEILING);
1554
- const results = [];
1555
- for (const { ordinal, line } of lines) {
1556
- re.lastIndex = 0;
1557
- const match = re.exec(line);
1558
- if (!match)
1559
- continue;
1560
- const half = Math.floor(contextChars / 2);
1561
- const start = Math.max(0, match.index - half);
1562
- const end = Math.min(line.length, match.index + match[0].length + half);
1563
- const truncated = start > 0 || end < line.length;
1564
- results.push({
1565
- ordinal,
1566
- excerpt: `${start > 0 ? "…" : ""}${line.slice(start, end)}${end < line.length ? "…" : ""}`,
1567
- truncated,
1568
- });
1569
- if (results.length >= maxMatches)
1570
- break;
1571
- }
1572
- return {
1573
- ok: true,
1574
- status: 200,
1575
- body: {
1576
- dispatch_id: args.dispatchId,
1577
- pattern: args.pattern,
1578
- total_lines: lines.length,
1579
- matched_lines: results.length,
1580
- results,
1581
- },
1582
- };
1583
- }
1584
- const tail = clampPositiveInt(args.tail, DEFAULT_TAIL_LINES, MAX_TAIL_LINES);
1585
- const results = lines.slice(-tail).map(({ ordinal, line }) => {
1586
- const truncated = line.length > contextChars;
1587
- return {
1588
- ordinal,
1589
- excerpt: truncated ? `${line.slice(0, contextChars)}…` : line,
1590
- truncated,
1591
- };
1592
- });
1593
- return {
1594
- ok: true,
1595
- status: 200,
1596
- body: {
1597
- dispatch_id: args.dispatchId,
1598
- total_lines: lines.length,
1599
- returned_lines: results.length,
1600
- results,
1601
- },
1602
- };
1603
- }
1604
- // ---------------- resource_fields (DX-3426) ----------------
1605
- const RESOURCES_BASE_PATH = "/api/resources";
1606
- /**
1607
- * `GET /api/resources/:resource/fields` — what a field TREE (`issue_get`'s
1608
- * `fields`, and any later tool built on the same shape) may name for ONE
1609
- * resource: `{resource, description, always, hashes, fields, relations}`.
1610
- * Install-global (no `board` — mirrors `failure_category_list`'s own
1611
- * board-less rationale just above: nothing server-side would read one).
1612
- * Unknown resource → 404 `unknown_resource`.
1613
- */
1614
- export async function resourceFields(client, args) {
1615
- return client.request({
1616
- method: "GET",
1617
- path: `/${encodeURIComponent(args.resource)}/fields`,
1618
- basePath: RESOURCES_BASE_PATH,
1619
- });
1620
- }