@thehammer/danx-dashboard-mcp 0.1.146 → 0.1.149

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
@@ -26,8 +26,8 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
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
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 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 |
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 |
29
+ | `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 |
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 THIS tool — see `issue_problem`'s answer / change_answer / unanswer actions |
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 |
@@ -60,10 +60,10 @@ CLAUDE_CODE_SESSION_ID=<session> npx -y @thehammer/danx-dashboard-mcp@<version>
60
60
  ```
61
61
 
62
62
  - **Credential — the session's own, never the ambient one (DX-2862).** The session id is the only thing `bridge` takes from its environment; `--resume-ids` is the only argument and holds no secret. Everything else comes from the connection record this session's own danx-dashboard MCP server wrote on `plan_connect` (`src/session-connection.ts`): the dashboard URL, the credential's SOURCE, and its FINGERPRINT. `bridge` resolves that source through the same resolver the server used and refuses to run unless the result fingerprints identically — `credential_mismatch`. Before that fix it used whatever `DANXBOT_DISPATCH_TOKEN` the session's environment held, and on a machine where that differed from the server's credential the dashboard admitted the stream and dropped every event, with nothing logged anywhere.
63
- - **No client-side reach check (DX-2920).** `bridge` used to prove board reach itself, before ever opening the stream (`GET /api/plans/mine?fields=cards` + a per-board `GET /api/issues/:id/problems`). That check is DELETED — the dashboard (DX-2863) now enforces the identical requirement server-side, and a second client-side copy of the same check was a duplicate source of truth, not a safety net: it refuses a mint (`403 issuer_cannot_read_plan_boards`), refuses stream admission (the SAME 403), and ends a live stream (`event: end`, `{"reason":"scope_narrowed","boards":[…]}`) the instant it stops being true. `bridge` only MAPS those refusals now — see "Stopping" below — it never independently verifies anything before streaming. Once a ticket is minted, `bridge` emits, once, `{"type":"ready"}` — no `boards` field, since reach is no longer proven client-side.
63
+ - **No client-side reach check (DX-2920).** `bridge` used to prove board reach itself, before ever opening the stream (`GET /api/plans/mine?fields=cards` + a per-board `GET /api/issues/:id/problems`). That check is DELETED. The dashboard briefly enforced the identical requirement server-side (DX-2863: refusing a mint or stream admission with `403 issuer_cannot_read_plan_boards`, and ending a live stream `{"reason":"scope_narrowed","boards":[…]}`), but DX-3468 removed that mechanism too — DX-3409 made every credential team-bound, so a plan session's issuer and every board its connected plan covers are always in the same team, and the check could never fire any more. `bridge` no longer independently verifies board reach at all, client- or server-side. Once a ticket is minted, `bridge` emits, once, `{"type":"ready"}` — no `boards` field, since reach is no longer proven client-side.
64
64
  - **Ticket.** `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.
65
65
  - **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.
66
- - **Stopping.** Last, one `{"type":"stopped","reason":"…","detail":"…","fix":"…"}` and exit, only on a terminal outcome. `fix` is the remedy shown to the session (`STOP_FIXES` in `src/bridge.ts` — never a log-only hint), so a session that hits a stop it cannot otherwise see still knows what to do about it. Terminal reasons: `no_connection_record` / `credential_unavailable` / `credential_mismatch` (the start-time checks above), `not_connected` (a `409 session_not_connected` at mint OR at stream admission, or a live `end("not_connected")` — all three mapped identically), `unauthorized` (401/403 for a reason OTHER than an unreadable board), `mint_refused` (any other non-transient mint refusal), `mint_bad_response`, `board_unreadable` (a `403 issuer_cannot_read_plan_boards` at mint OR at stream admission, naming the unreadable boards — DX-2920), `scope_narrowed` (a LIVE stream's `end("scope_narrowed", boards)` — the server's `end()` REQUIRES `boards` for this reason at the type level, `bridge` parses them off the wire and names them in `detail`/`fix`, never a generic message), `bad_end_payload` (a `scope_narrowed` end whose `boards` array was missing or malformed — a protocol error surfaced loudly, never silently downgraded to a boards-less `scope_narrowed`), `superseded` / `replaced` (exit 0 — a newer or replacing connection already serves this session, so `fix` says no action is needed), `revoked`, or `refused` (two freshly minted tickets refused in a row with no more specific classification). A transient mint failure (network, timeout, 408, 429, 5xx) backs off and retries; a lapsed ticket lease re-mints.
66
+ - **Stopping.** Last, one `{"type":"stopped","reason":"…","detail":"…","fix":"…"}` and exit, only on a terminal outcome. `fix` is the remedy shown to the session (`STOP_FIXES` in `src/bridge.ts` — never a log-only hint), so a session that hits a stop it cannot otherwise see still knows what to do about it. Terminal reasons: `no_connection_record` / `credential_unavailable` / `credential_mismatch` (the start-time checks above), `not_connected` (a `409 session_not_connected` at mint OR at stream admission, or a live `end("not_connected")` — all three mapped identically), `unauthorized` (401/403), `mint_refused` (any other non-transient mint refusal), `mint_bad_response`, `superseded` / `replaced` (exit 0 — a newer or replacing connection already serves this session, so `fix` says no action is needed), `revoked`, or `refused` (two freshly minted tickets refused in a row with no more specific classification). A transient mint failure (network, timeout, 408, 429, 5xx) backs off and retries; a lapsed ticket lease re-mints.
67
67
  - **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.
68
68
 
69
69
  ## Build + test
package/dist/bridge.js CHANGED
@@ -20,25 +20,17 @@
20
20
  * credential from its session's tools streamed happily and relayed nothing.
21
21
  * 3. Mints the session's listener ticket (`POST /api/plan-sessions/me/stream-ticket`).
22
22
  * The ticket stays in this process.
23
- * 4. DX-2920 — board-coverage reach is a SERVER-ENFORCED property now, never a
24
- * client-side verify: the stream admits an event only when the ticket
25
- * issuer's board allowlist covers that event's board (`isVisibleToStream`,
26
- * dashboard), and the dashboard itself (DX-2863) refuses to hand out a
27
- * ticket, refuses to admit a connection, and ends a live stream the instant
28
- * that stops being true — `403 issuer_cannot_read_plan_boards` at MINT
29
- * (`mintTicket`) AND at stream ADMISSION (`classifyAdmissionRefusal`,
30
- * reading `listen.ts`'s structured `refusal`) are BOTH mapped to
31
- * `board_unreadable`, naming the boards; `409 session_not_connected` at
32
- * mint AND at admission are both mapped to `not_connected`; and
33
- * `end("scope_narrowed", boards)` / `end("not_connected")` cover the
34
- * same two failures once the stream is already live — `end`'s own
35
- * overloaded signature in `plan-session-stream.ts` REQUIRES `boards` for
36
- * `scope_narrowed` at the type level (round 3), and `listen.ts` parses
37
- * them off the wire the same way it parses `reason`, so this module can
38
- * name them in `scope_narrowed`'s detail/fix exactly like it already
39
- * does for `board_unreadable`. This module no longer runs its own
40
- * duplicate board-by-board check before streaming — see DX-2920 and the
41
- * deleted `verifyPlanBoardsReadable`.
23
+ * 4. DX-3468 — board-coverage narrowing (`403 issuer_cannot_read_plan_boards`
24
+ * at mint or admission, mapped to `board_unreadable`; a live stream
25
+ * ending `scope_narrowed`) was removed as dead code: DX-3409 made every
26
+ * credential team-bound, so a plan session's issuer and every board its
27
+ * connected plan covers are always in the same team (see the dashboard's
28
+ * `plan-scope.ts` module banner). `409 session_not_connected` at mint
29
+ * AND at admission are both mapped to `not_connected`, and
30
+ * `end("not_connected")` covers the same failure once the stream is
31
+ * already live. This module no longer runs its own duplicate
32
+ * board-by-board check before streaming — see DX-2920 and the deleted
33
+ * `verifyPlanBoardsReadable`.
42
34
  * 5. Streams through `runListener` (`listen.ts`), which reconnects within the
43
35
  * ticket, and re-mints when that ticket's lease runs out or a fresh ticket
44
36
  * is refused once.
@@ -59,26 +51,9 @@
59
51
  * - `not_connected` the mint, or the stream's admission / keep-alive,
60
52
  * answered `session_not_connected` (409 at mint and
61
53
  * at admission; `end("not_connected")` once live);
62
- * - `unauthorized` the mint answered 401 / 403 for a reason OTHER
63
- * than an unreadable board;
54
+ * - `unauthorized` the mint answered 401 / 403;
64
55
  * - `mint_refused` any other non-transient mint refusal (400, 404, …);
65
56
  * - `mint_bad_response` a 2xx mint whose body is not a ticket;
66
- * - `board_unreadable` the mint OR the stream's admission was refused
67
- * `403 issuer_cannot_read_plan_boards`, naming the
68
- * boards this credential cannot read (DX-2920);
69
- * - `scope_narrowed` a LIVE stream ended because the plan grew, or the
70
- * credential's allowlist shrank past, a board this
71
- * credential can no longer read — `boards` names
72
- * them, parsed from the dashboard's own `end`
73
- * payload by `listen.ts` the same way `reason`
74
- * itself is parsed, and `detail`/`fix` are built
75
- * dynamically from them (DX-2920 round 3 —
76
- * `boardUnreadableDetail` / `scopeNarrowedFix`),
77
- * never a static generic message;
78
- * - `bad_end_payload` a `scope_narrowed` end whose `boards` array was
79
- * missing or malformed — a PROTOCOL error, never
80
- * silently downgraded to a boards-less
81
- * `scope_narrowed` stop (DX-2920 round 3);
82
57
  * - `superseded` / `replaced` (exit 0) — another listener now holds the session;
83
58
  * - `revoked` the dashboard revoked the ticket or its issuer;
84
59
  * - `refused` freshly minted tickets were refused twice in a row.
@@ -236,12 +211,6 @@ export const STOP_FIXES = {
236
211
  unauthorized: "give this session's danx-dashboard MCP server a dashboard credential that is accepted, then call plan_connect again",
237
212
  mint_refused: "check that this session is connected to a plan on the dashboard its MCP server points at, then call plan_connect again",
238
213
  mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a danxbot dashboard",
239
- board_unreadable: "scope this session's dashboard credential to read that board, or connect a plan whose cards all live on boards it can read",
240
- // DX-2920 (round 3) — a `scope_narrowed` end whose `boards` array was
241
- // missing or invalid (`listen.ts`'s parse-time check). Named separately
242
- // from `scope_narrowed` itself, which always carries boards and never
243
- // reaches this generic map at all.
244
- bad_end_payload: "call plan_connect again to mint a new listener ticket for this session",
245
214
  // A newer ticket exists (superseded) or another connection is already using
246
215
  // this one (replaced) — THIS process is ending because the session is
247
216
  // already served elsewhere, not because anything is broken.
@@ -278,9 +247,9 @@ export function parseBridgeArgs(argv, env) {
278
247
  export class BridgeStartError extends Error {
279
248
  // DX-2920 (round 4) — `ListenStopReason`, not `string`: a start-time failure
280
249
  // is always one of `no_connection_record` / `credential_unavailable` /
281
- // `credential_mismatch`, never `scope_narrowed` (there is no stream yet), so
282
- // this can be the closed reason type the eventual `write({type:"stopped",
283
- // reason: start.reason, ...})` needs it to be.
250
+ // `credential_mismatch` (there is no stream yet), so this can be the closed
251
+ // reason type the eventual `write({type:"stopped", reason: start.reason,
252
+ // ...})` needs it to be.
284
253
  reason;
285
254
  fix;
286
255
  /** DX-3028 — see `BridgeOptions.instanceId`. Always populated: minted before any of the three start-time checks run. */
@@ -367,39 +336,6 @@ export function resolveBridgeOptions(args, env, options = {}) {
367
336
  watchedPaths,
368
337
  };
369
338
  }
370
- /**
371
- * DX-2920 — `body.boards` off a `403 issuer_cannot_read_plan_boards` refusal
372
- * (`unreadableBoardsRefusal`, dashboard `plan-scope.ts`), or `[]` when the body
373
- * doesn't carry a readable list (defensive — never trust the wire shape blindly).
374
- */
375
- function boardsOf(body) {
376
- if (typeof body !== "object" || body === null)
377
- return [];
378
- const boards = body.boards;
379
- return Array.isArray(boards) ? boards.filter((b) => typeof b === "string") : [];
380
- }
381
- /**
382
- * DX-2920 — the ONE `board_unreadable` detail-line builder, shared by the mint
383
- * 403 classification and the stream admission's identical 403, so the two can
384
- * never drift into two different ideas of how to phrase "these boards".
385
- */
386
- function boardUnreadableDetail(boards, fallback) {
387
- return boards.length > 0
388
- ? `this session's dashboard credential cannot read board(s) ${boards.join(", ")} on the connected plan`
389
- : fallback;
390
- }
391
- /**
392
- * DX-2920 (round 3) — `scope_narrowed`'s fix, built dynamically from the
393
- * boards `listen.ts` parsed off the dashboard's own `end` payload (AC 30661:
394
- * "naming the missing boards and how to widen scope"), replacing the static
395
- * generic `STOP_FIXES` entry round 2 shipped before the server actually sent
396
- * a board list.
397
- */
398
- function scopeNarrowedFix(boards) {
399
- return boards.length > 0
400
- ? `widen this session's dashboard credential to cover board(s) ${boards.join(", ")}, or connect a plan whose cards all live on boards it can read`
401
- : "widen this session's dashboard credential to cover every board the connected plan's cards live on, or connect a plan whose cards all live on boards it can read";
402
- }
403
339
  /**
404
340
  * One authenticated request, with a timeout, classifying the failures that are
405
341
  * about THIS MOMENT (network, timeout, 408/429, 5xx) as transient. Shared by
@@ -486,19 +422,6 @@ export async function mintTicket(options, deps) {
486
422
  if (status === 409 && errorCodeOf(body) === "session_is_worker") {
487
423
  return { kind: "terminal", reason: "session_is_worker", detail: "this session id belongs to a worker dispatch, not an operator plan session" };
488
424
  }
489
- // DX-2920 (AC 30660) — this credential IS accepted; it just cannot read every
490
- // board the connected plan covers (`unreadableBoardsRefusal`, dashboard
491
- // `plan-scope.ts`). Calling that `unauthorized` told the session to fix its
492
- // credential, which was the wrong remedy for a scope problem — this is
493
- // `board_unreadable`, naming the boards, checked BEFORE the generic 401/403
494
- // fallback below so it never falls into it.
495
- if (status === 403 && errorCodeOf(body) === "issuer_cannot_read_plan_boards") {
496
- return {
497
- kind: "terminal",
498
- reason: "board_unreadable",
499
- detail: boardUnreadableDetail(boardsOf(body), `ticket mint HTTP 403: issuer_cannot_read_plan_boards ${oneLine(text, 300)}`),
500
- };
501
- }
502
425
  if (status === 401 || status === 403) {
503
426
  return { kind: "terminal", reason: "unauthorized", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
504
427
  }
@@ -558,18 +481,12 @@ export async function fetchPlanInventory(options, deps) {
558
481
  /**
559
482
  * DX-2920 — a stream admission refusal (`listen.ts`'s STRUCTURED `refused`
560
483
  * outcome — `{status, errorCode, body}`, parsed once in `connectOnce`) whose
561
- * shape matches one the mint route already gives its OWN name to. Two cases:
562
- *
563
- * - `409 session_not_connected` — the SAME shape the mint route uses for "no
564
- * plan connected" (`plan-session-stream.ts`, DX-2863 review finding M2). A
565
- * session with no plan connected is the identical terminal state whether
566
- * the dashboard says so at mint or at the stream's own admission check,
567
- * and the session should see ONE remedy either way.
568
- * - `403 issuer_cannot_read_plan_boards` — the SAME shape the mint route's
569
- * board-coverage refusal uses (AC 30660's fix 4 — a ticket minted before a
570
- * board became unreadable can still be refused ADMISSION for it later; that
571
- * refusal deserves the same `board_unreadable` naming as the mint-time one,
572
- * not a re-mint-and-retry loop that just hits the identical refusal again).
484
+ * shape matches one the mint route already gives its OWN name to: `409
485
+ * session_not_connected`, the SAME shape the mint route uses for "no plan
486
+ * connected" (`plan-session-stream.ts`, DX-2863 review finding M2). A session
487
+ * with no plan connected is the identical terminal state whether the
488
+ * dashboard says so at mint or at the stream's own admission check, and the
489
+ * session should see ONE remedy either way.
573
490
  *
574
491
  * Classifies by STATUS + ERROR CODE, never by re-deriving them from the
575
492
  * flattened `detail` string — the second-source-of-truth shape this card's fix
@@ -581,21 +498,15 @@ function classifyAdmissionRefusal(refusal) {
581
498
  if (refusal.status === 409 && refusal.errorCode === "session_not_connected") {
582
499
  return { reason: "not_connected", detail: "the session is not connected to a plan" };
583
500
  }
584
- if (refusal.status === 403 && refusal.errorCode === "issuer_cannot_read_plan_boards") {
585
- return {
586
- reason: "board_unreadable",
587
- detail: boardUnreadableDetail(boardsOf(refusal.body), "this session's dashboard credential cannot read a board on the connected plan"),
588
- };
589
- }
590
501
  return null;
591
502
  }
592
503
  // DX-3028 (code-review + architecture-review finding) — typed against
593
- // `ListenStopReason` (the closed union `scope_narrowed` aside — see that
594
- // type's own docblock), NOT bare `Set<string>`: a typo'd member here used to
595
- // have zero compile-time signal, silently falling into the generic
596
- // `degrade-server-fixable` default below — exactly the kind of drift this
597
- // safety-critical classification can least afford. `satisfies readonly
598
- // ListenStopReason[]` on each array is what makes a typo a build error.
504
+ // `ListenStopReason` (see that type's own docblock), NOT bare `Set<string>`:
505
+ // a typo'd member here used to have zero compile-time signal, silently
506
+ // falling into the generic `degrade-server-fixable` default below — exactly
507
+ // the kind of drift this safety-critical classification can least afford.
508
+ // `satisfies readonly ListenStopReason[]` on each array is what makes a typo
509
+ // a build error.
599
510
  const EXIT_NEVER_RESTART_LIST = [
600
511
  "no_connection_record",
601
512
  "not_connected",
@@ -746,16 +657,8 @@ export async function runBridge(options, deps) {
746
657
  }
747
658
  const policy = degradePolicyFor(reason);
748
659
  const degraded = policy !== "exit";
749
- const fix = reason === "scope_narrowed" ? scopeNarrowedFix(extra?.boards ?? []) : (extra?.fix ?? fixForStop(reason));
750
- if (reason === "scope_narrowed") {
751
- if (extra?.boards === undefined) {
752
- throw new Error('settle("scope_narrowed", ...) requires boards — unreachable through the exported overloads above');
753
- }
754
- deps.write({ type: "stopped", reason, detail, boards: extra.boards, fix, instanceId: options.instanceId, paths: options.watchedPaths, degraded });
755
- }
756
- else {
757
- deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal, fix, instanceId: options.instanceId, paths: options.watchedPaths, degraded });
758
- }
660
+ const fix = extra?.fix ?? fixForStop(reason);
661
+ deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal, fix, instanceId: options.instanceId, paths: options.watchedPaths, degraded });
759
662
  if (policy === "exit") {
760
663
  heartbeat.stop();
761
664
  return { kind: "exit", code };
@@ -1002,19 +905,6 @@ export async function runBridge(options, deps) {
1002
905
  }
1003
906
  continue;
1004
907
  }
1005
- if (stopped.reason === "scope_narrowed") {
1006
- // DX-2920 (round 4) — `stopped.boards` is REQUIRED at the type level now
1007
- // (`ListenStopped`'s `scope_narrowed` member), populated by `listen.ts`'s
1008
- // parse of the dashboard's own `end` payload (never a re-derivation from
1009
- // `detail`) — no `?? []`, no optional check, `stopped.boards` IS
1010
- // `string[]` once narrowed to this branch.
1011
- const boards = stopped.boards;
1012
- const outcome = await settle("scope_narrowed", boardUnreadableDetail(boards, stopped.detail), 1, { boards });
1013
- if (outcome.kind === "exit")
1014
- return outcome.code;
1015
- refusedInRow = 0;
1016
- continue;
1017
- }
1018
908
  {
1019
909
  const outcome = await settle(stopped.reason, stopped.detail, 1);
1020
910
  if (outcome.kind === "exit")
package/dist/handlers.js CHANGED
@@ -568,7 +568,31 @@ export async function issueChecklist(client, args) {
568
568
  * `.describe()` in `index.ts` — that description, not this comment, is what
569
569
  * a calling agent actually reads.
570
570
  *
571
- * No answer action, for the reason `issueSolution` gives.
571
+ * DX-3471 — `answer` {solution_id, note?} | {freeform} → POST :pid/answer;
572
+ * `change_answer` {base_hash, solution_id, note?} | {base_hash, freeform} →
573
+ * POST :pid/change-answer (retracts the live decision and records a new one
574
+ * in ONE server transaction); `unanswer` {base_hash} → POST :pid/unanswer
575
+ * (retracts with no new answer, reopening the problem). `base_hash` on these
576
+ * two names the live DECISION (`decisionContentHash`), NOT the problem —
577
+ * read it off a problem's `decisions[].content_hash` (from `list`), never
578
+ * off the problem's own `content_hash`. A stale one → 409 `stale_decision`
579
+ * with `currentHash` + `currentProblem`: merge, then retry.
580
+ *
581
+ * ALL THREE ARE OPERATOR-SESSION ONLY. The server's route policy admits only
582
+ * a `human` principal on these three routes (`route-policies/index.ts`) — a
583
+ * dispatched agent's machine credential is refused before it ever reaches a
584
+ * handler, for the DX-2830 reason: an agent that could answer its own
585
+ * question could release the very human gate it set to stop and wait for.
586
+ * An operator-session agent (running under the OPERATOR's own dashboard
587
+ * token, human-authenticated) CAN call these — this is what turns "the
588
+ * operator decided in chat" into a durable, correctly-attributed decision
589
+ * instead of a comment or a raw curl call. The decision records the human
590
+ * decider (`decided_by`) and, when this call carries the session id
591
+ * `@thehammer/danx-dashboard-mcp` already stamps on every request
592
+ * (`x-danx-session-id`, DX-2683 — no separate parameter needed here),
593
+ * which working session relayed it (`relayed_by_session` on the resulting
594
+ * decision, DX-3471) — so a decision entered from the dashboard's own UI
595
+ * (no header) stays distinguishable from one an agent relayed in chat.
572
596
  */
573
597
  /**
574
598
  * DX-3310 (nit) — per-action allow-list for `refuseInapplicableFields`.
@@ -583,6 +607,9 @@ const PROBLEM_ACTION_FIELDS = {
583
607
  add: ["statement", "context", "type", "summary", "solutions"],
584
608
  edit: ["problem_id", "base_hash", "statement", "context", "summary", "type"],
585
609
  remove: ["problem_id", "base_hash"],
610
+ answer: ["problem_id", "solution_id", "note", "freeform"],
611
+ change_answer: ["problem_id", "base_hash", "solution_id", "note", "freeform"],
612
+ unanswer: ["problem_id", "base_hash"],
586
613
  };
587
614
  export async function issueProblem(client, args) {
588
615
  const idEnc = encodeURIComponent(args.id);
@@ -652,8 +679,66 @@ export async function issueProblem(client, args) {
652
679
  board,
653
680
  });
654
681
  }
682
+ // DX-3471 — answer/change_answer share the exact-one-of solution_id/freeform
683
+ // shape the server's own `validateAnswer` enforces (`write/problem-answer.ts`);
684
+ // checked here too so a caller gets the same clear boundary error every
685
+ // OTHER missing/conflicting-arg case in this tool already gets, rather than
686
+ // a round trip to the server for a 400 the client could have caught.
687
+ case "answer": {
688
+ const problemId = need.id(args.problem_id, "problem_id");
689
+ return client.request({
690
+ method: "POST",
691
+ path: `/${idEnc}/problems/${problemId}/answer`,
692
+ body: answerChoiceBody(args, "answer"),
693
+ board,
694
+ });
695
+ }
696
+ case "change_answer": {
697
+ const problemId = need.id(args.problem_id, "problem_id");
698
+ const baseHash = need.string(args.base_hash, "base_hash");
699
+ return client.request({
700
+ method: "POST",
701
+ path: `/${idEnc}/problems/${problemId}/change-answer`,
702
+ body: { base_hash: baseHash, ...answerChoiceBody(args, "change_answer") },
703
+ board,
704
+ });
705
+ }
706
+ case "unanswer": {
707
+ const problemId = need.id(args.problem_id, "problem_id");
708
+ const baseHash = need.string(args.base_hash, "base_hash");
709
+ return client.request({
710
+ method: "POST",
711
+ path: `/${idEnc}/problems/${problemId}/unanswer`,
712
+ body: { base_hash: baseHash },
713
+ board,
714
+ });
715
+ }
655
716
  }
656
717
  }
718
+ /**
719
+ * DX-3471 — the either/or answer shape `answer`/`change_answer` both send:
720
+ * `{solution_id, note?}` XOR `{freeform}`, mirroring the server's own
721
+ * `validateAnswer` (`write/problem-answer.ts`) so a caller gets the SAME
722
+ * refusal at this client boundary that the server would otherwise 400 on,
723
+ * with no round trip.
724
+ */
725
+ function answerChoiceBody(args, mode) {
726
+ const hasSolution = args.solution_id !== undefined;
727
+ const hasFreeform = args.freeform !== undefined;
728
+ if (hasSolution === hasFreeform) {
729
+ throw new Error(`issue_problem ${mode} requires either solution_id or freeform (never both, never neither)`);
730
+ }
731
+ if (hasFreeform) {
732
+ if (args.note !== undefined) {
733
+ throw new Error(`issue_problem ${mode}: note qualifies a chosen solution — it is not valid with freeform`);
734
+ }
735
+ return { freeform: args.freeform };
736
+ }
737
+ const body = { solution_id: args.solution_id };
738
+ if (args.note !== undefined)
739
+ body.note = args.note;
740
+ return body;
741
+ }
657
742
  /**
658
743
  * One problem's candidate solutions, AND (DX-3310) one solution's individual
659
744
  * procedure steps, via `/api/issues/:id/problems/:pid/solutions[/:sid][/steps[/:stepId]]`
@@ -664,10 +749,13 @@ export async function issueProblem(client, args) {
664
749
  * solution/step id from the wrong scope → 404, nesting past depth 3 → 400)
665
750
  * pass through verbatim.
666
751
  *
667
- * There is deliberately NO answer action, on this tool or any other. Answering
668
- * releases the human gate on a card — an agent that could answer its own
669
- * question could release the very stop it set to wait for a human. The operator
670
- * answers in the dashboard.
752
+ * There is deliberately NO answer action on THIS tool — answering releases the
753
+ * human gate on a card, and this tool has no notion of "who is calling".
754
+ * `issue_problem`'s `answer` / `change_answer` / `unanswer` actions (DX-3471)
755
+ * are where that lives instead: server-side human-principal-only (a
756
+ * dispatched agent's machine credential is refused 403), so an operator
757
+ * session can record a decision but a dispatched worker cannot release its
758
+ * own gate.
671
759
  */
672
760
  const SOLUTION_ACTION_FIELDS = {
673
761
  add: ["title", "body", "pro", "con", "recommended", "steps"],
package/dist/index.js CHANGED
@@ -657,11 +657,39 @@ const STEP_INPUT = z.lazy(() => z
657
657
  steps: z.array(STEP_INPUT).optional().describe("this step's own children, nested — refused past 3 levels total"),
658
658
  })
659
659
  .strict());
