@thehammer/danx-dashboard-mcp 0.1.49 → 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",
@@ -446,6 +555,17 @@ export async function issueRequiresHuman(client, args) {
446
555
  *
447
556
  * `effort_level` (DX-1760) is an independent sibling write: present (incl.
448
557
  * `null`) sets `card_quality_gates.effort_level`; omitted leaves it untouched.
558
+ *
559
+ * **Effectiveness echo.** The server response body is `{issue, applied: true,
560
+ * effective, reason}`, not just `{issue}` — `client.request()` passes it
561
+ * through VERBATIM (see `http-client.ts`'s header doc: "the envelope
562
+ * passthrough is load-bearing"), so no transform is needed here for the
563
+ * calling agent to see `effective` (what `isGateEffectivelyRequired` resolves
564
+ * to right after this write, per the tri-state rule above) and `reason`
565
+ * (`null` when it matches the `required` value just sent, otherwise which
566
+ * board state overrode it). This closes the gap where a 200 alone could not
567
+ * tell a fully-honored write from one the board's `required`/`disabled`
568
+ * state made a complete no-op.
449
569
  */
450
570
  export async function issueQualityGate(client, args) {
451
571
  const body = { required: args.required };
@@ -651,3 +771,81 @@ export async function planSetArchitecture(client, args) {
651
771
  body: { content: args.content, base_hash: args.base_hash },
652
772
  });
653
773
  }
774
+ /**
775
+ * Create a new, empty plan via `POST /api/plans`. GLOBAL — a plan is not
776
+ * board-scoped, so this takes no board and creates no membership; the caller
777
+ * still owns zero cards, zero records and no architecture document until it
778
+ * adds them. This does NOT connect any session to the new plan — call
779
+ * `plan_connect` separately (mirroring how creating a card does not add it to
780
+ * a plan; these are two deliberately separate steps, same as everywhere else
781
+ * in this tool surface).
782
+ */
783
+ export async function planCreate(client, args) {
784
+ return client.request({
785
+ method: "POST",
786
+ path: "",
787
+ basePath: PLANS_BASE_PATH,
788
+ body: { name: args.name },
789
+ });
790
+ }
791
+ /**
792
+ * Read ONE goal/rule/caveat of the plan this session is connected to, via
793
+ * `GET /api/plans/mine/records/:rid`. TAKES NO PLAN ID: the plan is resolved
794
+ * from your connected session, same as `plan_add_record`. Useful both for an
795
+ * ordinary targeted read (skip pulling the whole plan via `plan_get` just to
796
+ * see one record) and for conflict recovery after a 409 `stale_plan_record`
797
+ * — though that refusal already carries `currentBody`, so a second read is
798
+ * rarely needed for that specific case. Not connected →
799
+ * `{error: "session_not_connected"}`. Unknown/foreign record id → 404.
800
+ */
801
+ export async function planGetRecord(client, args) {
802
+ return client.request({
803
+ method: "GET",
804
+ path: `/mine/records/${args.record_id}`,
805
+ basePath: PLANS_BASE_PATH,
806
+ });
807
+ }
808
+ /**
809
+ * Edit a goal/rule/caveat of the plan this session is connected to, via
810
+ * `PATCH /api/plans/mine/records/:rid`. The reference (`G-1`, `R-4`,
811
+ * `CAV-12`) NEVER moves — only the text changes. `content_hash` MUST be the
812
+ * record's `contentHash` from the immediately-prior `plan_get`/
813
+ * `plan_get_record`; the server compares it against the row's current hash
814
+ * and, on a mismatch, refuses the write ENTIRELY and fails loud with
815
+ * `{ok: false, status: 409, body: {error: "stale_plan_record", currentHash,
816
+ * currentBody}}` rather than overwriting whoever wrote in between.
817
+ * `currentBody` rides the SAME refusal — unlike the architecture document's
818
+ * `stale_plan_architecture`, which carries only `currentHash` — so you can
819
+ * merge and retry in ONE round trip without a second `plan_get_record` call.
820
+ * TAKES NO PLAN ID: the plan is resolved from your connected session, same as
821
+ * `plan_add_record`. Not connected → `{error: "session_not_connected"}`.
822
+ */
823
+ export async function planUpdateRecord(client, args) {
824
+ return client.request({
825
+ method: "PATCH",
826
+ path: `/mine/records/${args.record_id}`,
827
+ basePath: PLANS_BASE_PATH,
828
+ body: { body: args.body, content_hash: args.content_hash },
829
+ });
830
+ }
831
+ /**
832
+ * Soft-delete a goal/rule/caveat of the plan this session is connected to,
833
+ * via `DELETE /api/plans/mine/records/:rid`. Its reference (`G-1`, `R-4`,
834
+ * `CAV-12`) is retired PERMANENTLY and never reused by a later record of the
835
+ * same kind. `content_hash` MUST be the record's `contentHash` from the
836
+ * immediately-prior `plan_get`/`plan_get_record`; a stale hash refuses the
837
+ * delete ENTIRELY (nothing is removed) with the same
838
+ * `{ok: false, status: 409, body: {error: "stale_plan_record", currentHash,
839
+ * currentBody}}` shape `plan_update_record` uses. TAKES NO PLAN ID: the plan
840
+ * is resolved from your connected session, same as `plan_add_record`. Not
841
+ * connected → `{error: "session_not_connected"}`. Unknown/foreign/already-
842
+ * deleted record id → 404.
843
+ */
844
+ export async function planDeleteRecord(client, args) {
845
+ return client.request({
846
+ method: "DELETE",
847
+ path: `/mine/records/${args.record_id}`,
848
+ basePath: PLANS_BASE_PATH,
849
+ body: { content_hash: args.content_hash },
850
+ });
851
+ }
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
@@ -33,8 +34,12 @@
33
34
  * - brief_set_page PUT /api/brief/page (DX-2083 / DX-2484)
34
35
  * - plan_list GET /api/plans (DX-2683)
35
36
  * - plan_get GET /api/plans/:id/full | /api/plans/mine
37
+ * - plan_create POST /api/plans
36
38
  * - plan_connect POST /api/plan-sessions/me/plan
37
39
  * - plan_add_record POST /api/plans/mine/records
40
+ * - plan_get_record GET /api/plans/mine/records/:rid (DX-2681 follow-up)
41
+ * - plan_update_record PATCH /api/plans/mine/records/:rid (DX-2681 follow-up)
42
+ * - plan_delete_record DELETE /api/plans/mine/records/:rid (DX-2681 follow-up)
38
43
  * - plan_add_card POST /api/plans/mine/cards
39
44
  * - plan_set_architecture PUT /api/plans/mine/architecture
40
45
  *
@@ -75,7 +80,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
75
80
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
76
81
  import { z } from "zod";
77
82
  import { DashboardHttpClient } from "./http-client.js";
78
- import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueQualityGate, issueQualityGateVerdict, issueRequiresHuman, issueRetro, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, planAddCard, planAddRecord, planConnect, planGet, planList, planSetArchitecture, 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";
79
84
  import { PRIORITY_TIER_WORDS } from "./priority.js";
80
85
  function readEnvOrDie(name) {
81
86
  const v = process.env[name];
@@ -191,6 +196,7 @@ const NON_EPIC_TYPES = ["Bug", "Feature", "Story", "Chore", "Task"];
191
196
  // server 400, not silently.
192
197
  const LIST_FIELD_GROUPS = [
193
198
  "description",
199
+ "solutions",
194
200
  "ac",
195
201
  "comments",
196
202
  "retro",
@@ -251,6 +257,12 @@ const boardField = {
251
257
  .optional()
252
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."),
253
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.';
254
266
  // ---------------- issue_list ----------------
255
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.", {
256
268
  filter: z
@@ -270,26 +282,27 @@ server.tool("issue_list", "List issues for the dispatch's board by default via G
270
282
  fields: z
271
283
  .array(z.enum(LIST_FIELD_GROUPS))
272
284
  .optional()
273
- .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."),
274
286
  sort: sortField,
275
287
  limit: z.number().int().positive().max(1000).optional(),
276
288
  offset: z.number().int().nonnegative().optional(),
277
289
  ...boardField,
278
290
  }, async (args) => jsonResult(await issueList(client, args)));
279
291
  // ---------------- issue_get ----------------
280
- 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.", {
281
293
  id: z.string().min(1),
282
294
  fields: z
283
295
  .array(z.enum(GET_FIELD_GROUPS))
284
296
  .optional()
285
- .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."),
286
298
  ...boardField,
287
299
  }, async (args) => jsonResult(await issueGet(client, args)));
288
300
  // ---------------- issue_create ----------------
289
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).', {
290
302
  type: z.enum(ISSUE_TYPES),
