@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 +4 -4
- package/dist/bridge.js +29 -139
- package/dist/handlers.js +93 -5
- package/dist/index.js +33 -5
- package/dist/json-body.js +1 -1
- package/dist/listen.js +16 -64
- package/package.json +1 -1
- package/dist/entrypoint.test.js +0 -43
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
|
|
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
|
|
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
|
|
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-
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* `
|
|
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
|
|
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
|
|
282
|
-
//
|
|
283
|
-
//
|
|
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
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
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` (
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
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 =
|
|
750
|
-
|
|
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
|
-
*
|
|
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
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
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.
|
|
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
|
|
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\"|\"
|
|
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 "
|
|
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
|
|
18
|
+
* - `{type:"stopped", reason, detail, refusal?}` — once, last. `reason`
|
|
19
19
|
* is the dashboard's own end reason (`superseded`, `replaced`, `revoked`,
|
|
20
|
-
* `
|
|
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. `
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
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)
|
|
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
|
|
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.
|
|
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",
|
package/dist/entrypoint.test.js
DELETED
|
@@ -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
|
-
});
|