@thehammer/danx-dashboard-mcp 0.1.45 → 0.1.47

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/dist/handlers.js CHANGED
@@ -581,3 +581,73 @@ export async function issueAttach(client, args, deps = {}) {
581
581
  board: args.board,
582
582
  });
583
583
  }
584
+ /* ── Plans: what a connected session may read and add to (DX-2683) ────────── */
585
+ const PLANS_BASE_PATH = "/api/plans";
586
+ const PLAN_SESSIONS_BASE_PATH = "/api/plan-sessions";
587
+ /** Every plan, plus which one THIS session is connected to. */
588
+ export async function planList(client) {
589
+ return client.request({ method: "GET", path: "", basePath: PLANS_BASE_PATH });
590
+ }
591
+ /**
592
+ * One plan, entire — its cards, the boards they cover, its goals + rules +
593
+ * caveats, its architecture document, and the sessions working on it. ONE
594
+ * call rather than five, which is what keeps this tool surface small enough
595
+ * to be worth an agent's context.
596
+ */
597
+ export async function planGet(client, args = {}) {
598
+ return client.request({
599
+ method: "GET",
600
+ path: args.plan_id === undefined ? "/mine" : `/${args.plan_id}/full`,
601
+ basePath: PLANS_BASE_PATH,
602
+ });
603
+ }
604
+ /**
605
+ * Connect THIS session to a plan — the same write the operator's Connect
606
+ * action performs, reaching the same server-side code path. `me` in the URL
607
+ * is resolved from the forwarded session id, so this can only ever bind the
608
+ * caller's own session.
609
+ *
610
+ * A session already on another plan is MOVED, and the response says which
611
+ * plan it left (`movedFrom`).
612
+ */
613
+ export async function planConnect(client, args) {
614
+ return client.request({
615
+ method: "POST",
616
+ path: "/me/plan",
617
+ basePath: PLAN_SESSIONS_BASE_PATH,
618
+ body: { plan_id: args.plan_id },
619
+ });
620
+ }
621
+ /** Add a goal, rule or caveat to the connected plan. */
622
+ export async function planAddRecord(client, args) {
623
+ return client.request({
624
+ method: "POST",
625
+ path: "/mine/records",
626
+ basePath: PLANS_BASE_PATH,
627
+ body: { kind: args.kind, body: args.body },
628
+ });
629
+ }
630
+ /** Add an existing card to the connected plan. */
631
+ export async function planAddCard(client, args) {
632
+ return client.request({
633
+ method: "POST",
634
+ path: "/mine/cards",
635
+ basePath: PLANS_BASE_PATH,
636
+ body: { card_id: args.card_id },
637
+ });
638
+ }
639
+ /**
640
+ * Write the connected plan's architecture document, under the same
641
+ * optimistic-concurrency guard the dashboard editor uses: `base_hash` must be
642
+ * the `contentHash` the last `plan_get` returned, and a stale base is refused
643
+ * with `{error: "stale_plan_architecture", currentHash}` rather than
644
+ * overwriting whoever wrote in between.
645
+ */
646
+ export async function planSetArchitecture(client, args) {
647
+ return client.request({
648
+ method: "PUT",
649
+ path: "/mine/architecture",
650
+ basePath: PLANS_BASE_PATH,
651
+ body: { content: args.content, base_hash: args.base_hash },
652
+ });
653
+ }
@@ -18,6 +18,17 @@ export class DashboardHttpClient {
18
18
  if (this.config.traceparent) {
19
19
  headers["traceparent"] = this.config.traceparent;
20
20
  }
21
+ // DX-2683 — the working session's identity, stamped at the SAME single
22
+ // choke point as Authorization above, and for the same reason: it must
23
+ // ride EVERY call, not the plan calls. That is what lets the dashboard
24
+ // learn a session exists — and keep its last-active time current — purely
25
+ // from the card work the agent was already doing, with no cooperation
26
+ // from the agent and no per-tool parameter it could omit or falsify.
27
+ // Absent outside a Claude Code session; then nothing is stamped.
28
+ if (this.config.session) {
29
+ headers["x-danx-session-id"] = this.config.session.id;
30
+ headers["x-danx-session-title"] = this.config.session.title;
31
+ }
21
32
  let bodyString;
22
33
  if (args.body !== undefined) {
23
34
  headers["Content-Type"] = "application/json";
package/dist/index.js CHANGED
@@ -31,6 +31,20 @@
31
31
  * - master_plan_list GET /api/master-plan
32
32
  * - master_plan_get_page GET /api/master-plan/page (DX-2083 / DX-2484)
33
33
  * - master_plan_set_page PUT /api/master-plan/page (DX-2083 / DX-2484)
34
+ * - plan_list GET /api/plans (DX-2683)
35
+ * - plan_get GET /api/plans/:id/full | /api/plans/mine
36
+ * - plan_connect POST /api/plan-sessions/me/plan
37
+ * - plan_add_record POST /api/plans/mine/records
38
+ * - plan_add_card POST /api/plans/mine/cards
39
+ * - plan_set_architecture PUT /api/plans/mine/architecture
40
+ *
41
+ * DX-2683 — THE PLAN TOOLS ARE SESSION-BOUND, and asymmetrically so. Reads
42
+ * may name any plan; WRITES take no plan id at all and act on the plan this
43
+ * session is connected to, resolved server-side from the session id this
44
+ * package forwards on every request. A session therefore cannot modify a plan
45
+ * it is not connected to, whatever id it believes. A write from an
46
+ * unconnected session is refused loudly (`session_not_connected`), never
47
+ * silently dropped and never guessed at.
34
48
  *
35
49
  * BOARD-ONLY (DX-1171): board is the first-level concept; repo is
36
50
  * DERIVED from board server-side, never passed. The package composes the
@@ -55,12 +69,13 @@
55
69
  * agent reads `body.error` + structured fields to decide next action.
56
70
  * 5xx and network failures throw — never silently swallowed.
57
71
  */
72
+ import { basename } from "node:path";
58
73
  import { isEntrypointModule } from "./entrypoint.js";
59
74
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
60
75
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
61
76
  import { z } from "zod";
62
77
  import { DashboardHttpClient } from "./http-client.js";
63
- import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueQualityGate, issueQualityGateVerdict, issueRequiresHuman, issueRetro, issueTransition, issueTriage, masterPlanGetPage, masterPlanList, masterPlanSetPage, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
78
+ import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueQualityGate, issueQualityGateVerdict, issueRequiresHuman, issueRetro, issueTransition, issueTriage, masterPlanGetPage, masterPlanList, masterPlanSetPage, planAddCard, planAddRecord, planConnect, planGet, planList, planSetArchitecture, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
64
79
  import { PRIORITY_TIER_WORDS } from "./priority.js";
65
80
  function readEnvOrDie(name) {
66
81
  const v = process.env[name];
@@ -105,9 +120,42 @@ function boot() {
105
120
  // outbound call so the agent's card writes chain under the launch; absent
106
121
  // (untraced dispatch) → omitted, and the dashboard mints a fresh root.
107
122
  traceparent: readEnvOptional("DANXBOT_TRACEPARENT"),
123
+ // DX-2683 — the working session, when there is one. See below.
124
+ session: readSessionConfig(),
108
125
  };
109
126
  client = new DashboardHttpClient(config);
110
127
  }
128
+ /**
129
+ * DX-2683 — WHO THIS SESSION IS, read from the environment Claude Code
130
+ * already provides.
131
+ *
132
+ * `CLAUDE_CODE_SESSION_ID` is set on every process an interactive Claude Code
133
+ * session spawns — this server among them — so no launcher, no dispatch
134
+ * overlay and no agent cooperation is needed for a session to be identifiable.
135
+ * The same variable is what `src/agent/nested-claude-preflight.ts` already
136
+ * relies on to detect a nested session, so this is an established fact about
137
+ * the runtime rather than a new assumption.
138
+ *
139
+ * OPTIONAL, unlike every other env this package reads. A process running
140
+ * outside a Claude Code session has no session to report; it sends no session
141
+ * headers and behaves exactly as this package did before. Requiring it would
142
+ * break the tool-defs generator, the drift test, and any non-Claude consumer.
143
+ *
144
+ * The TITLE is a CREATION-TIME value only — the dashboard stamps it when the
145
+ * session first registers and never overwrites it afterwards, because the
146
+ * operator may have renamed it. `DANX_SESSION_TITLE` lets a launcher say what
147
+ * a session is; absent that, the working directory's name is what the session
148
+ * can honestly say about itself, and last-active time is what actually
149
+ * distinguishes two sessions in the same place.
150
+ */
151
+ function readSessionConfig() {
152
+ const id = readEnvOptional("CLAUDE_CODE_SESSION_ID");
153
+ if (id === undefined)
154
+ return undefined;
155
+ const cwdName = basename(process.cwd());
156
+ const fallback = cwdName === "" ? `session ${id.slice(0, 8)}` : cwdName;
157
+ return { id, title: readEnvOptional("DANX_SESSION_TITLE") ?? fallback };
158
+ }
111
159
  export const server = new McpServer({
112
160
  name: "danx-dashboard-mcp",
113
161
  version: "0.1.0",
@@ -319,13 +367,18 @@ server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues
319
367
  ...boardField,
320
368
  }, async (args) => jsonResult(await issueEdit(client, args)));
321
369
  // ---------------- issue_transition ----------------
322
- server.tool("issue_transition", "Stamp a lifecycle transition via POST /api/issues/:id/transition. **THIS IS THE ONLY WAY TO MOVE A CARD'S LIFECYCLE STATE** — DX-835 separated card lifecycle (this tool) from dispatch finalization (`mcp__danxbot__danxbot_complete`). The worker no longer infers card moves from `danxbot_complete.status`; agents that want the card to move MUST call this tool BEFORE calling `danxbot_complete`. Actions: ready (Review→ToDo), pickup (ToDo→In Progress — server checks every dispatch gate: ready_at, blocked_at, requires_human_reason, depends_on partners terminal, conflict_on partners idle; refuses 409 with failed_gate naming the cause; pass manual:true for OPERATOR-SESSION self-pickup — stamps dispatch_kind 'manual', bypasses every card-flow gate except terminal/deleted/already-dispatched, and the worker NEVER auto-transitions the card: no orphan-heal rollback, no Epic auto-rollup — use this whenever the work happens in YOUR current session rather than a worker dispatch, DX-946), rollback_pickup, **complete** (stamps completed_at — moves card to Done; this is YOUR explicit decision, not a side effect of danxbot_complete; REFUSES 409 on Epic if any phase child non-terminal — see non_terminal_phases[]; ALSO refuses 409 with failed_gate 'quality_gate_post' + failed_post_gates[] while any required POST quality gate row != pass — DX-1177), cancel (stamps cancelled_at — terminal), **block** (requires non-empty reason — stamps blocked_at + blocked_reason + clears dispatch; USE THIS when the CARD itself cannot proceed without human intervention; distinct from env-fault dispatch failures which use `danxbot_complete({status:'failed'})`), unblock, archive (parks to Backlog, clears ready_at), reopen (terminal→active, clears completed_at/cancelled_at/archived_at). Terminal cards refuse every action except reopen. Ladder timestamps preserved — forward stamps never clear earlier ones (CLAUDE.md Core Principle 2).", {
370
+ server.tool("issue_transition", "Stamp a lifecycle transition via POST /api/issues/:id/transition. **THIS IS THE ONLY WAY TO MOVE A CARD'S LIFECYCLE STATE** — DX-835 separated card lifecycle (this tool) from dispatch finalization (`mcp__danxbot__danxbot_complete`). The worker no longer infers card moves from `danxbot_complete.status`; agents that want the card to move MUST call this tool BEFORE calling `danxbot_complete`. Actions: ready (Review→ToDo), pickup (ToDo→In Progress — server checks every dispatch gate: ready_at, blocked_at, requires_human_reason, depends_on partners terminal, conflict_on partners idle; refuses 409 with failed_gate naming the cause; pass manual:true for OPERATOR-SESSION self-pickup — stamps dispatch_kind 'manual', bypasses every card-flow gate except terminal/deleted/already-dispatched, and the worker NEVER auto-transitions the card: no orphan-heal rollback, no Epic auto-rollup — use this whenever the work happens in YOUR current session rather than a worker dispatch, DX-946), rollback_pickup, **complete** (stamps completed_at — moves card to Done; this is YOUR explicit decision, not a side effect of danxbot_complete; REFUSES 409 on Epic if any phase child non-terminal — see non_terminal_phases[]; ALSO refuses 409 with failed_gate 'quality_gate_post' + failed_post_gates[] while any required POST quality gate row != pass — DX-1177), cancel (stamps cancelled_at — terminal), **block** (requires non-empty reason — stamps blocked_at + blocked_reason + clears dispatch; USE THIS when the CARD itself cannot proceed without human intervention; distinct from env-fault dispatch failures which use `danxbot_complete({status:'failed'})`), unblock, archive (parks to Backlog, clears ready_at), reopen (terminal→active, clears completed_at/cancelled_at/archived_at). Terminal cards refuse every action except reopen. Ladder timestamps preserved — forward stamps never clear earlier ones (CLAUDE.md Core Principle 2). **DX-2282 — every path into In Progress now requires an identified claimer.** For `manual:true` pickup called from a DISPATCHED-AGENT session (this MCP tool, bearer = your dispatch token): you MUST pass `assigned_agent` set to YOUR resolved agent/profile name — omitting it, or passing the shared dispatch-token identity itself, is refused 409 `assigned_agent (required, distinguishing)`. A genuine human dashboard session may omit it (auto-resolved from the real logged-in user). Every pickup flavor (work/gate/manual) is refused 409 `assigned_agent (required)` if it would otherwise leave the card with no owner at all.", {
323
371
  id: z.string().min(1),
324
372
  action: z.enum(TRANSITION_ACTIONS),
325
373
  reason: z.string().optional(),
326
374
  summary: z.string().optional(),
327
375
  dispatch_id: z.string().min(1).optional(),
328
376
  manual: z.boolean().optional(),
377
+ assigned_agent: z
378
+ .string()
379
+ .min(1)
380
+ .optional()
381
+ .describe("DX-2282 — REQUIRED for a manual:true pickup called by a dispatched agent (this MCP tool): your resolved agent/profile name, identifying YOU as the claimer. Never supply the generic shared dispatch-token identity — that is refused. Optional for a human dashboard session (auto-resolved from the real logged-in user). Ignored for every other action."),
329
382
  ...boardField,
330
383
  }, async (args) => jsonResult(await issueTransition(client, args)));
331
384
  // ---------------- issue_triage ----------------
@@ -496,6 +549,40 @@ server.tool("master_plan_set_page", 'Write one Master Plan page via PUT /api/mas
496
549
  .describe('The contentHash last read via master_plan_get_page ("" for a true first write). Omitted also normalizes to "" server-side, so it only succeeds against an empty/absent page — always get immediately before set.'),
497
550
  ...boardField,
498
551
  }, async (args) => jsonResult(await masterPlanSetPage(client, args)));
552
+ // ---------------- plans (DX-2683) ----------------
553
+ // THE BINDING ASYMMETRY IS IN THE SCHEMAS, NOT IN PROSE. `plan_get` takes an
554
+ // optional `plan_id`; `plan_add_record`, `plan_add_card` and
555
+ // `plan_set_architecture` take NO plan id in any form, so a connected session
556
+ // cannot even express "write to that other plan". `plan_connect` takes one
557
+ // because binding a session to a plan is the one operation that is ABOUT a
558
+ // plan id — and it can only ever bind the caller's own session.
559
+ server.tool("plan_list", "List every plan via GET /api/plans (DX-2683), and learn which plan THIS session is connected to. Plans are GLOBAL, not board-scoped: a plan is a named, dated set of cards an operator assembled by hand, and its cards may come from any repository. Returns `{ok, status, body: {plans: [{id, name, createdAt, cardCount, boards}], session}}`. `session` is your own registration — `{sessionId, title, planId, planName, firstSeenAt, lastActiveAt}` — or `null` if this process is not running inside a Claude Code session. A `planId` of null means you are connected to no plan: read any plan with `plan_get`, then `plan_connect` to the one you are working on (or ask the operator to connect you from the Plans list). NOTE this is NOT the board Master Plan (`master_plan_list`), which is a different feature entirely.", {}, async () => jsonResult(await planList(client)));
560
+ server.tool("plan_get", "Read one plan WHOLE via GET /api/plans (DX-2683) — its member cards (with the boards they cover), its goals, rules and caveats, its architecture document, the sessions working on it, and your own session state. One call, not five. Pass `plan_id` to read ANY plan (browsing another plan is useful and changes nothing); OMIT it to read the plan this session is connected to. Omitting it while connected to no plan fails loud with `{error: \"session_not_connected\"}` — connect first. Returns `{plan, cards, boards, records: {goal: [], rule: [], caveat: []}, architecture: {content, contentHash, updatedAt, updatedBy}, sessions, session}`. ALWAYS `plan_get` immediately before `plan_set_architecture` and pass the returned `architecture.contentHash` back as `base_hash`.", {
561
+ plan_id: z
562
+ .number()
563
+ .int()
564
+ .positive()
565
+ .optional()
566
+ .describe("A plan id from `plan_list`. Omit to read the plan this session is connected to."),
567
+ }, async (args) => jsonResult(await planGet(client, args)));
568
+ server.tool("plan_connect", "Connect THIS session to a plan via POST /api/plan-sessions/me/plan (DX-2683) — the same binding the operator's Connect action writes, through the same server-side path. A session is connected to AT MOST ONE plan (enforced by the schema, not by convention); connecting while already on another plan MOVES you, and the response says which plan you left: `{ok, status, body: {session, movedFrom: {id, name} | null}}`. `movedFrom: null` means you were on no plan, or already on this one. It can only ever bind your OWN session — `me` is resolved from the session id this server forwards, never from anything you pass. After this, every plan WRITE tool acts on this plan, and no plan id is accepted anywhere.", {
569
+ plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
570
+ }, async (args) => jsonResult(await planConnect(client, args)));
571
+ server.tool("plan_add_record", "Add a GOAL, RULE or CAVEAT to the plan this session is connected to, via POST /api/plans/mine/records (DX-2683). A goal is what the plan is FOR (the outcome the work is measured against — not a task). A rule is what must HOLD while it is worked. A caveat is what is known to be AWKWARD — the fact that will surprise the next person. Each gets a permanent short reference within the plan (`G-1`, `R-4`, `CAV-12`) allocated by the server, which is how a person cites it in a card or a commit. TAKES NO PLAN ID: the plan is resolved from your connected session, so you cannot write a plan you are not connected to. Not connected → `{error: \"session_not_connected\"}`; call `plan_connect` first. Returns the new record plus that kind's full list.", {
572
+ kind: z
573
+ .enum(["goal", "rule", "caveat"])
574
+ .describe("Which standing record this is. Determines the reference prefix."),
575
+ body: z.string().min(1).describe("The record's text. Plain text, not markdown."),
576
+ }, async (args) => jsonResult(await planAddRecord(client, args)));
577
+ server.tool("plan_add_card", "Add an existing card to the plan this session is connected to, via POST /api/plans/mine/cards (DX-2683). The card may live on ANY board — that is what a plan is for. Idempotent: re-adding a card already on the plan is a no-op, not an error, and a card may sit in several plans at once. This adds MEMBERSHIP only; it never edits the card. TAKES NO PLAN ID: the plan is resolved from your connected session. Not connected → `{error: \"session_not_connected\"}`. Unknown card → 404. Returns the plan's full member list.", {
578
+ card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
579
+ }, async (args) => jsonResult(await planAddCard(client, args)));
580
+ server.tool("plan_set_architecture", 'Write the ARCHITECTURE DOCUMENT of the plan this session is connected to, via PUT /api/plans/mine/architecture (DX-2683). One markdown document per plan — how the work is shaped, not a task list. `base_hash` MUST be the `architecture.contentHash` from the immediately-prior `plan_get`; the server compares it against the current hash and, on a mismatch, fails loud with `{ok: false, body: {error: "stale_plan_architecture", currentHash}}` rather than overwriting whoever wrote in between. On that refusal: re-`plan_get`, re-merge your changes into the fresh content, and retry with the new hash — never retry blindly. A never-written document reads as `contentHash: ""`, so a true first write passes `base_hash: ""`. Writing REPLACES the whole document, so send the full merged markdown, not a fragment. TAKES NO PLAN ID: the plan is resolved from your connected session.', {
581
+ content: z.string().describe("The complete markdown document, replacing what is stored."),
582
+ base_hash: z
583
+ .string()
584
+ .describe('The `architecture.contentHash` from the immediately-prior `plan_get` ("" for a true first write). Required — an absent hash is not read as "".'),
585
+ }, async (args) => jsonResult(await planSetArchitecture(client, args)));
499
586
  // ---------------- main ----------------
500
587
  async function main() {
501
588
  boot();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
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",