@thehammer/danx-dashboard-mcp 0.1.63 → 0.1.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,12 +25,13 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
25
25
  | `issue_get` | `GET /api/issues/:id` or `GET /api/issues/batch` | Pass `id` for one card, or `ids[]` (DX-2727, at most 100) to resolve many across boards in ONE call — global, so `ids` with `board` throws; per-id `not_found` rather than a whole-call 404. Minimal scalars by default; `fields` opts in |
26
26
  | `issue_create` | `POST /api/issues` | Epic REQUIRES non-empty `phase_children[]` (atomic insert). `title` = short domain-naming label; `summary` = 1–3 plain-language sentences, always shown; `description` = the collapsed "Context" body. Root and every phase child take their own `summary` |
27
27
  | `issue_edit` | `PATCH /api/issues/:id/edit` | Prose + structured keys (`title`, `summary` (null clears), `description`, `ac`, `checklists`, `effort_level`, `parent_id`, `priority`, `list_id`); semantic keys refused with 400 + pointer to dedicated handler. `priority` (DX-1532) takes a tier word (`low`/`high`/…) or a number in `[0,6)` — the ONLY way to set the numeric column the Trello label + dashboard badge read; never set priority via description prose |
28
- | `issue_transition` | `POST /api/issues/:id/transition` | Actions: ready, pickup, rollback_pickup, complete, cancel, block, unblock, archive, reopen. A successful `block` also returns `solutions_reminder: {solution_count, instruction}` |
29
- | `issue_solution` | `GET/POST/PATCH/DELETE /api/issues/:id/solutions[/:sid]` | Actions list / add / edit / remove. Edit + remove are hash-guarded (`base_hash`, 409 `stale_solution` with the current row); at most one live `recommended`; a chosen option cannot be edited. No answer action — the operator answers in the dashboard |
28
+ | `issue_transition` | `POST /api/issues/:id/transition` | Actions: ready, pickup, rollback_pickup, complete, cancel, block, unblock, archive, reopen. `block` is a dispatch hold only — it never marks the card as needing a human; use `issue_problem` + `issue_requires_human` for that |
29
+ | `issue_problem` | `GET/POST/PATCH/DELETE /api/issues/:id/problems[/:pid]` | Actions list / add / edit / remove. A problem is one statement the operator must resolve (a question or a plan flaw) with its own solutions and answers; `add` takes `statement` + optional `solutions[]` in one transaction. Edit + remove are hash-guarded (`base_hash`, 409 `stale_problem`); removing the last open problem while `requires_human` is set is refused 409 `last_open_problem` |
30
+ | `issue_solution` | `POST/PATCH/DELETE /api/issues/:id/problems/:pid/solutions[/:sid]` | Actions add / edit / remove, `problem_id` required (list via `issue_problem list`). Edit + remove are hash-guarded (`base_hash`, 409 `stale_solution` with the current row); at most one live `recommended` per problem; a chosen option cannot be edited. No answer action on any tool — the operator answers in the dashboard |
30
31
  | `issue_triage` | `POST /api/issues/:id/triage` | Send `{confidence, reason}` — an integer 0-5 score; the server computes the verdict (approve/cancel/keep/defer) against the board's configured thresholds (DX-2086). `keep`/`defer` now block the card. None of these are a cross-card ordering gate; use `issue_dependency` to sequence cards |
31
32
  | `issue_comment` | `POST/PATCH/DELETE /api/issues/:id/comments[/:cid]` | Author server-stamped, soft-delete preserved |
32
33
  | `issue_dependency` | `POST/DELETE /api/issues/:id/dependencies[/:did]` | `depends_on` cycle-checked; remove hardcodes `reason: "recorded_in_error"`. The only mechanism the dispatch picker enforces to sequence one card after another — status alone is not a substitute |
33
- | `issue_requires_human` | `POST/DELETE /api/issues/:id/requires-human` | Set replaces step rows atomically; clear soft-deletes them. A successful set also returns `solutions_reminder: {solution_count, instruction}` |
34
+ | `issue_requires_human` | `POST/DELETE /api/issues/:id/requires-human` | Set REQUIRES an open problem (409 `no_open_problem` with the server's `fix` otherwise) and replaces step rows atomically; clear soft-deletes them. The gate clears itself when the last open problem is answered. A successful set also returns `problems_reminder: {open_problem_count, instruction}` |
34
35
  | `issue_retro` | `PUT /api/issues/:id/retro` | Requires terminal card; replace semantics |
35
36
 
36
37
  ## `plan_get` — cheap by default, opt-in for the rest (DX-2727)
@@ -48,7 +49,7 @@ npx -y @thehammer/danx-dashboard-mcp@<version> listen --stream <dashboard>/api/p
48
49
  All three flags come from the dashboard's own ticket response; `plan_connect` assembles the command, so never build it by hand.
49
50
 
50
51
  - **Credential.** `--ticket` is a listener ticket minted by `POST /api/plan-sessions/me/stream-ticket` for THIS session only. It authorizes reading that one session's event stream and nothing else, and stops the moment the credential that minted it is revoked — the MCP server's own token never appears in the command. Minting a new one (calling `plan_connect` again) ends the previous listener; a second connection with the same ticket replaces the first.
51
- - **Output.** Exactly one line per event on the connected plan's cards — `[DX-8 "Title" repo:board] newms87 answered: chose "Pause E2E" — note: "only this week"`, `… commented: "…"`, `… set requires_human: "…"`, `… cleared requires_human`, `… blocked the card: "…"`, `… unblocked the card`. Nothing for keep-alives or reconnects. The session's own writes are never echoed back to it. An event it cannot read still produces one `could not read event` line.
52
+ - **Output.** Exactly one line per event on the connected plan's cards — `[DX-8 "Title" repo:board] newms87 answered "Which rollout order?": chose "Pause E2E" — note: "only this week"`, `… answered "…": "<free-form answer>"`, `… commented: "…"`, `… set requires_human: "…"`, `… cleared requires_human`, `… blocked the card: "…"`, `… unblocked the card`. Nothing for keep-alives or reconnects. The session's own writes are never echoed back to it. An event it cannot read still produces one `could not read event` line.
52
53
  - **Reconnect.** A read-idle timeout (three missed keep-alives) turns a silently dead connection into a drop. Capped exponential backoff (1s → 30s) that resets only after a healthy connection, with `Last-Event-ID`, so a dashboard restart replays what was missed and nothing is printed twice. One final `[danx-dashboard listen] …` line and exit when the ticket is refused or revoked (exit 1), when a newer listener takes over (exit 0), or after `--lease-ms` without a healthy connection (exit 1) — the remedy is always `plan_connect` again.
53
54
 
54
55
  ## Build + test
package/dist/handlers.js CHANGED
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import { readFile } from "node:fs/promises";
25
25
  import { basename, extname, isAbsolute } from "node:path";
26
+ import { oneLine } from "./one-line.js";
26
27
  import { resolvePriority } from "./priority.js";
27
28
  /**
28
29
  * `filter`/`fields`/`sort` are JSON/CSV-encoded onto the query string (the
@@ -255,62 +256,92 @@ export async function issueEdit(client, args) {
255
256
  }
256
257
  export async function issueTransition(client, args) {
257
258
  const { id, board, ...body } = args;
258
- const result = await client.request({
259
+ // DX-2735: no reminder on block. A block is a dispatch HOLD only — it never
260
+ // puts the card in front of a human — so there is no question to list
261
+ // options for. A card that needs a human uses issue_problem + issue_requires_human.
262
+ return client.request({
259
263
  method: "POST",
260
264
  path: `/${encodeURIComponent(id)}/transition`,
261
265
  body,
262
266
  board,
263
267
  });
264
- return args.action === "block" ? withSolutionsReminder(client, id, board, result) : result;
268
+ }
269
+ /** How much of a problem statement the reminder quotes — enough to recognise it. */
270
+ const REMINDER_STATEMENT_MAX = 120;
271
+ function isReminderProblem(value) {
272
+ const p = value;
273
+ return (typeof p === "object" &&
274
+ p !== null &&
275
+ typeof p.id === "number" &&
276
+ typeof p.statement === "string" &&
277
+ typeof p.open === "boolean" &&
278
+ Array.isArray(p.solutions));
265
279
  }
266
280
  /**
267
- * Attach the solutions reminder to a SUCCESSFUL gating write.
281
+ * Attach the problems reminder to a SUCCESSFUL requires-human set.
268
282
  *
269
- * ENFORCED BY FEEDBACK, NOT BY REFUSAL. The server never rejects a block or a
270
- * requires-human hold for lacking solutions: some holds legitimately have no
271
- * options to list (a credential only the operator can supply), and a refusal
272
- * there would push the agent into inventing solutions to get past the gate.
273
- * What the operator needs is that an agent which CAN lay out options always
274
- * does — so the reminder rides the success response, where it is read at the
275
- * exact moment the agent is deciding whether it is done.
283
+ * DX-2735 — THE SERVER NOW ENFORCES THE QUESTION, THIS ENFORCES THE OPTIONS.
284
+ * The set route refuses 409 `no_open_problem` unless the card has an open
285
+ * problem, so a card can no longer be put in front of a human without saying
286
+ * what the human must decide. Whether each open problem lists its viable
287
+ * solutions is still feedback, not refusal: a problem with zero solutions is
288
+ * valid (the operator answers free-form), and refusing it would push the agent
289
+ * into inventing options. So the reminder rides the success response and names
290
+ * the STILL-OPEN problems only — an answered sibling needs nothing more.
276
291
  *
277
- * The gating write's own envelope is returned untouched beside it. A refused
278
- * write gets no reminder (there is no stop to remind about). A failure READING
279
- * the solutions is reported inside the reminder rather than thrown: the gating
280
- * write already succeeded, and throwing would tell the agent its block failed
281
- * when it did not — the failure is surfaced, never swallowed.
292
+ * The write's own envelope is returned untouched beside it. A refused write
293
+ * (including `no_open_problem`, whose `fix` text passes through verbatim) gets
294
+ * no reminder and no extra request. A failure READING the problems is reported
295
+ * inside the reminder rather than thrown: the set already succeeded, and
296
+ * throwing would tell the agent it failed when it did not.
282
297
  */
283
- export async function withSolutionsReminder(client, id, board, result) {
298
+ export async function withProblemsReminder(client, id, board, result) {
284
299
  if (!result.ok)
285
300
  return result;
286
- const nextStep = `issue_solution({id: "${id}", action: "add", title, body, pro, con})`;
287
301
  let listed;
288
302
  try {
289
303
  listed = await client.request({
290
304
  method: "GET",
291
- path: `/${encodeURIComponent(id)}/solutions`,
305
+ path: `/${encodeURIComponent(id)}/problems`,
292
306
  board,
293
307
  });
294
308
  }
295
309
  catch (err) {
296
- return { ...result, solutions_reminder: unreadableReminder(id, err instanceof Error ? err.message : String(err)) };
310
+ return { ...result, problems_reminder: unreadableReminder(id, err instanceof Error ? err.message : String(err)) };
297
311
  }
298
312
  // `body` is the server's JSON verbatim — `null` is valid JSON, so read it null-safely;
299
313
  // a throw here would land OUTSIDE the try above and misreport the successful write.
300
- const solutions = listed.ok ? listed.body?.solutions : undefined;
301
- if (!Array.isArray(solutions)) {
302
- return { ...result, solutions_reminder: unreadableReminder(id, `HTTP ${listed.status}`) };
314
+ const problems = listed.ok ? listed.body?.problems : undefined;
315
+ if (!Array.isArray(problems) || !problems.every(isReminderProblem)) {
316
+ // A 2xx whose body is not the problem list is a contract break, not a refusal —
317
+ // say so, rather than leaving a bare "HTTP 200" that reads like success.
318
+ const detail = listed.ok ? `HTTP ${listed.status}, unexpected shape` : `HTTP ${listed.status}`;
319
+ return { ...result, problems_reminder: unreadableReminder(id, detail) };
320
+ }
321
+ const open = problems.filter((p) => p.open);
322
+ return { ...result, problems_reminder: { open_problem_count: open.length, instruction: openProblemsInstruction(id, open) } };
323
+ }
324
+ function openProblemsInstruction(id, open) {
325
+ if (open.length === 0) {
326
+ // Only reachable when every problem was answered between the set and this
327
+ // read — the gate has already released itself.
328
+ return `No problem on ${id} is open any more — each was answered after you set requires_human, which releases the gate. Re-read the card before you stop.`;
303
329
  }
304
- const count = solutions.length;
305
- const instruction = count === 0
306
- ? `NO SOLUTIONS ARE LISTED ON ${id}. Before you stop, add EVERY viable solution with ${nextStep}, and mark the one you recommend with recommended: true. The operator answers a stopped card by picking a listed solution — with none listed, they have to reconstruct the options from the description.`
307
- : `${count} solution${count === 1 ? " is" : "s are"} listed on ${id}. Before you stop, confirm EVERY viable solution is on the card and add any that are missing with ${nextStep}. The operator can only pick from what is listed.`;
308
- return { ...result, solutions_reminder: { solution_count: count, instruction } };
330
+ const listing = open
331
+ .map((p) => {
332
+ const statement = oneLine(p.statement, REMINDER_STATEMENT_MAX);
333
+ const count = p.solutions.length;
334
+ return `problem ${p.id} "${statement}" — ${count === 0 ? "NO SOLUTIONS LISTED" : `${count} solution${count === 1 ? "" : "s"}`}`;
335
+ })
336
+ .join("; ");
337
+ return (`${open.length} open problem${open.length === 1 ? "" : "s"} on ${id}: ${listing}. ` +
338
+ `Before you stop, list EVERY viable solution to each with issue_solution({id: "${id}", problem_id, action: "add", title, body, pro, con}) and mark the one you recommend with recommended: true, ` +
339
+ `and make every other question the operator must answer its own problem with issue_problem. The card needs a human until every open problem is answered.`);
309
340
  }
310
341
  function unreadableReminder(id, detail) {
311
342
  return {
312
- solution_count: null,
313
- instruction: `Could not read the solutions on ${id} (${detail}). Check with issue_solution({id: "${id}", action: "list"}) and make sure EVERY viable solution is listed before you stop.`,
343
+ open_problem_count: null,
344
+ instruction: `Could not read the problems on ${id} (${detail}). Check with issue_problem({id: "${id}", action: "list"}) and make sure EVERY viable solution to each open problem is listed before you stop.`,
314
345
  };
315
346
  }
316
347
  export async function issueTriage(client, args) {
@@ -322,72 +353,104 @@ export async function issueTriage(client, args) {
322
353
  board,
323
354
  });
324
355
  }
356
+ /**
357
+ * DX-2735 — the ONE boundary check every action-dispatched handler uses for its
358
+ * required args. The MCP schema already validates, but a handler can be called
359
+ * without it (tests, the composition E2E, a future caller), so each required arg
360
+ * is checked by TYPE, not merely for presence: `null`, a number where text
361
+ * belongs, or a string where a row id belongs all throw here, before any request
362
+ * is built, as `<tool> <mode> requires <arg> (<what it must be>)`.
363
+ *
364
+ * Returns the checkers rather than checking anything itself. `mode` is the
365
+ * condition the args are required under — `action=<action>` for the
366
+ * action-dispatched tools, `set=true` for issue_requires_human — so every tool
367
+ * refuses in the same wording.
368
+ */
369
+ function argCheckers(tool, mode) {
370
+ const fail = (name, expected) => {
371
+ throw new Error(`${tool} ${mode} requires ${name} (${expected})`);
372
+ };
373
+ return {
374
+ /** Text, a content hash, a name, an enum value. */
375
+ string(value, name) {
376
+ if (typeof value !== "string")
377
+ fail(name, "a string");
378
+ return value;
379
+ },
380
+ /** A database row id. */
381
+ id(value, name) {
382
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0)
383
+ fail(name, "a positive integer id");
384
+ return value;
385
+ },
386
+ /**
387
+ * A list of human-written lines (e.g. requires_human steps). DX-2735: every
388
+ * element must be a non-blank string, matching the server, which refuses a
389
+ * blank step — refused here before any request is built. An empty list is
390
+ * allowed, as the server allows it.
391
+ */
392
+ stringArray(value, name) {
393
+ const valid = Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim() !== "");
394
+ if (!valid)
395
+ fail(name, "an array of non-blank strings");
396
+ return value;
397
+ },
398
+ };
399
+ }
325
400
  export async function issueComment(client, args) {
326
401
  const idEnc = encodeURIComponent(args.id);
327
402
  const board = args.board;
403
+ const need = argCheckers("issue_comment", `action=${args.action}`);
328
404
  if (args.action === "add") {
329
- if (typeof args.text !== "string") {
330
- throw new Error("issue_comment action=add requires text");
331
- }
405
+ const text = need.string(args.text, "text");
332
406
  return client.request({
333
407
  method: "POST",
334
408
  path: `/${idEnc}/comments`,
335
- body: { text: args.text, ...(args.metadata !== undefined ? { metadata: args.metadata } : {}) },
409
+ body: { text, ...(args.metadata !== undefined ? { metadata: args.metadata } : {}) },
336
410
  board,
337
411
  });
338
412
  }
339
413
  if (args.action === "edit") {
340
- if (args.comment_id === undefined) {
341
- throw new Error("issue_comment action=edit requires comment_id");
342
- }
343
- if (typeof args.text !== "string") {
344
- throw new Error("issue_comment action=edit requires text");
345
- }
414
+ const commentId = need.id(args.comment_id, "comment_id");
415
+ const text = need.string(args.text, "text");
346
416
  return client.request({
347
417
  method: "PATCH",
348
- path: `/${idEnc}/comments/${args.comment_id}`,
349
- body: { text: args.text },
418
+ path: `/${idEnc}/comments/${commentId}`,
419
+ body: { text },
350
420
  board,
351
421
  });
352
422
  }
353
423
  // delete
354
- if (args.comment_id === undefined) {
355
- throw new Error("issue_comment action=delete requires comment_id");
356
- }
424
+ const commentId = need.id(args.comment_id, "comment_id");
357
425
  return client.request({
358
426
  method: "DELETE",
359
- path: `/${idEnc}/comments/${args.comment_id}`,
427
+ path: `/${idEnc}/comments/${commentId}`,
360
428
  board,
361
429
  });
362
430
  }
