@thehammer/danx-dashboard-mcp 0.1.188 → 0.1.190

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/bridge.js CHANGED
@@ -34,7 +34,7 @@
34
34
  * 5. Streams through `runListener` (`listen.ts`), which reconnects within the
35
35
  * ticket, and re-mints when that ticket's lease runs out or a fresh ticket
36
36
  * is refused once.
37
- * 6. Writes JSON Lines to stdout: ONE `{type:"ready", boards, cardCount, degraded}`
37
+ * 6. Writes JSON Lines to stdout: ONE `{type:"ready", boards, cardCount, degraded, inventoryError}`
38
38
  * once a ticket has been minted and streaming is about to start —
39
39
  * DX-2970's `fetchPlanInventory`, a single BEST-EFFORT (never a gate)
40
40
  * read of the connected plan's real board/card counts, purely so a
@@ -140,6 +140,13 @@ function formatDigest(entries) {
140
140
  return [header, ...entries.map((e) => e.text)].join("\n");
141
141
  }
142
142
  export const BRIDGE_SUBCOMMAND = "bridge";
143
+ /**
144
+ * DX-3912 — the longest heartbeat `reason` the bridge sends: one under the dashboard's own
145
+ * limit (`HEARTBEAT_REASON_MAX_CHARS` = 200, `src/issues/db/plan-session-heartbeat.ts`),
146
+ * because `oneLine` appends a "…" to a truncated string and a longer reason refuses the whole
147
+ * heartbeat. `bridge.test.ts` asserts the cap.
148
+ */
149
+ const HEARTBEAT_REASON_MAX_CODE_POINTS = 199;
143
150
  export const STREAM_TICKET_PATH = "/api/plan-sessions/me/stream-ticket";
144
151
  // DX-2970 — the bare (no `fields=`) cheap-scalars read, used ONLY to report the
145
152
  // connected plan's real `boards`/`cardCount` on the `ready` record (see
@@ -449,15 +456,29 @@ export async function mintTicket(options, deps) {
449
456
  leaseMs: ticket.leaseMs,
450
457
  };
451
458
  }
452
- /** What is wrong with a 2xx bare `/api/plans/mine` body, or `null` when it carries a usable inventory. */
459
+ /**
460
+ * What is wrong with a 2xx bare `/api/plans/mine` body, or `null` when it carries a usable inventory.
461
+ *
462
+ * DX-3912 — the field names here are the plan resource's WIRE names (`boards`, `card_count`,
463
+ * snake_case since DX-3428), the same ones `src/resources/plan/plan-resource.ts` emits; the
464
+ * contract test `src/issues/__tests__/plan-inventory-bridge-contract.test.ts` runs this parser
465
+ * on the server's own response, so a rename there fails that test instead of silently
466
+ * degrading every bridge. A missing field lists the keys the body DID carry, so a rename is
467
+ * named in the first message anyone reads.
468
+ */
453
469
  function badInventoryReason(body) {
454
- if (typeof body !== "object" || body === null)
470
+ if (!isRecord(body))
455
471
  return "the body is not a JSON object";
456
- const b = body;
457
- if (!Array.isArray(b.boards) || b.boards.some((x) => typeof x !== "string"))
458
- return "boards is not a string array";
459
- if (typeof b.cardCount !== "number" || !Number.isSafeInteger(b.cardCount) || b.cardCount < 0) {
460
- return "cardCount is not a non-negative integer";
472
+ if (!("boards" in body))
473
+ return `boards is missing (response keys: ${Object.keys(body).join(", ")})`;
474
+ if (!Array.isArray(body.boards) || body.boards.some((x) => typeof x !== "string")) {
475
+ return "boards is not an array of strings";
476
+ }
477
+ if (!("card_count" in body))
478
+ return `card_count is missing (response keys: ${Object.keys(body).join(", ")})`;
479
+ const count = body.card_count;
480
+ if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) {
481
+ return `card_count is not a non-negative integer (got ${typeof count === "number" ? count : typeof count})`;
461
482
  }
462
483
  return null;
463
484
  }
