@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 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` + `issue_requires_human` 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. Edit + remove are hash-guarded (`base_hash`, 409 `stale_problem`); removing the last open problem while `requires_human` is set is refused 409 `last_open_problem` |
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: "…"`, `… set requires_human: "…"`, `… cleared requires_human`, `… 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.
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 — it never
260
- // puts the card in front of a human — so there is no question to list
261
- // options for. A card that needs a human uses issue_problem + issue_requires_human.
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 requires-human set.
282
+ * Attach the problems reminder to a SUCCESSFUL `issue_problem add`.
282
283
  *
283
- * DX-2735 — THE SERVER NOW ENFORCES THE QUESTION, THIS ENFORCES THE OPTIONS.
284
- * The set route refuses 409 `no_open_problem` unless the card has an open
285
- * problem, so a card can no longer be put in front of a human without saying
286
- * what the human must decide. Whether each open problem lists its viable
287
- * solutions is still feedback, not refusal: a problem with zero solutions is
288
- * valid (the operator answers free-form), and refusing it would push the agent
289
- * into inventing options. So the reminder rides the success response and names
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
- * (including `no_open_problem`, whose `fix` text passes through verbatim) gets
294
- * no reminder and no extra request. A failure READING the problems is reported
295
- * inside the reminder rather than thrown: the set already succeeded, and
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 set and this
327
- // read — the gate has already released itself.
328
- return `No problem on ${id} is open any more — each was answered after you set requires_human, which releases the gate. Re-read the card before you stop.`;
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, `set=true` for issue_requires_human — so every tool
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, `last_open_problem`) pass through verbatim.
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
- return client.request({ method: "POST", path: `/${idEnc}/problems`, body, board });
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({ method: "GET", path: "", basePath: PLANS_BASE_PATH });
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: { plan_id: args.plan_id },
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
+ }
@@ -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, issueRequiresHuman, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, planAddArchitectureSection, planAddCard, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, 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
+ 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
- * The TITLE is a CREATION-TIME value only — the dashboard stamps it when the
158
- * session first registers and never overwrites it afterwards, because the
159
- * operator may have renamed it. `DANX_SESSION_TITLE` lets a launcher say what
160
- * a session is; absent that, the working directory's name is what the session
161
- * can honestly say about itself, and last-active time is what actually
162
- * distinguishes two sessions in the same place.
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
- const cwdName = basename(process.cwd());
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, requires_human, 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.", {
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), requires_human (gate + steps), 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. Single form: unknown id → 404. Batch form: `{issues: [...], not_found: [...ids]}` — an unknown or deleted id never fails the call.", {
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, requires_human, blocked) is refused 400 with offending_keys[] naming the right tool: issue_transition / issue_triage / issue_comment / issue_dependency / issue_requires_human / 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.', {
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, requires_human, 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 and is never auto-rolled-back); rollback_pickup; 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 then issue_requires_human; 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.", {
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) and sets requires_human, the same escalation shape every other machine writer uses (the dashboard's Needs You tab reads only requires_human_reason). 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.", {
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 until none is open, and issue_requires_human set is refused 409 `no_open_problem` until one is. 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); edit :pid {base_hash, statement}; remove :pid {base_hash} (409 `last_open_problem` while requires_human is set). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. No answer action — the operator answers in the dashboard.", {
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}}`. `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.", {}, async () => jsonResult(await planList(client)));
658
- 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, session, sessionListenerAttached, available_field_groups}` — no cards, records, or architecture body. 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`.", {
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}}}`. Use the returned `plan.id` with `plan_connect` to start working on it, or with `plan_get({plan_id})` to browse it.", {
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, requires_human change 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.", {
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 "requires_human_set":
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 "requires_human_set":
115
- return `${event.actor} set requires_human: ${quoted(d.reason)}`;
116
- case "requires_human_cleared":
117
- return `${event.actor} cleared requires_human`;
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 requires_human problems reminder
4
- * both go through it, so they can never disagree about how a statement looks.
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.69",
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",