@thehammer/danx-dashboard-mcp 0.1.51 → 0.1.53

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,15 +23,30 @@ 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
 
36
+ ## `listen` — a working session's event listener
37
+
38
+ `plan_connect` connects the session to a plan AND returns `listener: {command, persistent: true, instruction}`. The agent arms `command` with Claude Code's Monitor tool (`persistent: true`); every line the command prints becomes a notification that wakes the session.
39
+
40
+ ```bash
41
+ npx -y @thehammer/danx-dashboard-mcp@<version> listen --stream <dashboard>/api/plan-sessions/stream --ticket <ticket> --lease-ms <ms>
42
+ ```
43
+
44
+ All three flags come from the dashboard's own ticket response; `plan_connect` assembles the command, so never build it by hand.
45
+
46
+ - **Credential.** `--ticket` is a listener ticket minted by `POST /api/plan-sessions/me/stream-ticket` for THIS session only. It authorizes reading that one session's event stream and nothing else, and stops the moment the credential that minted it is revoked — the MCP server's own token never appears in the command. Minting a new one (calling `plan_connect` again) ends the previous listener; a second connection with the same ticket replaces the first.
47
+ - **Output.** Exactly one line per event on the connected plan's cards — `[DX-8 "Title" repo:board] newms87 answered: chose "Pause E2E" — note: "only this week"`, `… commented: "…"`, `… set requires_human: "…"`, `… cleared requires_human`, `… blocked the card: "…"`, `… unblocked the card`. Nothing for keep-alives or reconnects. The session's own writes are never echoed back to it. An event it cannot read still produces one `could not read event` line.
48
+ - **Reconnect.** A read-idle timeout (three missed keep-alives) turns a silently dead connection into a drop. Capped exponential backoff (1s → 30s) that resets only after a healthy connection, with `Last-Event-ID`, so a dashboard restart replays what was missed and nothing is printed twice. One final `[danx-dashboard listen] …` line and exit when the ticket is refused or revoked (exit 1), when a newer listener takes over (exit 0), or after `--lease-ms` without a healthy connection (exit 1) — the remedy is always `plan_connect` again.
49
+
35
50
  ## Build + test
36
51
 