@@ -485,7 +506,7 @@ export async function fetchPlanInventory(options, deps) {
485
506
  return { kind: "unavailable", detail: `plan inventory read HTTP ${status}: ${bad}` };
486
507
  }
487
508
  const b = body;
488
- return { kind: "ok", boards: b.boards, cardCount: b.cardCount };
509
+ return { kind: "ok", boards: b.boards, cardCount: b.card_count };
489
510
  }
490
511
  /**
491
512
  * DX-2920 — a stream admission refusal (`listen.ts`'s STRUCTURED `refused`
@@ -604,7 +625,8 @@ export async function runBridge(options, deps) {
604
625
  let connectionState = "reconnecting";
605
626
  let stoppedReason = null;
606
627
  let heartbeatSuppressed = false;
607
- let lastInventoryOk = true;
628
+ // DX-3912 — `null` is "the inventory read succeeded"; a string is why it did not.
629
+ let lastInventoryError = null;
608
630
  // DX-3099 — consecutive heartbeat POSTs that failed to LAND while this
609
631
  // process still believed `connectionState === "streaming"`. Reset to 0 the
610
632
  // moment a heartbeat lands, or the moment state leaves "streaming" for any
@@ -619,7 +641,9 @@ export async function runBridge(options, deps) {
619
641
  // process, so an in-process re-mint never touches its state.
620
642
  const sessionStats = new OperatorSessionStats(options.transcriptPath ?? null, deps.makeStatsWatcher);
621
643
  const connectionNow = () => connectionState === "streaming"
622
- ? { state: "streaming", inventory: lastInventoryOk ? "ok" : "unavailable" }
644
+ ? lastInventoryError === null
645
+ ? { state: "streaming", inventory: "ok" }
646
+ : { state: "streaming", inventory: "unavailable", reason: oneLine(lastInventoryError, HEARTBEAT_REASON_MAX_CODE_POINTS) }
623
647
  : connectionState === "stopped"
624
648
  ? { state: "stopped", reason: stoppedReason ?? undefined }
625
649
  : { state: "reconnecting" };
@@ -822,23 +846,24 @@ export async function runBridge(options, deps) {
822
846
  readyEmitted = true;
823
847
  const inventory = await fetchPlanInventory(options, deps);
824
848
  // DX-3028 (AC1, code-review + architecture-review finding) — this is
825
- // the ONE place `lastInventoryOk` is ever written; the 15s heartbeat
849
+ // the ONE place `lastInventoryError` is ever written; the 15s heartbeat
826
850
  // reads it on every "streaming" tick (see `heartbeatTick` above). Must
827
851
  // agree with the SAME `ready` record's own `degraded` decision below —
828
852
  // a heartbeat reporting `inventory:"ok"` while the `ready` record just
829
853
  // told the operator `degraded:true` would be exactly the untruthful
830
854
  // state AC1 exists to prevent.
831
- lastInventoryOk = inventory.kind === "ok";
855
+ lastInventoryError = inventory.kind === "ok" ? null : inventory.detail;
832
856
  if (inventory.kind === "ok") {
833
857
  deps.write({
834
858
  type: "ready",
835
859
  boards: inventory.boards,
836
860
  cardCount: inventory.cardCount,
837
861
  degraded: inventory.boards.length === 0 || inventory.cardCount === 0,
862
+ inventoryError: null,
838
863
  });
839
864
  }
840
865
  else {
841
- deps.write({ type: "ready", boards: null, cardCount: null, degraded: true });
866
+ deps.write({ type: "ready", boards: null, cardCount: null, degraded: true, inventoryError: inventory.detail });
842
867
  }
843
868
  }
844
869
  backoff = MINT_INITIAL_BACKOFF_MS;
package/dist/index.js CHANGED
@@ -422,7 +422,7 @@ strictTool("attach_file", "Attach a LOCAL file to a card: `card_id` + `path` (an
422
422
  // no existing plan for an id to name yet.
423
423
  strictTool("plan_connect",
424
424
  // DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
425
- "Connect THIS session to a plan. ONE CALL IS ENOUGH TO START: the reply carries `{session, movedFrom, browserInstruction, briefing, listenerHealth}` — `browserInstruction` is a server-built action to take NOW: it names the plan's URL and tells you to open it (in-app browser if you have one, else default), keep that tab open for the whole session without navigating it away, and use a different tab for your own browsing — the operator's tab for following and talking to you. Returned on every connect, including a re-connect, so it doubles as the post-context-loss reminder. `briefing` is every goal/rule/caveat (ref+body), every architecture section (id+title), the plan's own ref/name/status, a first page of open cards (id/type/status/title/openProblemCount/assignedAgent) with a `morePagesHint` when more exist, and the closed-card count — usually replacing the plan read + card batch-read a fresh session used to need. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and `movedFrom: {id, name} | null` says which plan it left (null = no plan, or already this one). Binds only your OWN session, resolved from the session id this server forwards; afterwards a `danxbot_api` call to a `/api/plans/mine/...` route (there is no dedicated plan-write tool) acts on this plan and takes no plan id. `listenerHealth` reports the event bridge's state — `null` (no session), or `{attached, state: \"unattached\"|\"credential_mismatch\"|\"healthy\", nextStep}` naming a concrete fix per unhealthy state (same shape the plan list/get routes report). Once healthy, every comment, answer, problem added and block/unblock on this plan's cards reaches the session unpolled, relayed by the plugin's event bridge as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<statement>\": chose \"Pause E2E\"`. 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 cannot read it itself. The response's `reminders[]` (when present) carries the mantra — read and follow it.", {
425
+ "Connect THIS session to a plan. ONE CALL IS ENOUGH TO START: the reply carries `{session, movedFrom, browserInstruction, briefing, listenerHealth}` — `browserInstruction` is a server-built action to take NOW: it names the plan's URL and tells you to open it (in-app browser if you have one, else default), keep that tab open for the whole session without navigating it away, and use a different tab for your own browsing — the operator's tab for following and talking to you. Returned on every connect, including a re-connect, so it doubles as the post-context-loss reminder. `briefing` is every goal/rule/caveat (ref+body), every architecture section (id+title), the plan's own ref/name/status, a first page of open cards (id/type/status/title/openProblemCount/assignedAgent) with a `morePagesHint` when more exist, and the closed-card count — usually replacing the plan read + card batch-read a fresh session used to need. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and `movedFrom: {id, name} | null` says which plan it left (null = no plan, or already this one). Binds only your OWN session, resolved from the session id this server forwards; afterwards a `danxbot_api` call to a `/api/plans/mine/...` route (there is no dedicated plan-write tool) acts on this plan and takes no plan id. `listenerHealth` reports the event bridge's state — `null` (no session), or `{attached, state: \"unattached\"|\"credential_mismatch\"|\"healthy\", nextStep}` naming a concrete fix per unhealthy state (same shape the plan list/get routes report). Once healthy, every comment, answer, problem added and block/unblock on this plan's cards reaches the session unpolled, relayed by the plugin's event bridge as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<statement>\": chose \"Pause E2E\"`. An action the operator DECLINES arrives as `... rejected the action \"<title>\" for \"<statement>\" — reason: \"<note>\". Do not perform it; re-plan from the reason` (a decision with `outcome: \"rejected\"` — never treat it as work done). 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 cannot read it itself. The response's `reminders[]` (when present) carries the mantra — read and follow it.", {
426
426
  plan_id: z.number().int().positive().describe("The plan id, from the plans list (`danxbot_api` `GET /api/plans`)."),
427
427
  title: z
428
428
  .string()
package/dist/listen.js CHANGED
@@ -78,6 +78,27 @@ function isRecord(value) {
78
78
  function isCappedText(value) {
79
79
  return isRecord(value) && typeof value.text === "string" && typeof value.truncated === "boolean";
80
80
  }
81
+ /**
82
+ * DX-3880 — the part `solution_answered` and `problem_answer_changed` share: the
83
+ * decision's `outcome` and the either/or body it implies. A rejection (the
84
+ * operator DECLINED an action) always names the solution and carries its reason
85
+ * as `solution.note`; an answer is a solution (optional note) or free-form text.
86
+ */
87
+ function invalidDecisionBody(d) {
88
+ if (d.outcome !== "answered" && d.outcome !== "rejected")
89
+ return 'detail.outcome is not "answered" or "rejected"';
90
+ if (d.solution === null) {
91
+ if (d.outcome === "rejected")
92
+ return "detail.solution is null on a rejection";
93
+ return isCappedText(d.freeform) ? null : "detail.freeform is not capped text";
94
+ }
95
+ if (!isRecord(d.solution) || typeof d.solution.title !== "string")
96
+ return "detail.solution is not {title, note}";
97
+ if (d.outcome === "rejected") {
98
+ return isCappedText(d.solution.note) ? null : "detail.solution.note (the rejection reason) is not capped text";
99
+ }
100
+ return d.solution.note === null || isCappedText(d.solution.note) ? null : "detail.solution.note is not capped text";
101
+ }
81
102
  /**
82
103
  * DX-2735 — why an event cannot be described, or `null` when it can.
83
104
  *
@@ -122,11 +143,7 @@ export function invalidEventReason(value) {
122
143
  if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
123
144
  return "detail.problem is not {id, statement}";
124
145
  }
125
- if (d.solution === null)
126
- return isCappedText(d.freeform) ? null : "detail.freeform is not capped text";
127
- if (!isRecord(d.solution) || typeof d.solution.title !== "string")
128
- return "detail.solution is not {title, note}";
129
- return d.solution.note === null || isCappedText(d.solution.note) ? null : "detail.solution.note is not capped text";
146
+ return invalidDecisionBody(d);
130
147
  }
131
148
  case "problem_added": {
132
149
  const problem = d.problem;
@@ -155,11 +172,7 @@ export function invalidEventReason(value) {
155
172
  return "detail.retracted_decision_id is not a number";
156
173
  if (typeof d.decision_id !== "number")
157
174
  return "detail.decision_id is not a number";
158
- if (d.solution === null)
159
- return isCappedText(d.freeform) ? null : "detail.freeform is not capped text";
160
- if (!isRecord(d.solution) || typeof d.solution.title !== "string")
161
- return "detail.solution is not {title, note}";
162
- return d.solution.note === null || isCappedText(d.solution.note) ? null : "detail.solution.note is not capped text";
175
+ return invalidDecisionBody(d);
163
176
  }
164
177
  case "blocked":
165
178
  return isCappedText(d.reason) ? null : "detail.reason is not capped text";
@@ -196,8 +209,13 @@ function describe(event) {
196
209
  // so the line names the problem — otherwise the agent cannot tell which of its
197
210
  // questions just closed.
198
211
  const problem = d.problem;
199
- const answered = `${who} answered ${quoted(problem.statement)}:`;
200
212
  const solution = d.solution;
213
+ // DX-3880 — a REJECTION is the opposite of work done: the operator declined
214
+ // the action, so the line says so and names the reason; the agent must not
215
+ // perform it and re-plans from the note.
216
+ if (d.outcome === "rejected")
217
+ return `${who} rejected the action "${solution.title}" for ${quoted(problem.statement)} — reason: ${quoted(solution.note)}. Do not perform it; re-plan from the reason`;
218
+ const answered = `${who} answered ${quoted(problem.statement)}:`;
201
219
  if (solution === null)
202
220
  return `${answered} ${quoted(d.freeform)}`;
203
221
  const note = solution.note === null ? "" : ` — note: ${quoted(solution.note)}`;
@@ -222,6 +240,8 @@ function describe(event) {
222
240
  // open questions knows which one just moved.
223
241
  const problem = d.problem;
224
242
  const solution = d.solution;
243
+ if (d.outcome === "rejected")
244
+ return `${who} changed the answer to ${quoted(problem.statement)}: now REJECTED the action "${solution.title}" — reason: ${quoted(solution.note)}. Do not perform it; re-plan from the reason`;
225
245
  const now = solution === null ? quoted(d.freeform) : `"${solution.title}"`;
226
246
  return `${who} changed the answer to ${quoted(problem.statement)}: now ${now}`;
227
247
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.188",
3
+ "version": "0.1.190",
4
4
  "description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, danxbot_complete, and other per-dispatch lifecycle callbacks) (DX-3465, PLN-11 R-25).",
5
5
  "license": "MIT",
6
6
  "type": "module",