291
- title: z.string().min(1),
292
- 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),
293
306
  parent_id: z.string().nullable().optional(),
294
307
  ac: z.array(z.object({ title: z.string().min(1) })).optional(),
295
308
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
@@ -306,8 +319,13 @@ server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-sco
306
319
  phase_children: z
307
320
  .array(z.object({
308
321
  type: z.enum(NON_EPIC_TYPES),
309
- title: z.string().min(1),
310
- 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),
311
329
  ac: z.array(z.object({ title: z.string().min(1) })).optional(),
312
330
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
313
331
  gate_decisions: z
@@ -332,10 +350,16 @@ server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-sco
332
350
  ...boardField,
333
351
  }, async (args) => jsonResult(await issueCreate(client, args, config.board)));
334
352
  // ---------------- issue_edit ----------------
335
- 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) — wholesale soft-delete + reinsert of that checklist\'s items. `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).', {
336
354
  id: z.string().min(1),
337
- title: z.string().min(1).optional(),
338
- 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),
339
363
  type: z
340
364
  .enum(ISSUE_TYPES)
341
365
  .optional()
@@ -352,8 +376,13 @@ server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues
352
376
  .string()
353
377
  .optional()
354
378
  .describe("DX-2653 — paired with `status`; required (non-empty) when `status` is `deferred`."),
