@thehammer/danx-dashboard-mcp 0.1.84 → 0.1.86

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.
Files changed (2) hide show
  1. package/dist/listen.js +116 -1
  2. package/package.json +1 -1
package/dist/listen.js CHANGED
@@ -11,7 +11,10 @@
11
11
  * - `{type:"event", id, text}` — exactly one per event, the moment it arrives.
12
12
  * `text` is what the session reads; an event it cannot read still produces one
13
13
  * (`could not read event …`), never silence. `id` is `null` only for a frame
14
- * that carried no usable id.
14
+ * that carried no usable id — which includes DX-2885's `nudge` frame
15
+ * (`formatNudgeLine`): it is advisory, re-derived fresh every tick by the
16
+ * dashboard's own keep-alive tick, and never replayed, so it rides this
17
+ * same shape with `id: null` rather than growing a THIRD output variant.
15
18
  * - `{type:"stopped", reason, detail, refusal?, boards?}` — once, last. `reason`
16
19
  * is the dashboard's own end reason (`superseded`, `replaced`, `revoked`,
17
20
  * `scope_narrowed`, `not_connected`), `refused` (the ticket was not
@@ -122,6 +125,32 @@ export function invalidEventReason(value) {
122
125
  }
123
126
  return null;
124
127
  }
128
+ // DX-2935 — the operator retracted a problem's answer with no new one yet.
129
+ case "problem_unanswered": {
130
+ const problem = d.problem;
131
+ if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
132
+ return "detail.problem is not {id, statement}";
133
+ }
134
+ return typeof d.decision_id === "number" ? null : "detail.decision_id is not a number";
135
+ }
136
+ // DX-2935 — the operator retracted a problem's answer AND recorded a new
137
+ // one, in one transaction — same either/or solution/freeform shape
138
+ // `solution_answered` carries, plus which decision was retracted.
139
+ case "problem_answer_changed": {
140
+ const problem = d.problem;
141
+ if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
142
+ return "detail.problem is not {id, statement}";
143
+ }
144
+ if (typeof d.retracted_decision_id !== "number")
145
+ return "detail.retracted_decision_id is not a number";
146
+ if (typeof d.decision_id !== "number")
147
+ return "detail.decision_id is not a number";
148
+ if (d.solution === null)
149
+ return isCappedText(d.freeform) ? null : "detail.freeform is not capped text";
150
+ if (!isRecord(d.solution) || typeof d.solution.title !== "string")
151
+ return "detail.solution is not {title, note}";
152
+ return d.solution.note === null || isCappedText(d.solution.note) ? null : "detail.solution.note is not capped text";
153
+ }
125
154
  case "blocked":
126
155
  return isCappedText(d.reason) ? null : "detail.reason is not capped text";
127
156
  default:
@@ -160,6 +189,22 @@ function describe(event) {
160
189
  const problem = d.problem;
161
190
  return `${event.actor} opened a problem: ${quoted(problem.statement)}`;
162
191
  }
192
+ case "problem_unanswered": {
193
+ // DX-2935 — MUST read as "a previous answer was withdrawn", never as a
194
+ // brand-new problem: a connected session that already acted on the
195
+ // retracted answer has to stop, not treat this as fresh work to start.
196
+ const problem = d.problem;
197
+ return `${event.actor} retracted the answer to ${quoted(problem.statement)}`;
198
+ }
199
+ case "problem_answer_changed": {
200
+ // DX-2935 — ONE line for the whole retract-and-reanswer (never two
201
+ // separate events), naming the statement so a listener with several
202
+ // open questions knows which one just moved.
203
+ const problem = d.problem;
204
+ const solution = d.solution;
205
+ const now = solution === null ? quoted(d.freeform) : `"${solution.title}"`;
206
+ return `${event.actor} changed the answer to ${quoted(problem.statement)}: now ${now}`;
207
+ }
163
208
  case "blocked":
164
209
  return `${event.actor} blocked the card: ${quoted(d.reason)}`;
165
210
  case "unblocked":
@@ -177,6 +222,46 @@ export function formatActivityLine(event) {
177
222
  throw new Error(`malformed activity event: ${invalid}`);
178
223
  return oneLine(`[${event.cardId} "${event.cardTitle}" ${event.boardId}] ${describe(event)}`);
179
224
  }