37
52
  ```bash
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",
@@ -612,6 +721,39 @@ export async function planGet(client, args = {}) {
612
721
  basePath: PLANS_BASE_PATH,
613
722
  });
614
723
  }
724
+ function readIssuedTicket(body) {
725
+ const b = body;
726
+ return b !== null &&
727
+ typeof b.ticket === "string" &&
728
+ b.ticket !== "" &&
729
+ typeof b.streamPath === "string" &&
730
+ typeof b.leaseMs === "number"
731
+ ? { ticket: b.ticket, streamPath: b.streamPath, leaseMs: b.leaseMs }
732
+ : null;
733
+ }
734
+ /**
735
+ * The exact shell command a Monitor runs. The ticket is the only credential in
736
+ * it; the stream URL and lease come from the dashboard's own ticket response, so
737
+ * the listener carries no copy of either.
738
+ */
739
+ export function listenCommand(target, issued) {
740
+ const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
741
+ const streamUrl = `${target.baseUrl.replace(/\/+$/, "")}${issued.streamPath}`;
742
+ return (`npx -y ${target.packageSpec} listen --stream ${quote(streamUrl)} ` +
743
+ `--ticket ${quote(issued.ticket)} --lease-ms ${issued.leaseMs}`);
744
+ }
745
+ function listenerNotArmed(status, connected, ticketResponse) {
746
+ return {
747
+ ok: false,
748
+ status,
749
+ body: {
750
+ error: "listener_not_armed",
751
+ message: "This session IS now connected to the plan, but no listener ticket was issued, so no listener can be armed and this plan's events will NOT reach this session. Resolve the problem below and call plan_connect again.",
752
+ connected,
753
+ ticket_response: ticketResponse,
754
+ },
755
+ };
756
+ }
615
757
  /**
616
758
  * Connect THIS session to a plan — the same write the operator's Connect
617
759
  * action performs, reaching the same server-side code path. `me` in the URL
@@ -620,14 +762,51 @@ export async function planGet(client, args = {}) {
620
762
  *
621
763
  * A session already on another plan is MOVED, and the response says which
622
764
  * plan it left (`movedFrom`).
765
+ *
766
+ * THEN IT ARMS THE LISTENER. A connected session must hear about its plan's
767
+ * cards the moment something happens, so the reply carries the exact Monitor
768
+ * command to run. The command holds a stream TICKET this server mints for the
769
+ * session — never this server's own long-lived token, which must not appear in
770
+ * a command line. Minting a ticket replaces the session's previous one, so
771
+ * re-connecting (after a restart, or to re-arm) leaves exactly one listener.
772
+ *
773
+ * A refused ticket is NOT reported as a successful connect: the binding did
774
+ * happen, but a session that believes it is listening when it is not would miss
775
+ * the operator's answer silently, so the whole call fails loud and says both.
623
776
  */
624
- export async function planConnect(client, args) {
625
- return client.request({
777
+ export async function planConnect(client, args, listener) {
778
+ const connected = await client.request({
626
779
  method: "POST",
627
780
  path: "/me/plan",
628
781
  basePath: PLAN_SESSIONS_BASE_PATH,
629
782
  body: { plan_id: args.plan_id },
630
783
  });
784
+ if (!connected.ok)
785
+ return connected;
786
+ let issued;
787
+ try {
788
+ issued = await client.request({
789
+ method: "POST",
790
+ path: "/me/stream-ticket",
791
+ basePath: PLAN_SESSIONS_BASE_PATH,
792
+ });
793
+ }
794
+ catch (err) {
795
+ // The binding already happened; a thrown ticket request must not erase that fact.
796
+ return listenerNotArmed(502, connected.body, { error: err instanceof Error ? err.message : String(err) });
797
+ }
798
+ const ticket = issued.ok ? readIssuedTicket(issued.body) : null;
799
+ if (ticket === null) {
800
+ return listenerNotArmed(issued.ok ? 502 : issued.status, connected.body, issued.body);
801
+ }
802
+ return {
803
+ ...connected,
804
+ listener: {
805
+ command: listenCommand(listener, ticket),
806
+ persistent: true,
807
+ instruction: "Arm this now with the Monitor tool (command above, persistent: true). Every comment, answer, requires_human change and block/unblock on this plan's cards will then arrive as a notification within seconds — do not poll for them. If the Monitor prints that it gave up or was ended, call plan_connect again and arm the new command.",
808
+ },
809
+ };
631
810
  }
632
811
  /** Add a goal, rule or caveat to the connected plan. */
633
812
  export async function planAddRecord(client, args) {
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
@@ -73,13 +74,15 @@
73
74
  * agent reads `body.error` + structured fields to decide next action.
74
75
  * 5xx and network failures throw — never silently swallowed.
75
76
  */
77
+ import { createRequire } from "node:module";
76
78
  import { basename } from "node:path";
77
79
  import { isEntrypointModule } from "./entrypoint.js";
80
+ import { LISTEN_SUBCOMMAND, runListenCommand } from "./listen.js";
78
81
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
79
82
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
80
83
  import { z } from "zod";
81
84
  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";
85
+ 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
86
  import { PRIORITY_TIER_WORDS } from "./priority.js";
84
87
  function readEnvOrDie(name) {
85
88
  const v = process.env[name];
@@ -160,6 +163,16 @@ function readSessionConfig() {
160
163
  const fallback = cwdName === "" ? `session ${id.slice(0, 8)}` : cwdName;
161
164
  return { id, title: readEnvOptional("DANX_SESSION_TITLE") ?? fallback };
162
165
  }
166
+ /**
167
+ * This build's own npm spec. `plan_connect` hands out a `listen` command pinned to
168
+ * it, so the listener that runs is always the one written for the stream this
169
+ * server's dashboard speaks — never whatever `npx` happens to have cached.
170
+ * `package.json` sits one level above both `src/` (tsx) and `dist/` (the bin).
171
+ */
172
+ const PACKAGE_SPEC = (() => {
173
+ const pkg = createRequire(import.meta.url)("../package.json");
174
+ return `${pkg.name}@${pkg.version}`;
175
+ })();
163
176
  export const server = new McpServer({
164
177
  name: "danx-dashboard-mcp",
165
178
  version: "0.1.0",
@@ -195,6 +208,7 @@ const NON_EPIC_TYPES = ["Bug", "Feature", "Story", "Chore", "Task"];
195
208
  // server 400, not silently.
196
209
  const LIST_FIELD_GROUPS = [
197
210
  "description",
211
+ "solutions",
198
212
  "ac",
199
213
  "comments",
200
214
  "retro",
@@ -255,6 +269,12 @@ const boardField = {
255
269
  .optional()
256
270
  .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
271
  };
272
+ // The three prose fields of a card, each with ONE job. Shared by issue_create
273
+ // (root + phase children) and issue_edit so the guidance an agent reads is
274
+ // identical wherever it writes the field.
275
+ 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".';
276
+ 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.";
277
+ 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
278
  // ---------------- issue_list ----------------
259
279
  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
280
  filter: z
@@ -274,26 +294,27 @@ server.tool("issue_list", "List issues for the dispatch's board by default via G
274
294
  fields: z
275
295
  .array(z.enum(LIST_FIELD_GROUPS))
276
296
  .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."),
297
+ .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
298
  sort: sortField,
279
299
  limit: z.number().int().positive().max(1000).optional(),
280
300
  offset: z.number().int().nonnegative().optional(),
281
301
  ...boardField,
282
302
  }, async (args) => jsonResult(await issueList(client, args)));
283
303
  // ---------------- 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.", {
304
+ 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
305
  id: z.string().min(1),
286
306
  fields: z
287
307
  .array(z.enum(GET_FIELD_GROUPS))
288
308
  .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."),
309
+ .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
310
  ...boardField,
291
311
  }, async (args) => jsonResult(await issueGet(client, args)));
292
312
  // ---------------- issue_create ----------------
293
313
  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
314
  type: z.enum(ISSUE_TYPES),
295
- title: z.string().min(1),
296
- description: z.string(),
315
+ title: z.string().min(1).describe(TITLE_DESCRIBE),
316
+ summary: z.string().min(1).optional().describe(SUMMARY_DESCRIBE),
317
+ description: z.string().describe(DESCRIPTION_DESCRIBE),
297
318
  parent_id: z.string().nullable().optional(),
298
319
  ac: z.array(z.object({ title: z.string().min(1) })).optional(),
299
320
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
@@ -310,8 +331,13 @@ server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-sco
310
331
  phase_children: z
311
332
  .array(z.object({
312
333
  type: z.enum(NON_EPIC_TYPES),
313
- title: z.string().min(1),
314
- description: z.string(),
334
+ title: z.string().min(1).describe(TITLE_DESCRIBE),
335
+ summary: z
336
+ .string()
337
+ .min(1)
338
+ .optional()
339
+ .describe(`${SUMMARY_DESCRIBE} This child's OWN summary — never inherited from the root card.`),
340
+ description: z.string().describe(DESCRIPTION_DESCRIBE),
315
341
  ac: z.array(z.object({ title: z.string().min(1) })).optional(),