379
+ check_item_id: z
380
+ .union([z.string(), z.number()])
381
+ .optional()
382
+ .describe("OPTIONAL — the item's real checklist-item id (the same value issue_get returns as ac[].check_item_id). Each incoming item is correlated to the checklist's current live rows by this id when supplied, else by an exact title match. An item that matches and is byte-identical (title + checked/status/detail) to its stored row is left untouched and KEEPS its id; only items that actually changed are updated in place, a title with no match is inserted fresh, and a live row missing from this array is removed. Supply check_item_id only to disambiguate two AC items that share an identical title — every other call can omit it and rely on title matching."),
355
383
  }))
356
- .optional(),
384
+ .optional()
385
+ .describe("The 2-state convenience onto the default Acceptance Criteria checklist (checked:true ↔ passing, false ↔ incomplete BY DEFAULT — pass `status` to reach `cancelled`/`failing`/`deferred` instead). Diffed against the checklist's current live items — unchanged items keep their id, changed ones are updated in place, new titles are inserted, and existing items missing from this array are removed. See check_item_id below for disambiguating duplicate titles, and status/detail below for the full status vocabulary."),
357
386
  checklists: z
358
387
  .array(z.object({
359
388
  name: z.string().min(1),
@@ -378,7 +407,7 @@ server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues
378
407
  ...boardField,
379
408
  }, async (args) => jsonResult(await issueEdit(client, args)));
380
409
  // ---------------- issue_transition ----------------
381
- 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.", {
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.", {
382
411
  id: z.string().min(1),
383
412
  action: z.enum(TRANSITION_ACTIONS),
384
413
  reason: z.string().optional(),
@@ -433,6 +462,26 @@ server.tool("issue_checklist", "Targeted checklist CUD via /api/issues/:id/check
433
462
  status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
434
463
  ...boardField,
435
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)));
436
485
  // ---------------- issue_dependency ----------------
