@thehammer/danx-dashboard-mcp 0.1.51 → 0.1.52

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
@@ -23,13 +23,14 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
23
23
  |---|---|---|
24
24
  | `issue_list` | `GET /api/issues` | filters: `type`, `parent_id` (null → root-only), `dispatchable_derived`, `assigned_agent`, `include_closed`, `limit`, `offset` |
25
25
  | `issue_get` | `GET /api/issues/:id` | Returns hydrated card + ancestor chain |
26
- | `issue_create` | `POST /api/issues` | Epic REQUIRES non-empty `phase_children[]` (atomic insert) |
27
- | `issue_edit` | `PATCH /api/issues/:id/edit` | Prose + structured keys (`title`, `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 |
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
+ | `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 |
29
30
  | `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 |
30
31
  | `issue_comment` | `POST/PATCH/DELETE /api/issues/:id/comments[/:cid]` | Author server-stamped, soft-delete preserved |
31
32
  | `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 |
32
- | `issue_requires_human` | `POST/DELETE /api/issues/:id/requires-human` | Set replaces step rows atomically; clear soft-deletes them |
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}` |
33
34
  | `issue_retro` | `PUT /api/issues/:id/retro` | Requires terminal card; replace semantics |
34
35
 
35
36
  ## Build + test
package/dist/handlers.js CHANGED
@@ -158,6 +158,8 @@ export async function issueCreate(client, args, defaultBoard) {
158
158
  title: args.title,
159
159
  description: args.description,
160
160
  };
161
+ if (args.summary !== undefined)
162
+ body.summary = args.summary;
161
163
  if (args.parent_id !== undefined)
162
164
  body.parent_id = args.parent_id;
163
165
  if (args.ac !== undefined)
@@ -205,12 +207,63 @@ export async function issueEdit(client, args) {
205
207
  }
206
208
  export async function issueTransition(client, args) {
207
209
  const { id, board, ...body } = args;
208
- return client.request({
210
+ const result = await client.request({
209
211
  method: "POST",
210
212
  path: `/${encodeURIComponent(id)}/transition`,
211
213
  body,
212
214
  board,
213
215
  });
216
+ return args.action === "block" ? withSolutionsReminder(client, id, board, result) : result;
217
+ }
218
+ /**
219
+ * Attach the solutions reminder to a SUCCESSFUL gating write.
220
+ *
221
+ * ENFORCED BY FEEDBACK, NOT BY REFUSAL. The server never rejects a block or a
222
+ * requires-human hold for lacking solutions: some holds legitimately have no
223
+ * options to list (a credential only the operator can supply), and a refusal
224
+ * there would push the agent into inventing solutions to get past the gate.
225
+ * What the operator needs is that an agent which CAN lay out options always
226
+ * does — so the reminder rides the success response, where it is read at the
227
+ * exact moment the agent is deciding whether it is done.
228
+ *
229
+ * The gating write's own envelope is returned untouched beside it. A refused
230
+ * write gets no reminder (there is no stop to remind about). A failure READING
231
+ * the solutions is reported inside the reminder rather than thrown: the gating
232
+ * write already succeeded, and throwing would tell the agent its block failed
233
+ * when it did not — the failure is surfaced, never swallowed.
234
+ */
235
+ export async function withSolutionsReminder(client, id, board, result) {
236
+ if (!result.ok)
237
+ return result;
238
+ const nextStep = `issue_solution({id: "${id}", action: "add", title, body, pro, con})`;
239
+ let listed;
240
+ try {
241
+ listed = await client.request({
242
+ method: "GET",
243
+ path: `/${encodeURIComponent(id)}/solutions`,
244
+ board,
245
+ });
246
+ }
247
+ catch (err) {
248
+ return { ...result, solutions_reminder: unreadableReminder(id, err instanceof Error ? err.message : String(err)) };
249
+ }
250
+ // `body` is the server's JSON verbatim — `null` is valid JSON, so read it null-safely;
251
+ // a throw here would land OUTSIDE the try above and misreport the successful write.
252
+ const solutions = listed.ok ? listed.body?.solutions : undefined;
253
+ if (!Array.isArray(solutions)) {
254
+ return { ...result, solutions_reminder: unreadableReminder(id, `HTTP ${listed.status}`) };
255
+ }
256
+ const count = solutions.length;
257
+ const instruction = count === 0
258
+ ? `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.`
259
+ : `${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.`;
260
+ return { ...result, solutions_reminder: { solution_count: count, instruction } };
261
+ }
262
+ function unreadableReminder(id, detail) {
263
+ return {
264
+ solution_count: null,
265
+ 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.`,
266
+ };
214
267
  }