316
342
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
317
343
  gate_decisions: z
@@ -336,10 +362,16 @@ server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-sco
336
362
  ...boardField,
337
363
  }, async (args) => jsonResult(await issueCreate(client, args, config.board)));
338
364
  // ---------------- 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).', {
365
+ 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
366
  id: z.string().min(1),
341
- title: z.string().min(1).optional(),
342
- description: z.string().optional(),
367
+ title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
368
+ summary: z
369
+ .string()
370
+ .min(1)
371
+ .nullable()
372
+ .optional()
373
+ .describe(`${SUMMARY_DESCRIBE} Pass null to clear it.`),
374
+ description: z.string().optional().describe(DESCRIPTION_DESCRIBE),
343
375
  type: z
344
376
  .enum(ISSUE_TYPES)
345
377
  .optional()
@@ -387,7 +419,7 @@ server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues
387
419
  ...boardField,
388
420
  }, async (args) => jsonResult(await issueEdit(client, args)));
389
421
  // ---------------- 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.", {
422
+ 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
423
  id: z.string().min(1),
392
424
  action: z.enum(TRANSITION_ACTIONS),
393
425
  reason: z.string().optional(),
@@ -442,6 +474,26 @@ server.tool("issue_checklist", "Targeted checklist CUD via /api/issues/:id/check
442
474
  status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
443
475
  ...boardField,
444
476
  }, async (args) => jsonResult(await issueChecklist(client, args)));