437
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.', {
438
487
  id: z.string().min(1),
@@ -444,7 +493,7 @@ server.tool("issue_dependency", 'Dependency CRUD via /api/issues/:id/dependencie
444
493
  ...boardField,
445
494
  }, async (args) => jsonResult(await issueDependency(client, args)));
446
495
  // ---------------- issue_requires_human ----------------
447
- 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.", {
448
497
  id: z.string().min(1),
449
498
  set: z.boolean(),
450
499
  reason: z.string().optional(),
@@ -452,7 +501,7 @@ server.tool("issue_requires_human", "Set or clear the requires_human dispatch ga
452
501
  ...boardField,
453
502
  }, async (args) => jsonResult(await issueRequiresHuman(client, args)));
454
503
  // ---------------- issue_quality_gate ----------------
455
- server.tool("issue_quality_gate", "Toggle a single card's per-card quality-gate `required` flag via POST /api/issues/:id/quality-gates/:gate {required} — the SAME write the dashboard drawer's Quality Gates tab performs (DX-1181). This is the ONLY post-create way to mark a gate required/not-required: `issue_create` carries `gate_decisions` at birth, and `issue_edit` REJECTS gate keys (400 offending_keys) — without this tool a card created without a gate can never have it turned on by an agent. `gate` is a registry name: `plan-dependency` | `plan-architecture` | `plan-tdd` | `code-test-quality` | `code-architecture` | `code-quality` (the PRE/plan- gates run before the work dispatch; the POST/code- gates block issue_transition complete). Unknown gate → 400 (never a silent no-op); a card with no seeded row for a registered gate → 500 (canonical corruption). NOTE board requirement is TRI-STATE per gate (`board_quality_gate_settings.default_state`, the Agents-tab surface), NOT a binary on/off: `required` = gate always runs (this flag irrelevant); `optional` = gate runs WHEN this per-card flag is true (per-card opt-in — `optional` is ENABLED, NOT off); `disabled` = never runs (this flag inert). So flipping `required:true` here LAUNCHES the gate when the board state is `required` OR `optional`; it is inert ONLY when the board state is `disabled`. Do not read `optional` as off. (Source of truth: `isGateEffectivelyRequired` in `src/issues/quality-gates/read.ts`.) DX-1760: optionally pass `effort_level` — a per-`(card, gate)` reviewer-rung override for a `plan-*` gate, written alongside `required`; omit to leave it untouched, pass `null` to clear a prior override. Returns the hydrated issue. Board-scoped; pass `board` (`<repo>:<slug>`) to target another board.", {
504
+ server.tool("issue_quality_gate", "Toggle a single card's per-card quality-gate `required` flag via POST /api/issues/:id/quality-gates/:gate {required} — the SAME write the dashboard drawer's Quality Gates tab performs (DX-1181). This is the ONLY post-create way to mark a gate required/not-required: `issue_create` carries `gate_decisions` at birth, and `issue_edit` REJECTS gate keys (400 offending_keys) — without this tool a card created without a gate can never have it turned on by an agent. `gate` is a registry name: `plan-dependency` | `plan-architecture` | `plan-tdd` | `code-test-quality` | `code-architecture` | `code-quality` (the PRE/plan- gates run before the work dispatch; the POST/code- gates block issue_transition complete). Unknown gate → 400 (never a silent no-op); a card with no seeded row for a registered gate → 500 (canonical corruption). NOTE board requirement is TRI-STATE per gate (`board_quality_gate_settings.default_state`, the Agents-tab surface), NOT a binary on/off: `required` = gate always runs (this flag irrelevant); `optional` = gate runs WHEN this per-card flag is true (per-card opt-in — `optional` is ENABLED, NOT off); `disabled` = never runs (this flag inert). So flipping `required:true` here LAUNCHES the gate when the board state is `required` OR `optional`; it is inert ONLY when the board state is `disabled`. Do not read `optional` as off. (Source of truth: `isGateEffectivelyRequired` in `src/issues/quality-gates/read.ts`.) DX-1760: optionally pass `effort_level` — a per-`(card, gate)` reviewer-rung override for a `plan-*` gate, written alongside `required`; omit to leave it untouched, pass `null` to clear a prior override. **The write itself ALWAYS succeeds and is echoed back** — the response is `{issue, applied: true, effective, reason}`, not just `{issue}`: `effective` is what `isGateEffectivelyRequired` resolves to for this (card, gate) right AFTER the write (accounts for the board tri-state above), and `reason` is `null` when `effective` matches the `required` value you just sent, or a plain-English explanation when the board's `required`/`disabled` state overrode it — READ `effective`/`reason`, not just the 200, to know whether this call actually changed whether the gate runs. Board-scoped; pass `board` (`<repo>:<slug>`) to target another board.", {
456
505
  id: z.string().min(1),
457
506
  gate: z.enum([
458
507
  "plan-dependency",
@@ -562,11 +611,14 @@ server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug
562
611
  }, async (args) => jsonResult(await briefSetPage(client, args)));
563
612
  // ---------------- plans (DX-2683) ----------------
564
613
  // THE BINDING ASYMMETRY IS IN THE SCHEMAS, NOT IN PROSE. `plan_get` takes an
565
- // optional `plan_id`; `plan_add_record`, `plan_add_card` and
614
+ // optional `plan_id`; `plan_add_record`, `plan_get_record`,
615
+ // `plan_update_record`, `plan_delete_record`, `plan_add_card` and
566
616
  // `plan_set_architecture` take NO plan id in any form, so a connected session
567
617
  // cannot even express "write to that other plan". `plan_connect` takes one
568
618
  // because binding a session to a plan is the one operation that is ABOUT a
569
- // plan id — and it can only ever bind the caller's own session.
619
+ // plan id — and it can only ever bind the caller's own session. `plan_create`
620
+ // also takes no plan id, but for a different reason: it MAKES a plan rather
621
+ // than acting on one, so there is no existing plan for an id to name yet.
570
622
  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}}`. `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). NOTE this is NOT the board Brief (`brief_list`), which is a different feature entirely.", {}, async () => jsonResult(await planList(client)));
571
623
  server.tool("plan_get", "Read one plan WHOLE via GET /api/plans (DX-2683) — its member cards (with the boards they cover), its goals, rules and caveats, its architecture document, the sessions working on it, and your own session state. One call, not five. 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. Returns `{plan, cards, boards, records: {goal: [], rule: [], caveat: []}, architecture: {content, contentHash, updatedAt, updatedBy}, sessions, session}`. ALWAYS `plan_get` immediately before `plan_set_architecture` and pass the returned `architecture.contentHash` back as `base_hash`.", {
572
624
  plan_id: z
@@ -576,6 +628,9 @@ server.tool("plan_get", "Read one plan WHOLE via GET /api/plans (DX-2683) — it
576
628
  .optional()
577
629
  .describe("A plan id from `plan_list`. Omit to read the plan this session is connected to."),
578
630
  }, async (args) => jsonResult(await planGet(client, args)));
631
+ 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 document; 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.", {
632
+ name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
633
+ }, async (args) => jsonResult(await planCreate(client, args)));
579
634
  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.", {
