@thehammer/danx-dashboard-mcp 0.1.69 → 0.1.71
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 +17 -5
- package/dist/handlers.js +123 -62
- package/dist/http-client.js +6 -1
- package/dist/index.js +87 -32
- package/dist/listen.js +13 -5
- package/dist/one-line.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,18 +25,30 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
|
|
|
25
25
|
| `issue_get` | `GET /api/issues/:id` or `GET /api/issues/batch` | Pass `id` for one card, or `ids[]` (DX-2727, at most 100) to resolve many across boards in ONE call — global, so `ids` with `board` throws; per-id `not_found` rather than a whole-call 404. Minimal scalars by default; `fields` opts in |
|
|
26
26
|
| `issue_create` | `POST /api/issues` | Epic REQUIRES non-empty `phase_children[]` (atomic insert). `title` = short domain-naming label; `summary` = 1–3 plain-language sentences, always shown; `description` = the collapsed "Context" body. Root and every phase child take their own `summary` |
|
|
27
27
|
| `issue_edit` | `PATCH /api/issues/:id/edit` | Prose + structured keys (`title`, `summary` (null clears), `description`, `ac`, `checklists`, `effort_level`, `parent_id`, `priority`, `list_id`); semantic keys refused with 400 + pointer to dedicated handler. `priority` (DX-1532) takes a tier word (`low`/`high`/…) or a number in `[0,6)` — the ONLY way to set the numeric column the Trello label + dashboard badge read; never set priority via description prose |
|
|
28
|
-
| `issue_transition` | `POST /api/issues/:id/transition` | Actions: ready, pickup, rollback_pickup, complete, cancel, block, unblock, archive, reopen. `block` is a dispatch hold only — it never marks the card as needing a human; use `issue_problem`
|
|
29
|
-
| `issue_problem` | `GET/POST/PATCH/DELETE /api/issues/:id/problems[/:pid]` | Actions list / add / edit / remove. A problem is one statement the operator must resolve (a question or a plan flaw) with its own solutions and answers; `add` takes `statement` + optional `solutions[]` in one transaction
|
|
28
|
+
| `issue_transition` | `POST /api/issues/:id/transition` | Actions: ready, pickup, rollback_pickup, complete, cancel, block, unblock, archive, reopen. `block` is a dispatch hold only — it never marks the card as needing a human; use `issue_problem` add for that |
|
|
29
|
+
| `issue_problem` | `GET/POST/PATCH/DELETE /api/issues/:id/problems[/:pid]` | Actions list / add / edit / remove. A problem is one statement the operator must resolve (a question or a plan flaw) with its own solutions and answers; `add` takes `statement` + optional `solutions[]` in one transaction and IS what puts the card in front of a human (`open_problem_count > 0`) — a successful add also returns `problems_reminder: {open_problem_count, instruction}`. Edit + remove are hash-guarded (`base_hash`, 409 `stale_problem`); removing the card's last open problem is always allowed — it just means the card no longer needs a human |
|
|
30
30
|
| `issue_solution` | `POST/PATCH/DELETE /api/issues/:id/problems/:pid/solutions[/:sid]` | Actions add / edit / remove, `problem_id` required (list via `issue_problem list`). Edit + remove are hash-guarded (`base_hash`, 409 `stale_solution` with the current row); at most one live `recommended` per problem; a chosen option cannot be edited. No answer action on any tool — the operator answers in the dashboard |
|
|
31
31
|
| `issue_triage` | `POST /api/issues/:id/triage` | Send `{confidence, reason}` — an integer 0-5 score; the server computes the verdict (approve/cancel/keep/defer) against the board's configured thresholds (DX-2086). `keep`/`defer` now block the card. None of these are a cross-card ordering gate; use `issue_dependency` to sequence cards |
|
|
32
32
|
| `issue_comment` | `POST/PATCH/DELETE /api/issues/:id/comments[/:cid]` | Author server-stamped, soft-delete preserved |
|
|
33
33
|
| `issue_dependency` | `POST/DELETE /api/issues/:id/dependencies[/:did]` | `depends_on` cycle-checked; remove hardcodes `reason: "recorded_in_error"`. The only mechanism the dispatch picker enforces to sequence one card after another — status alone is not a substitute |
|
|
34
|
-
| `issue_requires_human` | `POST/DELETE /api/issues/:id/requires-human` | Set REQUIRES an open problem (409 `no_open_problem` with the server's `fix` otherwise) and replaces step rows atomically; clear soft-deletes them. The gate clears itself when the last open problem is answered. A successful set also returns `problems_reminder: {open_problem_count, instruction}` |
|
|
35
34
|
| `issue_retro` | `PUT /api/issues/:id/retro` | Requires terminal card; replace semantics |
|
|
36
35
|
|
|
37
36
|
## `plan_get` — cheap by default, opt-in for the rest (DX-2727)
|
|
38
37
|
|
|
39
|
-
A bare `plan_get` (no `fields`) returns only the plan's cheap scalars — `plan`, `boards`, `cardCount`, `bucketCounts`, `session`, `sessionListenerAttached` — plus `available_field_groups` naming what else exists. Pass `fields` to opt into `cards` (paged: `cards_offset`, default 0, and `cards_limit`, 1..1000, default 200, pick the page; the response carries `cards_total` and `cards_offset`, so page with `cards_offset` while `cards_offset + cards.length < cards_total` — either paging arg without `cards` in `fields` is a 400), `records` (every goal/rule/caveat) or `records:goal` / `records:rule` / `records:caveat` (just one kind), `architecture`, and `sessions`. A `plan_get` made only to grab a hash before a one-line edit no longer pays for the architecture document or every member card.
|
|
38
|
+
A bare `plan_get` (no `fields`) returns only the plan's cheap scalars — `plan`, `boards`, `cardCount`, `bucketCounts`, `status`, `session`, `sessionListenerAttached` — plus `available_field_groups` naming what else exists. Pass `fields` to opt into `cards` (paged: `cards_offset`, default 0, and `cards_limit`, 1..1000, default 200, pick the page; the response carries `cards_total` and `cards_offset`, so page with `cards_offset` while `cards_offset + cards.length < cards_total` — either paging arg without `cards` in `fields` is a 400), `records` (every goal/rule/caveat) or `records:goal` / `records:rule` / `records:caveat` (just one kind), `architecture`, and `sessions`. A `plan_get` made only to grab a hash before a one-line edit no longer pays for the architecture document or every member card.
|
|
39
|
+
|
|
40
|
+
## `plan_list` / `plan_get` — computed `status` (DX-2834)
|
|
41
|
+
|
|
42
|
+
Every plan carries a `status`, computed fresh on every read and never stored — no writer ever sets it:
|
|
43
|
+
|
|
44
|
+
| Status | Meaning |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `complete` | At least one card, and every card Done or Cancelled. Wins even with no session — a finished plan needs nobody. |
|
|
47
|
+
| `awaiting-session` | Not complete, and no session is LIVE on the plan. A `plan_sessions` row is never released when a session merely ends (only when the plan itself is deleted), so this checks real liveness — an unrevoked/unexpired listener ticket, OR `last_active_at` within the same lease window — never just "has a session ever connected". |
|
|
48
|
+
| `building` | Not complete, a live session is connected, and at least one card is ToDo/In Progress, or carries an unnamed active state (Blocked, Needs Help — i.e. an OPEN problem, DX-2830 — and, once it exists, Verify): work started and is either moving or stuck. |
|
|
49
|
+
| `planning` | Everything else: no cards, or only Review/Backlog/terminal cards with at least one not Done/Cancelled. |
|
|
50
|
+
|
|
51
|
+
Evaluated in that order (`complete` > `awaiting-session` > `building` > `planning`) — first match wins. `plan_list` takes an optional `status` arg to filter to one of them (`GET /api/plans?status=`); `plan_get` always returns the one plan's own status, ungated.
|
|
40
52
|
|
|
41
53
|
## `bridge` — a working session's event stream client
|
|
42
54
|
|
|
@@ -48,7 +60,7 @@ DANXBOT_DASHBOARD_URL=<dashboard> DANXBOT_DISPATCH_TOKEN=<token> CLAUDE_CODE_SES
|
|
|
48
60
|
```
|
|
49
61
|
|
|
50
62
|
- **Credential.** The credential and session come from the environment; `--resume-ids` is the only argument and holds no secret. `bridge` mints the session's listener ticket (`POST /api/plan-sessions/me/stream-ticket`, 10 s timeout) and keeps it in the process. A ticket authorizes reading that one session's event stream and nothing else, and is only issued while the session is connected to a plan. Minting a new one ends the previous listener.
|
|
51
|
-
- **Output — JSON Lines.** One `{"type":"event","id":<id|null>,"text":"…"}` per event on the connected plan's cards, where `text` is `[DX-8 "Title" repo:board] newms87 answered "Which rollout order?": chose "Pause E2E" — note: "only this week"`, `… answered "…": "<free-form answer>"`, `… commented: "…"`, `…
|
|
63
|
+
- **Output — JSON Lines.** One `{"type":"event","id":<id|null>,"text":"…"}` per event on the connected plan's cards, where `text` is `[DX-8 "Title" repo:board] newms87 answered "Which rollout order?": chose "Pause E2E" — note: "only this week"`, `… answered "…": "<free-form answer>"`, `… commented: "…"`, `… opened a problem: "…"`, `… blocked the card: "…"`, or `… unblocked the card`. An event it cannot read still produces one, with a `could not read event` text. Nothing for keep-alives, reconnects or re-mints. The session's own writes are never echoed back to it.
|
|
52
64
|
- **Stopping.** Last, one `{"type":"stopped","reason":"…","detail":"…"}` and exit, only on a terminal outcome: `not_connected`, `unauthorized` (401/403), `mint_refused` (any other non-transient refusal), `mint_bad_response`, `superseded` / `replaced` (exit 0), `revoked`, or `refused` (two freshly minted tickets refused in a row). A transient mint failure (network, timeout, 408, 429, 5xx) backs off and retries; a lapsed ticket lease re-mints.
|
|
53
65
|
- **Reconnect and resume.** 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 emitted twice. `--resume-ids` carries the same guarantee across a process restart: the ids already delivered seed the duplicate guard, and the highest is the first `Last-Event-ID`. The dashboard floors that replay at the later of the session's first ticket and when it joined its current plan, so a restart loses nothing and a plan move replays nothing from before the move.
|
|
54
66
|
|
package/dist/handlers.js
CHANGED
|
@@ -256,9 +256,10 @@ export async function issueEdit(client, args) {
|
|
|
256
256
|
}
|
|
257
257
|
export async function issueTransition(client, args) {
|
|
258
258
|
const { id, board, ...body } = args;
|
|
259
|
-
// DX-2735: no reminder on block. A block is a dispatch HOLD only —
|
|
260
|
-
// puts the card in front of a human — so there is no question to
|
|
261
|
-
// options for. A card that needs a human uses issue_problem
|
|
259
|
+
// DX-2735 / DX-2830: no reminder on block. A block is a dispatch HOLD only —
|
|
260
|
+
// it never puts the card in front of a human — so there is no question to
|
|
261
|
+
// list options for. A card that needs a human uses issue_problem add, which
|
|
262
|
+
// carries its own reminder.
|
|
262
263
|
return client.request({
|
|
263
264
|
method: "POST",
|
|
264
265
|
path: `/${encodeURIComponent(id)}/transition`,
|
|
@@ -278,22 +279,20 @@ function isReminderProblem(value) {
|
|
|
278
279
|
Array.isArray(p.solutions));
|
|
279
280
|
}
|
|
280
281
|
/**
|
|
281
|
-
* Attach the problems reminder to a SUCCESSFUL
|
|
282
|
+
* Attach the problems reminder to a SUCCESSFUL `issue_problem add`.
|
|
282
283
|
*
|
|
283
|
-
* DX-
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
* the STILL-OPEN problems only — an answered sibling needs nothing more.
|
|
284
|
+
* DX-2830 — an added problem IS the moment a card is put in front of a human;
|
|
285
|
+
* there is no separate gate to set or refuse. Whether each open problem lists
|
|
286
|
+
* its viable solutions is feedback, never refusal: a problem with zero
|
|
287
|
+
* solutions is valid (the operator answers free-form), and refusing it would
|
|
288
|
+
* push the agent into inventing options. So the reminder rides the success
|
|
289
|
+
* response and names the STILL-OPEN problems only — an answered sibling needs
|
|
290
|
+
* nothing more.
|
|
291
291
|
*
|
|
292
292
|
* The write's own envelope is returned untouched beside it. A refused write
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
* throwing would tell the agent it failed when it did not.
|
|
293
|
+
* gets no reminder and no extra request. A failure READING the problems is
|
|
294
|
+
* reported inside the reminder rather than thrown: the add already succeeded,
|
|
295
|
+
* and throwing would tell the agent it failed when it did not.
|
|
297
296
|
*/
|
|
298
297
|
export async function withProblemsReminder(client, id, board, result) {
|
|
299
298
|
if (!result.ok)
|
|
@@ -323,9 +322,9 @@ export async function withProblemsReminder(client, id, board, result) {
|
|
|
323
322
|
}
|
|
324
323
|
function openProblemsInstruction(id, open) {
|
|
325
324
|
if (open.length === 0) {
|
|
326
|
-
// Only reachable when every problem was answered between the
|
|
327
|
-
// read — the
|
|
328
|
-
return `No problem on ${id} is open any more — each was answered
|
|
325
|
+
// Only reachable when every problem was answered between the add and this
|
|
326
|
+
// read — the card no longer needs a human.
|
|
327
|
+
return `No problem on ${id} is open any more — each was answered already. Re-read the card before you stop.`;
|
|
329
328
|
}
|
|
330
329
|
const listing = open
|
|
331
330
|
.map((p) => {
|
|
@@ -363,8 +362,7 @@ export async function issueTriage(client, args) {
|
|
|
363
362
|
*
|
|
364
363
|
* Returns the checkers rather than checking anything itself. `mode` is the
|
|
365
364
|
* condition the args are required under — `action=<action>` for the
|
|
366
|
-
* action-dispatched tools
|
|
367
|
-
* refuses in the same wording.
|
|
365
|
+
* action-dispatched tools — so every tool refuses in the same wording.
|
|
368
366
|
*/
|
|
369
367
|
function argCheckers(tool, mode) {
|
|
370
368
|
const fail = (name, expected) => {
|
|
@@ -383,18 +381,6 @@ function argCheckers(tool, mode) {
|
|
|
383
381
|
fail(name, "a positive integer id");
|
|
384
382
|
return value;
|
|
385
383
|
},
|
|
386
|
-
/**
|
|
387
|
-
* A list of human-written lines (e.g. requires_human steps). DX-2735: every
|
|
388
|
-
* element must be a non-blank string, matching the server, which refuses a
|
|
389
|
-
* blank step — refused here before any request is built. An empty list is
|
|
390
|
-
* allowed, as the server allows it.
|
|
391
|
-
*/
|
|
392
|
-
stringArray(value, name) {
|
|
393
|
-
const valid = Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim() !== "");
|
|
394
|
-
if (!valid)
|
|
395
|
-
fail(name, "an array of non-blank strings");
|
|
396
|
-
return value;
|
|
397
|
-
},
|
|
398
384
|
};
|
|
399
385
|
}
|
|
400
386
|
export async function issueComment(client, args) {
|
|
@@ -549,11 +535,14 @@ export async function issueChecklist(client, args) {
|
|
|
549
535
|
* operator must resolve — a question or a flaw in the plan — owning its own
|
|
550
536
|
* solutions and its own decisions. A missing required arg throws at this
|
|
551
537
|
* boundary (no round-trip); the server's refusals (`stale_problem` with the
|
|
552
|
-
* current row
|
|
538
|
+
* current row) pass through verbatim.
|
|
553
539
|
*
|
|
554
540
|
* `add` carries `solutions[]` in the SAME request so a problem and its options
|
|
555
541
|
* land in one server transaction — never a problem briefly visible to the
|
|
556
|
-
* operator with none of the options it was created with.
|
|
542
|
+
* operator with none of the options it was created with. DX-2830 — a card
|
|
543
|
+
* needs a human exactly when it has an open problem, so `add` IS the moment a
|
|
544
|
+
* card is put in front of one; the reminder that used to ride the retired
|
|
545
|
+
* `issue_requires_human({set: true})` now rides here instead.
|
|
557
546
|
*
|
|
558
547
|
* No answer action, for the reason `issueSolution` gives.
|
|
559
548
|
*/
|
|
@@ -568,7 +557,8 @@ export async function issueProblem(client, args) {
|
|
|
568
557
|
const body = { statement: need.string(args.statement, "statement") };
|
|
569
558
|
if (args.solutions !== undefined)
|
|
570
559
|
body.solutions = args.solutions;
|
|
571
|
-
|
|
560
|
+
const result = await client.request({ method: "POST", path: `/${idEnc}/problems`, body, board });
|
|
561
|
+
return withProblemsReminder(client, args.id, board, result);
|
|
572
562
|
}
|
|
573
563
|
case "edit": {
|
|
574
564
|
const problemId = need.id(args.problem_id, "problem_id");
|
|
@@ -647,29 +637,6 @@ export async function issueSolution(client, args) {
|
|
|
647
637
|
}
|
|
648
638
|
}
|
|
649
639
|
}
|
|
650
|
-
export async function issueRequiresHuman(client, args) {
|
|
651
|
-
const idEnc = encodeURIComponent(args.id);
|
|
652
|
-
const board = args.board;
|
|
653
|
-
if (args.set) {
|
|
654
|
-
// DX-2735: the same checkers every action-dispatched tool uses, so the refusal
|
|
655
|
-
// wording and type checks match — `issue_requires_human set=true requires ...`.
|
|
656
|
-
const need = argCheckers("issue_requires_human", "set=true");
|
|
657
|
-
const reason = need.string(args.reason, "reason");
|
|
658
|
-
const steps = need.stringArray(args.steps, "steps");
|
|
659
|
-
const result = await client.request({
|
|
660
|
-
method: "POST",
|
|
661
|
-
path: `/${idEnc}/requires-human`,
|
|
662
|
-
body: { reason, steps },
|
|
663
|
-
board,
|
|
664
|
-
});
|
|
665
|
-
return withProblemsReminder(client, args.id, board, result);
|
|
666
|
-
}
|
|
667
|
-
return client.request({
|
|
668
|
-
method: "DELETE",
|
|
669
|
-
path: `/${idEnc}/requires-human`,
|
|
670
|
-
board,
|
|
671
|
-
});
|
|
672
|
-
}
|
|
673
640
|
/**
|
|
674
641
|
* Flip a single card's per-card quality-gate `required` flag via
|
|
675
642
|
* POST /api/issues/:id/quality-gates/:gate {required} — the same write the
|
|
@@ -860,9 +827,23 @@ export const PLAN_FIELD_GROUPS = [
|
|
|
860
827
|
"architecture",
|
|
861
828
|
"sessions",
|
|
862
829
|
];
|
|
830
|
+
/**
|
|
831
|
+
* DX-2834 — the plan-status taxonomy, mirroring `PLAN_FIELD_GROUPS` just
|
|
832
|
+
* above: the ONE copy in this package (the `plan_list` zod enum in
|
|
833
|
+
* `index.ts` reads this const), duplicated from the server's
|
|
834
|
+
* `src/issues/db/plans.ts#PLAN_STATUS_IDS` because the published package
|
|
835
|
+
* cannot import server source at runtime. `__tests__/handlers.test.ts`
|
|
836
|
+
* asserts the two are equal, so drift fails a test rather than a live call.
|
|
837
|
+
*/
|
|
838
|
+
export const PLAN_STATUSES = ["awaiting-session", "planning", "building", "complete"];
|
|
863
839
|
/** Every plan, plus which one THIS session is connected to. */
|
|
864
|
-
export async function planList(client) {
|
|
865
|
-
return client.request({
|
|
840
|
+
export async function planList(client, args = {}) {
|
|
841
|
+
return client.request({
|
|
842
|
+
method: "GET",
|
|
843
|
+
path: "",
|
|
844
|
+
basePath: PLANS_BASE_PATH,
|
|
845
|
+
query: { status: args.status },
|
|
846
|
+
});
|
|
866
847
|
}
|
|
867
848
|
/**
|
|
868
849
|
* One plan — its cheap scalars by default, or opt into its cards, its goals
|
|
@@ -901,13 +882,21 @@ export async function planGet(client, args = {}) {
|
|
|
901
882
|
* that holds the session's ONE listener ticket and starts when this tool
|
|
902
883
|
* succeeds. The dashboard keeps one ticket per session, so a ticket minted here
|
|
903
884
|
* would end the bridge's stream.
|
|
885
|
+
*
|
|
886
|
+
* `title` (DX-2816) rides here, not on a header, because the AGENT — not this
|
|
887
|
+
* server — is the one thing able to read Claude's own session title (via a
|
|
888
|
+
* Desktop-side `get_session` tool no MCP server subprocess can call). Sent
|
|
889
|
+
* only when the caller supplied one; the server leaves an unset title alone.
|
|
904
890
|
*/
|
|
905
891
|
export async function planConnect(client, args) {
|
|
906
892
|
return client.request({
|
|
907
893
|
method: "POST",
|
|
908
894
|
path: "/me/plan",
|
|
909
895
|
basePath: PLAN_SESSIONS_BASE_PATH,
|
|
910
|
-
body: {
|
|
896
|
+
body: {
|
|
897
|
+
plan_id: args.plan_id,
|
|
898
|
+
...(args.title === undefined ? {} : { title: args.title }),
|
|
899
|
+
},
|
|
911
900
|
});
|
|
912
901
|
}
|
|
913
902
|
/** Add a goal, rule or caveat to the connected plan. `context` is sent only when given. */
|
|
@@ -1140,3 +1129,75 @@ export async function planDeleteRecord(client, args) {
|
|
|
1140
1129
|
body: { content_hash: args.content_hash },
|
|
1141
1130
|
});
|
|
1142
1131
|
}
|
|
1132
|
+
// ---------------- failure_category_list / _create / _update (DX-2792) ----------------
|
|
1133
|
+
/**
|
|
1134
|
+
* DX-2792 (Failure evaluation 3/4) — wraps `src/dashboard/failure-categories-routes.ts`,
|
|
1135
|
+
* the DX-2791 (Failure evaluation 2/4) category registry's REST API. INSTALL-
|
|
1136
|
+
* GLOBAL, not board-scoped: `failure_categories` carries no `board_id`
|
|
1137
|
+
* column (every category applies across the whole install), so — unlike
|
|
1138
|
+
* every `/api/issues/*`-backed tool above — these three never send a
|
|
1139
|
+
* `board` query param and the `board` override field is simply absent from
|
|
1140
|
+
* their schemas (mirrors the `plan_*` family's own board-less rationale in
|
|
1141
|
+
* `index.ts`'s `boardField` comment, for the same underlying reason: nothing
|
|
1142
|
+
* server-side would read it).
|
|
1143
|
+
*/
|
|
1144
|
+
const FAILURE_CATEGORIES_BASE_PATH = "/api/failure-categories";
|
|
1145
|
+
/**
|
|
1146
|
+
* List every failure category with its live matched-occurrence count and
|
|
1147
|
+
* last-seen time, via `GET /api/failure-categories`. Returns
|
|
1148
|
+
* `{categories: [{id, name, description, matchers, ignore, ignoreReason,
|
|
1149
|
+
* expectedRate, matchedCount, lastSeenMs, ...}]}`. A fresh install returns
|
|
1150
|
+
* `{categories: []}`.
|
|
1151
|
+
*/
|
|
1152
|
+
export async function failureCategoryList(client) {
|
|
1153
|
+
return client.request({
|
|
1154
|
+
method: "GET",
|
|
1155
|
+
path: "",
|
|
1156
|
+
basePath: FAILURE_CATEGORIES_BASE_PATH,
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Create a new failure category via `POST /api/failure-categories`. At
|
|
1161
|
+
* least one matcher, each with at least one of `sourceKind`/`tool`/
|
|
1162
|
+
* `regexPattern` set, is required (400 otherwise). `ignore: true` REQUIRES a
|
|
1163
|
+
* non-empty `ignoreReason` (400 otherwise). A matcher set that would overlap
|
|
1164
|
+
* an EXISTING category's matchers is refused 400 naming the conflicting
|
|
1165
|
+
* category — expand that category instead of creating a near-duplicate. On
|
|
1166
|
+
* success, the dashboard re-matches every existing uncategorized occurrence
|
|
1167
|
+
* against the new category before responding, so `matchedCount` in the
|
|
1168
|
+
* response already reflects any newly-covered signatures.
|
|
1169
|
+
*/
|
|
1170
|
+
export async function failureCategoryCreate(client, args) {
|
|
1171
|
+
return client.request({
|
|
1172
|
+
method: "POST",
|
|
1173
|
+
path: "",
|
|
1174
|
+
basePath: FAILURE_CATEGORIES_BASE_PATH,
|
|
1175
|
+
body: {
|
|
1176
|
+
name: args.name,
|
|
1177
|
+
description: args.description ?? "",
|
|
1178
|
+
matchers: args.matchers,
|
|
1179
|
+
ignore: args.ignore ?? false,
|
|
1180
|
+
ignoreReason: args.ignoreReason ?? null,
|
|
1181
|
+
expectedRate: args.expectedRate ?? null,
|
|
1182
|
+
},
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Patch an existing failure category via `PATCH /api/failure-categories/:id`
|
|
1187
|
+
* — the tool for BOTH "expand an existing category's matchers" (send the
|
|
1188
|
+
* full replacement `matchers` array) and "mark a category ignored" (send
|
|
1189
|
+
* `ignore: true` + a non-empty `ignoreReason`). At least one field is
|
|
1190
|
+
* required (400 otherwise). Same overlap refusal as create (excluding this
|
|
1191
|
+
* category's own prior matchers). On success, re-matches every
|
|
1192
|
+
* uncategorized occurrence against the updated matcher set before
|
|
1193
|
+
* responding.
|
|
1194
|
+
*/
|
|
1195
|
+
export async function failureCategoryUpdate(client, args) {
|
|
1196
|
+
const { id, ...patch } = args;
|
|
1197
|
+
return client.request({
|
|
1198
|
+
method: "PATCH",
|
|
1199
|
+
path: `/${id}`,
|
|
1200
|
+
basePath: FAILURE_CATEGORIES_BASE_PATH,
|
|
1201
|
+
body: patch,
|
|
1202
|
+
});
|
|
1203
|
+
}
|
package/dist/http-client.js
CHANGED
|
@@ -25,9 +25,14 @@ export class DashboardHttpClient {
|
|
|
25
25
|
// from the card work the agent was already doing, with no cooperation
|
|
26
26
|
// from the agent and no per-tool parameter it could omit or falsify.
|
|
27
27
|
// Absent outside a Claude Code session; then nothing is stamped.
|
|
28
|
+
//
|
|
29
|
+
// NO TITLE HEADER (DX-2816, revised). This server has no way to read
|
|
30
|
+
// Claude's own session title — see `readSessionConfig` in `index.ts`.
|
|
31
|
+
// The title travels instead as `plan_connect`'s own `title` argument,
|
|
32
|
+
// straight in that call's body, because the AGENT (not this process) is
|
|
33
|
+
// the one thing able to read it.
|
|
28
34
|
if (this.config.session) {
|
|
29
35
|
headers["x-danx-session-id"] = this.config.session.id;
|
|
30
|
-
headers["x-danx-session-title"] = this.config.session.title;
|
|
31
36
|
}
|
|
32
37
|
let bodyString;
|
|
33
38
|
if (args.body !== undefined) {
|
package/dist/index.js
CHANGED
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
* - issue_problem GET/POST/PATCH/DELETE /api/issues/:id/problems[/:pid] (DX-2735)
|
|
23
23
|
* - issue_solution POST/PATCH/DELETE /api/issues/:id/problems/:pid/solutions[/:sid] (DX-2735)
|
|
24
24
|
* - issue_dependency POST/DELETE /api/issues/:id/dependencies[/:did]
|
|
25
|
-
* - issue_requires_human POST/DELETE /api/issues/:id/requires-human
|
|
26
25
|
* - issue_quality_gate POST /api/issues/:id/quality-gates/:gate
|
|
27
26
|
* - issue_quality_gate_verdict
|
|
28
27
|
* PATCH /api/issues/:id/quality-gates/:gate
|
|
@@ -49,6 +48,9 @@
|
|
|
49
48
|
* - plan_update_architecture_section PATCH /api/plans/mine/architecture/sections/:sid (DX-2726)
|
|
50
49
|
* - plan_delete_architecture_section DELETE /api/plans/mine/architecture/sections/:sid (DX-2726)
|
|
51
50
|
* - plan_reorder_architecture_section PUT /api/plans/mine/architecture/sections/reorder (DX-2726)
|
|
51
|
+
* - failure_category_list GET /api/failure-categories (DX-2791/DX-2792, board-less)
|
|
52
|
+
* - failure_category_create POST /api/failure-categories (DX-2791/DX-2792, board-less)
|
|
53
|
+
* - failure_category_update PATCH /api/failure-categories/:id (DX-2791/DX-2792, board-less)
|
|
52
54
|
*
|
|
53
55
|
* DX-2683 — THE PLAN TOOLS ARE SESSION-BOUND, and asymmetrically so. Reads
|
|
54
56
|
* may name any plan; WRITES take no plan id at all and act on the plan this
|
|
@@ -81,14 +83,13 @@
|
|
|
81
83
|
* agent reads `body.error` + structured fields to decide next action.
|
|
82
84
|
* 5xx and network failures throw — never silently swallowed.
|
|
83
85
|
*/
|
|
84
|
-
import { basename } from "node:path";
|
|
85
86
|
import { isEntrypointModule } from "./entrypoint.js";
|
|
86
87
|
import { BRIDGE_SUBCOMMAND, runBridgeCommand } from "./bridge.js";
|
|
87
88
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
88
89
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
89
90
|
import { z } from "zod";
|
|
90
91
|
import { DashboardHttpClient } from "./http-client.js";
|
|
91
|
-
import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict,
|
|
92
|
+
import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, planAddArchitectureSection, planAddCard, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, PLAN_STATUSES, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
|
|
92
93
|
import { PRIORITY_TIER_WORDS } from "./priority.js";
|
|
93
94
|
function readEnvOrDie(name) {
|
|
94
95
|
const v = process.env[name];
|
|
@@ -154,20 +155,26 @@ function boot() {
|
|
|
154
155
|
* headers and behaves exactly as this package did before. Requiring it would
|
|
155
156
|
* break the tool-defs generator, the drift test, and any non-Claude consumer.
|
|
156
157
|
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
158
|
+
* NO TITLE HERE AT ALL (DX-2816, revised). This used to guess one from
|
|
159
|
+
* `basename(process.cwd())`, which is how a dashboard Connect list ended up
|
|
160
|
+
* showing "gpt-manager" for a session Claude itself calls "Fix program
|
|
161
|
+
* status tracking" — the repo name told two sessions in the same checkout
|
|
162
|
+
* apart from EACH OTHER, but never matched what the operator sees in Claude.
|
|
163
|
+
* A later `DANX_SESSION_TITLE` env fallback was tried next and also removed:
|
|
164
|
+
* DX-2816's research found no documented env var, hook input, or
|
|
165
|
+
* MCP-server-reachable signal carries Claude's own session title — this
|
|
166
|
+
* SERVER genuinely cannot read it. The AGENT can, though (a Desktop-side
|
|
167
|
+
* `get_session` tool this server has no access to), and the agent is what
|
|
168
|
+
* calls `plan_connect` — so the title now travels as that tool's own
|
|
169
|
+
* `title` argument (see `handlers.ts#planConnect`), not as a header stamped
|
|
170
|
+
* here. A session that never connects with a title keeps the dashboard's own
|
|
171
|
+
* placeholder (`session <id prefix>`) — an honest placeholder beats a guess.
|
|
163
172
|
*/
|
|
164
173
|
function readSessionConfig() {
|
|
165
174
|
const id = readEnvOptional("CLAUDE_CODE_SESSION_ID");
|
|
166
175
|
if (id === undefined)
|
|
167
176
|
return undefined;
|
|
168
|
-
|
|
169
|
-
const fallback = cwdName === "" ? `session ${id.slice(0, 8)}` : cwdName;
|
|
170
|
-
return { id, title: readEnvOptional("DANX_SESSION_TITLE") ?? fallback };
|
|
177
|
+
return { id };
|
|
171
178
|
}
|
|
172
179
|
export const server = new McpServer({
|
|
173
180
|
name: "danx-dashboard-mcp",
|
|
@@ -211,7 +218,6 @@ const LIST_FIELD_GROUPS = [
|
|
|
211
218
|
"retro",
|
|
212
219
|
"dependencies",
|
|
213
220
|
"triage",
|
|
214
|
-
"requires_human",
|
|
215
221
|
"assignment",
|
|
216
222
|
"quality_gates",
|
|
217
223
|
"children",
|
|
@@ -221,6 +227,8 @@ const GET_FIELD_GROUPS = [
|
|
|
221
227
|
...LIST_FIELD_GROUPS,
|
|
222
228
|
"mirrors",
|
|
223
229
|
"code_review_items",
|
|
230
|
+
// DX-2835 — every plan this card is on ({id, ref, name}[]), detail only.
|
|
231
|
+
"plans",
|
|
224
232
|
];
|
|
225
233
|
const SORT_ORDERS = ["asc", "desc"];
|
|
226
234
|
const sortField = z
|
|
@@ -276,7 +284,7 @@ const DESCRIPTION_DESCRIBE = 'The full body ("Context"): evidence, examples, tec
|
|
|
276
284
|
server.tool("issue_list",
|
|
277
285
|
// DX-2735: trimmed to pay for the problem tools inside the work-profile
|
|
278
286
|
// injected-surface budget — same facts, no repeated prose.
|
|
279
|
-
"List cards via GET /api/issues. Board-scoped; see `board`. `filter`: type, parent_id, dispatchable_derived, status_derived[], self_dispatchable_derived, assigned_agent, include_closed, include_deleted, q (free text over id+title+description). THE DEFAULT ROW IS MINIMAL — scalars only (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash), no joins; opt into heavy data with `fields` groups: description (+ summary), problems (open_problem_count), ac, comments, retro, dependencies, triage,
|
|
287
|
+
"List cards via GET /api/issues. Board-scoped; see `board`. `filter`: type, parent_id, dispatchable_derived, status_derived[], self_dispatchable_derived, assigned_agent, include_closed, include_deleted, q (free text over id+title+description). THE DEFAULT ROW IS MINIMAL — scalars only (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash), no joins; opt into heavy data with `fields` groups: description (+ summary), problems (open_problem_count — a card needs a human exactly when this is > 0), ac, comments, retro, dependencies, triage, assignment, quality_gates, children, effort. `sort`: [{column, order}] over id|priority|repo_name|title|type|status_derived|triage_ice_total|created_at|updated_at; default priority desc, repo_name asc, numeric-id tiebreaker. `limit`/`offset` page (uncapped by default). issue_get reads one card in full.", {
|
|
280
288
|
filter: z
|
|
281
289
|
.object({
|
|
282
290
|
q: z.string().optional(),
|
|
@@ -303,7 +311,7 @@ server.tool("issue_list",
|
|
|
303
311
|
server.tool("issue_get",
|
|
304
312
|
// DX-2735: trimmed to pay for the problem tools inside the work-profile
|
|
305
313
|
// injected-surface budget — same facts, no repeated prose.
|
|
306
|
-
"Fetch one card (GET /api/issues/:id, `id`) or many (GET /api/issues/batch, `ids`, at most " + ISSUE_BATCH_GET_MAX + " — split larger sets); pass exactly one. Ids are globally unique, so both resolve from any board; the batch form is global and throws with `board`. THE DEFAULT ROW IS MINIMAL — scalars only (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash: the concurrency token issue_edit needs for title/description/checklists). `fields` opts into groups, per card in a batch too: description (body + summary), problems (live problems in order, each {id, statement, content_hash, open} with its solutions[] and decisions[]), ac (acceptance criteria + checklists), comments, retro, dependencies (waiting_on/conflict_on/blocked), triage (history + ICE),
|
|
314
|
+
"Fetch one card (GET /api/issues/:id, `id`) or many (GET /api/issues/batch, `ids`, at most " + ISSUE_BATCH_GET_MAX + " — split larger sets); pass exactly one. Ids are globally unique, so both resolve from any board; the batch form is global and throws with `board`. THE DEFAULT ROW IS MINIMAL — scalars only (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash: the concurrency token issue_edit needs for title/description/checklists). `fields` opts into groups, per card in a batch too: description (body + summary), problems (live problems in order, each {id, statement, content_hash, open} with its solutions[] and decisions[] — a card needs a human exactly when open_problem_count > 0), ac (acceptance criteria + checklists), comments, retro, dependencies (waiting_on/conflict_on/blocked), triage (history + ICE), assignment (dispatch, assigned_agent, lifecycle timestamps), quality_gates (one row per gate {gate, required, status pending|pass|fail, completed_at, message}; a required PRE gate not `pass` pre-empts the work dispatch, and complete refuses while a required POST gate is not `pass`), children (ids + rollups), mirrors (external sync state), code_review_items, plans (every plan this card is on, `{id, ref, name}[]` via `plan_cards` — `ref` is the plan's `PLN-<id>`). Single form: unknown id → 404. Batch form: `{issues: [...], not_found: [...ids]}` — an unknown or deleted id never fails the call.", {
|
|
307
315
|
id: z.string().min(1).optional(),
|
|
308
316
|
ids: z.array(z.string().min(1)).min(1).max(ISSUE_BATCH_GET_MAX).optional(),
|
|
309
317
|
fields: z
|
|
@@ -365,7 +373,7 @@ server.tool("issue_create", 'Create a card via POST /api/issues. Board-scoped; s
|
|
|
365
373
|
...boardField,
|
|
366
374
|
}, async (args) => jsonResult(await issueCreate(client, args, config.board)));
|
|
367
375
|
// ---------------- issue_edit ----------------
|
|
368
|
-
server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED keys: title, summary, description, ac, checklists, effort_level, parent_id, priority, list_id, triage_enabled, type, content_hash. Any other key (lifecycle, triage, dependencies, retro,
|
|
376
|
+
server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED keys: title, summary, description, ac, checklists, effort_level, parent_id, priority, list_id, triage_enabled, type, content_hash. Any other key (lifecycle, triage, dependencies, retro, blocked) is refused 400 with offending_keys[] naming the right tool: issue_transition / issue_triage / issue_comment / issue_dependency / issue_problem / issue_retro. `type`: Story/Bug/Chore = eligible for autonomous pickup; Task or a container (Epic/Feature) removes that eligibility — how a planning item becomes work. `priority` (a tier word or a number) is the ONLY way to set priority; a "Priority:" line in the description does nothing. CHECKLISTS: each item has one status `incomplete|failing|passing|cancelled|deferred`; `deferred` (work done, a real-world/post-deploy check outstanding) REQUIRES `detail`, and a `📡`-prefixed item can never be `passing`. `ac` edits the default "Acceptance Criteria" checklist (items matched by check_item_id, else exact title); `checklists` REPLACES every named checklist with full status control (`{name, items:[{label, detail?, status}]}`). Send `ac` OR `checklists`, not both (400). `list_id` pins the card to a list by id or display NAME; its type must match the card\'s current derived status (400 otherwise — e.g. ready the card first before pinning it to a `ready`-type list); null clears the pin. `content_hash` (DX-2741) is the card\'s optimistic-concurrency token — REQUIRED whenever the edit touches `title` / `description` / `checklists` (NOT `ac`, which keeps its own check_item_id/title diffing): missing → 400, stale → 409 `stale_issue_content` carrying `currentHash` + `currentTitle` + `currentDescription`. Read it off `issue_get`/`issue_list`\'s `content_hash` scalar (present even minimal); on 409, re-`issue_get` and retry with the fresh hash — never blindly.', {
|
|
369
377
|
id: z.string().min(1),
|
|
370
378
|
title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
|
|
371
379
|
summary: z
|
|
@@ -429,7 +437,7 @@ server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED
|
|
|
429
437
|
// ---------------- issue_transition ----------------
|
|
430
438
|
server.tool("issue_transition",
|
|
431
439
|
// DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
|
|
432
|
-
"Move a card's lifecycle via POST /api/issues/:id/transition — the ONLY way; `danxbot_complete` never moves a card, so call this first. Actions: ready (Review→ToDo); pickup (ToDo→In Progress; checks every dispatch gate — ready, blocked,
|
|
440
|
+
"Move a card's lifecycle via POST /api/issues/:id/transition — the ONLY way; `danxbot_complete` never moves a card, so call this first. Actions: ready (Review→ToDo); pickup (ToDo→In Progress; checks every dispatch gate — ready, blocked, open_problem_count (a card needs a human exactly when this is > 0), depends_on terminal, conflict_on idle — and refuses 409 with failed_gate naming the cause; `manual:true` is a self-pickup for work in YOUR session: it bypasses card-flow gates EXCEPT open_problem_count, which never lets a card start, and is never auto-rolled-back); rollback_pickup (`keep_assignment:true` releases the card to ready WITHOUT clearing its assignment — use this to hand a manually-held card back to `ready` while you keep holding it, instead of a follow-up assigned-agent call); complete (your explicit decision; 409 on an Epic with non-terminal children (non_terminal_phases[]) or while a required POST quality gate is not pass (failed_gate 'quality_gate_post' + failed_post_gates[])); cancel (terminal); block (non-empty reason; only holds dispatch, never asks a human — for that use issue_problem add; env faults use `danxbot_complete({status:'failed'})`); unblock; archive (to Backlog, clears ready_at); reopen (terminal→active). Terminal cards refuse all but reopen; forward stamps never clear earlier ones. A dispatched agent's manual pickup MUST pass `assigned_agent` = your agent/profile name (409 otherwise); one that loses a race is refused 409 `failed_gate: \"dispatch_id\"` — re-read assigned_agent/dispatch_id, never retry blindly.", {
|
|
433
441
|
id: z.string().min(1),
|
|
434
442
|
action: z.enum(TRANSITION_ACTIONS),
|
|
435
443
|
reason: z.string().optional(),
|
|
@@ -441,10 +449,14 @@ server.tool("issue_transition",
|
|
|
441
449
|
.min(1)
|
|
442
450
|
.optional()
|
|
443
451
|
.describe("Required for a dispatched agent's manual:true pickup: your agent/profile name, never the shared dispatch-token identity. Optional for a human session; ignored by other actions."),
|
|
452
|
+
keep_assignment: z
|
|
453
|
+
.boolean()
|
|
454
|
+
.optional()
|
|
455
|
+
.describe("rollback_pickup-only. true preserves assigned_agent + assignment_mode across the release (the card lands ready still manually held, off the automated dispatcher) instead of the default clear-and-hand-back-to-automation."),
|
|
444
456
|
...boardField,
|
|
445
457
|
}, async (args) => jsonResult(await issueTransition(client, args)));
|
|
446
458
|
// ---------------- issue_triage ----------------
|
|
447
|
-
server.tool("issue_triage", "Record a triage confidence score via POST /api/issues/:id/triage (DX-2086). Caller sends a single `confidence` integer 0-5 plus a required non-empty `reason` — the server computes the verdict by comparing `confidence` against the board's configured thresholds (all band edges inclusive on the low side): confidence <= cancelThreshold -> cancel (stamps cancelled_at, terminal); cancelThreshold < confidence <= archiveThreshold -> defer (stamps archived_at AND ready_at:null); archiveThreshold < confidence <= reviewThreshold -> keep (no column stamp); confidence > reviewThreshold -> approve (stamps ready_at). REFUSES 409 on terminal cards. DX-2782 — a keep/defer verdict does NOT block the card: it opens a problem asking the reason with three real choices (approve and ready / defer / cancel, one recommended)
|
|
459
|
+
server.tool("issue_triage", "Record a triage confidence score via POST /api/issues/:id/triage (DX-2086). Caller sends a single `confidence` integer 0-5 plus a required non-empty `reason` — the server computes the verdict by comparing `confidence` against the board's configured thresholds (all band edges inclusive on the low side): confidence <= cancelThreshold -> cancel (stamps cancelled_at, terminal); cancelThreshold < confidence <= archiveThreshold -> defer (stamps archived_at AND ready_at:null); archiveThreshold < confidence <= reviewThreshold -> keep (no column stamp); confidence > reviewThreshold -> approve (stamps ready_at). REFUSES 409 on terminal cards. DX-2782 / DX-2830 — a keep/defer verdict does NOT block the card: it opens a problem asking the reason with three real choices (approve and ready / defer / cancel, one recommended) — opening it IS what puts the card in front of a human, the same escalation shape every other machine writer uses (the dashboard's Needs You tab reads only open_problem_count). Answering that problem applies the chosen outcome through the normal issue_transition actions automatically. It is not a cross-card ordering gate; use issue_dependency (kind: depends_on) to sequence one card after another.", {
|
|
448
460
|
id: z.string().min(1),
|
|
449
461
|
confidence: z.number().int().min(0).max(5),
|
|
450
462
|
reason: z.string().min(1),
|
|
@@ -494,7 +506,7 @@ const SOLUTION_FIELDS = {
|
|
|
494
506
|
con: z.string().optional(),
|
|
495
507
|
recommended: z.boolean().optional(),
|
|
496
508
|
};
|
|
497
|
-
server.tool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pid]: one statement the operator must resolve (a question, or a flaw in the plan), each with its own solutions/answers — one problem per question. OPEN = not yet answered; the card needs a human
|
|
509
|
+
server.tool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pid]: one statement the operator must resolve (a question, or a flaw in the plan), each with its own solutions/answers — one problem per question. OPEN = not yet answered; the card needs a human exactly while open_problem_count > 0 — there is no separate flag to set, adding a problem IS putting the card in front of a human. list → live problems in order, each {id, statement, content_hash, open, solutions[], decisions[]}; add {statement, solutions?} → problem_id + solution_ids in one transaction (zero solutions is valid: the operator answers free-form) plus `problems_reminder: {open_problem_count, instruction}` naming each open problem's solution count; edit :pid {base_hash, statement}; remove :pid {base_hash} — always allowed, even as the card's last open problem (DX-2830: removing it just means the card no longer needs a human). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. No answer action — the operator answers in the dashboard.", {
|
|
498
510
|
id: z.string().min(1),
|
|
499
511
|
action: z.enum(["list", "add", "edit", "remove"]),
|
|
500
512
|
problem_id: z.number().int().positive().optional().describe("edit/remove"),
|
|
@@ -527,14 +539,6 @@ server.tool("issue_dependency", 'Dependency CRUD via /api/issues/:id/dependencie
|
|
|
527
539
|
dependency_id: z.number().int().positive().optional(),
|
|
528
540
|
...boardField,
|
|
529
541
|
}, async (args) => jsonResult(await issueDependency(client, args)));
|
|
530
|
-
// ---------------- issue_requires_human ----------------
|
|
531
|
-
server.tool("issue_requires_human", "Set/clear the requires_human gate via /api/issues/:id/requires-human — the ONLY flag that puts a card in front of a human (block only holds dispatch). Escalate in order: 1) issue_problem add (statement + every viable solution, one recommended); 2) set=true {reason, steps[]} — refused 409 `no_open_problem` (with the server's `fix`) while no problem is open. Set stamps requires_human_reason (no pickup while non-null) and replaces the steps; it clears itself when the last open problem is answered. set=false → DELETE clears it and its steps. Terminal cards refuse 409. Success returns `problems_reminder: {open_problem_count, instruction}` naming each open problem's solution count.", {
|
|
532
|
-
id: z.string().min(1),
|
|
533
|
-
set: z.boolean(),
|
|
534
|
-
reason: z.string().optional(),
|
|
535
|
-
steps: z.array(z.string().min(1)).optional(),
|
|
536
|
-
...boardField,
|
|
537
|
-
}, async (args) => jsonResult(await issueRequiresHuman(client, args)));
|
|
538
542
|
// ---------------- issue_quality_gate ----------------
|
|
539
543
|
server.tool("issue_quality_gate", "Set one card's per-gate `required` flag via POST /api/issues/:id/quality-gates/:gate {required} — the only post-create way (issue_create takes gate_decisions; issue_edit refuses gate keys). PRE `plan-*` gates run before the work dispatch; POST `code-*` gates block complete. Unknown gate → 400. The board state per gate is tri-state: `required` always runs, `optional` runs WHEN this flag is true (optional is NOT off), `disabled` never runs. Optional `effort_level` overrides a `plan-*` gate's reviewer rung (null clears it). The write always succeeds and returns `{issue, applied: true, effective, reason}` — read `effective` (does the gate now run) and `reason` (why the board overrode your value), not just the 200. Board-scoped; see `board`.", {
|
|
540
544
|
id: z.string().min(1),
|
|
@@ -654,8 +658,13 @@ server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug
|
|
|
654
658
|
// plan id — and it can only ever bind the caller's own session. `plan_create`
|
|
655
659
|
// also takes no plan id, but for a different reason: it MAKES a plan rather
|
|
656
660
|
// than acting on one, so there is no existing plan for an id to name yet.
|
|
657
|
-
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}}
|
|
658
|
-
|
|
661
|
+
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, ref, name, createdAt, cardCount, boards, status}], session, sessionListenerAttached}}` — `ref` is the plan's short reference (`PLN-<id>`), the same thing a card's own id is for a card; cite it rather than a bare id. Each plan's `status` (DX-2834) is COMPUTED fresh on every read, never stored — one of `awaiting-session` (no session is live on it — a `plan_sessions` row is never released when a session merely ends, so this is a real liveness check, not just \"has anyone ever connected\"), `planning` (no cards, or only Review/Backlog/terminal cards with at least one not Done/Cancelled), `building` (a live session AND at least one card ToDo/In Progress or in an active-but-stuck state — Blocked, Needs Help), `complete` (at least one card and every one Done/Cancelled — wins even with no session). Pass `status` to filter to one of them. `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 session's event stream is attached (the danxbot plugin's plan event bridge holds it). It is `false` for a few seconds right after `plan_connect` while the bridge starts; still `false` after that while connected means its card events are NOT reaching you — tell the operator. There is nothing to arm. NOTE this is NOT the board Brief (`brief_list`), which is a different feature entirely.", {
|
|
662
|
+
status: z
|
|
663
|
+
.enum(PLAN_STATUSES)
|
|
664
|
+
.optional()
|
|
665
|
+
.describe("Filter to one computed status: awaiting-session, planning, building, complete. Omit for every plan."),
|
|
666
|
+
}, async (args) => jsonResult(await planList(client, args)));
|
|
667
|
+
server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id` to read ANY plan; OMIT to read the plan this session is connected to — omitting while connected to none fails loud `{error: \"session_not_connected\"}`, connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{plan, boards, cardCount, bucketCounts, status, session, sessionListenerAttached, available_field_groups}` — no cards, records, or architecture body. `plan` carries `{id, ref, name, createdAt}`; `ref` is the plan's short reference (`PLN-<id>`) — cite that, not the bare id. `status` (DX-2834) is computed fresh on every read, never stored — see `plan_list` for the four values and what each means. Pass `fields` to opt into: `cards` (member cards, PAGED: `cards_offset` (default 0) and `cards_limit` (1.." + LIST_PAGE_MAX_LIMIT + ", default " + PLAN_GET_CARDS_DEFAULT_LIMIT + ") pick the page, in stable card-reference order (board prefix, then card number — pages never repeat/skip unless membership changes between reads); response carries `cards_total`/`cards_offset` — page while cards_offset + cards.length < cards_total; either paging arg without `fields: [\"cards\"]` is a 400), `records` (every goal+rule+caveat, keyed by kind) or `records:goal` / `records:rule` / `records:caveat` (one kind, cheaper), `architecture` (`{sections: [{id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}]}`), `sessions` (every session connected to the plan). `session`/`sessionListenerAttached` and `available_field_groups` ride every response regardless. `sessionListenerAttached` is `false` for a few seconds right after `plan_connect` while the plugin's event bridge starts; still `false` after that while connected means the plan's card events are not reaching you — tell the operator. ALWAYS `plan_get`/`plan_get_architecture_section` immediately before `plan_update_architecture_section`/`plan_delete_architecture_section` and pass the returned `contentHash` back as `base_hash`.", {
|
|
659
668
|
plan_id: z
|
|
660
669
|
.number()
|
|
661
670
|
.int()
|
|
@@ -680,13 +689,18 @@ server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id
|
|
|
680
689
|
.optional()
|
|
681
690
|
.describe("How many cards one page holds, 1.." + LIST_PAGE_MAX_LIMIT + " (default " + PLAN_GET_CARDS_DEFAULT_LIMIT + "). Requires `fields` to include `cards`."),
|
|
682
691
|
}, async (args) => jsonResult(await planGet(client, args)));
|
|
683
|
-
server.tool("plan_create", "Create a new, empty plan via POST /api/plans (DX-2531). GLOBAL — a plan is not board-scoped, and this call adds no cards, no records, and no architecture sections; it does NOT connect any session to the new plan (call `plan_connect` separately, exactly as adding a card to a plan is its own separate step). Returns `{ok, status, body: {plan: {id, name, createdAt}}}
|
|
692
|
+
server.tool("plan_create", "Create a new, empty plan via POST /api/plans (DX-2531). GLOBAL — a plan is not board-scoped, and this call adds no cards, no records, and no architecture sections; it does NOT connect any session to the new plan (call `plan_connect` separately, exactly as adding a card to a plan is its own separate step). Returns `{ok, status, body: {plan: {id, ref, name, createdAt}}}` — `ref` is the plan's short reference (`PLN-<id>`). Use the returned `plan.id` with `plan_connect` to start working on it, or with `plan_get({plan_id})` to browse it.", {
|
|
684
693
|
name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
|
|
685
694
|
}, async (args) => jsonResult(await planCreate(client, args)));
|
|
686
695
|
server.tool("plan_connect",
|
|
687
696
|
// DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
|
|
688
|
-
"Connect THIS session to a plan via POST /api/plan-sessions/me/plan — the same binding the operator's Connect action writes. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and the reply says which plan it left: `{session, movedFrom: {id, name} | null}` (null = no plan, or already this one). It binds only your OWN session, resolved from the session id this server forwards. Afterwards every plan WRITE tool acts on this plan and takes no plan id. Every comment, answer,
|
|
697
|
+
"Connect THIS session to a plan via POST /api/plan-sessions/me/plan — the same binding the operator's Connect action writes. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and the reply says which plan it left: `{session, movedFrom: {id, name} | null}` (null = no plan, or already this one). It binds only your OWN session, resolved from the session id this server forwards. Afterwards every plan WRITE tool acts on this plan and takes no plan id. Every comment, answer, problem added and block/unblock on this plan's cards then reaches the session on its own, relayed by the danxbot plugin's plan event bridge as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<problem statement>\": chose \"Pause E2E\"`. Nothing to arm; never poll for these. DX-2816: pass `title` (call `get_session({session_id:\"self\"})` first and forward its `title` verbatim) so the dashboard shows the same name Claude does — this server has no way to read it itself.", {
|
|
689
698
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
699
|
+
title: z
|
|
700
|
+
.string()
|
|
701
|
+
.min(1)
|
|
702
|
+
.optional()
|
|
703
|
+
.describe("THIS session's own Claude session title — from `get_session({session_id:\"self\"}).title`, passed verbatim, never invented or derived from the repo/cwd. Stored on your session's row: a title different from what is already stored UPDATES it; omit to leave the stored title untouched. Not this plan's name. At most 200 characters — an overlong title is refused with a 400 naming its length."),
|
|
690
704
|
}, async (args) => jsonResult(await planConnect(client, args)));
|
|
691
705
|
server.tool("plan_add_record", "Add a goal, rule or caveat to your connected plan (POST /api/plans/mine/records). A GOAL is an outcome the work is measured against. A RULE is a constraint that must hold while it is worked. A CAVEAT is a lasting trade-off or limitation of the ARCHITECTURE — never progress, status or a session note (those are comments on the card). `body` is ONE plain statement of at most 250 characters; the evidence, history and detail go in `context` (markdown). An overlong body is refused with a 400 naming its length. The server allocates a permanent reference (`G-1`, `R-4`, `CAV-12`). Takes no plan id; `session_not_connected` → `plan_connect` first. Returns the record plus that kind's list.", {
|
|
692
706
|
kind: z.enum(["goal", "rule", "caveat"]).describe("goal = outcome, rule = constraint, caveat = architecture trade-off."),
|
|
@@ -717,7 +731,7 @@ server.tool("plan_remove_card", "Remove a card from a plan via DELETE /api/plans
|
|
|
717
731
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
718
732
|
card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
|
|
719
733
|
}, async (args) => jsonResult(await planRemoveCard(client, args)));
|
|
720
|
-
server.tool("plan_rename", "Rename a plan via PATCH /api/plans/:plan_id (DX-2740) — the ONLY way to change a plan's `name`; nothing else in this tool surface can fix a stale name. Takes an EXPLICIT `plan_id`, not your connected session's plan, so you may rename any plan you can name. `name` must be a non-empty string (400 otherwise). The new name is visible immediately in a follow-up `plan_list` or `plan_get`. Unknown plan → 404. Returns the renamed plan `{id, name, createdAt}`.", {
|
|
734
|
+
server.tool("plan_rename", "Rename a plan via PATCH /api/plans/:plan_id (DX-2740) — the ONLY way to change a plan's `name`; nothing else in this tool surface can fix a stale name. Takes an EXPLICIT `plan_id`, not your connected session's plan, so you may rename any plan you can name. `name` must be a non-empty string (400 otherwise). The new name is visible immediately in a follow-up `plan_list` or `plan_get`. Unknown plan → 404. Returns the renamed plan `{id, ref, name, createdAt}`.", {
|
|
721
735
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
722
736
|
name: z.string().min(1).describe("The plan's new name."),
|
|
723
737
|
}, async (args) => jsonResult(await planRename(client, args)));
|
|
@@ -744,6 +758,47 @@ server.tool("plan_reorder_architecture_section", "Reassign your connected plan's
|
|
|
744
758
|
.min(1)
|
|
745
759
|
.describe("Every live section id of the connected plan, in the desired order — exactly once each."),
|
|
746
760
|
}, async (args) => jsonResult(await planReorderArchitectureSection(client, args)));
|
|
761
|
+
// ---------------- failure_category_list / _create / _update (DX-2792) ----------------
|
|
762
|
+
const matcherField = z
|
|
763
|
+
.object({
|
|
764
|
+
sourceKind: z
|
|
765
|
+
.enum(["tool-error", "hook-refusal", "api-error", "usage-limit", "session-result"])
|
|
766
|
+
.optional()
|
|
767
|
+
.describe("Absent matches any source kind."),
|
|
768
|
+
tool: z.string().min(1).optional().describe("Exact tool name (e.g. \"Bash\"); absent matches any tool, including null."),
|
|
769
|
+
regexPattern: z
|
|
770
|
+
.string()
|
|
771
|
+
.min(1)
|
|
772
|
+
.optional()
|
|
773
|
+
.describe("Regex source tested against the normalized excerpt (paths/UUIDs/timestamps/ports already stripped)."),
|
|
774
|
+
})
|
|
775
|
+
.describe("At least one of sourceKind/tool/regexPattern must be set — an empty matcher is refused.");
|
|
776
|
+
const expectedRateField = z
|
|
777
|
+
.object({
|
|
778
|
+
count: z.number().int().min(0).describe("N — how many failures are expected."),
|
|
779
|
+
overDispatches: z.number().int().positive().describe("X — over how many dispatches."),
|
|
780
|
+
})
|
|
781
|
+
.nullable()
|
|
782
|
+
.optional()
|
|
783
|
+
.describe("Both fields together, or omit/null entirely — never a half-specified rate.");
|
|
784
|
+
server.tool("failure_category_list", "List every failure category via GET /api/failure-categories (DX-2791/DX-2792). Board-less — a category applies across the whole install, not one board. Returns `{categories: [{id, name, description, matchers, ignore, ignoreReason, expectedRate, matchedCount, lastSeenMs, createdAtMs, createdBy, updatedAtMs, updatedBy}]}`, id ascending. A fresh install returns `{categories: []}`. Read this immediately before `failure_category_create`/`failure_category_update` so your overlap/expand decision is against the CURRENT set, not a stale snapshot from earlier in the dispatch.", {}, async () => jsonResult(await failureCategoryList(client)));
|
|
785
|
+
server.tool("failure_category_create", 'Create a new failure category via POST /api/failure-categories (DX-2791/DX-2792). Board-less. `matchers` (at least one) is an OR-across-matchers set — a category matches an occurrence when ANY ONE matcher\'s fields all hold. `ignore: true` REQUIRES a non-empty `ignoreReason` (400 otherwise) — use this for an expected, non-actionable failure rather than leaving it uncategorized. A matcher set overlapping an EXISTING category is refused 400 naming the conflict — call `failure_category_list` first and EXPAND that category (`failure_category_update`) instead of creating a near-duplicate. On success, the dashboard re-matches every existing uncategorized occurrence against the new category before responding.', {
|
|
786
|
+
name: z.string().min(1).describe("The category's name."),
|
|
787
|
+
description: z.string().optional().describe("Optional free-text description. Defaults to empty."),
|
|
788
|
+
matchers: z.array(matcherField).min(1).describe("At least one matcher; a category matches an occurrence when ANY ONE matches (OR across matchers)."),
|
|
789
|
+
ignore: z.boolean().optional().describe("Mark this category as expected/non-actionable noise. Requires ignoreReason. Defaults to false."),
|
|
790
|
+
ignoreReason: z.string().nullable().optional().describe("Required (non-empty) when ignore is true."),
|
|
791
|
+
expectedRate: expectedRateField,
|
|
792
|
+
}, async (args) => jsonResult(await failureCategoryCreate(client, args)));
|
|
793
|
+
server.tool("failure_category_update", "Patch an existing failure category via PATCH /api/failure-categories/:id (DX-2791/DX-2792). Board-less. This is the tool for BOTH actions: expanding an existing category's matchers (send the FULL replacement `matchers` array — it REPLACES, not appends, so include every matcher you want to keep alongside the new one) and marking a category ignored (`ignore: true` + a non-empty `ignoreReason`). At least one field besides `id` is required (400 otherwise). Same overlap refusal as create, excluding this category's own prior matchers. On success, re-matches every uncategorized occurrence against the updated matcher set before responding.", {
|
|
794
|
+
id: z.number().int().positive().describe("The category id to patch — from failure_category_list."),
|
|
795
|
+
name: z.string().min(1).optional(),
|
|
796
|
+
description: z.string().optional(),
|
|
797
|
+
matchers: z.array(matcherField).min(1).optional().describe("REPLACES the full matcher set when sent — never a partial append."),
|
|
798
|
+
ignore: z.boolean().optional(),
|
|
799
|
+
ignoreReason: z.string().nullable().optional(),
|
|
800
|
+
expectedRate: expectedRateField,
|
|
801
|
+
}, async (args) => jsonResult(await failureCategoryUpdate(client, args)));
|
|
747
802
|
// ---------------- main ----------------
|
|
748
803
|
async function main() {
|
|
749
804
|
boot();
|
package/dist/listen.js
CHANGED
|
@@ -86,7 +86,13 @@ export function invalidEventReason(value) {
|
|
|
86
86
|
return "detail.solution is not {title, note}";
|
|
87
87
|
return d.solution.note === null || isCappedText(d.solution.note) ? null : "detail.solution.note is not capped text";
|
|
88
88
|
}
|
|
89
|
-
case "
|
|
89
|
+
case "problem_added": {
|
|
90
|
+
const problem = d.problem;
|
|
91
|
+
if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
|
|
92
|
+
return "detail.problem is not {id, statement}";
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
90
96
|
case "blocked":
|
|
91
97
|
return isCappedText(d.reason) ? null : "detail.reason is not capped text";
|
|
92
98
|
default:
|
|
@@ -111,10 +117,12 @@ function describe(event) {
|
|
|
111
117
|
const note = solution.note === null ? "" : ` — note: ${quoted(solution.note)}`;
|
|
112
118
|
return `${answered} chose "${solution.title}"${note}`;
|
|
113
119
|
}
|
|
114
|
-
case "
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
120
|
+
case "problem_added": {
|
|
121
|
+
// DX-2830 — this IS the "needs a human" signal now: a card needs one
|
|
122
|
+
// exactly when it has an open problem, and this is one being opened.
|
|
123
|
+
const problem = d.problem;
|
|
124
|
+
return `${event.actor} opened a problem: ${quoted(problem.statement)}`;
|
|
125
|
+
}
|
|
118
126
|
case "blocked":
|
|
119
127
|
return `${event.actor} blocked the card: ${quoted(d.reason)}`;
|
|
120
128
|
case "unblocked":
|
package/dist/one-line.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DX-2735 — the ONE way this package renders human text into an agent-facing
|
|
3
|
-
* line: the listen notification line and the
|
|
4
|
-
*
|
|
3
|
+
* line: the listen notification line and the problems reminder both go
|
|
4
|
+
* through it, so they can never disagree about how a statement looks.
|
|
5
5
|
*
|
|
6
6
|
* Every whitespace run (newlines included) collapses to a single space, because
|
|
7
7
|
* a notification is one line and a reminder quotes statements inline. The
|
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.71",
|
|
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",
|