477
+ // ---------------- issue_solution ----------------
478
+ 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.", {
479
+ id: z.string().min(1),
480
+ action: z.enum(["list", "add", "edit", "remove"]),
481
+ solution_id: z.number().int().positive().optional().describe("Target solution id — required for edit / remove."),
482
+ base_hash: z
483
+ .string()
484
+ .min(1)
485
+ .optional()
486
+ .describe("The solution's content_hash from your last read — required for edit / remove."),
487
+ title: z.string().min(1).optional().describe("Short name for the option — required for add."),
488
+ body: z.string().optional().describe("Markdown detail of what this option actually does."),
489
+ pro: z.string().optional().describe("The case FOR this option."),
490
+ con: z.string().optional().describe("The case AGAINST this option."),
491
+ recommended: z
492
+ .boolean()
493
+ .optional()
494
+ .describe("true on the single option you recommend. At most one live recommended solution per card."),
495
+ ...boardField,
496
+ }, async (args) => jsonResult(await issueSolution(client, args)));
445
497
  // ---------------- issue_dependency ----------------
446
498
  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
499
  id: z.string().min(1),
@@ -453,7 +505,7 @@ server.tool("issue_dependency", 'Dependency CRUD via /api/issues/:id/dependencie
453
505
  ...boardField,
454
506
  }, async (args) => jsonResult(await issueDependency(client, args)));
455
507
  // ---------------- 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.", {
508
+ 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
509
  id: z.string().min(1),
458
510
  set: z.boolean(),
459
511
  reason: z.string().optional(),
@@ -579,8 +631,8 @@ server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug
579
631
  // plan id — and it can only ever bind the caller's own session. `plan_create`
580
632
  // also takes no plan id, but for a different reason: it MAKES a plan rather
581
633
  // than acting on one, so there is no existing plan for an id to name yet.
582
- 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)));
583
- 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`.", {
634
+ server.tool("plan_list", "List every plan via GET /api/plans (DX-2683), and learn which plan THIS session is connected to. Plans are GLOBAL, not board-scoped: a plan is a named, dated set of cards an operator assembled by hand, and its cards may come from any repository. Returns `{ok, status, body: {plans: [{id, name, createdAt, cardCount, boards}], session, sessionListenerAttached}}`. `session` is your own registration — `{sessionId, title, planId, planName, firstSeenAt, lastActiveAt}` — or `null` if this process is not running inside a Claude Code session. A `planId` of null means you are connected to no plan: read any plan with `plan_get`, then `plan_connect` to the one you are working on (or ask the operator to connect you from the Plans list). `sessionListenerAttached` says whether your event listener is running: `false` while connected to a plan means you will NOT hear about its cards — call `plan_connect` again and arm the Monitor it returns. NOTE this is NOT the board Brief (`brief_list`), which is a different feature entirely.", {}, async () => jsonResult(await planList(client)));
635
+ 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, sessionListenerAttached}` — `sessionListenerAttached: false` while connected means your event listener is not running; call `plan_connect` again and arm the Monitor it returns. ALWAYS `plan_get` immediately before `plan_set_architecture` and pass the returned `architecture.contentHash` back as `base_hash`.", {
584
636
  plan_id: z
585
637
  .number()
586
638
  .int()
@@ -591,9 +643,9 @@ server.tool("plan_get", "Read one plan WHOLE via GET /api/plans (DX-2683) — it
591
643
  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.", {
592
644
  name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
593
645
  }, async (args) => jsonResult(await planCreate(client, args)));