580
635
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
581
636
  }, async (args) => jsonResult(await planConnect(client, args)));
@@ -585,6 +640,22 @@ server.tool("plan_add_record", "Add a GOAL, RULE or CAVEAT to the plan this sess
585
640
  .describe("Which standing record this is. Determines the reference prefix."),
586
641
  body: z.string().min(1).describe("The record's text. Plain text, not markdown."),
587
642
  }, async (args) => jsonResult(await planAddRecord(client, args)));
643
+ server.tool("plan_get_record", "Read ONE goal/rule/caveat of the plan this session is connected to, via GET /api/plans/mine/records/:rid (DX-2681 follow-up). Useful for an ordinary targeted read (skip pulling the whole plan via `plan_get` just to see one record) and for conflict recovery after a 409 `stale_plan_record` from `plan_update_record`/`plan_delete_record` — though that refusal already carries `currentBody`, so a second read is rarely needed for that specific case. TAKES NO PLAN ID: the plan is resolved from your connected session, same as `plan_add_record`. Not connected → `{error: \"session_not_connected\"}`. Unknown or another plan's record id → 404. Returns `{record: {id, planId, kind, refNum, ref, body, contentHash, createdAt, updatedAt}}`.", {
644
+ record_id: z.number().int().positive().describe("A record id, from `plan_add_record`, `plan_get` or `plan_get_record` itself."),
645
+ }, async (args) => jsonResult(await planGetRecord(client, args)));
646
+ server.tool("plan_update_record", 'Edit a goal/rule/caveat of the plan this session is connected to, via PATCH /api/plans/mine/records/:rid (DX-2681 follow-up). The reference (`G-1`, `R-4`, `CAV-12`) NEVER moves — only the text changes. `content_hash` MUST be the record\'s `contentHash` from the immediately-prior `plan_get`/`plan_get_record`/`plan_add_record`; the server compares it against the row\'s current hash and, on a mismatch, refuses the write ENTIRELY (nothing is changed) and fails loud with `{ok: false, body: {error: "stale_plan_record", currentHash, currentBody}}` rather than overwriting whoever wrote in between. `currentBody` rides that SAME refusal — unlike the architecture document\'s `stale_plan_architecture`, which carries only `currentHash` — so you can merge and retry in ONE round trip, no second read needed. On a refusal: merge your edit into `currentBody`, and retry with `content_hash: currentHash` — never retry blindly against the old hash. TAKES NO PLAN ID: the plan is resolved from your connected session. Not connected → `{error: "session_not_connected"}`. Returns the updated record plus that kind\'s full list.', {
647
+ record_id: z.number().int().positive().describe("The record id to edit."),
648
+ content_hash: z
649
+ .string()
650
+ .describe("The record's `contentHash` from the immediately-prior read. Required — an absent hash is not read as a wildcard."),
651
+ body: z.string().min(1).describe("The record's new text, replacing what is stored. Plain text, not markdown."),
652
+ }, async (args) => jsonResult(await planUpdateRecord(client, args)));
653
+ server.tool("plan_delete_record", 'Soft-delete a goal/rule/caveat of the plan this session is connected to, via DELETE /api/plans/mine/records/:rid (DX-2681 follow-up). Its reference (`G-1`, `R-4`, `CAV-12`) is retired PERMANENTLY and never reused by a later record of the same kind. `content_hash` MUST be the record\'s `contentHash` from the immediately-prior `plan_get`/`plan_get_record`/`plan_add_record`; a stale hash refuses the delete ENTIRELY (nothing is removed) with the same `{ok: false, body: {error: "stale_plan_record", currentHash, currentBody}}` shape `plan_update_record` uses — merge and retry with `content_hash: currentHash` rather than retrying blindly. TAKES NO PLAN ID: the plan is resolved from your connected session. Not connected → `{error: "session_not_connected"}`. Unknown, foreign, or already-deleted record id → 404. Returns that kind\'s remaining list.', {
654
+ record_id: z.number().int().positive().describe("The record id to delete."),
655
+ content_hash: z
656
+ .string()
657
+ .describe("The record's `contentHash` from the immediately-prior read. Required — an absent hash is not read as a wildcard."),
658
+ }, async (args) => jsonResult(await planDeleteRecord(client, args)));
588
659
  server.tool("plan_add_card", "Add an existing card to the plan this session is connected to, via POST /api/plans/mine/cards (DX-2683). The card may live on ANY board — that is what a plan is for. Idempotent: re-adding a card already on the plan is a no-op, not an error, and a card may sit in several plans at once. This adds MEMBERSHIP only; it never edits the card. TAKES NO PLAN ID: the plan is resolved from your connected session. Not connected → `{error: \"session_not_connected\"}`. Unknown card → 404. Returns the plan's full member list.", {
589
660
  card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
590
661
  }, async (args) => jsonResult(await planAddCard(client, args)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.49",
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",