@thehammer/danx-dashboard-mcp 0.1.162 → 0.1.164

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
@@ -15,42 +15,20 @@ Each tool is a thin envelope over one HTTP route — Zod-validated at the MCP bo
15
15
 
16
16
  BOARD-ONLY (DX-1171): board is the first-level concept; repo is DERIVED from board server-side, never passed. At boot the package composes the dispatch's qualified board id `<repo>:<slug>` from `DANX_REPO_NAME` + `DANXBOT_BOARD_NAME` and appends it as `?board=<id>` (and `body.board` on create) to every `/api/issues/*` request. Each tool exposes an optional `board` arg — a FULL qualified id `<repo>:<slug>` (e.g. `platform:the-supply-operations-hub`) — that overrides it per-call for cross-board reads/writes (unknown board → 404). The dashboard owns board→repo resolution; this package never sends `repo`.
17
17
 
18
- ## Tool surface
18
+ ## Tool surface (DX-3465 — one generic tool, not one per operation)
19
19
 
20
- All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `mcp.template.json`.
20
+ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `mcp.template.json`. There is no per-operation tool any more (no `issue_get`, `issue_create`, `plan_add_note`, …) — three tools cover the whole dashboard API:
21
21
 
22
- | Tool | HTTP | Notes |
23
- |---|---|---|
24
- | `issue_list` | `GET /api/issues` | filters: `type`, `parent_id` (null → root-only), `dispatchable_derived`, `assigned_agent`, `include_closed`, `limit`, `offset`. `fields` (DX-3427) is the SAME recursive field tree `issue_get` takes, against the same issue resource — list-row fields (`ac_total`, `comments_count`, `waiting_on`, `quality_gates`, `next_pre_gate`, …) sit alongside every detail field. See `resource_fields` |
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` (DX-3426) opts in via a recursive field TREE — `{"description": true, "problems": {"solutions": true}, "comments": {"limit": 10}}` — not a flat group list; a cursor-paged relation (today only `comments`) takes its page args (`limit`/`before`) inside its own nested object, so paging now works on the batch form too. See `resource_fields` |
26
- | `resource_fields` | `GET /api/resources/:resource/fields` | DX-3426 — what an `issue_get`-style field tree may name for one resource: `{resource, description, always, hashes, fields, relations}`. Install-global, no `board`. `resource: "issue"` is the root; a relation's own `resource` in the response is what to call this again with, one level deeper |
27
- | `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` |
28
- | `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 |
29
- | `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 |
30
- | `issue_problem` | `GET/POST/PATCH/DELETE /api/issues/:id/problems[/:pid]` + `POST .../answer`, `change-answer`, `unanswer` | Actions list / add / edit / remove / answer / change_answer / unanswer (DX-3471). 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 a `reminders: [{key, text}]` array (DX-3365 — the reminder registry's middleware, DB-driven and operator-overridable from the dashboard). 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. `answer` `{solution_id, note?}` \| `{freeform}` and `change_answer` (same + `base_hash` naming the live DECISION) / `unanswer` (`{base_hash}`) record or retract an operator's decision — server-refused 403 for a dispatched agent's machine credential (DX-2830), reachable from an OPERATOR SESSION relaying an in-chat decision, which also durably records the relaying session (`relayed_by_session`) via the same session header every call already carries |
31
- | `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 THIS tool — see `issue_problem`'s answer / change_answer / unanswer actions |
32
- | `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 |
33
- | `issue_comment` | `POST/PATCH/DELETE /api/issues/:id/comments[/:cid]` | Author server-stamped, soft-delete preserved |
34
- | `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 |
35
- | `issue_retire_branch` | `POST /api/issues/:id/card-branch-retire` | Mark this card's own `card/<id>` branch RETIRED (unsafe to merge) — `{reason}`, `by` server-stamped. NO status/terminal gate (DX-2845): settable the instant an audit/split judges the branch unsafe, whatever the card's status. The dispatched worker reads this at its next bootstrap and forces `origin/main` instead of re-attaching to the retired content |
36
- | `issue_retro` | `PUT /api/issues/:id/retro` | Requires terminal card; replace semantics. `correctable_danxbot_problem` + `correctable_danxbot_problem_description` (DX-2794) are REQUIRED on every write, like `tests[]` — answer honestly whether THIS dispatch hit a fixable problem in danxbot's own code/configuration (not "the card was hard"); a true+described retro starts exactly one automated repair |
37
-
38
- ## `plan_get` — cheap by default, opt-in for the rest (DX-2727)
39
-
40
- 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.
41
-
42
- ## `plan_list` / `plan_get` — computed `status` (DX-2834)
43
-
44
- Every plan carries a `status`, computed fresh on every read and never stored — no writer ever sets it:
45
-
46
- | Status | Meaning |
22
+ | Tool | Purpose |
47
23
  |---|---|
