@thehammer/danx-dashboard-mcp 0.1.70 → 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. */
@@ -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
@@ -84,14 +83,13 @@
84
83
  * agent reads `body.error` + structured fields to decide next action.
85
84
  * 5xx and network failures throw — never silently swallowed.
86
85
  */
87
- import { basename } from "node:path";
88
86
  import { isEntrypointModule } from "./entrypoint.js";
89
87
  import { BRIDGE_SUBCOMMAND, runBridgeCommand } from "./bridge.js";
90
88
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
91
89
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
92
90
  import { z } from "zod";
93
91
  import { DashboardHttpClient } from "./http-client.js";
94
- import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRequiresHuman, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, 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";
95
93
  import { PRIORITY_TIER_WORDS } from "./priority.js";
96
94
  function readEnvOrDie(name) {
97
95
  const v = process.env[name];
@@ -157,20 +155,26 @@ function boot() {
157
155
  * headers and behaves exactly as this package did before. Requiring it would
158
156
  * break the tool-defs generator, the drift test, and any non-Claude consumer.
159
157
  *
160
- * The TITLE is a CREATION-TIME value only — the dashboard stamps it when the
161
- * session first registers and never overwrites it afterwards, because the
162
- * operator may have renamed it. `DANX_SESSION_TITLE` lets a launcher say what
163
- * a session is; absent that, the working directory's name is what the session
164
- * can honestly say about itself, and last-active time is what actually
165
- * 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.
166
172
  */
167
173
  function readSessionConfig() {
168
174
  const id = readEnvOptional("CLAUDE_CODE_SESSION_ID");
169
175
  if (id === undefined)
170
176
  return undefined;
171
- const cwdName = basename(process.cwd());
172
- const fallback = cwdName === "" ? `session ${id.slice(0, 8)}` : cwdName;
173
- return { id, title: readEnvOptional("DANX_SESSION_TITLE") ?? fallback };
177
+ return { id };
174
178
  }
175
179
  export const server = new McpServer({
176
180
  name: "danx-dashboard-mcp",
@@ -214,7 +218,6 @@ const LIST_FIELD_GROUPS = [
214
218
  "retro",
215
219
  "dependencies",
216
220
  "triage",
217
- "requires_human",
218
221
  "assignment",
219
222
  "quality_gates",
220
223
  "children",
@@ -224,6 +227,8 @@ const GET_FIELD_GROUPS = [
224
227
  ...LIST_FIELD_GROUPS,
225
228
  "mirrors",
226
229
  "code_review_items",
230
+ // DX-2835 — every plan this card is on ({id, ref, name}[]), detail only.
231
+ "plans",
227
232
  ];
228
233
  const SORT_ORDERS = ["asc", "desc"];
229
234
  const sortField = z
@@ -279,7 +284,7 @@ const DESCRIPTION_DESCRIBE = 'The full body ("Context"): evidence, examples, tec
279
284
  server.tool("issue_list",
280
285
  // DX-2735: trimmed to pay for the problem tools inside the work-profile
281
286
  // injected-surface budget — same facts, no repeated prose.
282
- "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.", {
283
288
  filter: z
284
289
  .object({
285
290
  q: z.string().optional(),
@@ -306,7 +311,7 @@ server.tool("issue_list",
306
311
  server.tool("issue_get",
307
312
  // DX-2735: trimmed to pay for the problem tools inside the work-profile
308
313
  // injected-surface budget — same facts, no repeated prose.
309
- "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.", {
310
315
  id: z.string().min(1).optional(),
311
316
  ids: z.array(z.string().min(1)).min(1).max(ISSUE_BATCH_GET_MAX).optional(),
312
317
  fields: z
@@ -368,7 +373,7 @@ server.tool("issue_create", 'Create a card via POST /api/issues. Board-scoped; s
368
373
  ...boardField,
369
374
  }, async (args) => jsonResult(await issueCreate(client, args, config.board)));
370
375
  // ---------------- issue_edit ----------------
371
- 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.', {
372
377
  id: z.string().min(1),
373
378
  title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
374
379
  summary: z
@@ -432,7 +437,7 @@ server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED
432
437
  // ---------------- issue_transition ----------------
433
438
  server.tool("issue_transition",
434
439
  // DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
435
- "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 (`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 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.", {
436
441
  id: z.string().min(1),
437
442
  action: z.enum(TRANSITION_ACTIONS),
438
443
  reason: z.string().optional(),
@@ -451,7 +456,7 @@ server.tool("issue_transition",
451
456
  ...boardField,
452
457
  }, async (args) => jsonResult(await issueTransition(client, args)));
453
458
  // ---------------- issue_triage ----------------
454
- 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.", {
455
460
  id: z.string().min(1),
456
461
  confidence: z.number().int().min(0).max(5),
457
462
  reason: z.string().min(1),
@@ -501,7 +506,7 @@ const SOLUTION_FIELDS = {
501
506
  con: z.string().optional(),
502
507
  recommended: z.boolean().optional(),
503
508
  };
504
- 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.", {
505
510
  id: z.string().min(1),
506
511
  action: z.enum(["list", "add", "edit", "remove"]),
507
512
  problem_id: z.number().int().positive().optional().describe("edit/remove"),
@@ -534,14 +539,6 @@ server.tool("issue_dependency", 'Dependency CRUD via /api/issues/:id/dependencie
534
539
  dependency_id: z.number().int().positive().optional(),
535
540
  ...boardField,
536
541
  }, async (args) => jsonResult(await issueDependency(client, args)));
537
- // ---------------- issue_requires_human ----------------
538
- 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.", {
539
- id: z.string().min(1),
540
- set: z.boolean(),
541
- reason: z.string().optional(),
542
- steps: z.array(z.string().min(1)).optional(),
543
- ...boardField,
544
- }, async (args) => jsonResult(await issueRequiresHuman(client, args)));
545
542
  // ---------------- issue_quality_gate ----------------
546
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`.", {
547
544
  id: z.string().min(1),
@@ -661,8 +658,13 @@ server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug
661
658
  // plan id — and it can only ever bind the caller's own session. `plan_create`
662
659
  // also takes no plan id, but for a different reason: it MAKES a plan rather
663
660
  // than acting on one, so there is no existing plan for an id to name yet.
664
- 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}], 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. `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)));
665
- 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. `plan` carries `{id, ref, name, createdAt}`; `ref` is the plan's short reference (`PLN-<id>`) — cite that, not the bare id. 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`.", {
666
668
  plan_id: z
667
669
  .number()
668
670
  .int()
@@ -692,8 +694,13 @@ server.tool("plan_create", "Create a new, empty plan via POST /api/plans (DX-253
692
694
  }, async (args) => jsonResult(await planCreate(client, args)));
693
695
  server.tool("plan_connect",
694
696
  // DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
695
- "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.", {
696
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."),
697
704
  }, async (args) => jsonResult(await planConnect(client, args)));
698
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.", {
699
706
  kind: z.enum(["goal", "rule", "caveat"]).describe("goal = outcome, rule = constraint, caveat = architecture trade-off."),
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.70",
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",