225
+ function isNudgeCardRef(value) {
226
+ return isRecord(value) && typeof value.id === "string" && typeof value.title === "string";
227
+ }
228
+ function isNudgeCardRefArray(value) {
229
+ return Array.isArray(value) && value.every(isNudgeCardRef);
230
+ }
231
+ /** Why a nudge cannot be read, or `null` when it can — same checked-before-describing shape as `invalidEventReason`. */
232
+ export function invalidNudgeReason(value) {
233
+ if (!isRecord(value))
234
+ return "the nudge is not an object";
235
+ if (typeof value.idleMinutes !== "number" || !Number.isFinite(value.idleMinutes)) {
236
+ return "idleMinutes is not a number";
237
+ }
238
+ if (!isNudgeCardRefArray(value.startable))
239
+ return "startable is not a {id,title}[] array";
240
+ if (!isNudgeCardRefArray(value.held))
241
+ return "held is not a {id,title}[] array";
242
+ return null;
243
+ }
244
+ function cardList(cards) {
245
+ return cards.map((c) => `${c.id} "${c.title}"`).join(", ");
246
+ }
247
+ /**
248
+ * The one readable line for a nudge (DX-2885). Always names the actual
249
+ * waiting cards — never a generic "keep going" — because that is the whole
250
+ * point of the feature: a session that only sees "you're idle" learns
251
+ * nothing a bare timer couldn't have told it. Always a single line; throws,
252
+ * naming the fault, on a malformed nudge.
253
+ */
254
+ export function formatNudgeLine(event) {
255
+ const invalid = invalidNudgeReason(event);
256
+ if (invalid !== null)
257
+ throw new Error(`malformed nudge event: ${invalid}`);
258
+ const parts = [];
259
+ if (event.startable.length > 0)
260
+ parts.push(`startable: ${cardList(event.startable)}`);
261
+ if (event.held.length > 0)
262
+ parts.push(`held: ${cardList(event.held)}`);
263
+ return oneLine(`${LINE_PREFIX} this session has been idle ${event.idleMinutes}m with work waiting — ${parts.join("; ")}`);
264
+ }
180
265
  /**
181
266
  * Incremental SSE parser. Comment lines (keep-alives, the connect marker) and
182
267
  * field-less blocks produce no message, which is what keeps them out of the output.
@@ -305,6 +390,36 @@ export async function runListener(options, deps) {
305
390
  // elsewhere — see `ListenStopReason`'s docblock.
306
391
  return { kind: "ended", reason: isKnownWireEndReason(reason) ? reason : "unknown" };
307
392
  }
393
+ if (message.event === "nudge") {
394
+ // DX-2885 — a nudge carries no usable id (it is advisory, re-derived
395
+ // fresh every tick, never replayed — see `plan-session-stream.ts`'s
396
+ // module doc), so it rides the SAME `{type:"event", id:null, text}`
397
+ // shape an ordinary activity line uses (`id` is `null` only for a frame
398
+ // that carried no usable id — a nudge is exactly that case). That is
399
+ // what lets `bridge.ts` and the plugin's relay
400
+ // (`danxbot/scripts/plan-event-bridge.mjs`, outside this repo) forward
401
+ // it with NO changes at all: both already forward any `{type:"event",
402
+ // id, text}` line generically, keyed only on `type`.
403
+ //
404
+ // DX-2885 — this frame must stay exempt from DX-2730 D4's digest-
405
+ // batching when that lands (see DX-2730 AC 29939, and the matching note
406
+ // at the dashboard's own emit site): it must keep delivering
407
+ // immediately, never held for a later digest flush.
408
+ let parsed;
409
+ let invalid;
410
+ try {
411
+ parsed = JSON.parse(message.data);
412
+ invalid = invalidNudgeReason(parsed);
413
+ }
414
+ catch (err) {
415
+ invalid = `not JSON: ${err.message}`;
416
+ }
417
+ const text = invalid === null
418
+ ? formatNudgeLine(parsed)
419
+ : `${LINE_PREFIX} could not read nudge (${invalid}): ${oneLine(message.data, 300)}`;
420
+ deps.write({ type: "event", id: null, text });
421
+ return null;
422
+ }
308
423
  if (message.event !== "activity")
309
424
  return null;
310
425
  // `Number(null)` is 0, so a frame with no id must never be read as event 0: that id
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.84",
3
+ "version": "0.1.86",
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",