660
- strictTool("issue_problem", "A card's PROBLEMS: one statement the operator must resolve (a question, or a flaw in the plan) OR an action only a person can carry out, 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 — adding a problem IS putting the card in front of a human, there is no separate flag. list → live problems in order, each {id, statement, context, type, summary, content_hash, open, solutions[], decisions[]}; add {statement, context?, type?, summary?, solutions?} → problem_id + solution_ids in one transaction (zero solutions is valid: the operator answers free-form) plus a `reminders: [{key, text}]` array (DX-3365 — every MCP-response reminder rides this ONE field now, DB-registry-driven and operator-overridable from the dashboard) naming each still-open problem's solution count; edit :pid {base_hash, statement, context?, summary?}; remove :pid {base_hash} — always allowed, even as the card's last open problem (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. `statement` is capped at 200 characters (400 names the actual length otherwise): one plain sentence, with any investigation detail in `context` (markdown, no cap) instead. Read `type`'s and `summary`'s own descriptions below before your first `add` — together they teach which type this is and the three-field split (`statement`/`summary`/`context`) an action needs.", {
660
+ strictTool("issue_problem", "A card's PROBLEMS: one statement the operator must resolve (a question, or a flaw in the plan) OR an action only a person can carry out, 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 — adding a problem IS putting the card in front of a human, there is no separate flag. list → live problems in order, each {id, statement, context, type, summary, content_hash, open, solutions[], decisions[]} (each decision carries `relayed_by_session`, DX-3471 — the working session that recorded it, or null for one entered directly in the dashboard); add {statement, context?, type?, summary?, solutions?} → problem_id + solution_ids in one transaction (zero solutions is valid: the operator answers free-form) plus a `reminders: [{key, text}]` array (DX-3365 — every MCP-response reminder rides this ONE field now, DB-registry-driven and operator-overridable from the dashboard) naming each still-open problem's solution count; edit :pid {base_hash, statement, context?, summary?}; remove :pid {base_hash} — always allowed, even as the card's last open problem (removing it just means the card no longer needs a human). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. " +
661
+ "DX-3471 — answer :pid {solution_id, note?} | {freeform} records the operator's decision on a LIVE problem (exactly one of solution_id/freeform; note only qualifies a chosen solution); change_answer :pid {base_hash, solution_id, note?} | {base_hash, freeform} retracts the current decision and records a new one in ONE server transaction; unanswer :pid {base_hash} retracts with no new answer, reopening the problem. `base_hash` on these two names the live DECISION (from that problem's `decisions[].content_hash` in `list`), NOT the problem's own hash — a stale one → 409 `stale_decision` with currentHash + currentDecision: merge, then retry. " +
662
+ "**These three are OPERATOR-SESSION ONLY** — the server refuses a dispatched agent's machine credential 403 (DX-2830: an agent that could answer its own question could release the very human gate it set to stop and wait for); call them only when relaying a decision the OPERATOR actually made in this chat, never a decision you are making yourself. Session attribution is automatic (the same `x-danx-session-id` every call already carries, DX-2683) — no session parameter to pass here. " +
663
+ "`statement` is capped at 200 characters (400 names the actual length otherwise): one plain sentence, with any investigation detail in `context` (markdown, no cap) instead. Read `type`'s and `summary`'s own descriptions below before your first `add` — together they teach which type this is and the three-field split (`statement`/`summary`/`context`) an action needs.", {
661
664
  id: z.string().min(1),
662
- action: z.enum(["list", "add", "edit", "remove"]),
663
- problem_id: z.number().int().positive().optional().describe("edit/remove"),
664
- base_hash: z.string().min(1).optional().describe("content_hash last read; edit/remove"),
665
+ action: z.enum(["list", "add", "edit", "remove", "answer", "change_answer", "unanswer"]),
666
+ problem_id: z.number().int().positive().optional().describe("edit/remove/answer/change_answer/unanswer"),
667
+ base_hash: z
668
+ .string()
669
+ .min(1)
670
+ .optional()
671
+ .describe("edit/remove: content_hash last read for the PROBLEM. change_answer/unanswer: content_hash last read for " +
672
+ "the live DECISION instead (that problem's `decisions[].content_hash` from `list`) — the two are never " +
673
+ "interchangeable."),
674
+ solution_id: z
675
+ .number()
676
+ .int()
677
+ .positive()
678
+ .optional()
679
+ .describe("answer/change_answer only. The chosen option's id, from this problem's own solutions[]. Exactly one of " +
680
+ "solution_id / freeform — never both, never neither (refused before any request)."),
681
+ note: z
682
+ .string()
683
+ .min(1)
684
+ .optional()
685
+ .describe("answer/change_answer only, solution_id answers only: an optional qualification (\"this, but…\"). Refused " +
686
+ "alongside freeform, which is already the operator's own words."),
687
+ freeform: z
688
+ .string()
689
+ .min(1)
690
+ .optional()
691
+ .describe("answer/change_answer only. The operator's own words, when no listed solution fits. Exactly one of " +
692
+ "solution_id / freeform — never both, never neither."),
665
693
  statement: z
666
694
  .string()
667
695
  .min(1)
@@ -981,7 +1009,7 @@ strictTool("plan_create", "Create a new, empty plan. Global — not board-scoped
981
1009
  }, async (args) => jsonResult(await planCreate(client, args)));
982
1010
  strictTool("plan_connect",
983
1011
  // DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
984
- "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_get({fields:[...]})` + 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 every 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\"|\"board_scope_narrowed\"|\"healthy\", nextStep}` naming a concrete fix per unhealthy state (same shape `plan_list`/`plan_get` 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.", {
1012
+ "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_get({fields:[...]})` + 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 every 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 `plan_list`/`plan_get` 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.", {
985
1013
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
986
1014
  title: z
987
1015
  .string()
package/dist/json-body.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * that wanted to react to a SPECIFIC refusal (the admission-time 409 the
10
10
  * mint route also uses) had to regex-scrape that flattened string back apart,
11
11
  * a second, fragile source of truth for the same fact. This module is generic
12
- * — it knows nothing about "session_not_connected" or "issuer_cannot_read_plan_boards",
12
+ * — it knows nothing about "session_not_connected" or "stream_ticket_invalid",
13
13
  * only that a JSON object may carry a top-level string field named `error` —
14
14
  * so it can live in BOTH the domain-aware `bridge.ts` and the deliberately
15
15
  * generic `listen.ts` (whose own docblock states it carries no domain
package/dist/listen.js CHANGED
@@ -15,27 +15,21 @@
15
15
  * (`formatNudgeLine`): it is advisory, re-derived fresh every tick by the
16
16
  * dashboard's own keep-alive tick, and never replayed, so it rides this
17
17
  * same shape with `id: null` rather than growing a THIRD output variant.
18
- * - `{type:"stopped", reason, detail, refusal?, boards?}` — once, last. `reason`
18
+ * - `{type:"stopped", reason, detail, refusal?}` — once, last. `reason`
19
19
  * is the dashboard's own end reason (`superseded`, `replaced`, `revoked`,
20
- * `scope_narrowed`, `not_connected`), `refused` (the ticket was not
21
- * admitted), `bad_end_payload` (a `scope_narrowed` end whose `boards` array
22
- * was missing/invalid — a protocol error, never silently downgraded), or
20
+ * `not_connected`), `refused` (the ticket was not admitted), or
23
21
  * `lease_expired` (no healthy connection for the lease). DX-2920 —
24
22
  * `refusal` is populated ONLY for `reason === "refused"`: `{status,
25
23
  * errorCode, body}`, the admission response parsed ONCE here, so a
26
24
  * consumer can recognize a SPECIFIC refusal (the dashboard's own
27
25
  * `409 session_not_connected`, the same shape the mint route uses) by
28
26
  * status + error code rather than re-deriving it from the flattened
29
- * `detail` string. `boards` is REQUIRED (round 4 — a discriminated union,
30
- * not an optional field: see `ListenStopped` / `ListenStopReason`) for
31
- * `reason === "scope_narrowed"` and absent for every other reason, parsed
32
- * from the dashboard's `end` payload the SAME way `reason` itself is
33
- * parsed — never re-derived from `detail`. A wire `reason` value this
34
- * build does not recognize (a newer dashboard's reason an older bridge
35
- * hasn't shipped yet) normalizes to `"unknown"` rather than passing the
36
- * raw string through, so the closed set stays closed — `fixForStop`'s
37
- * generic `DEFAULT_STOP_FIX` handles it exactly as it already handled a
38
- * verbatim unrecognized string.
27
+ * `detail` string. A wire `reason` value this build does not recognize (a
28
+ * newer dashboard's reason an older bridge hasn't shipped yet)
29
+ * normalizes to `"unknown"` rather than passing the raw string through,
30
+ * so the closed set stays closed — `fixForStop`'s generic
31
+ * `DEFAULT_STOP_FIX` handles it exactly as it already handled a verbatim
32
+ * unrecognized string.
39
33
  * - nothing for keep-alives, the connect marker, or a successful reconnect.
40
34
  *
41
35
  * RECONNECT AND RESUME. The stream drops whenever the dashboard restarts, and can
@@ -345,16 +339,12 @@ function parseBlock(block) {
345
339
  }
346
340
  /**
347
341
  * DX-2920 (round 4) — the dashboard's own `StreamEndReason` values this
348
- * client recognizes on the wire, EXCLUDING `scope_narrowed` (parsed by its own
349
- * dedicated branch in `handle()` below, since it alone carries `boards`).
350
- * Hand-copied from `StreamEndReason` in `src/issues/plan-session-listeners.ts`
351
- * — this published package cannot import danxbot source. A wire `reason` this
352
- * set does not contain (a newer dashboard's reason this build predates)
353
- * normalizes to `"unknown"` rather than passing the raw string through, which
354
- * is what lets `Outcome`'s `reason` field below be a closed union instead of
355
- * `string` — see `ListenStopReason`'s docblock for why that closure is what
356
- * makes the `scope_narrowed`-requires-`boards` invariant an actual compile
357
- * error rather than a comment's promise.
342
+ * client recognizes on the wire. Hand-copied from `StreamEndReason` in
343
+ * `src/issues/plan-session-listeners.ts` — this published package cannot
344
+ * import danxbot source. A wire `reason` this set does not contain (a newer
345
+ * dashboard's reason this build predates) normalizes to `"unknown"` rather
346
+ * than passing the raw string through, which is what lets `Outcome`'s
347
+ * `reason` field below be a closed union instead of `string`.
358
348
  */
359
349
  const KNOWN_WIRE_END_REASONS = new Set(["superseded", "replaced", "revoked", "not_connected"]);
360
350
  function isKnownWireEndReason(reason) {
@@ -390,27 +380,13 @@ export async function runListener(options, deps) {
390
380
  for (const id of capResumeIds(options.resumeIds))
391
381
  remember(id);
392
382
  function stop(reason, detail, code, extra) {
393
- if (reason === "scope_narrowed") {
394
- if (extra?.boards === undefined) {
395
- throw new Error('stop("scope_narrowed", ...) requires boards — unreachable through the exported overloads above');
396
- }
397
- deps.write({ type: "stopped", reason, detail, boards: extra.boards });
398
- return code;
399
- }
400
383
  deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal });
401
384
  return code;
402
385
  }
403
386
  const handle = (message) => {
404
387
  if (message.event === "end") {
405
388
  // DX-2920 (round 3) — a JSON.parse failure here falls through to the
406
- // pre-existing "unknown" reason below via `isRecord(null)`, unchanged
407
- // from before this round. Only a `scope_narrowed` reason additionally
408
- // REQUIRES a valid `boards` string array (matching the server's `end`
409
- // overload, which cannot emit `scope_narrowed` without one) — a
410
- // `scope_narrowed` frame that fails that check is a PROTOCOL error
411
- // (a server too old to have sent boards, or a corrupted frame), never
412
- // silently downgraded to the generic "the dashboard ended this
413
- // listener" message the caller cannot act on.
389
+ // pre-existing "unknown" reason below via `isRecord(null)`.
414
390
  let parsed;
415
391
  try {
416
392
  parsed = JSON.parse(message.data);
@@ -420,23 +396,10 @@ export async function runListener(options, deps) {
420
396
  }
421
397
  const reasonRaw = isRecord(parsed) ? parsed.reason : undefined;
422
398
  const reason = typeof reasonRaw === "string" ? reasonRaw : "unknown";
423
- if (reason === "scope_narrowed") {
424
- const boardsRaw = isRecord(parsed) ? parsed.boards : undefined;
425
- const boards = Array.isArray(boardsRaw) && boardsRaw.length > 0 && boardsRaw.every((b) => typeof b === "string")
426
- ? boardsRaw
427
- : null;
428
- if (boards === null) {
429
- return { kind: "ended", reason: "bad_end_payload" };
430
- }
431
- return { kind: "ended", reason, boards };
432
- }
433
399
  // DX-2920 (round 4) — a wire reason this build does not recognize (a
434
400
  // newer dashboard's reason an older bridge hasn't shipped yet)
435
401
  // normalizes to "unknown" rather than passing the raw string through:
436
- // `Outcome`'s "ended" reason is now a closed set (`EndedReason`), and an
437
- // open passthrough here would be exactly the hole that lets a
438
- // missing-`boards` "scope_narrowed" construction slip past the compiler
439
- // elsewhere — see `ListenStopReason`'s docblock.
402
+ // `Outcome`'s "ended" reason is a closed set (`EndedReason`).
440
403
  return { kind: "ended", reason: isKnownWireEndReason(reason) ? reason : "unknown" };
441
404
  }
442
405
  if (message.event === "nudge") {
@@ -597,17 +560,6 @@ export async function runListener(options, deps) {
597
560
  if (outcome.reason === "superseded" || outcome.reason === "replaced") {
598
561
  return stop(outcome.reason, "another listener for this session took over", 0);
599
562
  }
600
- if (outcome.reason === "bad_end_payload") {
601
- return stop("bad_end_payload", "the dashboard's scope_narrowed end event carried no valid boards array — this is a protocol error, not an ordinary end", 1);
602
- }
603
- // DX-2920 (round 4) — split explicitly rather than the old conditional
604
- // `outcome.boards === undefined ? undefined : {boards}` pass-through:
605
- // `Outcome`'s "ended" kind is now itself discriminated on `reason`, so
606
- // `outcome.boards` is GUARANTEED present here (no `?? []`, no optional
607
- // check needed) once narrowed to `"scope_narrowed"`.
608
- if (outcome.reason === "scope_narrowed") {
609
- return stop("scope_narrowed", "the dashboard ended this listener", 1, { boards: outcome.boards });
610
- }
611
563
  return stop(outcome.reason, "the dashboard ended this listener", 1);
612
564
  }
613
565
  if (outcome.kind === "refused") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.146",
3
+ "version": "0.1.149",
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",
@@ -1,43 +0,0 @@
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
- });