48
- | `complete` | At least one card, and every card Done or Cancelled. Wins even with no session — a finished plan needs nobody. |
49
- | `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 — `last_active_at` within the last minute — never just "has a session ever connected". A working session's event stream stamps that activity every 15 seconds on its own, so an open, idle session stays live with no tool calls. |
50
- | `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. |
51
- | `planning` | Everything else: no cards, or only Review/Backlog/terminal cards with at least one not Done/Cancelled. |
24
+ | `danxbot_api` | `{method, path, query?, body?}` — calls any dashboard route the caller's credential may reach (card/plan CRUD, comments, dependencies, problems/solutions, quality gates, retro, triage, transitions, …). Returns `{ok, status, body}`; `body` carries the route's own response, or its structured error verbatim when `ok:false`. |
25
+ | `danxbot_api_list` | No args. Lists the routes this caller's credential may reach, one line each (`{method, path, summary, permission}`) — call this first when you don't know the route for what you need to do. |
26
+ | `danxbot_api_spec` | `{endpoints}` (comma-joined `METHOD /path` list, required, min 1). Returns the exact OpenAPI contract for those routes — call this when you know the route but not its exact request/response shape. |
27
+ | `resource_fields` | `GET /api/resources/:resource/fields` — what an opt-in field TREE may name for one resource: `{resource, description, always, hashes, fields, relations}`. Install-global, no `board`. `resource: "issue"` is the root; a relation's own `resource` in the response is what to call this again with, one level deeper. |
28
+
29
+ A dedicated tool survives only for something about the agent itself, never a card/plan operation: `plan_connect` (binds the session to a plan; the event-bridge hook keys on it) and the per-dispatch worker callbacks (`danxbot_complete`, `danxbot_slack_reply`, `danxbot_slack_post_update`, `load_template_app`/`save_template_app`, `quality_gate_complete`, `link_thread_to_issue`, `attach_file`, the `slack_recovery_*` trio, `danxbot_set_evaluator_summary`) — see `src/lifecycle.ts`. The live list is always `tool-defs.json` in this package, regenerated by `npm run gen-tool-defs` — read it rather than trusting a list copied into prose.
52
30
 
53
- 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.
31
+ Card and plan reads/writes go through `danxbot_api` against the issue/plan route families documented in `GET /api/openapi` (via `danxbot_api_spec`) — `POST /api/issues`, `GET /api/issues/:id` (or `/batch` for many at once, DX-2727), `PATCH /api/issues/:id/edit`, `POST /api/issues/:id/transition`, the problems/solutions/dependencies/comments/quality-gates/retro sub-resource routes, and the equivalent `/api/plans/*` family (`GET /api/plans/:planId` — cheap scalars by default, `fields` opts into `cards`/`records`/`architecture`/`sessions`; `GET /api/plans` for the list, with an optional `status` filter over the same computed `complete`/`awaiting-session`/`building`/`planning` value `GET /api/plans/:planId` always returns ungated).
54
32
 
55
33
  ## `bridge` — a working session's event stream client