363
431
  export async function issueDependency(client, args) {
364
432
  const idEnc = encodeURIComponent(args.id);
365
433
  const board = args.board;
434
+ const need = argCheckers("issue_dependency", `action=${args.action}`);
366
435
  if (args.action === "add") {
367
- if (args.kind === undefined) {
368
- throw new Error("issue_dependency action=add requires kind");
369
- }
370
- if (args.target_id === undefined) {
371
- throw new Error("issue_dependency action=add requires target_id");
372
- }
436
+ const kind = need.string(args.kind, "kind");
437
+ const targetId = need.string(args.target_id, "target_id");
373
438
  return client.request({
374
439
  method: "POST",
375
440
  path: `/${idEnc}/dependencies`,
376
441
  body: {
377
- kind: args.kind,
378
- target_id: args.target_id,
442
+ kind,
443
+ target_id: targetId,
379
444
  reason: args.reason ?? "",
380
445
  },
381
446
  board,
382
447
  });
383
448
  }
384
449
  // remove
385
- if (args.dependency_id === undefined) {
386
- throw new Error("issue_dependency action=remove requires dependency_id");
387
- }
450
+ const dependencyId = need.id(args.dependency_id, "dependency_id");
388
451
  return client.request({
389
452
  method: "DELETE",
390
- path: `/${idEnc}/dependencies/${args.dependency_id}`,
453
+ path: `/${idEnc}/dependencies/${dependencyId}`,
391
454
  // Server demands literal "recorded_in_error" — any other value is
392
455
  // a 400. The MCP boundary hardcodes it to remove a footgun: an
393
456
  // agent that types the wrong reason gets a refusal here at MCP
@@ -407,12 +470,10 @@ export async function issueDependency(client, args) {
407
470
  export async function issueChecklist(client, args) {
408
471
  const idEnc = encodeURIComponent(args.id);
409
472
  const board = args.board;
473
+ const need = argCheckers("issue_checklist", `action=${args.action}`);
410
474
  switch (args.action) {
411
475
  case "add_list": {
412
- if (typeof args.name !== "string") {
413
- throw new Error("issue_checklist action=add_list requires name");
414
- }
415
- const body = { name: args.name };
476
+ const body = { name: need.string(args.name, "name") };
416
477
  if (args.items !== undefined)
417
478
  body.items = args.items;
418
479
  return client.request({
@@ -423,55 +484,40 @@ export async function issueChecklist(client, args) {
423
484
  });
424
485
  }
425
486
  case "update_list": {
426
- if (args.checklist_id === undefined) {
427
- throw new Error("issue_checklist action=update_list requires checklist_id");
428
- }
429
- if (typeof args.name !== "string") {
430
- throw new Error("issue_checklist action=update_list requires name");
431
- }
487
+ const checklistId = need.id(args.checklist_id, "checklist_id");
488
+ const name = need.string(args.name, "name");
432
489
  return client.request({
433
490
  method: "PATCH",
434
- path: `/${idEnc}/checklists/${args.checklist_id}`,
435
- body: { name: args.name },
491
+ path: `/${idEnc}/checklists/${checklistId}`,
492
+ body: { name },
436
493
  board,
437
494
  });
438
495
  }
439
496
  case "remove_list": {
440
- if (args.checklist_id === undefined) {
441
- throw new Error("issue_checklist action=remove_list requires checklist_id");
442
- }
497
+ const checklistId = need.id(args.checklist_id, "checklist_id");
443
498
  return client.request({
444
499
  method: "DELETE",
445
- path: `/${idEnc}/checklists/${args.checklist_id}`,
500
+ path: `/${idEnc}/checklists/${checklistId}`,
446
501
  board,
447
502
  });
448
503
  }
449
504
  case "add_item": {
450
- if (args.checklist_id === undefined) {
451
- throw new Error("issue_checklist action=add_item requires checklist_id");
452
- }
453
- if (typeof args.label !== "string") {
454
- throw new Error("issue_checklist action=add_item requires label");
455
- }
456
- const body = { label: args.label };
505
+ const checklistId = need.id(args.checklist_id, "checklist_id");
506
+ const body = { label: need.string(args.label, "label") };
457
507
  if (args.detail !== undefined)
458
508
  body.detail = args.detail;
459
509
  if (args.status !== undefined)
460
510
  body.status = args.status;
461
511
  return client.request({
462
512
  method: "POST",
463
- path: `/${idEnc}/checklists/${args.checklist_id}/items`,
513
+ path: `/${idEnc}/checklists/${checklistId}/items`,
464
514
  body,
465
515
  board,
466
516
  });
467
517
  }
468
518
  case "update_item": {
469
- if (args.checklist_id === undefined) {
470
- throw new Error("issue_checklist action=update_item requires checklist_id");
471
- }
472
- if (args.item_id === undefined) {
473
- throw new Error("issue_checklist action=update_item requires item_id");
474
- }
519
+ const checklistId = need.id(args.checklist_id, "checklist_id");
520
+ const itemId = need.id(args.item_id, "item_id");
475
521
  const body = {};
476
522
  if (args.label !== undefined)
477
523
  body.label = args.label;
@@ -481,39 +527,92 @@ export async function issueChecklist(client, args) {
481
527
  body.status = args.status;
482
528
  return client.request({
483
529
  method: "PATCH",
484
- path: `/${idEnc}/checklists/${args.checklist_id}/items/${args.item_id}`,
530
+ path: `/${idEnc}/checklists/${checklistId}/items/${itemId}`,
485
531
  body,
486
532
  board,
487
533
  });
488
534
  }
489
535
  case "remove_item": {
490
- if (args.checklist_id === undefined) {
491
- throw new Error("issue_checklist action=remove_item requires checklist_id");
492
- }
493
- if (args.item_id === undefined) {
494
- throw new Error("issue_checklist action=remove_item requires item_id");
495
- }
536
+ const checklistId = need.id(args.checklist_id, "checklist_id");
537
+ const itemId = need.id(args.item_id, "item_id");
538
+ return client.request({
539
+ method: "DELETE",
540
+ path: `/${idEnc}/checklists/${checklistId}/items/${itemId}`,
541
+ board,
542
+ });
543
+ }
544
+ }
545
+ }
546
+ /**
547
+ * A card's PROBLEMS via `/api/issues/:id/problems[/:pid]` (DX-2735),
548
+ * action-dispatched like `issue_checklist`. A problem is one statement the
549
+ * operator must resolve — a question or a flaw in the plan — owning its own
550
+ * solutions and its own decisions. A missing required arg throws at this
551
+ * boundary (no round-trip); the server's refusals (`stale_problem` with the
552
+ * current row, `last_open_problem`) pass through verbatim.
553
+ *
554
+ * `add` carries `solutions[]` in the SAME request so a problem and its options
555
+ * land in one server transaction — never a problem briefly visible to the
556
+ * operator with none of the options it was created with.
557
+ *
558
+ * No answer action, for the reason `issueSolution` gives.
559
+ */
560
+ export async function issueProblem(client, args) {
561
+ const idEnc = encodeURIComponent(args.id);
562
+ const board = args.board;
563
+ const need = argCheckers("issue_problem", `action=${args.action}`);
564
+ switch (args.action) {
565
+ case "list":
566
+ return client.request({ method: "GET", path: `/${idEnc}/problems`, board });
567
+ case "add": {
568
+ const body = { statement: need.string(args.statement, "statement") };
569
+ if (args.solutions !== undefined)
570
+ body.solutions = args.solutions;
571
+ return client.request({ method: "POST", path: `/${idEnc}/problems`, body, board });
572
+ }
573
+ case "edit": {
574
+ const problemId = need.id(args.problem_id, "problem_id");
575
+ const baseHash = need.string(args.base_hash, "base_hash");
576
+ const statement = need.string(args.statement, "statement");
577
+ return client.request({
578
+ method: "PATCH",
579
+ path: `/${idEnc}/problems/${problemId}`,
580
+ body: { base_hash: baseHash, statement },
581
+ board,
582
+ });
583
+ }
584
+ case "remove": {
585
+ const problemId = need.id(args.problem_id, "problem_id");
586
+ const baseHash = need.string(args.base_hash, "base_hash");
496
587
  return client.request({
497
588
  method: "DELETE",
498
- path: `/${idEnc}/checklists/${args.checklist_id}/items/${args.item_id}`,
589
+ path: `/${idEnc}/problems/${problemId}`,
590
+ body: { base_hash: baseHash },
499
591
  board,
500
592
  });
501
593
  }
502
594
  }
503
595
  }
504
596
  /**
505
- * A card's candidate solutions via `/api/issues/:id/solutions[/:sid]`,
506
- * action-dispatched like `issue_checklist`. A missing required arg for the
507
- * chosen action throws at this boundary (no round-trip); the server's refusal
508
- * envelopes (`stale_solution` with the current row, a second recommendation,
509
- * editing a chosen option) pass through verbatim.
597
+ * One problem's candidate solutions via
598
+ * `/api/issues/:id/problems/:pid/solutions[/:sid]` (DX-2735), action-dispatched
599
+ * like `issue_checklist`. A missing required arg for the chosen action throws at
600
+ * this boundary (no round-trip); the server's refusal envelopes
601
+ * (`stale_solution` with the current row, a second recommendation on the same
602
+ * problem, editing a chosen option, a solution id from another problem → 404)
603
+ * pass through verbatim.
510
604
  *
511
- * There is deliberately NO answer action. Answering a card releases the human
512
- * gates on it — an agent that could answer its own question could release the
513
- * very stop it set to wait for a human. The operator answers in the dashboard.
605
+ * There is deliberately NO answer action, on this tool or any other. Answering
606
+ * releases the human gate on a card — an agent that could answer its own
607
+ * question could release the very stop it set to wait for a human. The operator
608
+ * answers in the dashboard.
514
609
  */
515
610
  export async function issueSolution(client, args) {
516
- const base = `/${encodeURIComponent(args.id)}/solutions`;
611
+ const need = argCheckers("issue_solution", `action=${args.action}`);
612
+ // DX-2735: checked at runtime too, not only by the schema — a caller that skips
613
+ // the MCP boundary must never build `/problems/undefined/solutions`.
614
+ const problemId = need.id(args.problem_id, "problem_id");
615
+ const base = `/${encodeURIComponent(args.id)}/problems/${problemId}/solutions`;
517
616
  const board = args.board;
518
617
  const content = {};
519
618
  for (const key of ["title", "body", "pro", "con", "recommended"]) {
@@ -521,58 +620,49 @@ export async function issueSolution(client, args) {
521
620
  content[key] = args[key];
522
621
  }
523
622
  switch (args.action) {
524
- case "list":
525
- return client.request({ method: "GET", path: base, board });
526
- case "add":
527
- if (typeof args.title !== "string") {
528
- throw new Error("issue_solution action=add requires title");
529
- }
530
- return client.request({ method: "POST", path: base, body: content, board });
531
- case "edit":
532
- if (args.solution_id === undefined) {
533
- throw new Error("issue_solution action=edit requires solution_id");
534
- }
535
- if (typeof args.base_hash !== "string") {
536
- throw new Error("issue_solution action=edit requires base_hash");
537
- }
623
+ case "add": {
624
+ // DX-2735: the POST carries the CHECKED title, not a re-read of args.title.
625
+ const title = need.string(args.title, "title");
626
+ return client.request({ method: "POST", path: base, body: { ...content, title }, board });
627
+ }
628
+ case "edit": {
629
+ const solutionId = need.id(args.solution_id, "solution_id");
630
+ const baseHash = need.string(args.base_hash, "base_hash");
538
631
  return client.request({
539
632
  method: "PATCH",
540
- path: `${base}/${args.solution_id}`,
541
- body: { base_hash: args.base_hash, ...content },
633
+ path: `${base}/${solutionId}`,
634
+ body: { base_hash: baseHash, ...content },
542
635
  board,
543
636
  });
544
- case "remove":
545
- if (args.solution_id === undefined) {
546
- throw new Error("issue_solution action=remove requires solution_id");
547
- }
548
- if (typeof args.base_hash !== "string") {
549
- throw new Error("issue_solution action=remove requires base_hash");
550
- }
637
+ }
638
+ case "remove": {
639
+ const solutionId = need.id(args.solution_id, "solution_id");
640
+ const baseHash = need.string(args.base_hash, "base_hash");
551
641
  return client.request({
552
642
  method: "DELETE",
553
- path: `${base}/${args.solution_id}`,
554
- body: { base_hash: args.base_hash },
643
+ path: `${base}/${solutionId}`,
644
+ body: { base_hash: baseHash },
555
645
  board,
556
646
  });
647
+ }
557
648
  }
558
649
  }
559
650
  export async function issueRequiresHuman(client, args) {
560
651
  const idEnc = encodeURIComponent(args.id);
561
652
  const board = args.board;
562
653
  if (args.set) {
563
- if (typeof args.reason !== "string") {
564
- throw new Error("issue_requires_human set=true requires reason");
565
- }
566
- if (!Array.isArray(args.steps)) {
567
- throw new Error("issue_requires_human set=true requires steps[]");
568
- }
654
+ // DX-2735: the same checkers every action-dispatched tool uses, so the refusal
655
+ // wording and type checks match — `issue_requires_human set=true requires ...`.
656
+ const need = argCheckers("issue_requires_human", "set=true");
657
+ const reason = need.string(args.reason, "reason");
658
+ const steps = need.stringArray(args.steps, "steps");
569
659
  const result = await client.request({
570
660
  method: "POST",
571
661
  path: `/${idEnc}/requires-human`,
572
- body: { reason: args.reason, steps: args.steps },
662
+ body: { reason, steps },
573
663
  board,
574
664
  });
575
- return withSolutionsReminder(client, args.id, board, result);
665
+ return withProblemsReminder(client, args.id, board, result);
576
666
  }
577
667
  return client.request({
578
668
  method: "DELETE",
package/dist/index.js CHANGED
@@ -19,7 +19,8 @@
19
19
  * - issue_triage POST /api/issues/:id/triage
20
20
  * - issue_comment POST/PATCH/DELETE /api/issues/:id/comments[/:cid]
21
21
  * - issue_checklist POST/PATCH/DELETE /api/issues/:id/checklists[/:cid[/items[/:iid]]]
22
- * - issue_solution GET/POST/PATCH/DELETE /api/issues/:id/solutions[/:sid]
22
+ * - issue_problem GET/POST/PATCH/DELETE /api/issues/:id/problems[/:pid] (DX-2735)
23
+ * - issue_solution POST/PATCH/DELETE /api/issues/:id/problems/:pid/solutions[/:sid] (DX-2735)
23
24
  * - issue_dependency POST/DELETE /api/issues/:id/dependencies[/:did]
24
25
  * - issue_requires_human POST/DELETE /api/issues/:id/requires-human
25
26
  * - issue_quality_gate POST /api/issues/:id/quality-gates/:gate
@@ -88,7 +89,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
88
89
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
89
90
  import { z } from "zod";
90
91
  import { DashboardHttpClient } from "./http-client.js";
91
- import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueQualityGate, issueQualityGateVerdict, issueRequiresHuman, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, planAddArchitectureSection, planAddCard, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
92
+ import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRequiresHuman, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, planAddArchitectureSection, planAddCard, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
92
93
  import { PRIORITY_TIER_WORDS } from "./priority.js";
93
94
  function readEnvOrDie(name) {
94
95
  const v = process.env[name];
@@ -214,7 +215,8 @@ const NON_EPIC_TYPES = ["Bug", "Feature", "Story", "Chore", "Task"];
214
215
  // server 400, not silently.
215
216
  const LIST_FIELD_GROUPS = [
216
217
  "description",
217
- "solutions",
218
+ // DX-2735: replaced the flat "solutions" group (hard cut, no alias).
219
+ "problems",
218
220
  "ac",
219
221
  "comments",
220
222
  "retro",
@@ -273,16 +275,19 @@ const boardField = {
273
275
  .string()
274
276
  .min(1)
275
277
  .optional()
276
- .describe("Target another board by its qualified id `<repo>:<slug>` (e.g. `platform:the-supply-operations-hub`); omit to use this dispatch's board. Unknown board → 404."),
278
+ .describe("Target another board by its qualified id `<repo>:<slug>`; omit for this dispatch's board. Unknown → 404."),
277
279
  };
278
280
  // The three prose fields of a card, each with ONE job. Shared by issue_create
279
281
  // (root + phase children) and issue_edit so the guidance an agent reads is
280
282
  // identical wherever it writes the field.
281
- const TITLE_DESCRIBE = 'Short, specific label that names the domain, so a reader recognises the card without opening it (e.g. "Guest checkout rejects carts holding a gift card"). Never a generic phrase like "2 real decisions needed", "Fix bug" or "Follow-up".';
282
- const SUMMARY_DESCRIBE = "1–3 plain-language sentences — no markdown, no jargon — for someone who has never seen this codebase: what the card is about and why it matters. Always shown, never collapsed. It must stand on its own: not a second title, not a teaser for the description.";
283
- const DESCRIPTION_DESCRIBE = 'The full body ("Context"): evidence, examples, technical detail. Long and markdown is normal; UIs collapse it by default. Candidate answers to the question a card is stopped on do NOT go here — list each one with issue_solution.';
283
+ const TITLE_DESCRIBE = 'Short, specific label naming the domain, so a reader recognises the card unopened (e.g. "Guest checkout rejects carts holding a gift card"). Never generic ("2 real decisions needed", "Fix bug", "Follow-up").';
284
+ const SUMMARY_DESCRIBE = "1–3 plain-language sentences, no markdown/jargon, for someone new to this codebase: what the card is and why it matters. Always shown, never collapsed — not a second title, not a teaser for the description.";
285
+ const DESCRIPTION_DESCRIBE = 'The full body ("Context"): evidence, examples, technical detail; markdown, collapsed by default. A question for the operator and its options go in issue_problem, not here.';
284
286
  // ---------------- issue_list ----------------
285
- server.tool("issue_list", "List issues for the dispatch's board by default via GET /api/issues. Board-scoped; defaults to the dispatch's board. Pass `board` (a qualified id `<repo>:<slug>`) to list another board instead (unknown board → 404). Nested envelope (DX-935 / DX-937 — hard-cut, no flat params): `filter` — the OLD flat filters, now nested (type, parent_id, dispatchable_derived, status_derived[], self_dispatchable_derived, assigned_agent, include_closed, include_deleted, and `q` — free-text over id+title+description, the former standalone `q` param now lives at `filter.q`). `fields` — opt-in named field-GROUPS (description, ac, comments, retro, dependencies, triage, requires_human, assignment, quality_gates, children, effort); THE DEFAULT RESPONSE (no `fields`) IS MINIMAL — only cheap scalar columns (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash), zero joins. Point any heavy read (full description, comments[], retro, ac items, dependency edges, triage history, quality-gate rows, children ids) at the matching `fields` entry rather than assuming it's already on the row. `sort` — ordered [{column, order}] (id|priority|repo_name|title|type|status_derived|triage_ice_total|created_at|updated_at); absent → default order (priority desc, repo_name asc) with an always-appended numeric-id tiebreaker (DX-10 follows DX-9). `limit`/`offset` — optional paging (no cap by default). Use issue_get for a single fully-detailed card.", {
287
+ server.tool("issue_list",
288
+ // DX-2735: trimmed to pay for the problem tools inside the work-profile
289
+ // injected-surface budget — same facts, no repeated prose.
290
+ "List cards via GET /api/issues. Board-scoped; see `board`. `filter`: type, parent_id, dispatchable_derived, status_derived[], self_dispatchable_derived, assigned_agent, include_closed, include_deleted, q (free text over id+title+description). THE DEFAULT ROW IS MINIMAL — scalars only (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash), no joins; opt into heavy data with `fields` groups: description (+ summary), problems (open_problem_count), ac, comments, retro, dependencies, triage, requires_human, assignment, quality_gates, children, effort. `sort`: [{column, order}] over id|priority|repo_name|title|type|status_derived|triage_ice_total|created_at|updated_at; default priority desc, repo_name asc, numeric-id tiebreaker. `limit`/`offset` page (uncapped by default). issue_get reads one card in full.", {
286
291
  filter: z
287
292
  .object({
288
293
  q: z.string().optional(),
@@ -295,34 +300,31 @@ server.tool("issue_list", "List issues for the dispatch's board by default via G
295
300
  include_closed: z.boolean().optional(),
296
301
  include_deleted: z.boolean().optional(),
297
302
  })
298
- .optional()
299
- .describe("Nested list filters (DX-935) — the hard-cut replacement for the old flat top-level params, including the former standalone `q` (now `filter.q`). Omit entirely for no filtering."),
303
+ .optional(),
300
304
  fields: z
301
305
  .array(z.enum(LIST_FIELD_GROUPS))
302
306
  .optional()
303
- .describe("Opt-in field-GROUPS to add to the minimal default row: description (description + summary), solutions (solutions_count), ac, comments, retro, dependencies, triage, requires_human, assignment, quality_gates, children, effort. Absent/empty = minimal scalars only — no joins."),
307
+ .describe("Field groups to add; absent = minimal scalars."),
304
308
  sort: sortField,
305
309
  limit: z.number().int().positive().max(LIST_PAGE_MAX_LIMIT).optional(),
306
310
  offset: z.number().int().nonnegative().optional(),
307
311
  ...boardField,
308
312
  }, async (args) => jsonResult(await issueList(client, args)));
309
313
  // ---------------- issue_get ----------------
310
- server.tool("issue_get", "Fetch one card via GET /api/issues/:id, or MANY via GET /api/issues/batch?ids=... (DX-2727) — pass exactly one of `id` (single) or `ids` (batch, an array). Board-scoped for the single form; defaults to the dispatch's board. Issue ids are globally unique, so BOTH forms resolve from any dispatch regardless of `board`; the batch form is a global, cross-board read and REFUSES `board` (passing `ids` with `board` throws). The batch form takes at most " + ISSUE_BATCH_GET_MAX + " ids per call — split a larger set. DEFAULT RESPONSE IS MINIMAL (DX-935 / DX-937) — only cheap scalar columns (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash — DX-2741, the card's optimistic-concurrency token; pass it back as `content_hash` on an `issue_edit` touching title/description/checklists); no joined collections. Pass `fields` to opt into named field-GROUPS, applied per card in the batch form too: description (full description body + the plain-language summary), solutions (the card's live candidate solutions, each with its content_hash, + every operator answer in decisions[]), ac (acceptance-criteria + checklists model), comments (comments[]), retro (retro good/bad/action_items/commits), dependencies (waiting_on/conflict_on/blocked gate state), triage (triage history + ICE), requires_human (the requires_human gate + steps), assignment (dispatch/assigned_agent/lifecycle timestamps), quality_gates (DX-1177 — one row per registered gate {gate, required, status pending|pass|fail, completed_at, message}; a required PRE gate not yet `pass` pre-empts the work dispatch, and `issue_transition complete` refuses while a required POST gate row != pass), children (child id list + rollups), mirrors (external mirror sync state), code_review_items (code-review findings). Point any heavy read at the matching `fields` entry rather than assuming it's already on the row. Single form: 404 envelope on unknown id. Batch form: returns `{issues: [...found cards...], not_found: [...ids that didn't resolve...]}` — an unknown or soft-deleted id never fails the whole call, it just lands in `not_found` alongside every card that DID resolve.", {
311
- id: z.string().min(1).optional().describe("A single card id. Mutually exclusive with `ids`."),
312
- ids: z
313
- .array(z.string().min(1))
314
- .min(1)
315
- .max(ISSUE_BATCH_GET_MAX)
316
- .optional()
317
- .describe(`Batch form (DX-2727): every requested id (at most ${ISSUE_BATCH_GET_MAX}), resolved globally in ONE call. Mutually exclusive with \`id\`, and never combined with \`board\` (the batch is global; passing both throws). Reports not_found per id rather than failing the whole call.`),
314
+ server.tool("issue_get",
315
+ // DX-2735: trimmed to pay for the problem tools inside the work-profile
316
+ // injected-surface budget — same facts, no repeated prose.
317
+ "Fetch one card (GET /api/issues/:id, `id`) or many (GET /api/issues/batch, `ids`, at most " + ISSUE_BATCH_GET_MAX + " — split larger sets); pass exactly one. Ids are globally unique, so both resolve from any board; the batch form is global and throws with `board`. THE DEFAULT ROW IS MINIMAL — scalars only (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash: the concurrency token issue_edit needs for title/description/checklists). `fields` opts into groups, per card in a batch too: description (body + summary), problems (live problems in order, each {id, statement, content_hash, open} with its solutions[] and decisions[]), ac (acceptance criteria + checklists), comments, retro, dependencies (waiting_on/conflict_on/blocked), triage (history + ICE), requires_human (gate + steps), assignment (dispatch, assigned_agent, lifecycle timestamps), quality_gates (one row per gate {gate, required, status pending|pass|fail, completed_at, message}; a required PRE gate not `pass` pre-empts the work dispatch, and complete refuses while a required POST gate is not `pass`), children (ids + rollups), mirrors (external sync state), code_review_items. Single form: unknown id → 404. Batch form: `{issues: [...], not_found: [...ids]}` — an unknown or deleted id never fails the call.", {
318
+ id: z.string().min(1).optional(),
319
+ ids: z.array(z.string().min(1)).min(1).max(ISSUE_BATCH_GET_MAX).optional(),
318
320
  fields: z
319
321
  .array(z.enum(GET_FIELD_GROUPS))
320
322
  .optional()
321
- .describe("Opt-in field-GROUPS to add to the minimal default row: description, solutions, ac, comments, retro, dependencies, triage, requires_human, assignment, quality_gates, children, mirrors, code_review_items. Absent/empty = minimal scalars only."),
323
+ .describe("Field groups to add; absent = minimal scalars."),
322
324
  ...boardField,
323
325
  }, async (args) => jsonResult(await issueGet(client, args)));
324
326
  // ---------------- issue_create ----------------
325
- server.tool("issue_create", 'Create a card via POST /api/issues on this dispatch\'s board, or another via `board` (`<repo>:<slug>`; unknown → 404). type=Epic REQUIRES non-empty phase_children[] (epic and phases inserted in one transaction; children get the epic as parent); other types refuse phase_children[] (400). Status starts at Review. `list_id` places the card straight into a column — a board_lists id or the list\'s display NAME (case-insensitive, emoji-tolerant): a `ready`-type queue lands it in ToDo, a `completed` list in Done, with no follow-up transition. Not valid on Epic; unknown name/id → 400. `gate_decisions` is REQUIRED when the board has an OPTIONAL quality gate for the card\'s type: a missing one fails closed with 400 `{error, required_gate_decisions:[...]}` naming each gate — retry with one `{gate, enabled, note}` per listed gate. ALWAYS pass `triage_enabled` explicitly on the root card and every phase child: true only when it should enter automatic triage/dispatch without human review; absent → false.', {
327
+ server.tool("issue_create", 'Create a card via POST /api/issues. Board-scoped; see `board`. type=Epic REQUIRES non-empty phase_children[] (epic and phases inserted in one transaction; children get the epic as parent); other types refuse phase_children[] (400). Status starts at Review. `list_id` places the card straight into a column — a board_lists id or the list\'s display NAME (case-insensitive, emoji-tolerant): a `ready`-type queue lands it in ToDo, a `completed` list in Done, with no follow-up transition. Not valid on Epic; unknown name/id → 400. `gate_decisions` is REQUIRED when the board has an OPTIONAL quality gate for the card\'s type: a missing one fails closed with 400 `{error, required_gate_decisions:[...]}` naming each gate — retry with one `{gate, enabled, note}` per listed gate. ALWAYS pass `triage_enabled` explicitly on the root card and every phase child: true only when it should enter automatic triage/dispatch without human review; absent → false.', {
326
328
  type: z.enum(ISSUE_TYPES),
327
329
  title: z.string().min(1).describe(TITLE_DESCRIBE),
328
330
  summary: z.string().min(1).optional().describe(SUMMARY_DESCRIBE),
@@ -374,7 +376,7 @@ server.tool("issue_create", 'Create a card via POST /api/issues on this dispatch
374
376
  ...boardField,
375
377
  }, async (args) => jsonResult(await issueCreate(client, args, config.board)));
376
378
  // ---------------- issue_edit ----------------
377
- server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED keys: title, summary, description, ac, checklists, effort_level, parent_id, priority, list_id, triage_enabled, type, content_hash. Any other key (lifecycle, triage, dependencies, retro, requires_human, blocked) is refused 400 with offending_keys[] naming the right tool: issue_transition / issue_triage / issue_comment / issue_dependency / issue_requires_human / issue_retro. `type`: Story/Bug/Chore makes a card eligible for autonomous pickup; Task or a container (Epic/Feature) removes that eligibility — how a planning item becomes work. `priority` (a tier word or a number) is the ONLY way to set priority; a "Priority:" line in the description changes nothing. CHECKLISTS: each item has one status `incomplete|failing|passing|cancelled|deferred`; `deferred` (work done, a real-world/post-deploy check outstanding) REQUIRES `detail`, and a `📡`-prefixed item can never be `passing`. `ac` edits the default "Acceptance Criteria" checklist (items matched by check_item_id, else exact title); `checklists` REPLACES every named checklist with full status control (`{name, items:[{label, detail?, status}]}`). Send `ac` OR `checklists`, not both (400). `list_id` pins the card to a list by id or display NAME; its type must match the card\'s current derived status (400 otherwise — ready a card before pinning it to a `ready` queue); null clears the pin. `content_hash` (DX-2741) is the card\'s optimistic-concurrency token — REQUIRED (never optional/defaulted) whenever the edit touches `title` / `description` / `checklists` (NOT `ac`, which keeps its own check_item_id/title diffing): a missing hash on one of those fields 400s, a stale one 409s `stale_issue_content` carrying `currentHash` + `currentTitle` + `currentDescription`. Read it off `issue_get`/`issue_list`\'s `content_hash` scalar field (present even in the minimal response) immediately before editing; on a 409, re-`issue_get` and retry with the fresh hash — never blindly.', {
379
+ server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED keys: title, summary, description, ac, checklists, effort_level, parent_id, priority, list_id, triage_enabled, type, content_hash. Any other key (lifecycle, triage, dependencies, retro, requires_human, blocked) is refused 400 with offending_keys[] naming the right tool: issue_transition / issue_triage / issue_comment / issue_dependency / issue_requires_human / issue_retro. `type`: Story/Bug/Chore = eligible for autonomous pickup; Task or a container (Epic/Feature) removes that eligibility — how a planning item becomes work. `priority` (a tier word or a number) is the ONLY way to set priority; a "Priority:" line in the description does nothing. CHECKLISTS: each item has one status `incomplete|failing|passing|cancelled|deferred`; `deferred` (work done, a real-world/post-deploy check outstanding) REQUIRES `detail`, and a `📡`-prefixed item can never be `passing`. `ac` edits the default "Acceptance Criteria" checklist (items matched by check_item_id, else exact title); `checklists` REPLACES every named checklist with full status control (`{name, items:[{label, detail?, status}]}`). Send `ac` OR `checklists`, not both (400). `list_id` pins the card to a list by id or display NAME; its type must match the card\'s current derived status (400 otherwise — e.g. ready the card first before pinning it to a `ready`-type list); null clears the pin. `content_hash` (DX-2741) is the card\'s optimistic-concurrency token — REQUIRED whenever the edit touches `title` / `description` / `checklists` (NOT `ac`, which keeps its own check_item_id/title diffing): missing → 400, stale → 409 `stale_issue_content` carrying `currentHash` + `currentTitle` + `currentDescription`. Read it off `issue_get`/`issue_list`\'s `content_hash` scalar (present even minimal); on 409, re-`issue_get` and retry with the fresh hash — never blindly.', {
378
380
  id: z.string().min(1),
379
381
  title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
380
382
  summary: z
@@ -436,7 +438,9 @@ server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED
436
438
  ...boardField,
437
439
  }, async (args) => jsonResult(await issueEdit(client, args)));
438
440
  // ---------------- issue_transition ----------------
439
- server.tool("issue_transition", "Move a card's lifecycle via POST /api/issues/:id/transition — the ONLY way to; `danxbot_complete` never moves a card, so call this BEFORE it. Actions: ready (Review→ToDo); pickup (ToDo→In Progress; checks every dispatch gate — ready, blocked, requires_human, depends_on terminal, conflict_on idle — and refuses 409 with failed_gate naming the cause; `manual:true` is an operator-session self-pickup that bypasses card-flow gates and is never auto-rolled-back: use it when the work happens in YOUR session); rollback_pickup; complete (your explicit decision; refuses 409 on an Epic with non-terminal children, non_terminal_phases[], or while a required POST quality gate is not pass, failed_gate 'quality_gate_post' + failed_post_gates[]); cancel (terminal); block (non-empty reason; the CARD needs a human — env faults use `danxbot_complete({status:'failed'})` instead); unblock; archive (to Backlog, clears ready_at); reopen (terminal→active). Terminal cards refuse everything but reopen; forward stamps never clear earlier ones. Every path into In Progress needs an identified claimer: a dispatched agent's manual pickup MUST pass `assigned_agent` = your agent/profile name (409 otherwise). A manual pickup that loses a race is refused 409 `failed_gate: \"dispatch_id\"` — re-read the card's assigned_agent/dispatch_id, never retry blindly. A successful block returns `solutions_reminder: {solution_count, instruction}`: before stopping, list EVERY viable solution with issue_solution; zero means you listed none.", {
441
+ server.tool("issue_transition",
442
+ // DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
443
+ "Move a card's lifecycle via POST /api/issues/:id/transition — the ONLY way; `danxbot_complete` never moves a card, so call this first. Actions: ready (Review→ToDo); pickup (ToDo→In Progress; checks every dispatch gate — ready, blocked, requires_human, depends_on terminal, conflict_on idle — and refuses 409 with failed_gate naming the cause; `manual:true` is a self-pickup for work in YOUR session: it bypasses card-flow gates and is never auto-rolled-back); rollback_pickup; complete (your explicit decision; 409 on an Epic with non-terminal children (non_terminal_phases[]) or while a required POST quality gate is not pass (failed_gate 'quality_gate_post' + failed_post_gates[])); cancel (terminal); block (non-empty reason; only holds dispatch, never asks a human — for that use issue_problem then issue_requires_human; env faults use `danxbot_complete({status:'failed'})`); unblock; archive (to Backlog, clears ready_at); reopen (terminal→active). Terminal cards refuse all but reopen; forward stamps never clear earlier ones. A dispatched agent's manual pickup MUST pass `assigned_agent` = your agent/profile name (409 otherwise); one that loses a race is refused 409 `failed_gate: \"dispatch_id\"` — re-read assigned_agent/dispatch_id, never retry blindly.", {
440
444
  id: z.string().min(1),
441
445
  action: z.enum(TRANSITION_ACTIONS),
442
446
  reason: z.string().optional(),
@@ -472,7 +476,7 @@ const CHECKLIST_ITEM_INPUT = z.object({
472
476
  detail: z.string().optional(),
473
477
  status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
474
478
  });
475
- server.tool("issue_checklist", "Targeted checklist CUD via /api/issues/:id/checklists[/:cid[/items[/:iid]]] (DX-1362). Mutates ONE checklist or item WITHOUT the wholesale `issue_edit({checklists})` replace — use this for the common case (flip an item's status, add/rename a checklist, add/edit/remove an item); the wholesale path silently DROPS any checklist you omit and churns every item id (orphaning its Trello mirror), so prefer this for single-item changes. Action-dispatched: add_list (POST :id/checklists {name, items?}) — create a named checklist, optionally with initial items; update_list (PATCH :id/checklists/:cid {name}) — rename; remove_list (DELETE :id/checklists/:cid) — soft-delete the checklist (audit trail preserved); add_item (POST :id/checklists/:cid/items {label, detail?, status?}) — append an item (status defaults `incomplete`); update_item (PATCH :id/checklists/:cid/items/:iid {label?, detail?, status?}) — change ONLY the fields you pass, in place (the item keeps its id + Trello linkage; at least one field required); remove_item (DELETE :id/checklists/:cid/items/:iid) — soft-delete one item. Status: incomplete|failing|passing|cancelled|deferred (terminal = passing|cancelled|deferred; DX-2653 added `deferred` — the honest disposition for a criterion whose work is done but names a real-world/post-deploy check still outstanding, REQUIRES a non-empty `detail`). checklist_id is required for every action except add_list; item_id for update_item/remove_item. Each returns the {ok,status,body} envelope; unknown card/checklist/item → 404, invalid status → 400. ADDITIVE — the wholesale `issue_edit({checklists})` path stays for bulk authoring.", {
479
+ server.tool("issue_checklist", "Targeted checklist CUD via /api/issues/:id/checklists[/:cid[/items[/:iid]]] (DX-1362). Mutates ONE checklist/item without the wholesale `issue_edit({checklists})` replace, which DROPS any checklist you omit and churns every item id (orphaning its Trello mirror) — prefer this for single-item changes. Action-dispatched: add_list (POST :id/checklists {name, items?}) — create a named checklist, optionally with initial items; update_list (PATCH :id/checklists/:cid {name}) — rename; remove_list (DELETE :id/checklists/:cid) — soft-delete the checklist (audit trail preserved); add_item (POST :id/checklists/:cid/items {label, detail?, status?}) — append an item (status defaults `incomplete`); update_item (PATCH :id/checklists/:cid/items/:iid {label?, detail?, status?}) — change ONLY the fields you pass, in place (keeps id + Trello link; ≥1 field required); remove_item (DELETE :id/checklists/:cid/items/:iid) — soft-delete one item. Status: incomplete|failing|passing|cancelled|deferred (terminal = passing|cancelled|deferred; DX-2653 added `deferred` — the honest disposition for a criterion whose work is done but names a real-world/post-deploy check still outstanding, REQUIRES a non-empty `detail`). checklist_id is required for every action except add_list; item_id for update_item/remove_item. Each returns the {ok,status,body} envelope; unknown card/checklist/item → 404, invalid status → 400. ADDITIVE — `issue_edit({checklists})` still works for bulk authoring.", {
476
480
  id: z.string().min(1),
477
481
  action: z.enum([
478
482
  "add_list",
@@ -491,24 +495,37 @@ server.tool("issue_checklist", "Targeted checklist CUD via /api/issues/:id/check
491
495
  status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
492
496
  ...boardField,
493
497
  }, async (args) => jsonResult(await issueChecklist(client, args)));
494
- // ---------------- issue_solution ----------------
495
- server.tool("issue_solution", "A card's candidate SOLUTIONS — the options the operator picks from when a card is stopped for a human decision — via /api/issues/:id/solutions[/:sid]. Whenever you block a card or set requires_human, list EVERY viable solution here first, and mark the one you recommend. Action-dispatched: list (GET) — the live solutions (each with its `id` and `content_hash`) plus every operator answer recorded so far (`decisions[]`, oldest first); add (POST {title, body?, pro?, con?, recommended?}) — append one option; edit (PATCH :sid {base_hash, title?, body?, pro?, con?, recommended?}) — change ONLY the fields you pass; remove (DELETE :sid {base_hash}) — soft-remove an option. `title` is a short name for the option, `body` the markdown detail of what it actually does, `pro` / `con` the case for and against. HASH-GUARDED: edit and remove REQUIRE `base_hash` = the `content_hash` you last read; a stale one is refused 409 `stale_solution` carrying `currentHash` + `currentSolution` — merge against that and retry, never re-send blindly. A card recommends AT MOST ONE live solution: a second `recommended: true` is refused 409 naming `recommended_solution_id` (edit that one to recommended:false first). An option the operator has already CHOSEN cannot be edited (409) — add a new solution instead; it can still be removed, and the answer keeps its title. There is no answer action: the operator answers in the dashboard.", {
498
+ // ---------------- issue_problem ----------------
499
+ // DX-2735: the option fields, shared by `issue_problem` add's inline solutions[]
500
+ // and `issue_solution`. Their meaning is stated ONCE, in issue_solution's
501
+ // description — per-field describes repeated it on every call's context.
502
+ const SOLUTION_FIELDS = {
503
+ body: z.string().optional(),
504
+ pro: z.string().optional(),
505
+ con: z.string().optional(),
506
+ recommended: z.boolean().optional(),
507
+ };
508
+ server.tool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pid]: one statement the operator must resolve (a question, or a flaw in the plan), each with its own solutions/answers — one problem per question. OPEN = not yet answered; the card needs a human until none is open, and issue_requires_human set is refused 409 `no_open_problem` until one is. list → live problems in order, each {id, statement, content_hash, open, solutions[], decisions[]}; add {statement, solutions?} → problem_id + solution_ids in one transaction (zero solutions is valid: the operator answers free-form); edit :pid {base_hash, statement}; remove :pid {base_hash} (409 `last_open_problem` while requires_human is set). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. No answer action — the operator answers in the dashboard.", {
496
509
  id: z.string().min(1),
497
510
  action: z.enum(["list", "add", "edit", "remove"]),
498
- solution_id: z.number().int().positive().optional().describe("Target solution id — required for edit / remove."),
499
- base_hash: z
500
- .string()
501
- .min(1)
502
- .optional()
503
- .describe("The solution's content_hash from your last read — required for edit / remove."),
504
- title: z.string().min(1).optional().describe("Short name for the option — required for add."),
505
- body: z.string().optional().describe("Markdown detail of what this option actually does."),
506
- pro: z.string().optional().describe("The case FOR this option."),
507
- con: z.string().optional().describe("The case AGAINST this option."),
508
- recommended: z
509
- .boolean()
511
+ problem_id: z.number().int().positive().optional().describe("edit/remove"),
512
+ base_hash: z.string().min(1).optional().describe("content_hash last read; edit/remove"),
513
+ statement: z.string().min(1).optional().describe("add/edit"),
514
+ solutions: z
515
+ .array(z.object({ title: z.string().min(1), ...SOLUTION_FIELDS }))
510
516
  .optional()
511
- .describe("true on the single option you recommend. At most one live recommended solution per card."),
517
+ .describe("add only; fields as issue_solution add"),
518
+ ...boardField,
519
+ }, async (args) => jsonResult(await issueProblem(client, args)));
520
+ // ---------------- issue_solution ----------------
521
+ server.tool("issue_solution", "One problem's options via /api/issues/:id/problems/:pid/solutions[/:sid]; `problem_id` is REQUIRED (from issue_problem list/add; another problem's solution id → 404). add {title, body?, pro?, con?, recommended?}: title names the option, body is its markdown detail, pro/con the case for and against; edit :sid {base_hash, ...only the changed fields}; remove :sid {base_hash}. A stale base_hash → 409 `stale_solution` with currentHash + currentSolution: merge, then retry. At most ONE live recommended per problem: a second → 409 naming `recommended_solution_id`. A chosen option cannot be edited (409 — add a new one) but can be removed.", {
522
+ id: z.string().min(1),
523
+ action: z.enum(["add", "edit", "remove"]),
524
+ problem_id: z.number().int().positive(),
525
+ solution_id: z.number().int().positive().optional().describe("edit/remove"),
526
+ base_hash: z.string().min(1).optional().describe("content_hash last read; edit/remove"),
527
+ title: z.string().min(1).optional().describe("add"),
528
+ ...SOLUTION_FIELDS,
512
529
  ...boardField,
513
530
  }, async (args) => jsonResult(await issueSolution(client, args)));
514
531
  // ---------------- issue_dependency ----------------
@@ -522,7 +539,7 @@ server.tool("issue_dependency", 'Dependency CRUD via /api/issues/:id/dependencie
522
539
  ...boardField,
523
540
  }, async (args) => jsonResult(await issueDependency(client, args)));
524
541
  // ---------------- issue_requires_human ----------------
525
- server.tool("issue_requires_human", "Set or clear the requires_human dispatch gate via /api/issues/:id/requires-human. set=true → POST {reason, steps[]} — sets requires_human_reason (the dispatch gate per DX-704 — poller refuses pickup while non-null), set_by from bearer, set_at NOW(), REPLACES the step rows (prior soft-deleted, fresh ordinals). set=false → DELETE — clears the columns and soft-deletes every live step. Terminal cards refuse 409 on set. **A successful set=true returns a `solutions_reminder` beside the envelope** — `{solution_count, instruction}`: before you stop, EVERY viable solution to the question you are asking must be listed on the card with issue_solution, so the operator can answer by picking one. A count of zero means you have listed none.", {
542
+ server.tool("issue_requires_human", "Set/clear the requires_human gate via /api/issues/:id/requires-human — the ONLY flag that puts a card in front of a human (block only holds dispatch). Escalate in order: 1) issue_problem add (statement + every viable solution, one recommended); 2) set=true {reason, steps[]} — refused 409 `no_open_problem` (with the server's `fix`) while no problem is open. Set stamps requires_human_reason (no pickup while non-null) and replaces the steps; it clears itself when the last open problem is answered. set=false → DELETE clears it and its steps. Terminal cards refuse 409. Success returns `problems_reminder: {open_problem_count, instruction}` naming each open problem's solution count.", {
526
543
  id: z.string().min(1),
527
544
  set: z.boolean(),
528
545
  reason: z.string().optional(),
@@ -530,7 +547,7 @@ server.tool("issue_requires_human", "Set or clear the requires_human dispatch ga
530
547
  ...boardField,
531
548
  }, async (args) => jsonResult(await issueRequiresHuman(client, args)));
532
549
  // ---------------- issue_quality_gate ----------------
533
- server.tool("issue_quality_gate", "Set one card's per-gate `required` flag via POST /api/issues/:id/quality-gates/:gate {required} — the only post-create way (issue_create takes gate_decisions; issue_edit refuses gate keys). PRE `plan-*` gates run before the work dispatch; POST `code-*` gates block complete. Unknown gate → 400. The board state per gate is tri-state: `required` always runs, `optional` runs WHEN this flag is true (optional is NOT off), `disabled` never runs. Optional `effort_level` overrides a `plan-*` gate's reviewer rung (null clears it). The write always succeeds and returns `{issue, applied: true, effective, reason}` — read `effective` (does the gate now run) and `reason` (why the board overrode your value), not just the 200. Pass `board` to target another board.", {
550
+ server.tool("issue_quality_gate", "Set one card's per-gate `required` flag via POST /api/issues/:id/quality-gates/:gate {required} — the only post-create way (issue_create takes gate_decisions; issue_edit refuses gate keys). PRE `plan-*` gates run before the work dispatch; POST `code-*` gates block complete. Unknown gate → 400. The board state per gate is tri-state: `required` always runs, `optional` runs WHEN this flag is true (optional is NOT off), `disabled` never runs. Optional `effort_level` overrides a `plan-*` gate's reviewer rung (null clears it). The write always succeeds and returns `{issue, applied: true, effective, reason}` — read `effective` (does the gate now run) and `reason` (why the board overrode your value), not just the 200. Board-scoped; see `board`.", {
534
551
  id: z.string().min(1),
535
552
  gate: z.enum([
536
553
  "plan-dependency",
@@ -545,7 +562,7 @@ server.tool("issue_quality_gate", "Set one card's per-gate `required` flag via P
545
562
  ...boardField,
546
563
  }, async (args) => jsonResult(await issueQualityGate(client, args)));
547
564
  // ---------------- issue_quality_gate_verdict ----------------
548
- server.tool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate VERDICT via PATCH /api/issues/:id/quality-gates/:gate {status, message} — the same write the dashboard Gates-tab Pass / Fail / Revert controls perform (DX-1373). SIBLING of `issue_quality_gate`, not a replacement: that one flips the per-card `required` FLAG (does this gate run at all), THIS one records the VERDICT (did it pass) — the server exposes them as POST vs PATCH on the same resource and neither substitutes for the other. **Use this to close out a card you picked up with `issue_transition pickup {manual:true}`** (DX-946 operator-session self-pickup): `issue_transition complete` REFUSES 409 (`failed_gate: \"quality_gate_post\"`, `failed_post_gates[]`) while any required POST gate (`code-quality` / `code-test-quality` / `code-architecture`) is not `pass`, so without a verdict a manually-claimed card can never reach Done — it strands In Progress and its `conflict_on` / `waiting_on` edges then stall OTHER cards' dispatch. `status`: `pass` | `fail` | `pending` (revert a prior verdict, clears the message). `message` is the accountability record for the override and is REQUIRED at >= 20 characters for `pass`/`fail` (shorter → 400); it is ignored for `pending`. Record the REAL reviewer finding here, not a rubber stamp — this is a human-attributed override, stamped with the operator actor, and it is what a later reader sees instead of a reviewer dispatch. A manual verdict is a PURE row write: unlike the worker's in-dispatch gate route it fires NO side effects — a manual `fail` never blocks the card and a manual `pass` never releases a dispatch. Unknown gate → 400; status outside the three values → 400; unknown card → 404. Board-scoped; pass `board` (`<repo>:<slug>`) to target another board.", {
565
+ server.tool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate VERDICT via PATCH /api/issues/:id/quality-gates/:gate {status, message} — the same write the dashboard Gates-tab Pass / Fail / Revert controls perform (DX-1373). SIBLING of `issue_quality_gate`, not a replacement: that one flips the per-card `required` FLAG (does this gate run at all), THIS one records the VERDICT (did it pass) — POST vs PATCH on the same resource, neither substitutes for the other. **Use this to close out a card you picked up with `issue_transition pickup {manual:true}`** (DX-946 operator-session self-pickup): `issue_transition complete` REFUSES 409 (`failed_gate: \"quality_gate_post\"`, `failed_post_gates[]`) while any required POST gate (`code-quality` / `code-test-quality` / `code-architecture`) is not `pass`, so without a verdict a manually-claimed card can never reach Done — it strands In Progress and its `conflict_on` / `waiting_on` edges then stall OTHER cards' dispatch. `status`: `pass` | `fail` | `pending` (revert a prior verdict, clears the message). `message` is the accountability record for the override, REQUIRED at >= 20 characters for `pass`/`fail` (shorter → 400), ignored for `pending`. Record the REAL reviewer finding, not a rubber stamp — a human-attributed override, stamped with the operator actor, standing in for a reviewer dispatch. A manual verdict is a PURE row write: no side effects — a manual `fail` never blocks the card and a manual `pass` never releases a dispatch. Unknown gate → 400; bad status → 400; unknown card → 404. Board-scoped; see `board`.", {
549
566
  id: z.string().min(1),
550
567
  gate: z.enum([
551
568
  "plan-dependency",
@@ -560,7 +577,7 @@ server.tool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate
560
577
  ...boardField,
561
578
  }, async (args) => jsonResult(await issueQualityGateVerdict(client, args)));
562
579
  // ---------------- issue_retro ----------------
563
- server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retro. Body: {good, bad, action_item_ids[], commits[], tests[]}. REFUSES 409 unless the card is terminal (completed_at OR cancelled_at) — retro ships when work concludes. Replace semantics: good/bad upsert; action_item_ids[] + commits[] + tests[] soft-delete prior live rows and insert with fresh ordinals. action_item_ids[] entries MUST match <PREFIX>-N. commits[] entries take {sha, subject?}. tests[] (DX-1646) is REQUIRED (empty array allowed — the \"ran no tests\" case): one row per test GROUP that ran (a whole suite/class — name the group, do NOT list individual unit tests) or per individual e2e test (kind:'e2e', listed explicitly since they are few + expensive). Each row: {name, kind:'group'|'e2e', num_tests, num_passing_tests, duration_ms} required; num_assertions + num_passing_assertions NULLABLE (vitest surfaces no assertion totals — pass null or omit).", {
580
+ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retro. Body: {good, bad, action_item_ids[], commits[], tests[]}. REFUSES 409 unless the card is terminal (completed_at OR cancelled_at) — retro ships when work concludes. Replace semantics: good/bad upsert; action_item_ids[] + commits[] + tests[] soft-delete prior live rows and insert with fresh ordinals. action_item_ids[] entries MUST match <PREFIX>-N. commits[] entries take {sha, subject?}. tests[] (DX-1646) is REQUIRED (empty array allowed — the \"ran no tests\" case): one row per test GROUP that ran (a whole suite/class — name the group, do NOT list individual unit tests) or per individual e2e test (kind:'e2e', few + expensive so listed explicitly). Each row: {name, kind:'group'|'e2e', num_tests, num_passing_tests, duration_ms} required; num_assertions + num_passing_assertions NULLABLE (vitest has no assertion totals — pass null or omit).", {
564
581
  id: z.string().min(1),
565
582
  good: z.string(),
566
583
  bad: z.string(),
@@ -590,7 +607,7 @@ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retr
590
607
  // route's MAX_DECODED_BYTES (src/issues/write/attachments.ts). This package is
591
608
  // a separate published artifact and cannot import that constant, so the number
592
609
  // is restated here as prose — keep the two in sync if the backend ceiling moves.
593
- server.tool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/issues/:id/attachments. Pass `id` (the card) and `file_path` (an ABSOLUTE path to a file on the dispatch's shared filesystem — e.g. a screenshot, exported CSV, or diagram you wrote). This MCP server reads the bytes, infers the MIME type from the extension, and uploads through the dashboard, which: stores the bytes in S3, inserts ONE danxbot-origin issue_attachments row, and auto-mirrors the file to the card's linked Trello card AND its Slack card-view thread (DX-1122 outbound projection) — no extra step needed. Board-scoped; defaults to the dispatch's board. Pass `board` (a qualified id `<repo>:<slug>`) to attach on another board (unknown board → 404). Fail-loud: a relative/empty path is rejected at the MCP boundary, and a missing/unreadable file throws BEFORE any upload (no partial S3 object, no row). 25 MB decoded ceiling (413). Returns the hydrated issue plus the new attachment id.", {
610
+ server.tool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/issues/:id/attachments. Pass `id` (the card) and `file_path` (an ABSOLUTE path to a file on the dispatch's shared filesystem — e.g. a screenshot, exported CSV, or diagram you wrote). This MCP server reads the bytes, infers the MIME type from the extension, and uploads through the dashboard, which: stores the bytes in S3, inserts ONE danxbot-origin issue_attachments row, and auto-mirrors the file to the card's linked Trello card AND its Slack card-view thread (DX-1122 outbound projection) — no extra step needed. Board-scoped; see `board`. Fail-loud: a relative/empty path is rejected at the MCP boundary, and a missing/unreadable file throws BEFORE any upload (no partial S3 object, no row). 25 MB decoded ceiling (413). Returns the hydrated issue plus the new attachment id.", {
594
611
  id: z.string().min(1),
595
612
  file_path: z
596
613
  .string()
@@ -599,11 +616,11 @@ server.tool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/
599
616
  ...boardField,
600
617
  }, async (args) => jsonResult(await issueAttach(client, args)));
601
618
  // ---------------- repo_knowledge_get ----------------
602
- server.tool("repo_knowledge_get", "Fetch the board's working-knowledge markdown doc via GET /api/repo-knowledge (DX-1128, Story 2). Board-scoped; defaults to the dispatch's board. Pass `board` (a qualified id `<repo>:<slug>`) to read another board's doc. Returns `{ok, status, body: {content, contentHash, updatedAt, updatedBy, boardId}}` — an unset doc reads as the empty view (`content: \"\"`, `contentHash: \"\"`), NOT a 404. Ground exploratory answers in `content`; before `repo_knowledge_set`, ALWAYS `repo_knowledge_get` immediately first and pass its `contentHash` back as `base_hash` — the server's optimistic-concurrency guard rejects a stale write.", {
619
+ server.tool("repo_knowledge_get", "Fetch the board's working-knowledge markdown doc via GET /api/repo-knowledge (DX-1128, Story 2). Board-scoped; see `board`. Returns `{ok, status, body: {content, contentHash, updatedAt, updatedBy, boardId}}` — an unset doc reads as the empty view (`content: \"\"`, `contentHash: \"\"`), NOT a 404. Ground exploratory answers in `content`; before `repo_knowledge_set`, ALWAYS `repo_knowledge_get` immediately first and pass its `contentHash` back as `base_hash` — the server's optimistic-concurrency guard rejects a stale write.", {
603
620
  ...boardField,
604
621
  }, async (args) => jsonResult(await repoKnowledgeGet(client, args)));
605
622
  // ---------------- repo_knowledge_set ----------------
606
- server.tool("repo_knowledge_set", 'Write the board\'s working-knowledge markdown doc via PUT /api/repo-knowledge (DX-1128, Story 2). Board-scoped; defaults to the dispatch\'s board. `base_hash` MUST be the `contentHash` from the immediately-prior `repo_knowledge_get` call ("" for the true first write, when the board has no doc yet) — the server compares it against the CURRENT hash and, on mismatch, fails loud with `{ok: false, body: {error: "stale_repo_knowledge", currentHash}}` rather than silently overwriting a concurrent write. On that refusal: re-`repo_knowledge_get`, re-merge your insight into the fresh content, and retry `repo_knowledge_set` with the new hash. On success, persists to the DB, publishes `repo-knowledge:updated` over SSE (live in the dashboard editor), and returns the new view.', {
623
+ server.tool("repo_knowledge_set", 'Write the board\'s working-knowledge markdown doc via PUT /api/repo-knowledge (DX-1128, Story 2). Board-scoped; see `board`. `base_hash` MUST be the `contentHash` from the immediately-prior `repo_knowledge_get` call ("" for the true first write, when the board has no doc yet) — the server compares it against the CURRENT hash and, on mismatch, fails loud with `{ok: false, body: {error: "stale_repo_knowledge", currentHash}}` rather than silently overwriting a concurrent write. On that refusal: re-`repo_knowledge_get`, re-merge your insight into the fresh content, and retry `repo_knowledge_set` with the new hash. On success, persists to the DB, publishes `repo-knowledge:updated` over SSE (live in the dashboard editor), and returns the new view.', {
607
624
  content: z.string(),
608
625
  base_hash: z
609
626
  .string()
@@ -612,11 +629,11 @@ server.tool("repo_knowledge_set", 'Write the board\'s working-knowledge markdown
612
629
  ...boardField,
613
630
  }, async (args) => jsonResult(await repoKnowledgeSet(client, args)));
614
631
  // ---------------- brief_list ----------------
615
- server.tool("brief_list", "List the board's named Brief pages via GET /api/brief (DX-2083 / DX-2484). Board-scoped; defaults to the dispatch's board. Pass `board` (a qualified id `<repo>:<slug>`) to list another board's pages. Returns `{boardId, pages: [{slug, title, contentHash, sortOrder, updatedAt, updatedBy}]}` — metadata only, no page content (use `brief_get_page` for that). This is the list+page-shaped sibling of `repo_knowledge_get`/`repo_knowledge_set` (one board-level doc) — Brief pages are MANY named pages per board (the Goals / Architecture / Rules / Caveats tabs), keyed by `(board, slug)`. The reserved `index` slug always exists — every board carries exactly one.", {
632
+ server.tool("brief_list", "List the board's named Brief pages via GET /api/brief (DX-2083 / DX-2484). Board-scoped; see `board`. Returns `{boardId, pages: [{slug, title, contentHash, sortOrder, updatedAt, updatedBy}]}` — metadata only, no page content (use `brief_get_page` for that). This is the list+page-shaped sibling of `repo_knowledge_get`/`repo_knowledge_set` (one board-level doc) — Brief pages are MANY named pages per board (the Goals / Architecture / Rules / Caveats tabs), keyed by `(board, slug)`. The reserved `index` slug always exists — every board carries exactly one.", {
616
633
  ...boardField,
617
634
  }, async (args) => jsonResult(await briefList(client, args)));
618
635
  // ---------------- brief_get_page ----------------
619
- server.tool("brief_get_page", 'Fetch one Brief page by slug via GET /api/brief/page?slug=<slug> (DX-2083 / DX-2484). Board-scoped; defaults to the dispatch\'s board. Returns `{boardId, slug, title, content, contentHash, sortOrder, updatedAt, updatedBy}` — a missing/not-yet-created page reads as the empty view (`content: ""`, `contentHash: ""`), NOT a 404, matching `repo_knowledge_get`\'s convention. Before `brief_set_page`, ALWAYS `brief_get_page` immediately first and pass its `contentHash` back as `base_hash` — the server\'s optimistic-concurrency guard rejects a stale write.', {
636
+ server.tool("brief_get_page", 'Fetch one Brief page by slug via GET /api/brief/page?slug=<slug> (DX-2083 / DX-2484). Board-scoped; see `board`. Returns `{boardId, slug, title, content, contentHash, sortOrder, updatedAt, updatedBy}` — a missing/not-yet-created page reads as the empty view (`content: ""`, `contentHash: ""`), NOT a 404, matching `repo_knowledge_get`\'s convention. Before `brief_set_page`, ALWAYS `brief_get_page` immediately first and pass its `contentHash` back as `base_hash` — the server\'s optimistic-concurrency guard rejects a stale write.', {
620
637
  slug: z
621
638
  .string()
622
639
  .min(1)
@@ -624,7 +641,7 @@ server.tool("brief_get_page", 'Fetch one Brief page by slug via GET /api/brief/p
624
641
  ...boardField,
625
642
  }, async (args) => jsonResult(await briefGetPage(client, args)));
626
643
  // ---------------- brief_set_page ----------------
627
- server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug=<slug> (DX-2083 / DX-2484). Board-scoped; defaults to the dispatch\'s board. Body: `{content, title?, sortOrder?, base_hash?}` — mirrors `repo_knowledge_set`\'s optimistic-concurrency shape but targets one named page instead of the board\'s single working-knowledge doc. `base_hash` MUST be the `contentHash` from the immediately-prior `brief_get_page` call ("" for a true first write, when the page doesn\'t exist yet) — the server compares it against the CURRENT hash and, on mismatch, fails loud with `{ok: false, body: {error: "stale_brief_page", currentHash}}` rather than silently overwriting a concurrent write — re-get, re-merge, and retry on that refusal, never retry blindly or overwrite. On success, persists to the DB, publishes `brief:updated` over SSE, and returns the new view. NO delete tool is exposed on this surface — the reserved `index` slug can never be deleted through the tool surface, matching the route\'s own refusal; deleting a non-index page is dashboard-UI-only for now.', {
644
+ server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug=<slug> (DX-2083 / DX-2484). Board-scoped; see `board`. Body: `{content, title?, sortOrder?, base_hash?}` — mirrors `repo_knowledge_set`\'s optimistic-concurrency shape but targets one named page instead of the board\'s single working-knowledge doc. `base_hash` MUST be the `contentHash` from the immediately-prior `brief_get_page` call ("" for a true first write, when the page doesn\'t exist yet) — the server compares it against the CURRENT hash and, on mismatch, fails loud with `{ok: false, body: {error: "stale_brief_page", currentHash}}` rather than silently overwriting a concurrent write — re-get, re-merge, and retry on that refusal, never retry blindly or overwrite. On success, persists to the DB, publishes `brief:updated` over SSE, and returns the new view. NO delete tool is exposed on this surface — the reserved `index` slug can never be deleted through the tool surface, matching the route\'s own refusal; deleting a non-index page is dashboard-UI-only for now.', {
628
645
  slug: z
629
646
  .string()
630
647
  .min(1)
@@ -649,7 +666,7 @@ server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug
649
666
  // also takes no plan id, but for a different reason: it MAKES a plan rather
650
667
  // than acting on one, so there is no existing plan for an id to name yet.
651
668
  server.tool("plan_list", "List every plan via GET /api/plans (DX-2683), and learn which plan THIS session is connected to. Plans are GLOBAL, not board-scoped: a plan is a named, dated set of cards an operator assembled by hand, and its cards may come from any repository. Returns `{ok, status, body: {plans: [{id, name, createdAt, cardCount, boards}], session, sessionListenerAttached}}`. `session` is your own registration — `{sessionId, title, planId, planName, firstSeenAt, lastActiveAt}` — or `null` if this process is not running inside a Claude Code session. A `planId` of null means you are connected to no plan: read any plan with `plan_get`, then `plan_connect` to the one you are working on (or ask the operator to connect you from the Plans list). `sessionListenerAttached` says whether your event listener is running: `false` while connected to a plan means you will NOT hear about its cards — call `plan_connect` again and arm the Monitor it returns. NOTE this is NOT the board Brief (`brief_list`), which is a different feature entirely.", {}, async () => jsonResult(await planList(client)));
652
- server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id` to read ANY plan (browsing another plan is useful and changes nothing); OMIT it to read the plan this session is connected to. Omitting it while connected to no plan fails loud with `{error: \"session_not_connected\"}` — connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{plan, boards, cardCount, bucketCounts, session, sessionListenerAttached, available_field_groups}` — no member cards, no goals/rules/caveats, no architecture body. Pass `fields` to opt into the rest, one call at a time: `cards` (member cards, PAGED: `cards_offset` (default 0) and `cards_limit` (1.." + LIST_PAGE_MAX_LIMIT + ", default " + PLAN_GET_CARDS_DEFAULT_LIMIT + ") pick the page, in card-reference order (board prefix, then card number — stable while cards are edited, so pages never repeat or skip a card unless the plan's membership changes between reads), and the response carries `cards_total` and `cards_offset` — page with cards_offset while cards_offset + cards.length < cards_total; either paging arg without `fields: [\"cards\"]` is a 400), `records` (every goal+rule+caveat, keyed by kind) or `records:goal` / `records:rule` / `records:caveat` (just that one kind — cheaper than the full union), `architecture` (`{sections: [{id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}]}`), `sessions` (every session connected to the plan). `session`/`sessionListenerAttached` (your own connection state) and `available_field_groups` ride EVERY response, gated or not. `sessionListenerAttached: false` while connected means your event listener is not running; call `plan_connect` again and arm the Monitor it returns. ALWAYS `plan_get`/`plan_get_architecture_section` immediately before `plan_update_architecture_section`/`plan_delete_architecture_section` and pass the returned `contentHash` back as `base_hash`.", {
669
+ server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id` to read ANY plan; OMIT to read the plan this session is connected to — omitting while connected to none fails loud `{error: \"session_not_connected\"}`, connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{plan, boards, cardCount, bucketCounts, session, sessionListenerAttached, available_field_groups}` — no cards, records, or architecture body. Pass `fields` to opt into: `cards` (member cards, PAGED: `cards_offset` (default 0) and `cards_limit` (1.." + LIST_PAGE_MAX_LIMIT + ", default " + PLAN_GET_CARDS_DEFAULT_LIMIT + ") pick the page, in stable card-reference order (board prefix, then card number — pages never repeat/skip unless membership changes between reads); response carries `cards_total`/`cards_offset` — page while cards_offset + cards.length < cards_total; either paging arg without `fields: [\"cards\"]` is a 400), `records` (every goal+rule+caveat, keyed by kind) or `records:goal` / `records:rule` / `records:caveat` (one kind, cheaper), `architecture` (`{sections: [{id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}]}`), `sessions` (every session connected to the plan). `session`/`sessionListenerAttached` and `available_field_groups` ride every response regardless. `sessionListenerAttached: false` while connected means your event listener is not running; call `plan_connect` again and arm the Monitor it returns. ALWAYS `plan_get`/`plan_get_architecture_section` immediately before `plan_update_architecture_section`/`plan_delete_architecture_section` and pass the returned `contentHash` back as `base_hash`.", {
653
670
  plan_id: z
654
671
  .number()
655
672
  .int()
@@ -677,7 +694,9 @@ server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id
677
694
  server.tool("plan_create", "Create a new, empty plan via POST /api/plans (DX-2531). GLOBAL — a plan is not board-scoped, and this call adds no cards, no records, and no architecture sections; it does NOT connect any session to the new plan (call `plan_connect` separately, exactly as adding a card to a plan is its own separate step). Returns `{ok, status, body: {plan: {id, name, createdAt}}}`. Use the returned `plan.id` with `plan_connect` to start working on it, or with `plan_get({plan_id})` to browse it.", {
678
695
  name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
679
696
  }, async (args) => jsonResult(await planCreate(client, args)));
680
- server.tool("plan_connect", "Connect THIS session to a plan via POST /api/plan-sessions/me/plan (DX-2683) — the same binding the operator's Connect action writes, through the same server-side path. A session is connected to AT MOST ONE plan (enforced by the schema, not by convention); connecting while already on another plan MOVES you, and the response says which plan you left: `{ok, status, body: {session, movedFrom: {id, name} | null}}`. `movedFrom: null` means you were on no plan, or already on this one. It can only ever bind your OWN session — `me` is resolved from the session id this server forwards, never from anything you pass. After this, every plan WRITE tool acts on this plan, and no plan id is accepted anywhere. THE REPLY ALSO CARRIES `listener: {command, persistent: true, instruction}` — arm it IMMEDIATELY with the Monitor tool (`command` as given, `persistent: true`): from then on every comment, answer, requires_human change and block/unblock on this plan's cards arrives as a notification line like `[DX-8 \"Title\" repo:board] newms87 answered: chose \"Pause E2E\" — note: \"…\"`. Never poll for these. The command carries a narrow stream ticket, not a credential; calling plan_connect again (same plan is fine) issues a new one and ends the old listener, which is how you re-arm after a session restart or after the Monitor reports it gave up. If the ticket cannot be issued the call fails with `listener_not_armed` even though the connect itself happened.", {
697
+ server.tool("plan_connect",
698
+ // DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
699
+ "Connect THIS session to a plan via POST /api/plan-sessions/me/plan — the same binding the operator's Connect action writes. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and the reply says which plan it left: `{session, movedFrom: {id, name} | null}` (null = no plan, or already this one). It binds only your OWN session, resolved from the session id this server forwards. Afterwards every plan WRITE tool acts on this plan and takes no plan id. THE REPLY ALSO CARRIES `listener: {command, persistent: true, instruction}` — arm it IMMEDIATELY with the Monitor tool (`persistent: true`): every comment, answer, requires_human change and block/unblock on this plan's cards then arrives as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<problem statement>\": chose \"Pause E2E\" — note: \"…\"`. Never poll for these. The command holds a narrow stream ticket, not a credential; calling plan_connect again (same plan is fine) issues a new one and ends the old listener — how you re-arm after a restart or a give-up. If no ticket can be issued the call fails `listener_not_armed`, though the connect happened.", {
681
700
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
682
701
  }, async (args) => jsonResult(await planConnect(client, args, { baseUrl: config.baseUrl, packageSpec: PACKAGE_SPEC })));
683
702
  server.tool("plan_add_record", "Add a goal, rule or caveat to your connected plan (POST /api/plans/mine/records). A GOAL is an outcome the work is measured against. A RULE is a constraint that must hold while it is worked. A CAVEAT is a lasting trade-off or limitation of the ARCHITECTURE — never progress, status or a session note (those are comments on the card). `body` is ONE plain statement of at most 250 characters; the evidence, history and detail go in `context` (markdown). An overlong body is refused with a 400 naming its length. The server allocates a permanent reference (`G-1`, `R-4`, `CAV-12`). Takes no plan id; `session_not_connected` → `plan_connect` first. Returns the record plus that kind's list.", {
package/dist/listen.js CHANGED
@@ -33,6 +33,7 @@
33
33
  * (`src/issues/db/issue-activity.ts`); this published package cannot import
34
34
  * danxbot source.
35
35
  */
36
+ import { oneLine } from "./one-line.js";
36
37
  export const LISTEN_SUBCOMMAND = "listen";
37
38
  export const INITIAL_BACKOFF_MS = 1_000;
38
39
  export const MAX_BACKOFF_MS = 30_000;
@@ -66,17 +67,71 @@ export function parseListenArgs(argv) {
66
67
  function quoted(value) {
67
68
  return `"${value.text}${value.truncated ? "…" : ""}"`;
68
69
  }
70
+ function isRecord(value) {
71
+ return typeof value === "object" && value !== null && !Array.isArray(value);
72
+ }
73
+ function isCappedText(value) {
74
+ return isRecord(value) && typeof value.text === "string" && typeof value.truncated === "boolean";
75
+ }
76
+ /**
77
+ * DX-2735 — why an event cannot be described, or `null` when it can.
78
+ *
79
+ * Checked BEFORE describing, so a malformed event is reported by what is
80
+ * actually wrong with it — never by whichever TypeError a missing field happens
81
+ * to raise (whose wording is the JS engine's, not ours). The envelope is checked
82
+ * for every kind; the detail only for the kinds `describe` reads, so a kind newer
83
+ * than this listener still produces its generic line.
84
+ */
85
+ export function invalidEventReason(value) {
86
+ if (!isRecord(value))
87
+ return "the event is not an object";
88
+ if (typeof value.id !== "number" || !Number.isSafeInteger(value.id))
89
+ return "id is not an integer";
90
+ for (const field of ["cardId", "cardTitle", "boardId", "actor", "kind"]) {
91
+ if (typeof value[field] !== "string")
92
+ return `${field} is not a string`;
93
+ }
94
+ const d = value.detail;
95
+ if (!isRecord(d))
96
+ return "detail is not an object";
97
+ switch (value.kind) {
98
+ case "comment_added":
99
+ return isCappedText(d.excerpt) ? null : "detail.excerpt is not capped text";
100
+ case "solution_answered": {
101
+ const problem = d.problem;
102
+ if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
103
+ return "detail.problem is not {id, statement}";
104
+ }
105
+ if (d.solution === null)
106
+ return isCappedText(d.freeform) ? null : "detail.freeform is not capped text";
107
+ if (!isRecord(d.solution) || typeof d.solution.title !== "string")
108
+ return "detail.solution is not {title, note}";
109
+ return d.solution.note === null || isCappedText(d.solution.note) ? null : "detail.solution.note is not capped text";
110
+ }
111
+ case "requires_human_set":
112
+ case "blocked":
113
+ return isCappedText(d.reason) ? null : "detail.reason is not capped text";
114
+ default:
115
+ return null;
116
+ }
117
+ }
118
+ /** Only ever called on an event `invalidEventReason` accepted, so its casts are checked. */
69
119
  function describe(event) {
70
120
  const d = event.detail;
71
121
  switch (event.kind) {
72
122
  case "comment_added":
73
123
  return `${event.actor} commented: ${quoted(d.excerpt)}`;
74
124
  case "solution_answered": {
125
+ // DX-2735: every answer answers ONE problem on a card that may carry several,
126
+ // so the line names the problem — otherwise the agent cannot tell which of its
127
+ // questions just closed.
128
+ const problem = d.problem;
129
+ const answered = `${event.actor} answered ${quoted(problem.statement)}:`;
75
130
  const solution = d.solution;
76
131
  if (solution === null)
77
- return `${event.actor} answered: ${quoted(d.freeform)}`;
132
+ return `${answered} ${quoted(d.freeform)}`;
78
133
  const note = solution.note === null ? "" : ` — note: ${quoted(solution.note)}`;
79
- return `${event.actor} answered: chose "${solution.title}"${note}`;
134
+ return `${answered} chose "${solution.title}"${note}`;
80
135
  }
81
136
  case "requires_human_set":
82
137
  return `${event.actor} set requires_human: ${quoted(d.reason)}`;
@@ -92,9 +147,12 @@ function describe(event) {
92
147
  return `${event.actor}: ${event.kind}`;
93
148
  }
94
149
  }
95
- /** The one notification line for an event. Always a single line; throws on a malformed event. */
150
+ /** The one notification line for an event. Always a single line; throws, naming the fault, on a malformed event. */
96
151
  export function formatActivityLine(event) {
97
- return `[${event.cardId} "${event.cardTitle}" ${event.boardId}] ${describe(event)}`.replace(/\s+/g, " ");
152
+ const invalid = invalidEventReason(event);
153
+ if (invalid !== null)
154
+ throw new Error(`malformed activity event: ${invalid}`);
155
+ return oneLine(`[${event.cardId} "${event.cardTitle}" ${event.boardId}] ${describe(event)}`);
98
156
  }
99
157
  /**
100
158
  * Incremental SSE parser. Comment lines (keep-alives, the connect marker) and
@@ -165,14 +223,27 @@ export async function runListener(options, deps) {
165
223
  const id = Number(message.id);
166
224
  if (Number.isSafeInteger(id) && printed.has(id))
167
225
  return null;
226
+ // DX-2735: an event that is not JSON, or JSON of the wrong shape, is reported
227
+ // from an explicit check naming the fault — never from a TypeError thrown
228
+ // partway through describing it. Never silence, and never a reconnect loop on
229
+ // the same bad event: say so in one line and move past it.
230
+ let parsed;
231
+ let invalid;
168
232
  try {
169
- deps.write(formatActivityLine(JSON.parse(message.data)));
233
+ parsed = JSON.parse(message.data);
234
+ invalid = invalidEventReason(parsed);
170
235
  }
171
236
  catch (err) {
172
- // Never silence, and never a reconnect loop on the same bad event: say so
173
- // in one line and move past it.
174
- deps.write(`${LINE_PREFIX} could not read event ${message.id ?? "(no id)"} (${err.message}): ` +
175
- `${message.data.slice(0, 300)}`.replace(/\s+/g, " "));
237
+ invalid = `not JSON: ${err.message}`;
238
+ }
239
+ if (invalid === null) {
240
+ deps.write(formatActivityLine(parsed));
241
+ }
242
+ else {
243
+ deps.write(`${LINE_PREFIX} could not read event ${message.id ?? "(no id)"} (${invalid}): ` +
244
+ // DX-2735: oneLine caps by code point, so a bad event carrying an emoji
245
+ // at the cut can never leave a lone surrogate in the line.
246
+ oneLine(message.data, 300));
176
247
  }
177
248
  if (Number.isSafeInteger(id))
178
249
  remember(id);
@@ -198,7 +269,7 @@ export async function runListener(options, deps) {
198
269
  armIdle();
199
270
  const response = await deps.fetch(options.streamUrl, { headers, signal: controller.signal });
200
271
  if (response.status >= 400 && response.status < 500 && !RETRYABLE_CLIENT_STATUSES.has(response.status)) {
201
- return { kind: "refused", detail: `HTTP ${response.status} ${(await response.text()).slice(0, 300)}` };
272
+ return { kind: "refused", detail: `HTTP ${response.status} ${oneLine(await response.text(), 300)}` };
202
273
  }
203
274
  if (!response.ok || response.body === null) {
204
275
  await response.body?.cancel();
@@ -0,0 +1,18 @@
1
+ /**
2
+ * DX-2735 — the ONE way this package renders human text into an agent-facing
3
+ * line: the listen notification line and the requires_human problems reminder
4
+ * both go through it, so they can never disagree about how a statement looks.
5
+ *
6
+ * Every whitespace run (newlines included) collapses to a single space, because
7
+ * a notification is one line and a reminder quotes statements inline. The
8
+ * optional cap counts CODE POINTS, never UTF-16 units: `String#slice` can cut
9
+ * an emoji or any astral character in half and leave a lone surrogate, which
10
+ * renders as garbage. A capped string ends with "…" so truncation is visible.
11
+ */
12
+ export function oneLine(text, maxCodePoints) {
13
+ const collapsed = text.replace(/\s+/g, " ");
14
+ if (maxCodePoints === undefined)
15
+ return collapsed;
16
+ const points = Array.from(collapsed);
17
+ return points.length > maxCodePoints ? `${points.slice(0, maxCodePoints).join("")}…` : collapsed;
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.63",
3
+ "version": "0.1.65",
4
4
  "description": "Stdio MCP server wrapping danxbot's dashboard /api/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
5
5
  "license": "MIT",
6
6
  "type": "module",