@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 +5 -4
- package/dist/handlers.js +200 -2
- package/dist/index.js +88 -17
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) —
|
|
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
|
-
|
|
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.
|
|
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`, `
|
|
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.
|
|
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",
|