215
268
  export async function issueTriage(client, args) {
216
269
  const { id, board, ...body } = args;
@@ -400,6 +453,61 @@ export async function issueChecklist(client, args) {
400
453
  }
401
454
  }
402
455
  }
456
+ /**
457
+ * A card's candidate solutions via `/api/issues/:id/solutions[/:sid]`,
458
+ * action-dispatched like `issue_checklist`. A missing required arg for the
459
+ * chosen action throws at this boundary (no round-trip); the server's refusal
460
+ * envelopes (`stale_solution` with the current row, a second recommendation,
461
+ * editing a chosen option) pass through verbatim.
462
+ *
463
+ * There is deliberately NO answer action. Answering a card releases the human
464
+ * gates on it — an agent that could answer its own question could release the
465
+ * very stop it set to wait for a human. The operator answers in the dashboard.
466
+ */
467
+ export async function issueSolution(client, args) {
468
+ const base = `/${encodeURIComponent(args.id)}/solutions`;
469
+ const board = args.board;
470
+ const content = {};
471
+ for (const key of ["title", "body", "pro", "con", "recommended"]) {
472
+ if (args[key] !== undefined)
473
+ content[key] = args[key];
474
+ }
475
+ switch (args.action) {
476
+ case "list":
477
+ return client.request({ method: "GET", path: base, board });
478
+ case "add":
479
+ if (typeof args.title !== "string") {
480
+ throw new Error("issue_solution action=add requires title");
481
+ }
482
+ return client.request({ method: "POST", path: base, body: content, board });
483
+ case "edit":
484
+ if (args.solution_id === undefined) {
485
+ throw new Error("issue_solution action=edit requires solution_id");
486
+ }
487
+ if (typeof args.base_hash !== "string") {
488
+ throw new Error("issue_solution action=edit requires base_hash");
489
+ }
490
+ return client.request({
491
+ method: "PATCH",
492
+ path: `${base}/${args.solution_id}`,
493
+ body: { base_hash: args.base_hash, ...content },
494
+ board,
495
+ });
496
+ case "remove":
497
+ if (args.solution_id === undefined) {
498
+ throw new Error("issue_solution action=remove requires solution_id");
499
+ }
500
+ if (typeof args.base_hash !== "string") {
501
+ throw new Error("issue_solution action=remove requires base_hash");
502
+ }
503
+ return client.request({
504
+ method: "DELETE",
505
+ path: `${base}/${args.solution_id}`,
506
+ body: { base_hash: args.base_hash },
507
+ board,
508
+ });
509
+ }
510
+ }
403
511
  export async function issueRequiresHuman(client, args) {
404
512
  const idEnc = encodeURIComponent(args.id);
405
513
  const board = args.board;
@@ -410,12 +518,13 @@ export async function issueRequiresHuman(client, args) {
410
518
  if (!Array.isArray(args.steps)) {
411
519
  throw new Error("issue_requires_human set=true requires steps[]");
412
520
  }
413
- return client.request({
521
+ const result = await client.request({
414
522
  method: "POST",
415
523
  path: `/${idEnc}/requires-human`,
416
524
  body: { reason: args.reason, steps: args.steps },
417
525
  board,
418
526
  });
527
+ return withSolutionsReminder(client, args.id, board, result);
419
528
  }
420
529
  return client.request({
421
530
  method: "DELETE",
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@
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
23
  * - issue_dependency POST/DELETE /api/issues/:id/dependencies[/:did]
23
24
  * - issue_requires_human POST/DELETE /api/issues/:id/requires-human
24
25
  * - issue_quality_gate POST /api/issues/:id/quality-gates/:gate
@@ -79,7 +80,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
79
80
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
80
81
  import { z } from "zod";
81
82
  import { DashboardHttpClient } from "./http-client.js";
82
- import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueQualityGate, issueQualityGateVerdict, issueRequiresHuman, issueRetro, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, planAddCard, planAddRecord, planConnect, planCreate, planDeleteRecord, planGet, planGetRecord, planList, planSetArchitecture, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
83
+ import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueQualityGate, issueQualityGateVerdict, issueRequiresHuman, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, planAddCard, planAddRecord, planConnect, planCreate, planDeleteRecord, planGet, planGetRecord, planList, planSetArchitecture, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
83
84
  import { PRIORITY_TIER_WORDS } from "./priority.js";
84
85
  function readEnvOrDie(name) {
85
86
  const v = process.env[name];
@@ -195,6 +196,7 @@ const NON_EPIC_TYPES = ["Bug", "Feature", "Story", "Chore", "Task"];
195
196
  // server 400, not silently.
196
197
  const LIST_FIELD_GROUPS = [
197
198
  "description",
199
+ "solutions",
198
200
  "ac",
199
201
  "comments",
200
202
  "retro",
@@ -255,6 +257,12 @@ const boardField = {
255
257
  .optional()
256
258
  .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."),
257
259
  };
260
+ // The three prose fields of a card, each with ONE job. Shared by issue_create
261
+ // (root + phase children) and issue_edit so the guidance an agent reads is
262
+ // identical wherever it writes the field.
263
+ 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".';
264
+ 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.";
265
+ 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.';
258
266
  // ---------------- issue_list ----------------
259
267
  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), 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.", {
260
268
  filter: z
@@ -274,26 +282,27 @@ server.tool("issue_list", "List issues for the dispatch's board by default via G
274
282
  fields: z
275
283
  .array(z.enum(LIST_FIELD_GROUPS))
276
284
  .optional()
277
- .describe("Opt-in field-GROUPS to add to the minimal default row: description, ac, comments, retro, dependencies, triage, requires_human, assignment, quality_gates, children, effort. Absent/empty = minimal scalars only — no joins."),
285
+ .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."),
278
286
  sort: sortField,
279
287
  limit: z.number().int().positive().max(1000).optional(),
280
288
  offset: z.number().int().nonnegative().optional(),
281
289
  ...boardField,
282
290
  }, async (args) => jsonResult(await issueList(client, args)));
283
291
  // ---------------- issue_get ----------------
284
- server.tool("issue_get", "Fetch a single issue via GET /api/issues/:id. Board-scoped; defaults to the dispatch's board. Issue ids are globally unique, so this resolves from any dispatch regardless of `board`. 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); no joined collections. Pass `fields` to opt into named field-GROUPS: description (full description body), 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. 404 envelope on unknown id.", {
292
+ server.tool("issue_get", "Fetch a single issue via GET /api/issues/:id. Board-scoped; defaults to the dispatch's board. Issue ids are globally unique, so this resolves from any dispatch regardless of `board`. 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); no joined collections. Pass `fields` to opt into named field-GROUPS: 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. 404 envelope on unknown id.", {
285
293
  id: z.string().min(1),
286
294
  fields: z
287
295
  .array(z.enum(GET_FIELD_GROUPS))
288
296
  .optional()
289
- .describe("Opt-in field-GROUPS to add to the minimal default row: description, ac, comments, retro, dependencies, triage, requires_human, assignment, quality_gates, children, mirrors, code_review_items. Absent/empty = minimal scalars only."),
297
+ .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."),
290
298
  ...boardField,
291
299
  }, async (args) => jsonResult(await issueGet(client, args)));
292
300
  // ---------------- issue_create ----------------
293
301
  server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-scoped; defaults to the dispatch\'s board. Pass `board` (a qualified id `<repo>:<slug>`) to create the card on another board (forwarded into body.board + ?board=; unknown board → 404). INVARIANT: type=Epic REQUIRES non-empty phase_children[] (epic-with-phases atomicity per DX-575) and the route atomically inserts the epic + every phase in ONE transaction. Non-Epic types REFUSE phase_children[] with 400. Status defaults to Review (no lifecycle timestamps stamped on create). parent_id optional. ac items take {title}; phase children inherit the new epic\'s id as parent_id. Optional list_id PLACES the card directly into a column in ONE call. **Pass EITHER a board_lists id OR the list\'s display NAME (case-insensitive, emoji-tolerant — e.g. a queue name like "⚙️ Fulfillment Queue" or just "Fulfillment Queue") — the server resolves a name to its id.** The card lands DIRECTLY in that column with the matching lifecycle stamped automatically — a `ready`-type queue → ToDo, a `completed` list → Done, etc. **You do NOT need a separate issue_transition(ready) + issue_edit(list_id) afterward — just pass the queue name here and the card is created already in that column.** Omit list_id for the default (Review). NOT valid on type=Epic (Epic status derives from children) → 400. Unknown name/id → 400. **gate_decisions is REQUIRED whenever the board has any OPTIONAL quality gate for the card\'s type** (DX-1594): supply one `{gate, enabled, note}` per board-optional gate. The create FAILS CLOSED — a missing decision returns 400 `{error, required_gate_decisions:[...]}` enumerating exactly which gates to answer, so just retry with a decision for each listed gate. `required`/`disabled` board gates take no decision; a board with no optional gates needs no gate_decisions at all. **ALWAYS pass `triage_enabled` explicitly** (root card AND every phase_children[] entry): decide per card whether it should enter the automatic triage/dispatch pipeline — `true` only when auto-triage is expected without further human review; absent → false, the card is NEVER auto-triaged (explicit-only, reverting DX-1928 — auto-created cards must never silently enter the dispatch pipeline).', {
294
302
  type: z.enum(ISSUE_TYPES),
295
- title: z.string().min(1),
296
- description: z.string(),
303
+ title: z.string().min(1).describe(TITLE_DESCRIBE),
304
+ summary: z.string().min(1).optional().describe(SUMMARY_DESCRIBE),
305
+ description: z.string().describe(DESCRIPTION_DESCRIBE),
297
306
  parent_id: z.string().nullable().optional(),
298
307
  ac: z.array(z.object({ title: z.string().min(1) })).optional(),
299
308
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
@@ -310,8 +319,13 @@ server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-sco
310
319
  phase_children: z
311
320
  .array(z.object({
312
321
  type: z.enum(NON_EPIC_TYPES),
313
- title: z.string().min(1),
314
- description: z.string(),
322
+ title: z.string().min(1).describe(TITLE_DESCRIBE),
323
+ summary: z
324
+ .string()
325
+ .min(1)
326
+ .optional()
327
+ .describe(`${SUMMARY_DESCRIBE} This child's OWN summary — never inherited from the root card.`),
328
+ description: z.string().describe(DESCRIPTION_DESCRIBE),
315
329
  ac: z.array(z.object({ title: z.string().min(1) })).optional(),
316
330
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
317
331
  gate_decisions: z
@@ -336,10 +350,16 @@ server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-sco
336
350
  ...boardField,
337
351
  }, async (args) => jsonResult(await issueCreate(client, args, config.board)));
338
352
  // ---------------- issue_edit ----------------
339
- server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues/:id/edit. ALLOWED keys: title, description, ac, checklists, effort_level, parent_id, priority, list_id, triage_enabled, type. ANY OTHER KEY (lifecycle timestamps, triage state, dependencies, retro, requires_human, blocked/dispatch gates) returns 400 with offending_keys[] and a pointer to the dedicated semantic handler — use issue_transition / issue_triage / issue_comment / issue_dependency / issue_requires_human / issue_retro instead. TYPE (DX-2484): change the card\'s `type` via the `type` key — the route recomputes `dispatchable_derived` immediately when it changes. Changing type TO `Story`, `Bug`, or `Chore` makes the card eligible for autonomous pickup (subject to every other dispatch gate); changing it TO `Task` or a container (`Epic`/`Feature`) REMOVES that eligibility — a planning record or container is never dispatched, in ANY status. This is the way to promote a planning item into real work. PRIORITY (DX-1532): set card priority via the `priority` key — a tier WORD ("lowest"/"low"/"medium"/"high"/"very_high"/"critical", resolved to the tier midpoint) OR a raw number in [0,6). This is the ONLY way to change priority: the numeric `issues.priority` column is what the Trello priority label AND the dashboard badge read — editing a "Priority: <x>" line in the DESCRIPTION changes nothing downstream (a silent false-positive). To honor a "set priority" request, write `priority` here, do NOT edit description prose. CHECKLISTS (DX-1290, extended DX-2653): a card carries 0..N named checklists, each item ONE status `incomplete|failing|passing|cancelled|deferred` (terminal = passing|cancelled|deferred). `deferred` is 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`; a criterion marked with the `📡` post-deploy-evidence prefix can never be `passing` at all (refused 409 at completion, resolve to `deferred` instead). `ac` is the 2-state CONVENIENCE onto the default "Acceptance Criteria" checklist (checked:true ↔ passing, false ↔ incomplete BY DEFAULT — pass `status` alongside `checked` to reach `cancelled`/`failing`/`deferred` instead) — each incoming item is DIFFED against that checklist\'s current live items (matched by the optional `check_item_id`, else by exact title) so an unchanged item keeps its id; only changed items are updated in place, new titles are inserted, and items missing from the array are removed. `checklists` is the GENERIC wholesale write path: it REPLACES every named checklist on the card with full status control (each `{name, items:[{label, detail?, status}]}`) — use it to author named checklists like "Feature Tests". Send EITHER `ac` OR `checklists`, NOT both (400). list_id (DX-1192 / DX-1200) PINS the card to a specific board list. **Pass EITHER a board_lists id OR the list\'s display NAME (case-insensitive, e.g. a queue name like "⚙️ Fulfillment Queue") — the server resolves a name to its id.** Its type MUST match the card\'s CURRENT derived-status list-type (so to route a ToDo card into a `ready`-type queue, ready it first; mismatch / unknown name or id → 400); pass null to clear the pin (back to default-for-type).', {
353
+ server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues/:id/edit. ALLOWED keys: title, summary, description, ac, checklists, effort_level, parent_id, priority, list_id, triage_enabled, type. ANY OTHER KEY (lifecycle timestamps, triage state, dependencies, retro, requires_human, blocked/dispatch gates) returns 400 with offending_keys[] and a pointer to the dedicated semantic handler — use issue_transition / issue_triage / issue_comment / issue_dependency / issue_requires_human / issue_retro instead. TYPE (DX-2484): change the card\'s `type` via the `type` key — the route recomputes `dispatchable_derived` immediately when it changes. Changing type TO `Story`, `Bug`, or `Chore` makes the card eligible for autonomous pickup (subject to every other dispatch gate); changing it TO `Task` or a container (`Epic`/`Feature`) REMOVES that eligibility — a planning record or container is never dispatched, in ANY status. This is the way to promote a planning item into real work. PRIORITY (DX-1532): set card priority via the `priority` key — a tier WORD ("lowest"/"low"/"medium"/"high"/"very_high"/"critical", resolved to the tier midpoint) OR a raw number in [0,6). This is the ONLY way to change priority: the numeric `issues.priority` column is what the Trello priority label AND the dashboard badge read — editing a "Priority: <x>" line in the DESCRIPTION changes nothing downstream (a silent false-positive). To honor a "set priority" request, write `priority` here, do NOT edit description prose. CHECKLISTS (DX-1290, extended DX-2653): a card carries 0..N named checklists, each item ONE status `incomplete|failing|passing|cancelled|deferred` (terminal = passing|cancelled|deferred). `deferred` is 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`; a criterion marked with the `📡` post-deploy-evidence prefix can never be `passing` at all (refused 409 at completion, resolve to `deferred` instead). `ac` is the 2-state CONVENIENCE onto the default "Acceptance Criteria" checklist (checked:true ↔ passing, false ↔ incomplete BY DEFAULT — pass `status` alongside `checked` to reach `cancelled`/`failing`/`deferred` instead) — each incoming item is DIFFED against that checklist\'s current live items (matched by the optional `check_item_id`, else by exact title) so an unchanged item keeps its id; only changed items are updated in place, new titles are inserted, and items missing from the array are removed. `checklists` is the GENERIC wholesale write path: it REPLACES every named checklist on the card with full status control (each `{name, items:[{label, detail?, status}]}`) — use it to author named checklists like "Feature Tests". Send EITHER `ac` OR `checklists`, NOT both (400). list_id (DX-1192 / DX-1200) PINS the card to a specific board list. **Pass EITHER a board_lists id OR the list\'s display NAME (case-insensitive, e.g. a queue name like "⚙️ Fulfillment Queue") — the server resolves a name to its id.** Its type MUST match the card\'s CURRENT derived-status list-type (so to route a ToDo card into a `ready`-type queue, ready it first; mismatch / unknown name or id → 400); pass null to clear the pin (back to default-for-type).', {
340
354
  id: z.string().min(1),
341
- title: z.string().min(1).optional(),
342
- description: z.string().optional(),
355
+ title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
356
+ summary: z
357
+ .string()
358
+ .min(1)
359
+ .nullable()
360
+ .optional()
361
+ .describe(`${SUMMARY_DESCRIBE} Pass null to clear it.`),
362
+ description: z.string().optional().describe(DESCRIPTION_DESCRIBE),
343
363
  type: z
344
364
  .enum(ISSUE_TYPES)
345
365
  .optional()
@@ -387,7 +407,7 @@ server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues
387
407
  ...boardField,
388
408
  }, async (args) => jsonResult(await issueEdit(client, args)));
389
409
  // ---------------- issue_transition ----------------
390
- server.tool("issue_transition", "Stamp a lifecycle transition via POST /api/issues/:id/transition. **THIS IS THE ONLY WAY TO MOVE A CARD'S LIFECYCLE STATE** — DX-835 separated card lifecycle (this tool) from dispatch finalization (`mcp__danxbot__danxbot_complete`). The worker no longer infers card moves from `danxbot_complete.status`; agents that want the card to move MUST call this tool BEFORE calling `danxbot_complete`. Actions: ready (Review→ToDo), pickup (ToDo→In Progress — server checks every dispatch gate: ready_at, blocked_at, requires_human_reason, depends_on partners terminal, conflict_on partners idle; refuses 409 with failed_gate naming the cause; pass manual:true for OPERATOR-SESSION self-pickup — stamps dispatch_kind 'manual', bypasses every card-flow gate except terminal/deleted/already-dispatched, and the worker NEVER auto-transitions the card: no orphan-heal rollback, no Epic auto-rollup — use this whenever the work happens in YOUR current session rather than a worker dispatch, DX-946), rollback_pickup, **complete** (stamps completed_at — moves card to Done; this is YOUR explicit decision, not a side effect of danxbot_complete; REFUSES 409 on Epic if any phase child non-terminal — see non_terminal_phases[]; ALSO refuses 409 with failed_gate 'quality_gate_post' + failed_post_gates[] while any required POST quality gate row != pass — DX-1177), cancel (stamps cancelled_at — terminal), **block** (requires non-empty reason — stamps blocked_at + blocked_reason + clears dispatch; USE THIS when the CARD itself cannot proceed without human intervention; distinct from env-fault dispatch failures which use `danxbot_complete({status:'failed'})`), unblock, archive (parks to Backlog, clears ready_at), reopen (terminal→active, clears completed_at/cancelled_at/archived_at). Terminal cards refuse every action except reopen. Ladder timestamps preserved — forward stamps never clear earlier ones (CLAUDE.md Core Principle 2). **DX-2282 — every path into In Progress now requires an identified claimer.** For `manual:true` pickup called from a DISPATCHED-AGENT session (this MCP tool, bearer = your dispatch token): you MUST pass `assigned_agent` set to YOUR resolved agent/profile name — omitting it, or passing the shared dispatch-token identity itself, is refused 409 `assigned_agent (required, distinguishing)`. A genuine human dashboard session may omit it (auto-resolved from the real logged-in user). Every pickup flavor (work/gate/manual) is refused 409 `assigned_agent (required)` if it would otherwise leave the card with no owner at all. **A manual pickup can now also be refused 409 `failed_gate: \"dispatch_id\"` — \"pickup refused — card claimed by another actor between read and write\" — when it loses a genuine race against a concurrent manual pickup of the same idle card.** Before this, two overlapping manual pickups of the same card could both return 200: the second write silently overwrote the first winner's claim with no error and no distinguishing status code. The claiming write is now atomic (fenced on `dispatch_id IS NULL`), so the loser gets this 409 instead of a false success — on this response, do NOT retry blindly; re-check the card's current `assigned_agent`/`dispatch_id` first, since another actor already has it.", {
410
+ server.tool("issue_transition", "Stamp a lifecycle transition via POST /api/issues/:id/transition. **THIS IS THE ONLY WAY TO MOVE A CARD'S LIFECYCLE STATE** — DX-835 separated card lifecycle (this tool) from dispatch finalization (`mcp__danxbot__danxbot_complete`). The worker no longer infers card moves from `danxbot_complete.status`; agents that want the card to move MUST call this tool BEFORE calling `danxbot_complete`. Actions: ready (Review→ToDo), pickup (ToDo→In Progress — server checks every dispatch gate: ready_at, blocked_at, requires_human_reason, depends_on partners terminal, conflict_on partners idle; refuses 409 with failed_gate naming the cause; pass manual:true for OPERATOR-SESSION self-pickup — stamps dispatch_kind 'manual', bypasses every card-flow gate except terminal/deleted/already-dispatched, and the worker NEVER auto-transitions the card: no orphan-heal rollback, no Epic auto-rollup — use this whenever the work happens in YOUR current session rather than a worker dispatch, DX-946), rollback_pickup, **complete** (stamps completed_at — moves card to Done; this is YOUR explicit decision, not a side effect of danxbot_complete; REFUSES 409 on Epic if any phase child non-terminal — see non_terminal_phases[]; ALSO refuses 409 with failed_gate 'quality_gate_post' + failed_post_gates[] while any required POST quality gate row != pass — DX-1177), cancel (stamps cancelled_at — terminal), **block** (requires non-empty reason — stamps blocked_at + blocked_reason + clears dispatch; USE THIS when the CARD itself cannot proceed without human intervention; distinct from env-fault dispatch failures which use `danxbot_complete({status:'failed'})`), unblock, archive (parks to Backlog, clears ready_at), reopen (terminal→active, clears completed_at/cancelled_at/archived_at). Terminal cards refuse every action except reopen. Ladder timestamps preserved — forward stamps never clear earlier ones (CLAUDE.md Core Principle 2). **DX-2282 — every path into In Progress now requires an identified claimer.** For `manual:true` pickup called from a DISPATCHED-AGENT session (this MCP tool, bearer = your dispatch token): you MUST pass `assigned_agent` set to YOUR resolved agent/profile name — omitting it, or passing the shared dispatch-token identity itself, is refused 409 `assigned_agent (required, distinguishing)`. A genuine human dashboard session may omit it (auto-resolved from the real logged-in user). Every pickup flavor (work/gate/manual) is refused 409 `assigned_agent (required)` if it would otherwise leave the card with no owner at all. **A manual pickup can now also be refused 409 `failed_gate: \"dispatch_id\"` — \"pickup refused — card claimed by another actor between read and write\" — when it loses a genuine race against a concurrent manual pickup of the same idle card.** Before this, two overlapping manual pickups of the same card could both return 200: the second write silently overwrote the first winner's claim with no error and no distinguishing status code. The claiming write is now atomic (fenced on `dispatch_id IS NULL`), so the loser gets this 409 instead of a false success — on this response, do NOT retry blindly; re-check the card's current `assigned_agent`/`dispatch_id` first, since another actor already has it. **A successful `block` returns a `solutions_reminder` beside the envelope** — `{solution_count, instruction}` — because blocking stops the card for a human: before you stop, EVERY viable solution must be listed on the card with issue_solution (the operator answers by picking one). A count of zero means you have listed none.", {
391
411
  id: z.string().min(1),
392
412
  action: z.enum(TRANSITION_ACTIONS),
393
413
  reason: z.string().optional(),
@@ -442,6 +462,26 @@ server.tool("issue_checklist", "Targeted checklist CUD via /api/issues/:id/check
442
462
  status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
443
463
  ...boardField,
444
464
  }, async (args) => jsonResult(await issueChecklist(client, args)));
465
+ // ---------------- issue_solution ----------------
466
+ 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.", {
467
+ id: z.string().min(1),
468
+ action: z.enum(["list", "add", "edit", "remove"]),
469
+ solution_id: z.number().int().positive().optional().describe("Target solution id — required for edit / remove."),
470
+ base_hash: z
471
+ .string()
472
+ .min(1)
473
+ .optional()
474
+ .describe("The solution's content_hash from your last read — required for edit / remove."),
475
+ title: z.string().min(1).optional().describe("Short name for the option — required for add."),
476
+ body: z.string().optional().describe("Markdown detail of what this option actually does."),
477
+ pro: z.string().optional().describe("The case FOR this option."),
478
+ con: z.string().optional().describe("The case AGAINST this option."),
479
+ recommended: z
480
+ .boolean()
481
+ .optional()
482
+ .describe("true on the single option you recommend. At most one live recommended solution per card."),
483
+ ...boardField,
484
+ }, async (args) => jsonResult(await issueSolution(client, args)));
445
485
  // ---------------- issue_dependency ----------------
446
486
  server.tool("issue_dependency", 'Dependency CRUD via /api/issues/:id/dependencies[/:did]. action=add → POST {kind, target_id, reason} where kind ∈ {depends_on, conflict_on}. depends_on adds are CYCLE-CHECKED (BFS from target back to source — 409 if loop). Idempotent: re-adding a live triple returns the existing id. Self-loops refuse 409. action=remove → DELETE /:did. The server REQUIRES the literal reason="recorded_in_error" on removal (encodes "removal means NOT related, never satisfied") — this MCP boundary hardcodes it, so callers do not pass reason on remove. This is the ONLY mechanism the dispatch picker enforces to sequence one card after another — leaving a card at a Review/held status (e.g. an issue_triage "keep" verdict) is NOT a substitute and provides no cross-card ordering protection.', {
447
487
  id: z.string().min(1),
@@ -453,7 +493,7 @@ server.tool("issue_dependency", 'Dependency CRUD via /api/issues/:id/dependencie
453
493
  ...boardField,
454
494
  }, async (args) => jsonResult(await issueDependency(client, args)));
455
495
  // ---------------- issue_requires_human ----------------
456
- 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.", {
496
+ 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.", {
457
497
  id: z.string().min(1),
458
498
  set: z.boolean(),
459
499
  reason: z.string().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.51",
3
+ "version": "0.1.52",
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",