56
34
 
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { mkdtempSync, writeFileSync, symlinkSync, rmSync, realpathSync, } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { isEntrypointModule } from "./entrypoint.js";
7
+ describe("isEntrypointModule (DX-1647)", () => {
8
+ let dir;
9
+ let real;
10
+ let link;
11
+ beforeAll(() => {
12
+ dir = mkdtempSync(join(tmpdir(), "entrypoint-test-"));
13
+ real = join(dir, "index.js");
14
+ writeFileSync(real, "// stub entry\n");
15
+ link = join(dir, "danx-dashboard-mcp"); // mimics node_modules/.bin symlink
16
+ symlinkSync(real, link);
17
+ });
18
+ afterAll(() => {
19
+ rmSync(dir, { recursive: true, force: true });
20
+ });
21
+ // import.meta.url always reports the module REALPATH — model that here.
22
+ const moduleUrl = () => pathToFileURL(realpathSync(real)).href;
23
+ it("true when argv[1] is the real file (direct `node index.js`)", () => {
24
+ expect(isEntrypointModule(moduleUrl(), real)).toBe(true);
25
+ });
26
+ it("true when argv[1] is a SYMLINK to the file (npx / global bin) — the fleet regression", () => {
27
+ // The symlink path !== the realpath, but the entrypoint MUST still be
28
+ // detected, or npx imports the module and exits without booting the server.
29
+ expect(link).not.toBe(realpathSync(link));
30
+ expect(isEntrypointModule(moduleUrl(), link)).toBe(true);
31
+ });
32
+ it("false when argv[1] is undefined (module imported, not run as bin)", () => {
33
+ expect(isEntrypointModule(moduleUrl(), undefined)).toBe(false);
34
+ });
35
+ it("false when argv[1] is an empty string", () => {
36
+ expect(isEntrypointModule(moduleUrl(), "")).toBe(false);
37
+ });
38
+ it("false when argv[1] points at a different real file", () => {
39
+ const other = join(dir, "other.js");
40
+ writeFileSync(other, "// other\n");
41
+ expect(isEntrypointModule(moduleUrl(), other)).toBe(false);
42
+ });
43
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * `danx-dashboard-mcp event-text <event>` — the ONE-SHOT client of
3
+ * `GET /api/reminders/event/<event>` (DX-3421).
4
+ *
5
+ * WHO RUNS IT. The claude-plugins `danxbot` plugin's single event hook
6
+ * (`event-hook.sh`, SessionStart matcher "startup|resume|compact" plus
7
+ * SubagentStart matcher ".*") — replaces the old `mantra` subcommand's ONE
8
+ * consumer, `mantra.sh` (deleted, DX-3421, along with
9
+ * `plan-workflow-autoload.sh` and `compaction-skill-reload.sh`; all three
10
+ * collapse into the one hook + this one subcommand). `<event>` is one of
11
+ * `session_start` | `session_resume` | `after_compaction` |
12
+ * `sub_agent_start` — the SAME `npx -y @thehammer/danx-dashboard-mcp@<pin>`
13
+ * path the plugin's other dashboard calls already use (`plan-state`,
14
+ * `bridge`) — never a separate install, never a deep import of this
15
+ * package's `dist/` internals.
16
+ *
17
+ * THE CREDENTIAL — the SAME resolver `bridge` / `plan-state` use
18
+ * (`resolveBridgeOptions`, `bridge.ts`): the dashboard URL, credential source
19
+ * and credential all come from the connection record this session's OWN
20
+ * danx-dashboard MCP server wrote on its last successful `plan_connect`
21
+ * (`session-connection.ts`), never from this process's own ambient env. This
22
+ * is only ever called once a session is already connected (`event-hook.sh`
23
+ * checks that first via `plan-connection.mjs` and stays completely silent
24
+ * otherwise — DX-3421 comment 7748/7752 verdict: no output at all before a
25
+ * plan is connected), so a connection record always exists by the time this
26
+ * runs.
27
+ *
28
+ * THE CONTRACT — `event-hook.sh` needs to tell success from failure so it
29
+ * can print the operator-facing "could not load" notice (DX-3421 AC: no
30
+ * committed offline fallback exists any more — a fetch failure is ALWAYS
31
+ * reported to the agent, never silently swallowed and never a `mantra.md`
32
+ * reread). So:
33
+ *
34
+ * - success: the event's EFFECTIVE text on stdout, nothing else, exit 0.
35
+ * - failure (missing env, no connection record, network error, timeout,
36
+ * non-2xx, 404, bad shape): NOTHING on stdout, one short reason on
37
+ * stderr, exit 1. `event-hook.sh` is the one that decides what to print
38
+ * for the agent — this module only reports whether the fetch worked.
39
+ *
40
+ * A HARD TIMEOUT (mirrors `plan-state.ts`) — a SessionStart hook must never
41
+ * hang on a wedged dashboard.
42
+ */
43
+ import { SESSION_ID_HEADER, resolveBridgeOptions } from "./bridge.js";
44
+ export const EVENT_TEXT_SUBCOMMAND = "event-text";
45
+ export const EVENT_TEXT_REQUEST_TIMEOUT_MS = 5_000;
46
+ const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${EVENT_TEXT_SUBCOMMAND} <event>`;
47
+ function eventPath(event) {
48
+ return `/api/reminders/event/${encodeURIComponent(event)}`;
49
+ }
50
+ /** One authenticated GET, with a hard timeout — never throws, never bubbles a non-2xx. */
51
+ export async function fetchEventText(event, options, deps) {
52
+ const controller = new AbortController();
53
+ const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
54
+ let status;
55
+ let text;
56
+ try {
57
+ try {
58
+ const response = await deps.fetch(`${options.dashboardUrl}${eventPath(event)}`, {
59
+ method: "GET",
60
+ headers: {
61
+ Authorization: `Bearer ${options.token}`,
62
+ Accept: "application/json",
63
+ [SESSION_ID_HEADER]: options.sessionId,
64
+ },
65
+ signal: controller.signal,
66
+ });
67
+ status = response.status;
68
+ text = await response.text();
69
+ }
70
+ catch {
71
+ return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
72
+ }
73
+ }
74
+ finally {
75
+ clearTimeout(timer);
76
+ }
77
+ if (status === 401 || status === 403)
78
+ return { ok: false, reason: "unauthorized" };
79
+ if (status === 404)
80
+ return { ok: false, reason: "not_found" };
81
+ if (status < 200 || status >= 300)
82
+ return { ok: false, reason: "http_error" };
83
+ let body;
84
+ try {
85
+ body = text === "" ? null : JSON.parse(text);
86
+ }
87
+ catch {
88
+ return { ok: false, reason: "bad_response" };
89
+ }
90
+ const effectiveText = typeof body === "object" &&
91
+ body !== null &&
92
+ typeof body.effective_text === "string"
93
+ ? body.effective_text
94
+ : null;
95
+ if (effectiveText === null)
96
+ return { ok: false, reason: "bad_response" };
97
+ return { ok: true, text: effectiveText };
98
+ }
99
+ /**
100
+ * The bin's `event-text` subcommand, wired to the real process. Success
101
+ * prints the effective text on stdout and returns 0; every failure prints
102
+ * nothing on stdout, one reason on stderr, and returns 1 — `event-hook.sh`
103
+ * reads the exit code, never parses stdout to guess.
104
+ */
105
+ export async function runEventTextCommand(argv, env = process.env,
106
+ /** Test seam: the home the session's connection record is read from. */
107
+ resolveFrom = {}) {
108
+ const [event, ...rest] = argv;
109
+ if (rest.length !== 0 || typeof event !== "string" || event === "") {
110
+ process.stderr.write(`missing or extra arguments\n${USAGE}\n`);
111
+ return 1;
112
+ }
113
+ const sessionId = env.CLAUDE_CODE_SESSION_ID;
114
+ if (!sessionId) {
115
+ process.stderr.write(`no_session_id: CLAUDE_CODE_SESSION_ID is not set\n${USAGE}\n`);
116
+ return 1;
117
+ }
118
+ let options;
119
+ try {
120
+ options = resolveBridgeOptions({ sessionId, resumeIds: [] }, env, resolveFrom);
121
+ }
122
+ catch (err) {
123
+ const start = err;
124
+ process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
125
+ return 1;
126
+ }
127
+ const output = await fetchEventText(event, options, {
128
+ fetch: (input, init) => fetch(input, init),
129
+ requestTimeoutMs: EVENT_TEXT_REQUEST_TIMEOUT_MS,
130
+ });
131
+ if (!output.ok) {
132
+ process.stderr.write(`${output.reason}: could not fetch the effective "${event}" text from the dashboard\n`);
133
+ return 1;
134
+ }
135
+ process.stdout.write(output.text);
136
+ return 0;
137
+ }
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * DX-3426 — the shared recursive FIELD-TREE shape.
3
3
  *
4
- * The dashboard's single-card, batch card and plan reads (`issue_get`,
5
- * `issue_list`, `plan_get` — DX-3428) take a nested JSON tree instead of a
4
+ * The dashboard's single-card, batch card and plan reads (`danxbot_api`
5
+ * against `GET /api/issues/:id`, `GET /api/issues`, `GET /api/plans/:planId`
6
+ * — DX-3428) take a nested JSON tree instead of a
6
7
  * flat field-GROUP list: each key names a field or relation of the resource
7
8
  * at that level, and each value is either `true` (the field, or a relation's
8
9
  * child with its default fields), a non-negative integer (a cursor-paged
@@ -24,8 +25,8 @@
24
25
  * self-reference to a local `$ref` rather than unrolling it infinitely —
25
26
  * verified directly against the listed tool schema in
26
27
  * `__tests__/tool-defs.test.ts`, not assumed from reading the library's
27
- * source. `issue_solution`'s pre-existing `steps` field (DX-3310) is the
28
- * same `z.lazy` self-reference pattern already shipping on this server.
28
+ * source. The problem-solutions route's pre-existing `steps` field (DX-3310)
29
+ * is the same `z.lazy` self-reference pattern already shipping on this server.
29
30
  */
30
31
  import { z } from "zod";
31
32
  /**