594
- 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.", {
646
+ server.tool("plan_connect", "Connect THIS session to a plan via POST /api/plan-sessions/me/plan (DX-2683) — the same binding the operator's Connect action writes, through the same server-side path. A session is connected to AT MOST ONE plan (enforced by the schema, not by convention); connecting while already on another plan MOVES you, and the response says which plan you left: `{ok, status, body: {session, movedFrom: {id, name} | null}}`. `movedFrom: null` means you were on no plan, or already on this one. It can only ever bind your OWN session — `me` is resolved from the session id this server forwards, never from anything you pass. After this, every plan WRITE tool acts on this plan, and no plan id is accepted anywhere. THE REPLY ALSO CARRIES `listener: {command, persistent: true, instruction}` — arm it IMMEDIATELY with the Monitor tool (`command` as given, `persistent: true`): from then on every comment, answer, requires_human change and block/unblock on this plan's cards arrives as a notification line like `[DX-8 \"Title\" repo:board] newms87 answered: chose \"Pause E2E\" — note: \"…\"`. Never poll for these. The command carries a narrow stream ticket, not a credential; calling plan_connect again (same plan is fine) issues a new one and ends the old listener, which is how you re-arm after a session restart or after the Monitor reports it gave up. If the ticket cannot be issued the call fails with `listener_not_armed` even though the connect itself happened.", {
595
647
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
596
- }, async (args) => jsonResult(await planConnect(client, args)));
648
+ }, async (args) => jsonResult(await planConnect(client, args, { baseUrl: config.baseUrl, packageSpec: PACKAGE_SPEC })));
597
649
  server.tool("plan_add_record", "Add a GOAL, RULE or CAVEAT to the plan this session is connected to, via POST /api/plans/mine/records (DX-2683). A goal is what the plan is FOR (the outcome the work is measured against — not a task). A rule is what must HOLD while it is worked. A caveat is what is known to be AWKWARD — the fact that will surprise the next person. Each gets a permanent short reference within the plan (`G-1`, `R-4`, `CAV-12`) allocated by the server, which is how a person cites it in a card or a commit. TAKES NO PLAN ID: the plan is resolved from your connected session, so you cannot write a plan you are not connected to. Not connected → `{error: \"session_not_connected\"}`; call `plan_connect` first. Returns the new record plus that kind's full list.", {
598
650
  kind: z
599
651
  .enum(["goal", "rule", "caveat"])
@@ -638,9 +690,28 @@ async function main() {
638
690
  // schemas can be introspected without spawning a dispatch. The check is
639
691
  // symlink-aware (DX-1647) so it holds under the symlinked `npx` bin the worker
640
692
  // spawns, not just a direct `node dist/index.js`.
693
+ //
694
+ // `listen` is the one subcommand: the session event listener `plan_connect`
695
+ // tells the agent to arm as a Monitor. It never boots the MCP server and reads
696
+ // none of the MCP env — its whole configuration is its two flags. Any OTHER
697
+ // argument is refused rather than ignored, so a typo cannot silently start an
698
+ // MCP server on stdio where a listener was meant to run.
641
699
  if (isEntrypointModule(import.meta.url, process.argv[1])) {
642
- main().catch((err) => {
643
- console.error(`[danx-dashboard-mcp] fatal: ${err.message}`);
644
- process.exit(1);
645
- });
700
+ const [subcommand, ...rest] = process.argv.slice(2);
701
+ if (subcommand === LISTEN_SUBCOMMAND) {
702
+ runListenCommand(rest).then((code) => process.exit(code), (err) => {
703
+ console.error(`[danx-dashboard-mcp] listen fatal: ${err.message}`);
704
+ process.exit(1);
705
+ });
706
+ }
707
+ else if (subcommand !== undefined) {
708
+ console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only one is "${LISTEN_SUBCOMMAND}")`);
709
+ process.exit(2);
710
+ }
711
+ else {
712
+ main().catch((err) => {
713
+ console.error(`[danx-dashboard-mcp] fatal: ${err.message}`);
714
+ process.exit(1);
715
+ });
716
+ }
646
717
  }
package/dist/listen.js ADDED
@@ -0,0 +1,278 @@
1
+ /**
2
+ * `danx-dashboard-mcp listen --stream <url> --ticket <ticket> --lease-ms <ms>` —
3
+ * a working session's event listener.
4
+ *
5
+ * WHY IT EXISTS. A Claude Code session cannot be interrupted from outside, but
6
+ * its Monitor tool runs a background command and turns every line that command
7
+ * prints into a notification that wakes the session. This command is that
8
+ * background process: it holds the dashboard's session event stream open and
9
+ * prints ONE line per event on the plan the session is connected to — a comment,
10
+ * an answer, a `requires_human` change, a block — so the operator's answer
11
+ * reaches the agent within seconds, with no polling anywhere.
12
+ *
13
+ * THE OUTPUT CONTRACT, BECAUSE EVERY LINE WAKES THE AGENT:
14
+ * - exactly one line per event, written the moment it arrives;
15
+ * - nothing for keep-alives, the connect marker, or a successful reconnect;
16
+ * - one line for an event it cannot read (never silence);
17
+ * - one final line, and exit, only when it gives up or the dashboard ends it.
18
+ *
19
+ * RECONNECT. The stream drops whenever the dashboard restarts, and can also die
20
+ * silently (sleep, NAT, proxy). A read-idle timeout of three missed keep-alives
21
+ * turns silent death into a drop. Retries use capped exponential backoff and send
22
+ * `Last-Event-ID`; the dashboard replays what was missed (with a small overlap)
23
+ * and an id already printed is never printed twice. The backoff only resets once
24
+ * a connection proves healthy — it delivered an event, or stayed up past one
25
+ * keep-alive — so a dashboard that admits and immediately drops is not hammered.
26
+ * The listener gives up once it has gone `--lease-ms` without a healthy
27
+ * connection (past that the ticket cannot be admitted anyway), or at once when
28
+ * the ticket is refused.
29
+ *
30
+ * Everything server-specific — the stream URL and the lease — arrives as flags
31
+ * from `plan_connect`, which read them off the dashboard's own ticket response.
32
+ * Only the event shape is hand-copied from the dashboard's `IssueActivityEvent`
33
+ * (`src/issues/db/issue-activity.ts`); this published package cannot import
34
+ * danxbot source.
35
+ */
36
+ export const LISTEN_SUBCOMMAND = "listen";
37
+ export const INITIAL_BACKOFF_MS = 1_000;
38
+ export const MAX_BACKOFF_MS = 30_000;
39
+ /** Three of the dashboard's 15 s keep-alives without a byte means the connection is dead. */
40
+ export const READ_IDLE_TIMEOUT_MS = 45_000;
41
+ /** A connection that stays up this long has proven the dashboard healthy. */
42
+ export const HEALTHY_CONNECTION_MS = 20_000;
43
+ /** How many printed event ids the duplicate guard remembers — well past the replay overlap. */
44
+ const PRINTED_ID_MEMORY = 1_000;
45
+ const LINE_PREFIX = "[danx-dashboard listen]";
46
+ const REARM = "Call plan_connect again and arm the Monitor it returns to keep receiving this plan's events.";
47
+ const USAGE = `usage: danx-dashboard-mcp ${LISTEN_SUBCOMMAND} --stream <stream-url> --ticket <ticket> --lease-ms <ms>`;
48
+ /** Parse the three required flags; anything else is refused. */
49
+ export function parseListenArgs(argv) {
50
+ const values = new Map();
51
+ for (let i = 0; i < argv.length; i += 2) {
52
+ const flag = argv[i];
53
+ const value = argv[i + 1];
54
+ if (!["--stream", "--ticket", "--lease-ms"].includes(flag) || value === undefined || value === "") {
55
+ throw new Error(USAGE);
56
+ }
57
+ values.set(flag, value);
58
+ }
59
+ const streamUrl = values.get("--stream");
60
+ const ticket = values.get("--ticket");
61
+ const leaseMs = Number(values.get("--lease-ms"));
62
+ if (!streamUrl || !ticket || !Number.isSafeInteger(leaseMs) || leaseMs <= 0)
63
+ throw new Error(USAGE);
64
+ return { streamUrl, ticket, leaseMs };
65
+ }
66
+ function quoted(value) {
67
+ return `"${value.text}${value.truncated ? "…" : ""}"`;
68
+ }
69
+ function describe(event) {
70
+ const d = event.detail;
71
+ switch (event.kind) {
72
+ case "comment_added":
73
+ return `${event.actor} commented: ${quoted(d.excerpt)}`;
74
+ case "solution_answered": {
75
+ const solution = d.solution;
76
+ if (solution === null)
77
+ return `${event.actor} answered: ${quoted(d.freeform)}`;
78
+ const note = solution.note === null ? "" : ` — note: ${quoted(solution.note)}`;
79
+ return `${event.actor} answered: chose "${solution.title}"${note}`;
80
+ }
81
+ case "requires_human_set":
82
+ return `${event.actor} set requires_human: ${quoted(d.reason)}`;
83
+ case "requires_human_cleared":
84
+ return `${event.actor} cleared requires_human`;
85
+ case "blocked":
86
+ return `${event.actor} blocked the card: ${quoted(d.reason)}`;
87
+ case "unblocked":
88
+ return `${event.actor} unblocked the card`;
89
+ default:
90
+ // A kind newer than this listener still produces a line: an event the
91
+ // agent is never told about is exactly the failure this command prevents.
92
+ return `${event.actor}: ${event.kind}`;
93
+ }
94
+ }
95
+ /** The one notification line for an event. Always a single line; throws on a malformed event. */
96
+ export function formatActivityLine(event) {
97
+ return `[${event.cardId} "${event.cardTitle}" ${event.boardId}] ${describe(event)}`.replace(/\s+/g, " ");
98
+ }
99
+ /**
100
+ * Incremental SSE parser. Comment lines (keep-alives, the connect marker) and
101
+ * field-less blocks produce no message, which is what keeps them off stdout.
102
+ */
103
+ export class SseParser {
104
+ buffer = "";
105
+ push(chunk) {
106
+ this.buffer += chunk.replace(/\r\n?/g, "\n");
107
+ const messages = [];
108
+ let boundary = this.buffer.indexOf("\n\n");
109
+ while (boundary !== -1) {
110
+ const message = parseBlock(this.buffer.slice(0, boundary));
111
+ this.buffer = this.buffer.slice(boundary + 2);
112
+ if (message !== null)
113
+ messages.push(message);
114
+ boundary = this.buffer.indexOf("\n\n");
115
+ }
116
+ return messages;
117
+ }
118
+ }
119
+ function parseBlock(block) {
120
+ let id = null;
121
+ let event = "message";
122
+ const data = [];
123
+ let sawField = false;
124
+ for (const line of block.split("\n")) {
125
+ if (line === "" || line.startsWith(":"))
126
+ continue;
127
+ const colon = line.indexOf(":");
128
+ const field = colon === -1 ? line : line.slice(0, colon);
129
+ const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
130
+ sawField = true;
131
+ if (field === "id")
132
+ id = value;
133
+ else if (field === "event")
134
+ event = value;
135
+ else if (field === "data")
136
+ data.push(value);
137
+ }
138
+ return sawField ? { id, event, data: data.join("\n") } : null;
139
+ }
140
+ /** Statuses that describe the moment, not the ticket — retry them. */
141
+ const RETRYABLE_CLIENT_STATUSES = new Set([408, 429]);
142
+ /**
143
+ * Run until the dashboard ends the stream or the listener gives up. Returns the
144
+ * exit code: 0 when a newer listener took over, 1 when revoked, refused, or
145
+ * given up.
146
+ */
147
+ export async function runListener(options, deps) {
148
+ const printed = new Set();
149
+ let lastEventId = null;
150
+ let unhealthySince = null;
151
+ let attempt = 0;
152
+ const remember = (id) => {
153
+ printed.add(id);
154
+ if (printed.size > PRINTED_ID_MEMORY)
155
+ printed.delete(printed.values().next().value);
156
+ lastEventId = lastEventId === null ? id : Math.max(lastEventId, id);
157
+ };
158
+ const handle = (message) => {
159
+ if (message.event === "end") {
160
+ const reason = JSON.parse(message.data).reason;
161
+ return { kind: "ended", reason: typeof reason === "string" ? reason : "unknown" };
162
+ }
163
+ if (message.event !== "activity")
164
+ return null;
165
+ const id = Number(message.id);
166
+ if (Number.isSafeInteger(id) && printed.has(id))
167
+ return null;
168
+ try {
169
+ deps.write(formatActivityLine(JSON.parse(message.data)));
170
+ }
171
+ catch (err) {
172
+ // Never silence, and never a reconnect loop on the same bad event: say so
173
+ // in one line and move past it.
174
+ deps.write(`${LINE_PREFIX} could not read event ${message.id ?? "(no id)"} (${err.message}): ` +
175
+ `${message.data.slice(0, 300)}`.replace(/\s+/g, " "));
176
+ }
177
+ if (Number.isSafeInteger(id))
178
+ remember(id);
179
+ return null;
180
+ };
181
+ const connectOnce = async () => {
182
+ const controller = new AbortController();
183
+ let idle;
184
+ const armIdle = () => {
185
+ clearTimeout(idle);
186
+ idle = setTimeout(() => controller.abort(), deps.readIdleTimeoutMs);
187
+ };
188
+ const headers = {
189
+ Authorization: `Bearer ${options.ticket}`,
190
+ Accept: "text/event-stream",
191
+ };
192
+ if (lastEventId !== null)
193
+ headers["Last-Event-ID"] = String(lastEventId);
194
+ const openedAt = deps.now();
195
+ let delivered = false;
196
+ const healthy = () => delivered || deps.now() - openedAt >= HEALTHY_CONNECTION_MS;
197
+ try {
198
+ armIdle();
199
+ const response = await deps.fetch(options.streamUrl, { headers, signal: controller.signal });
200
+ if (response.status >= 400 && response.status < 500 && !RETRYABLE_CLIENT_STATUSES.has(response.status)) {
201
+ return { kind: "refused", detail: `HTTP ${response.status} ${(await response.text()).slice(0, 300)}` };
202
+ }
203
+ if (!response.ok || response.body === null) {
204
+ await response.body?.cancel();
205
+ return { kind: "dropped", healthy: false };
206
+ }
207
+ const parser = new SseParser();
208
+ const decoder = new TextDecoder();
209
+ for await (const chunk of response.body) {
210
+ armIdle();
211
+ for (const message of parser.push(decoder.decode(chunk, { stream: true }))) {
212
+ const outcome = handle(message);
213
+ if (outcome !== null)
214
+ return outcome;
215
+ if (message.event === "activity")
216
+ delivered = true;
217
+ }
218
+ }
219
+ return { kind: "dropped", healthy: healthy() };
220
+ }
221
+ catch {
222
+ return { kind: "dropped", healthy: healthy() };
223
+ }
224
+ finally {
225
+ clearTimeout(idle);
226
+ controller.abort();
227
+ }
228
+ };
229
+ for (;;) {
230
+ const outcome = await connectOnce();
231
+ if (outcome.kind === "ended") {
232
+ if (outcome.reason === "superseded" || outcome.reason === "replaced") {
233
+ deps.write(`${LINE_PREFIX} stopped: another listener for this session took over (${outcome.reason}). ` +
234
+ `If you did not just call plan_connect, something else did — ${REARM}`);
235
+ return 0;
236
+ }
237
+ deps.write(`${LINE_PREFIX} stopped: the dashboard ended this listener (${outcome.reason}). ${REARM}`);
238
+ return 1;
239
+ }
240
+ if (outcome.kind === "refused") {
241
+ deps.write(`${LINE_PREFIX} gave up: the dashboard refused the listener ticket (${outcome.detail}). ${REARM}`);
242
+ return 1;
243
+ }
244
+ if (outcome.healthy) {
245
+ unhealthySince = null;
246
+ attempt = 0;
247
+ }
248
+ const now = deps.now();
249
+ unhealthySince ??= now;
250
+ if (now - unhealthySince >= options.leaseMs) {
251
+ deps.write(`${LINE_PREFIX} gave up: no healthy connection to ${options.streamUrl} for ` +
252
+ `${Math.round((now - unhealthySince) / 60_000)} minutes, past the ticket's lease. ${REARM}`);
253
+ return 1;
254
+ }
255
+ await deps.sleep(Math.min(MAX_BACKOFF_MS, INITIAL_BACKOFF_MS * 2 ** attempt));
256
+ attempt += 1;
257
+ }
258
+ }
259
+ /** The bin's `listen` subcommand, wired to the real process. */
260
+ export async function runListenCommand(argv) {
261
+ let options;
262
+ try {
263
+ options = parseListenArgs(argv);
264
+ }
265
+ catch (err) {
266
+ process.stderr.write(`${err.message}\n`);
267
+ return 2;
268
+ }
269
+ return runListener(options, {
270
+ fetch,
271
+ write: (line) => {
272
+ process.stdout.write(`${line}\n`);
273
+ },
274
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
275
+ now: Date.now,
276
+ readIdleTimeoutMs: READ_IDLE_TIMEOUT_MS,
277
+ });
278
+ }
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.53",
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",