@thehammer/danx-dashboard-mcp 0.1.86 → 0.1.88
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 +2 -2
- package/dist/bridge.js +140 -174
- package/dist/handlers.js +1 -85
- package/dist/index.js +13 -58
- package/dist/listen.js +15 -256
- package/package.json +1 -1
- package/dist/json-body.js +0 -23
- package/dist/plan-state.js +0 -194
package/README.md
CHANGED
|
@@ -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
|
-
- **
|
|
63
|
+
- **Reach check.** After minting, `bridge` proves the credential can read every board the connected plan's cards live on (`GET /api/plans/mine?fields=cards`, then `GET /api/issues/:id/problems` for one card per board — the routes that run the same board-allowlist + `read` check the stream itself applies). A board it cannot read stops it with `board_unreadable` rather than streaming on and dropping that board's events. Then, once, `{"type":"ready","boards":[…]}`.
|
|
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
|
|
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`, `unauthorized` (401/403), `mint_refused` (any other non-transient refusal), `mint_bad_response`, `board_unreadable` / `scope_check_failed` (the reach check above), `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). 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,59 +20,29 @@
|
|
|
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.
|
|
24
|
-
*
|
|
25
|
-
* issuer's board allowlist covers that
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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. VERIFIES THE REACH IT JUST BOUGHT: the credential must be able to read
|
|
24
|
+
* every board the connected plan's cards live on, because the stream admits
|
|
25
|
+
* an event only when the ticket issuer's board allowlist covers that
|
|
26
|
+
* event's board (`isVisibleToStream`, dashboard). A board it cannot read is
|
|
27
|
+
* a board whose events would vanish without a word, so it is a terminal,
|
|
28
|
+
* named outcome instead.
|
|
42
29
|
* 5. Streams through `runListener` (`listen.ts`), which reconnects within the
|
|
43
30
|
* ticket, and re-mints when that ticket's lease runs out or a fresh ticket
|
|
44
31
|
* is refused once.
|
|
45
|
-
* 6. Writes JSON Lines to stdout: ONE `{type:"ready"}` once
|
|
46
|
-
*
|
|
47
|
-
*
|
|
32
|
+
* 6. Writes JSON Lines to stdout: ONE `{type:"ready", boards}` once the stream
|
|
33
|
+
* is live and verified, `{type:"event", id, text}` per event, and ONE final
|
|
34
|
+
* `{type:"stopped", reason, detail, fix}` before exiting.
|
|
48
35
|
*
|
|
49
36
|
* TERMINAL OUTCOMES — each ends the process with its stop record, never a retry:
|
|
50
37
|
* - `no_connection_record` this session has no connection record to read;
|
|
51
38
|
* - `credential_unavailable` its declared credential cannot be resolved here;
|
|
52
39
|
* - `credential_mismatch` what resolved is not the server's credential;
|
|
53
|
-
* - `not_connected` the mint
|
|
54
|
-
*
|
|
55
|
-
* at admission; `end("not_connected")` once live);
|
|
56
|
-
* - `unauthorized` the mint answered 401 / 403 for a reason OTHER
|
|
57
|
-
* than an unreadable board;
|
|
40
|
+
* - `not_connected` the mint answered 409 `session_not_connected`;
|
|
41
|
+
* - `unauthorized` the mint or a scope read answered 401 / 403;
|
|
58
42
|
* - `mint_refused` any other non-transient mint refusal (400, 404, …);
|
|
59
43
|
* - `mint_bad_response` a 2xx mint whose body is not a ticket;
|
|
60
|
-
* - `board_unreadable` the
|
|
61
|
-
*
|
|
62
|
-
* boards this credential cannot read (DX-2920);
|
|
63
|
-
* - `scope_narrowed` a LIVE stream ended because the plan grew, or the
|
|
64
|
-
* credential's allowlist shrank past, a board this
|
|
65
|
-
* credential can no longer read — `boards` names
|
|
66
|
-
* them, parsed from the dashboard's own `end`
|
|
67
|
-
* payload by `listen.ts` the same way `reason`
|
|
68
|
-
* itself is parsed, and `detail`/`fix` are built
|
|
69
|
-
* dynamically from them (DX-2920 round 3 —
|
|
70
|
-
* `boardUnreadableDetail` / `scopeNarrowedFix`),
|
|
71
|
-
* never a static generic message;
|
|
72
|
-
* - `bad_end_payload` a `scope_narrowed` end whose `boards` array was
|
|
73
|
-
* missing or malformed — a PROTOCOL error, never
|
|
74
|
-
* silently downgraded to a boards-less
|
|
75
|
-
* `scope_narrowed` stop (DX-2920 round 3);
|
|
44
|
+
* - `board_unreadable` the credential cannot read a board of this plan;
|
|
45
|
+
* - `scope_check_failed` the plan's boards could not be established;
|
|
76
46
|
* - `superseded` / `replaced` (exit 0) — another listener now holds the session;
|
|
77
47
|
* - `revoked` the dashboard revoked the ticket or its issuer;
|
|
78
48
|
* - `refused` freshly minted tickets were refused twice in a row.
|
|
@@ -84,13 +54,14 @@
|
|
|
84
54
|
* session's own MCP server declared, resolved here from that declaration.
|
|
85
55
|
*/
|
|
86
56
|
import { homedir } from "node:os";
|
|
87
|
-
import { errorCodeOf } from "./json-body.js";
|
|
88
57
|
import { oneLine } from "./one-line.js";
|
|
89
58
|
import { credentialFingerprint, describeCredentialSource, readCredential, } from "./credential.js";
|
|
90
59
|
import { readSessionConnection } from "./session-connection.js";
|
|
91
60
|
import { DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, READ_IDLE_TIMEOUT_MS, runListener, } from "./listen.js";
|
|
92
61
|
export const BRIDGE_SUBCOMMAND = "bridge";
|
|
93
62
|
export const STREAM_TICKET_PATH = "/api/plan-sessions/me/stream-ticket";
|
|
63
|
+
export const PLAN_MINE_PATH = "/api/plans/mine";
|
|
64
|
+
export const ISSUES_PATH = "/api/issues";
|
|
94
65
|
export const SESSION_ID_HEADER = "x-danx-session-id";
|
|
95
66
|
/** How long one dashboard request may take before it counts as a transient failure. */
|
|
96
67
|
export const REQUEST_TIMEOUT_MS = 10_000;
|
|
@@ -98,7 +69,10 @@ export const MINT_INITIAL_BACKOFF_MS = 1_000;
|
|
|
98
69
|
export const MINT_MAX_BACKOFF_MS = 60_000;
|
|
99
70
|
/** A freshly minted ticket refused this many times in a row is a terminal outcome, not a loop. */
|
|
100
71
|
export const MAX_REFUSED_TICKETS_IN_A_ROW = 2;
|
|
72
|
+
/** One page of the connected plan's cards, for the board-reach check. The server's ceiling. */
|
|
73
|
+
export const PLAN_CARDS_PAGE_SIZE = 1_000;
|
|
101
74
|
const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
|
|
75
|
+
const TAKEOVER_REASONS = new Set(["superseded", "replaced"]);
|
|
102
76
|
const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${BRIDGE_SUBCOMMAND} ` +
|
|
103
77
|
`[--resume-ids <event-id>[,<event-id>...]]`;
|
|
104
78
|
/**
|
|
@@ -121,11 +95,7 @@ export const STOP_FIXES = {
|
|
|
121
95
|
mint_refused: "check that this session is connected to a plan on the dashboard its MCP server points at, then call plan_connect again",
|
|
122
96
|
mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a danxbot dashboard",
|
|
123
97
|
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",
|
|
124
|
-
|
|
125
|
-
// missing or invalid (`listen.ts`'s parse-time check). Named separately
|
|
126
|
-
// from `scope_narrowed` itself, which always carries boards and never
|
|
127
|
-
// reaches this generic map at all.
|
|
128
|
-
bad_end_payload: "call plan_connect again to mint a new listener ticket for this session",
|
|
98
|
+
scope_check_failed: "check the dashboard is reachable and this session is connected to a plan, then call plan_connect again",
|
|
129
99
|
// A newer ticket exists (superseded) or another connection is already using
|
|
130
100
|
// this one (replaced) — THIS process is ending because the session is
|
|
131
101
|
// already served elsewhere, not because anything is broken.
|
|
@@ -157,11 +127,6 @@ export function parseBridgeArgs(argv, env) {
|
|
|
157
127
|
}
|
|
158
128
|
/** A start this process cannot make, named the way the session will be told about it. */
|
|
159
129
|
export class BridgeStartError extends Error {
|
|
160
|
-
// DX-2920 (round 4) — `ListenStopReason`, not `string`: a start-time failure
|
|
161
|
-
// is always one of `no_connection_record` / `credential_unavailable` /
|
|
162
|
-
// `credential_mismatch`, never `scope_narrowed` (there is no stream yet), so
|
|
163
|
-
// this can be the closed reason type the eventual `write({type:"stopped",
|
|
164
|
-
// reason: start.reason, ...})` needs it to be.
|
|
165
130
|
reason;
|
|
166
131
|
fix;
|
|
167
132
|
constructor(reason, detail, fix) {
|
|
@@ -210,43 +175,15 @@ export function resolveBridgeOptions(args, env, options = {}) {
|
|
|
210
175
|
resumeIds: args.resumeIds,
|
|
211
176
|
};
|
|
212
177
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
*/
|
|
218
|
-
function boardsOf(body) {
|
|
219
|
-
if (typeof body !== "object" || body === null)
|
|
220
|
-
return [];
|
|
221
|
-
const boards = body.boards;
|
|
222
|
-
return Array.isArray(boards) ? boards.filter((b) => typeof b === "string") : [];
|
|
223
|
-
}
|
|
224
|
-
/**
|
|
225
|
-
* DX-2920 — the ONE `board_unreadable` detail-line builder, shared by the mint
|
|
226
|
-
* 403 classification and the stream admission's identical 403, so the two can
|
|
227
|
-
* never drift into two different ideas of how to phrase "these boards".
|
|
228
|
-
*/
|
|
229
|
-
function boardUnreadableDetail(boards, fallback) {
|
|
230
|
-
return boards.length > 0
|
|
231
|
-
? `this session's dashboard credential cannot read board(s) ${boards.join(", ")} on the connected plan`
|
|
232
|
-
: fallback;
|
|
233
|
-
}
|
|
234
|
-
/**
|
|
235
|
-
* DX-2920 (round 3) — `scope_narrowed`'s fix, built dynamically from the
|
|
236
|
-
* boards `listen.ts` parsed off the dashboard's own `end` payload (AC 30661:
|
|
237
|
-
* "naming the missing boards and how to widen scope"), replacing the static
|
|
238
|
-
* generic `STOP_FIXES` entry round 2 shipped before the server actually sent
|
|
239
|
-
* a board list.
|
|
240
|
-
*/
|
|
241
|
-
function scopeNarrowedFix(boards) {
|
|
242
|
-
return boards.length > 0
|
|
243
|
-
? `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`
|
|
244
|
-
: "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";
|
|
178
|
+
function errorCodeOf(body) {
|
|
179
|
+
return typeof body === "object" && body !== null && typeof body.error === "string"
|
|
180
|
+
? body.error
|
|
181
|
+
: null;
|
|
245
182
|
}
|
|
246
183
|
/**
|
|
247
184
|
* One authenticated request, with a timeout, classifying the failures that are
|
|
248
|
-
* about THIS MOMENT (network, timeout, 408/429, 5xx) as transient. Shared by
|
|
249
|
-
*
|
|
185
|
+
* about THIS MOMENT (network, timeout, 408/429, 5xx) as transient. Shared by the
|
|
186
|
+
* mint and the board-reach check so the two can never disagree about which
|
|
250
187
|
* failures are worth retrying.
|
|
251
188
|
*/
|
|
252
189
|
async function request(options, deps, what, url, init = { method: "GET" }) {
|
|
@@ -322,19 +259,6 @@ export async function mintTicket(options, deps) {
|
|
|
322
259
|
if (status === 409 && errorCodeOf(body) === "session_not_connected") {
|
|
323
260
|
return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
|
|
324
261
|
}
|
|
325
|
-
// DX-2920 (AC 30660) — this credential IS accepted; it just cannot read every
|
|
326
|
-
// board the connected plan covers (`unreadableBoardsRefusal`, dashboard
|
|
327
|
-
// `plan-scope.ts`). Calling that `unauthorized` told the session to fix its
|
|
328
|
-
// credential, which was the wrong remedy for a scope problem — this is
|
|
329
|
-
// `board_unreadable`, naming the boards, checked BEFORE the generic 401/403
|
|
330
|
-
// fallback below so it never falls into it.
|
|
331
|
-
if (status === 403 && errorCodeOf(body) === "issuer_cannot_read_plan_boards") {
|
|
332
|
-
return {
|
|
333
|
-
kind: "terminal",
|
|
334
|
-
reason: "board_unreadable",
|
|
335
|
-
detail: boardUnreadableDetail(boardsOf(body), `ticket mint HTTP 403: issuer_cannot_read_plan_boards ${oneLine(text, 300)}`),
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
262
|
if (status === 401 || status === 403) {
|
|
339
263
|
return { kind: "terminal", reason: "unauthorized", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
|
|
340
264
|
}
|
|
@@ -353,60 +277,121 @@ export async function mintTicket(options, deps) {
|
|
|
353
277
|
leaseMs: ticket.leaseMs,
|
|
354
278
|
};
|
|
355
279
|
}
|
|
280
|
+
/** One card per board of the connected plan, read a page at a time. */
|
|
281
|
+
function cardsPageOf(body) {
|
|
282
|
+
if (typeof body !== "object" || body === null)
|
|
283
|
+
return "the body is not a JSON object";
|
|
284
|
+
const b = body;
|
|
285
|
+
if (!Array.isArray(b.cards))
|
|
286
|
+
return "cards is not an array";
|
|
287
|
+
if (typeof b.cards_total !== "number" || !Number.isSafeInteger(b.cards_total))
|
|
288
|
+
return "cards_total is not an integer";
|
|
289
|
+
const cards = [];
|
|
290
|
+
for (const card of b.cards) {
|
|
291
|
+
if (typeof card !== "object" || card === null)
|
|
292
|
+
return "a card is not an object";
|
|
293
|
+
const c = card;
|
|
294
|
+
if (typeof c.id !== "string" || typeof c.boardId !== "string")
|
|
295
|
+
return "a card has no id / boardId";
|
|
296
|
+
cards.push({ id: c.id, boardId: c.boardId });
|
|
297
|
+
}
|
|
298
|
+
return { cards, total: b.cards_total };
|
|
299
|
+
}
|
|
356
300
|
/**
|
|
357
|
-
*
|
|
358
|
-
* outcome — `{status, errorCode, body}`, parsed once in `connectOnce`) whose
|
|
359
|
-
* shape matches one the mint route already gives its OWN name to. Two cases:
|
|
301
|
+
* Prove this credential can read every board the connected plan's cards live on.
|
|
360
302
|
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
* board-coverage refusal uses (AC 30660's fix 4 — a ticket minted before a
|
|
368
|
-
* board became unreadable can still be refused ADMISSION for it later; that
|
|
369
|
-
* refusal deserves the same `board_unreadable` naming as the mint-time one,
|
|
370
|
-
* not a re-mint-and-retry loop that just hits the identical refusal again).
|
|
303
|
+
* WHY THIS EXISTS AT ALL. The stream admits an event only when the ticket
|
|
304
|
+
* issuer's board allowlist covers the event's board and the issuer still holds
|
|
305
|
+
* `read` (`isVisibleToStream` / `resolveIssuerScope`, dashboard). A credential
|
|
306
|
+
* short of that streams exactly as happily as a correct one and simply never
|
|
307
|
+
* relays those boards' events — silence that looks identical to "nothing has
|
|
308
|
+
* happened yet".
|
|
371
309
|
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
310
|
+
* WHY THESE TWO ROUTES. `GET /api/plans/mine?fields=cards` is the only read that
|
|
311
|
+
* names the connected plan's cards, and `GET /api/issues/:id/problems` is a card
|
|
312
|
+
* read that runs the SAME board-allowlist + `read` check the stream does
|
|
313
|
+
* (`withV2Handler` + `resolveIssueBoardId`). Deliberately NOT `GET /api/issues`
|
|
314
|
+
* or `GET /api/issues/:id`: neither checks the caller's board allowlist at all,
|
|
315
|
+
* so a check built on them would pass precisely where the stream drops events.
|
|
375
316
|
*/
|
|
376
|
-
function
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
317
|
+
export async function verifyPlanBoardsReadable(options, deps) {
|
|
318
|
+
const firstCardPerBoard = new Map();
|
|
319
|
+
let offset = 0;
|
|
320
|
+
for (;;) {
|
|
321
|
+
const url = `${options.dashboardUrl}${PLAN_MINE_PATH}?fields=cards&cards_limit=${PLAN_CARDS_PAGE_SIZE}&cards_offset=${offset}`;
|
|
322
|
+
const outcome = await request(options, deps, "plan read", url);
|
|
323
|
+
if (outcome.kind === "transient")
|
|
324
|
+
return outcome;
|
|
325
|
+
const { status, text, body } = outcome;
|
|
326
|
+
if (status === 409 && errorCodeOf(body) === "session_not_connected") {
|
|
327
|
+
return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
|
|
328
|
+
}
|
|
329
|
+
if (status === 401 || status === 403) {
|
|
330
|
+
return { kind: "terminal", reason: "unauthorized", detail: `plan read HTTP ${status} ${oneLine(text, 300)}` };
|
|
331
|
+
}
|
|
332
|
+
if (status < 200 || status >= 300) {
|
|
333
|
+
return { kind: "terminal", reason: "scope_check_failed", detail: `plan read HTTP ${status} ${oneLine(text, 300)}` };
|
|
334
|
+
}
|
|
335
|
+
const page = cardsPageOf(body);
|
|
336
|
+
if (typeof page === "string") {
|
|
337
|
+
return { kind: "terminal", reason: "scope_check_failed", detail: `plan read HTTP ${status}: ${page}` };
|
|
338
|
+
}
|
|
339
|
+
for (const card of page.cards) {
|
|
340
|
+
if (!firstCardPerBoard.has(card.boardId))
|
|
341
|
+
firstCardPerBoard.set(card.boardId, card.id);
|
|
342
|
+
}
|
|
343
|
+
offset += page.cards.length;
|
|
344
|
+
if (page.cards.length === 0 || offset >= page.total)
|
|
345
|
+
break;
|
|
381
346
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
347
|
+
for (const [boardId, cardId] of firstCardPerBoard) {
|
|
348
|
+
// `?board=` is not read by this route's auth check (it derives the board from
|
|
349
|
+
// `cardId`'s own `board_id` — `resolveIssueBoardId`) — kept only so the URL and
|
|
350
|
+
// the `board read of ${boardId}` label above it name the same board at a glance.
|
|
351
|
+
const url = `${options.dashboardUrl}${ISSUES_PATH}/${encodeURIComponent(cardId)}/problems?board=${encodeURIComponent(boardId)}`;
|
|
352
|
+
const outcome = await request(options, deps, `board read of ${boardId}`, url);
|
|
353
|
+
if (outcome.kind === "transient")
|
|
354
|
+
return outcome;
|
|
355
|
+
const { status, text } = outcome;
|
|
356
|
+
if (status === 403) {
|
|
357
|
+
return {
|
|
358
|
+
kind: "terminal",
|
|
359
|
+
reason: "board_unreadable",
|
|
360
|
+
detail: `this session's dashboard credential cannot read board ${boardId}, which the connected plan's card ` +
|
|
361
|
+
`${cardId} lives on — the event stream drops that board's events silently (${oneLine(text, 200)})`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (status === 401) {
|
|
365
|
+
return { kind: "terminal", reason: "unauthorized", detail: `board read of ${boardId} HTTP 401 ${oneLine(text, 200)}` };
|
|
366
|
+
}
|
|
367
|
+
if (status === 404) {
|
|
368
|
+
// The card moved or was deleted between the two reads. Nothing is wrong
|
|
369
|
+
// with the credential — re-read the plan rather than accuse it.
|
|
370
|
+
return { kind: "transient", detail: `card ${cardId} vanished between the plan read and the board read` };
|
|
371
|
+
}
|
|
372
|
+
if (status < 200 || status >= 300) {
|
|
373
|
+
return {
|
|
374
|
+
kind: "terminal",
|
|
375
|
+
reason: "scope_check_failed",
|
|
376
|
+
detail: `board read of ${boardId} HTTP ${status} ${oneLine(text, 200)}`,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
387
379
|
}
|
|
388
|
-
return
|
|
380
|
+
return { kind: "ok", boards: [...firstCardPerBoard.keys()] };
|
|
389
381
|
}
|
|
390
382
|
/**
|
|
391
|
-
* Mint, stream, re-mint — until a terminal outcome, which it writes as
|
|
392
|
-
* `stopped` record. Returns 0 when another listener took over, 1 otherwise.
|
|
383
|
+
* Mint, verify, stream, re-mint — until a terminal outcome, which it writes as
|
|
384
|
+
* the one `stopped` record. Returns 0 when another listener took over, 1 otherwise.
|
|
393
385
|
*/
|
|
394
386
|
export async function runBridge(options, deps) {
|
|
395
387
|
const delivered = options.resumeIds.slice(-DELIVERED_ID_MEMORY);
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
if (extra?.boards === undefined) {
|
|
399
|
-
throw new Error('stop("scope_narrowed", ...) requires boards — unreachable through the exported overloads above');
|
|
400
|
-
}
|
|
401
|
-
deps.write({ type: "stopped", reason, detail, boards: extra.boards, fix: scopeNarrowedFix(extra.boards) });
|
|
402
|
-
return code;
|
|
403
|
-
}
|
|
404
|
-
deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal, fix: extra?.fix ?? fixForStop(reason) });
|
|
388
|
+
const stop = (reason, detail, code) => {
|
|
389
|
+
deps.write({ type: "stopped", reason, detail, fix: fixForStop(reason) });
|
|
405
390
|
return code;
|
|
406
|
-
}
|
|
391
|
+
};
|
|
407
392
|
let backoff = MINT_INITIAL_BACKOFF_MS;
|
|
408
393
|
let refusedInRow = 0;
|
|
409
|
-
let
|
|
394
|
+
let verified = false;
|
|
410
395
|
for (;;) {
|
|
411
396
|
const minted = await mintTicket(options, deps);
|
|
412
397
|
if (minted.kind === "terminal")
|
|
@@ -416,13 +401,19 @@ export async function runBridge(options, deps) {
|
|
|
416
401
|
backoff = Math.min(MINT_MAX_BACKOFF_MS, backoff * 2);
|
|
417
402
|
continue;
|
|
418
403
|
}
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
404
|
+
// The reach check runs on the FIRST ticket only: it is a property of the
|
|
405
|
+
// credential and the plan, and a re-mint (a lapsed lease) changes neither.
|
|
406
|
+
if (!verified) {
|
|
407
|
+
const scope = await verifyPlanBoardsReadable(options, deps);
|
|
408
|
+
if (scope.kind === "terminal")
|
|
409
|
+
return stop(scope.reason, scope.detail, 1);
|
|
410
|
+
if (scope.kind === "transient") {
|
|
411
|
+
await deps.sleep(backoff);
|
|
412
|
+
backoff = Math.min(MINT_MAX_BACKOFF_MS, backoff * 2);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
verified = true;
|
|
416
|
+
deps.write({ type: "ready", boards: scope.boards });
|
|
426
417
|
}
|
|
427
418
|
backoff = MINT_INITIAL_BACKOFF_MS;
|
|
428
419
|
const run = { stopped: null, emitted: false };
|
|
@@ -446,28 +437,12 @@ export async function runBridge(options, deps) {
|
|
|
446
437
|
const stopped = run.stopped;
|
|
447
438
|
if (stopped === null)
|
|
448
439
|
throw new Error("the stream reader ended without a stop record");
|
|
449
|
-
|
|
450
|
-
// `Set.has()` is a runtime check only, so it cannot narrow `stopped.reason`
|
|
451
|
-
// for the compiler — leaving it a `Set` lookup here would keep every
|
|
452
|
-
// downstream branch's exhaustive narrowing (which is what makes the final
|
|
453
|
-
// fallthrough's `stop(stopped.reason, ...)` call type-check against the
|
|
454
|
-
// closed `ListenStopReason` overload) from working.
|
|
455
|
-
if (stopped.reason === "superseded" || stopped.reason === "replaced")
|
|
440
|
+
if (TAKEOVER_REASONS.has(stopped.reason))
|
|
456
441
|
return stop(stopped.reason, stopped.detail, 0);
|
|
457
442
|
if (stopped.reason === "lease_expired") {
|
|
458
443
|
refusedInRow = 0;
|
|
459
444
|
continue;
|
|
460
445
|
}
|
|
461
|
-
if (stopped.reason === "refused") {
|
|
462
|
-
const classified = classifyAdmissionRefusal(stopped.refusal);
|
|
463
|
-
if (classified !== null) {
|
|
464
|
-
// DX-2920 — a NAMED admission refusal is terminal at once, exactly
|
|
465
|
-
// like its mint-time counterpart — never counted toward the generic
|
|
466
|
-
// refused-ticket retry budget below, which exists for an admission
|
|
467
|
-
// refusal with no more specific explanation than "not admitted".
|
|
468
|
-
return stop(classified.reason, classified.detail, 1);
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
446
|
if (stopped.reason === "refused") {
|
|
472
447
|
const provedHealthy = run.emitted || deps.now() - startedAt >= HEALTHY_CONNECTION_MS;
|
|
473
448
|
refusedInRow = provedHealthy ? 1 : refusedInRow + 1;
|
|
@@ -476,15 +451,6 @@ export async function runBridge(options, deps) {
|
|
|
476
451
|
}
|
|
477
452
|
continue;
|
|
478
453
|
}
|
|
479
|
-
if (stopped.reason === "scope_narrowed") {
|
|
480
|
-
// DX-2920 (round 4) — `stopped.boards` is REQUIRED at the type level now
|
|
481
|
-
// (`ListenStopped`'s `scope_narrowed` member), populated by `listen.ts`'s
|
|
482
|
-
// parse of the dashboard's own `end` payload (never a re-derivation from
|
|
483
|
-
// `detail`) — no `?? []`, no optional check, `stopped.boards` IS
|
|
484
|
-
// `string[]` once narrowed to this branch.
|
|
485
|
-
const boards = stopped.boards;
|
|
486
|
-
return stop("scope_narrowed", boardUnreadableDetail(boards, stopped.detail), 1, { boards });
|
|
487
|
-
}
|
|
488
454
|
return stop(stopped.reason, stopped.detail, 1);
|
|
489
455
|
}
|
|
490
456
|
}
|
package/dist/handlers.js
CHANGED
|
@@ -392,11 +392,7 @@ export async function issueComment(client, args) {
|
|
|
392
392
|
return client.request({
|
|
393
393
|
method: "POST",
|
|
394
394
|
path: `/${idEnc}/comments`,
|
|
395
|
-
body: {
|
|
396
|
-
text,
|
|
397
|
-
...(args.metadata !== undefined ? { metadata: args.metadata } : {}),
|
|
398
|
-
...(args.problem_id !== undefined ? { problem_id: args.problem_id } : {}),
|
|
399
|
-
},
|
|
395
|
+
body: { text, ...(args.metadata !== undefined ? { metadata: args.metadata } : {}) },
|
|
400
396
|
board,
|
|
401
397
|
});
|
|
402
398
|
}
|
|
@@ -848,7 +844,6 @@ export const PLAN_FIELD_GROUPS = [
|
|
|
848
844
|
"records:caveat",
|
|
849
845
|
"architecture",
|
|
850
846
|
"sessions",
|
|
851
|
-
"notes",
|
|
852
847
|
];
|
|
853
848
|
/**
|
|
854
849
|
* DX-2834 — the plan-status taxonomy, mirroring `PLAN_FIELD_GROUPS` just
|
|
@@ -1152,85 +1147,6 @@ export async function planDeleteRecord(client, args) {
|
|
|
1152
1147
|
body: { content_hash: args.content_hash },
|
|
1153
1148
|
});
|
|
1154
1149
|
}
|
|
1155
|
-
/**
|
|
1156
|
-
* Write a milestone note to a plan, via `POST /api/plans/:plan_id/notes`.
|
|
1157
|
-
* TAKES AN EXPLICIT `plan_id`, unlike `plan_add_record`/`plan_add_architecture_section`
|
|
1158
|
-
* — mirrors `plan_add_card`'s sibling `plan_remove_card`/`plan_rename`: a
|
|
1159
|
-
* dispatched worker has no plan connection when it finishes a card, and a
|
|
1160
|
-
* card can sit on several plans, so the writer names which one the note
|
|
1161
|
-
* belongs to. A note is a MILESTONE, not a log — write one when a card (or a
|
|
1162
|
-
* related group of cards) finishes, an important decision lands, or a
|
|
1163
|
-
* goal/rule/caveat/architecture section changes meaningfully; routine step
|
|
1164
|
-
* progress stays a card comment. Links resolve on read into what they point
|
|
1165
|
-
* at (a card's title, a record's ref + body, a section's title) — an unknown
|
|
1166
|
-
* card id, an unparseable or foreign record ref, or an unknown or foreign
|
|
1167
|
-
* section id is refused 400 naming exactly which one. A card link does NOT
|
|
1168
|
-
* require the card to be a member of this plan; a record/section link MUST
|
|
1169
|
-
* belong to THIS plan. `author` is stamped server-side from your identity,
|
|
1170
|
-
* never sent by you. Unknown plan → 404. Returns the new note plus the
|
|
1171
|
-
* plan's latest notes page.
|
|
1172
|
-
*/
|
|
1173
|
-
export async function planAddNote(client, args) {
|
|
1174
|
-
return client.request({
|
|
1175
|
-
method: "POST",
|
|
1176
|
-
path: `/${args.plan_id}/notes`,
|
|
1177
|
-
basePath: PLANS_BASE_PATH,
|
|
1178
|
-
body: {
|
|
1179
|
-
title: args.title,
|
|
1180
|
-
body: args.body,
|
|
1181
|
-
...(args.card_ids === undefined ? {} : { card_ids: args.card_ids }),
|
|
1182
|
-
...(args.record_refs === undefined ? {} : { record_refs: args.record_refs }),
|
|
1183
|
-
...(args.section_ids === undefined ? {} : { section_ids: args.section_ids }),
|
|
1184
|
-
},
|
|
1185
|
-
});
|
|
1186
|
-
}
|
|
1187
|
-
/**
|
|
1188
|
-
* Edit a plan note, via `PATCH /api/plans/:plan_id/notes/:note_id`.
|
|
1189
|
-
* `content_hash` MUST be the note's `contentHash` from your last read; on a
|
|
1190
|
-
* mismatch nothing is written and you get `{error: "stale_plan_note",
|
|
1191
|
-
* currentHash, currentTitle, currentBody, currentLinks}` — merge into those
|
|
1192
|
-
* and retry with `content_hash: currentHash`, never blindly. `title`/`body`
|
|
1193
|
-
* are each optional and keep their stored value when omitted. The LINK
|
|
1194
|
-
* fields are all-or-nothing as a GROUP: omit all three to keep the stored
|
|
1195
|
-
* link set untouched; send ANY one of them to REPLACE THE WHOLE SET (never a
|
|
1196
|
-
* per-link add/remove — the set is the unit of change). The hash covers
|
|
1197
|
-
* title, body AND the link set, so a stale read of any of the three is
|
|
1198
|
-
* refused. TAKES AN EXPLICIT `plan_id`, same reason as `plan_add_note`.
|
|
1199
|
-
* Unknown plan or note id → 404. Returns the edited note plus the plan's
|
|
1200
|
-
* latest notes page.
|
|
1201
|
-
*/
|
|
1202
|
-
export async function planUpdateNote(client, args) {
|
|
1203
|
-
return client.request({
|
|
1204
|
-
method: "PATCH",
|
|
1205
|
-
path: `/${args.plan_id}/notes/${args.note_id}`,
|
|
1206
|
-
basePath: PLANS_BASE_PATH,
|
|
1207
|
-
body: {
|
|
1208
|
-
content_hash: args.content_hash,
|
|
1209
|
-
...(args.title === undefined ? {} : { title: args.title }),
|
|
1210
|
-
...(args.body === undefined ? {} : { body: args.body }),
|
|
1211
|
-
...(args.card_ids === undefined ? {} : { card_ids: args.card_ids }),
|
|
1212
|
-
...(args.record_refs === undefined ? {} : { record_refs: args.record_refs }),
|
|
1213
|
-
...(args.section_ids === undefined ? {} : { section_ids: args.section_ids }),
|
|
1214
|
-
},
|
|
1215
|
-
});
|
|
1216
|
-
}
|
|
1217
|
-
/**
|
|
1218
|
-
* Soft-delete a plan note, via `DELETE /api/plans/:plan_id/notes/:note_id`.
|
|
1219
|
-
* `content_hash` MUST be the note's `contentHash` from your last read; a
|
|
1220
|
-
* stale hash deletes nothing and returns the same `{error:
|
|
1221
|
-
* "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}`
|
|
1222
|
-
* shape `plan_update_note` uses. TAKES AN EXPLICIT `plan_id`, same reason as
|
|
1223
|
-
* `plan_add_note`. Unknown plan, unknown note, or an already-deleted note →
|
|
1224
|
-
* 404. Returns the plan's remaining latest notes page.
|
|
1225
|
-
*/
|
|
1226
|
-
export async function planDeleteNote(client, args) {
|
|
1227
|
-
return client.request({
|
|
1228
|
-
method: "DELETE",
|
|
1229
|
-
path: `/${args.plan_id}/notes/${args.note_id}`,
|
|
1230
|
-
basePath: PLANS_BASE_PATH,
|
|
1231
|
-
body: { content_hash: args.content_hash },
|
|
1232
|
-
});
|
|
1233
|
-
}
|
|
1234
1150
|
// ---------------- failure_category_list / _create / _update (DX-2792) ----------------
|
|
1235
1151
|
/**
|
|
1236
1152
|
* DX-2792 (Failure evaluation 3/4) — wraps `src/dashboard/failure-categories-routes.ts`,
|
package/dist/index.js
CHANGED
|
@@ -94,14 +94,13 @@
|
|
|
94
94
|
*/
|
|
95
95
|
import { isEntrypointModule } from "./entrypoint.js";
|
|
96
96
|
import { BRIDGE_SUBCOMMAND, runBridgeCommand } from "./bridge.js";
|
|
97
|
-
import { PLAN_STATE_SUBCOMMAND, runPlanStateCommand } from "./plan-state.js";
|
|
98
97
|
import { resolveDeclaredCredential } from "./credential.js";
|
|
99
98
|
import { recordSessionConnectionAfterConnect } from "./session-connection.js";
|
|
100
99
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
101
100
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
102
101
|
import { z } from "zod";
|
|
103
102
|
import { DashboardHttpClient } from "./http-client.js";
|
|
104
|
-
import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, planAddArchitectureSection, planAddCard,
|
|
103
|
+
import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, planAddArchitectureSection, planAddCard, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, PLAN_STATUSES, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
|
|
105
104
|
import { PRIORITY_TIER_WORDS } from "./priority.js";
|
|
106
105
|
function readEnvOrDie(name) {
|
|
107
106
|
const v = process.env[name];
|
|
@@ -504,13 +503,12 @@ server.tool("issue_triage", "Record a triage confidence score via POST /api/issu
|
|
|
504
503
|
...boardField,
|
|
505
504
|
}, async (args) => jsonResult(await issueTriage(client, args)));
|
|
506
505
|
// ---------------- issue_comment ----------------
|
|
507
|
-
server.tool("issue_comment", "Comment CRUD via /api/issues/:id/comments[/:cid]. action=add → POST {text, metadata
|
|
506
|
+
server.tool("issue_comment", "Comment CRUD via /api/issues/:id/comments[/:cid]. action=add → POST {text, metadata?} (server stamps author from bearer + auto-incrementing ordinal); action=edit → PATCH /:cid {text}; action=delete → DELETE /:cid (soft-delete, audit trail preserved — comments are NEVER hard-deleted). Client-supplied author is IGNORED (server-stamped to prevent impersonation). `metadata` (DX-2157, action=add only) is an OPTIONAL opaque JSON object a calling app attaches to the comment — e.g. a generated `{sql, explanation}` packet its own UI renders specially. danxbot stores + returns it verbatim and enforces NO shape on its contents; omit for a plain markdown-only comment (unaffected either way).", {
|
|
508
507
|
id: z.string().min(1),
|
|
509
508
|
action: z.enum(["add", "edit", "delete"]),
|
|
510
509
|
comment_id: z.number().int().positive().optional(),
|
|
511
510
|
text: z.string().min(1).optional(),
|
|
512
511
|
metadata: z.record(z.unknown()).optional(),
|
|
513
|
-
problem_id: z.number().int().positive().optional().describe("action=add only — thread this comment as a follow-up under a LIVE problem of this same card, without answering it"),
|
|
514
512
|
...boardField,
|
|
515
513
|
}, async (args) => jsonResult(await issueComment(client, args)));
|
|
516
514
|
// ---------------- issue_checklist ----------------
|
|
@@ -794,32 +792,6 @@ server.tool("plan_delete_record", 'Soft-delete a goal/rule/caveat of your connec
|
|
|
794
792
|
record_id: z.number().int().positive().describe("The record id to delete."),
|
|
795
793
|
content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
|
|
796
794
|
}, async (args) => jsonResult(await planDeleteRecord(client, args)));
|
|
797
|
-
server.tool("plan_add_note", "Write a milestone note to a plan's timeline, via POST /api/plans/:plan_id/notes (DX-2915). A note is a MILESTONE, not a log — write one for a card (or related group of cards) finishing, an important decision, or a meaningful goal/rule/caveat/architecture-section change; routine step progress stays a card comment, never a note. Terse tone: `title` at most 60 characters, `body` (the wrap-up) at most 250 (both 400 if too long, naming the limit and actual length). Links resolve on read into what they point at (a card's title, a record's ref+body, a section's title): an unknown card, an unparseable or foreign record ref, or an unknown or foreign section id is refused 400 naming exactly which one. A card link does NOT require the card to be a member of this plan; a record/section link MUST belong to THIS plan. `author` is stamped from your identity server-side — there is no field for it. TAKES AN EXPLICIT `plan_id` (like `plan_remove_card`/`plan_rename`), so a dispatched worker with no plan connection can still write. Unknown plan → 404. Returns the new note plus the plan's latest notes page.", {
|
|
798
|
-
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
799
|
-
title: z.string().min(1).describe("At most 60 characters."),
|
|
800
|
-
body: z.string().min(1).describe("The wrap-up, at most 250 characters."),
|
|
801
|
-
card_ids: z.array(z.string().min(1)).optional().describe("Card ids this note concerns, e.g. `[\"DX-2894\"]`."),
|
|
802
|
-
record_refs: z
|
|
803
|
-
.array(z.string().min(1))
|
|
804
|
-
.optional()
|
|
805
|
-
.describe("Goal/rule/caveat references this note announces, e.g. `[\"G-1\", \"R-3\", \"CAV-2\"]`."),
|
|
806
|
-
section_ids: z.array(z.number().int().positive()).optional().describe("Architecture section ids this note announces."),
|
|
807
|
-
}, async (args) => jsonResult(await planAddNote(client, args)));
|
|
808
|
-
server.tool("plan_update_note", 'Edit a plan note, via PATCH /api/plans/:plan_id/notes/:note_id (DX-2915). `content_hash` MUST be the note\'s `contentHash` from your last read; a mismatch writes NOTHING and returns `{error: "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}` — merge into those and retry with `content_hash: currentHash`, never blindly. `title`/`body` are each optional and keep their stored value when omitted. The link fields (`card_ids`/`record_refs`/`section_ids`) are all-or-nothing AS A GROUP: omit all three to leave the stored link set untouched; send ANY one of them to REPLACE THE WHOLE SET — there is no per-link add/remove. The hash covers title, body AND the link set. TAKES AN EXPLICIT `plan_id`, same reason as `plan_add_note`. Unknown plan or note id → 404. Returns the edited note plus the plan\'s latest notes page.', {
|
|
809
|
-
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
810
|
-
note_id: z.number().int().positive().describe("The note id to edit."),
|
|
811
|
-
content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
|
|
812
|
-
title: z.string().min(1).optional().describe("New title, at most 60 characters. Omit to keep the stored title."),
|
|
813
|
-
body: z.string().min(1).optional().describe("New wrap-up, at most 250 characters. Omit to keep the stored body."),
|
|
814
|
-
card_ids: z.array(z.string().min(1)).optional().describe("REPLACES the whole link set when sent (with record_refs/section_ids)."),
|
|
815
|
-
record_refs: z.array(z.string().min(1)).optional().describe("REPLACES the whole link set when sent (with card_ids/section_ids)."),
|
|
816
|
-
section_ids: z.array(z.number().int().positive()).optional().describe("REPLACES the whole link set when sent (with card_ids/record_refs)."),
|
|
817
|
-
}, async (args) => jsonResult(await planUpdateNote(client, args)));
|
|
818
|
-
server.tool("plan_delete_note", 'Soft-delete a plan note, via DELETE /api/plans/:plan_id/notes/:note_id (DX-2915). `content_hash` must be the note\'s `contentHash` from your last read; a stale hash deletes nothing and returns `{error: "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}` — the same shape `plan_update_note` uses. TAKES AN EXPLICIT `plan_id`, same reason as `plan_add_note`. Unknown plan, unknown note, or an already-deleted note → 404. Returns the plan\'s remaining latest notes page.', {
|
|
819
|
-
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
820
|
-
note_id: z.number().int().positive().describe("The note id to delete."),
|
|
821
|
-
content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
|
|
822
|
-
}, async (args) => jsonResult(await planDeleteNote(client, args)));
|
|
823
795
|
server.tool("plan_add_card", "Add an existing card to the plan this session is connected to, via POST /api/plans/mine/cards (DX-2683). The card may live on ANY board — that is what a plan is for. Idempotent: re-adding a card already on the plan is a no-op, not an error, and a card may sit in several plans at once. This adds MEMBERSHIP only; it never edits the card. TAKES NO PLAN ID: the plan is resolved from your connected session. Not connected → `{error: \"session_not_connected\"}`. Unknown card → 404. Returns the plan's full member list.", {
|
|
824
796
|
card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
|
|
825
797
|
}, async (args) => jsonResult(await planAddCard(client, args)));
|
|
@@ -909,25 +881,16 @@ async function main() {
|
|
|
909
881
|
// symlink-aware (DX-1647) so it holds under the symlinked `npx` bin the worker
|
|
910
882
|
// spawns, not just a direct `node dist/index.js`.
|
|
911
883
|
//
|
|
912
|
-
// `bridge`
|
|
913
|
-
//
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
// `GET /api/plan-sessions/me/turn-state`, run by the DX-2888 plugin's
|
|
923
|
-
// turn-start / turn-end hooks — see `plan-state.ts`'s own doc comment for the
|
|
924
|
-
// full contract. It ALWAYS exits 0 (success or failure alike: the hook must
|
|
925
|
-
// stay silent), so even an unexpected rejection here is caught and reported
|
|
926
|
-
// the same `{ok:false, reason}` way rather than propagating a non-zero exit
|
|
927
|
-
// or an uncaught stack trace into the hook.
|
|
928
|
-
//
|
|
929
|
-
// Any OTHER argument is refused rather than ignored, so a typo cannot
|
|
930
|
-
// silently start an MCP server on stdio where a subcommand was meant to run.
|
|
884
|
+
// `bridge` is the one subcommand: the session event stream client the danxbot
|
|
885
|
+
// plugin's plan event bridge runs. It never boots the MCP server; its only
|
|
886
|
+
// input is `CLAUDE_CODE_SESSION_ID` (read from its environment) plus
|
|
887
|
+
// `--resume-ids` — the dashboard URL, credential source, and credential itself
|
|
888
|
+
// come from the session-connection record this server's own `plan_connect`
|
|
889
|
+
// wrote (`session-connection.ts`), never from the bridge's own environment
|
|
890
|
+
// (DX-2862: a bridge trusting its own ambient `DANXBOT_DISPATCH_TOKEN` is the
|
|
891
|
+
// exact bug this repo fixed). Any OTHER argument is refused rather than
|
|
892
|
+
// ignored, so a typo cannot silently start an MCP server on stdio where the
|
|
893
|
+
// bridge was meant to run.
|
|
931
894
|
if (isEntrypointModule(import.meta.url, process.argv[1])) {
|
|
932
895
|
const [subcommand, ...rest] = process.argv.slice(2);
|
|
933
896
|
if (subcommand === BRIDGE_SUBCOMMAND) {
|
|
@@ -936,16 +899,8 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
|
|
|
936
899
|
process.exit(1);
|
|
937
900
|
});
|
|
938
901
|
}
|
|
939
|
-
else if (subcommand === PLAN_STATE_SUBCOMMAND) {
|
|
940
|
-
runPlanStateCommand(rest).then((code) => process.exit(code), (err) => {
|
|
941
|
-
console.error(`[danx-dashboard-mcp] plan-state fatal: ${err.message}`);
|
|
942
|
-
process.stdout.write(`${JSON.stringify({ ok: false, reason: "fatal" })}\n`);
|
|
943
|
-
process.exit(0);
|
|
944
|
-
});
|
|
945
|
-
}
|
|
946
902
|
else if (subcommand !== undefined) {
|
|
947
|
-
console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only
|
|
948
|
-
`"${BRIDGE_SUBCOMMAND}" and "${PLAN_STATE_SUBCOMMAND}")`);
|
|
903
|
+
console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only one is "${BRIDGE_SUBCOMMAND}")`);
|
|
949
904
|
process.exit(2);
|
|
950
905
|
}
|
|
951
906
|
else {
|
package/dist/listen.js
CHANGED
|
@@ -11,31 +11,10 @@
|
|
|
11
11
|
* - `{type:"event", id, text}` — exactly one per event, the moment it arrives.
|
|
12
12
|
* `text` is what the session reads; an event it cannot read still produces one
|
|
13
13
|
* (`could not read event …`), never silence. `id` is `null` only for a frame
|
|
14
|
-
* that carried no usable id
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* - `{type:"stopped", reason, detail, refusal?, boards?}` — once, last. `reason`
|
|
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
|
|
23
|
-
* `lease_expired` (no healthy connection for the lease). DX-2920 —
|
|
24
|
-
* `refusal` is populated ONLY for `reason === "refused"`: `{status,
|
|
25
|
-
* errorCode, body}`, the admission response parsed ONCE here, so a
|
|
26
|
-
* consumer can recognize a SPECIFIC refusal (the dashboard's own
|
|
27
|
-
* `409 session_not_connected`, the same shape the mint route uses) by
|
|
28
|
-
* 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.
|
|
14
|
+
* that carried no usable id.
|
|
15
|
+
* - `{type:"stopped", reason, detail}` — once, last. `reason` is the dashboard's
|
|
16
|
+
* own end reason (`superseded`, `replaced`, `revoked`), `refused` (the ticket
|
|
17
|
+
* was not admitted), or `lease_expired` (no healthy connection for the lease).
|
|
39
18
|
* - nothing for keep-alives, the connect marker, or a successful reconnect.
|
|
40
19
|
*
|
|
41
20
|
* RECONNECT AND RESUME. The stream drops whenever the dashboard restarts, and can
|
|
@@ -53,7 +32,6 @@
|
|
|
53
32
|
* (`src/issues/db/issue-activity.ts`); this published package cannot import
|
|
54
33
|
* danxbot source.
|
|
55
34
|
*/
|
|
56
|
-
import { errorCodeOf } from "./json-body.js";
|
|
57
35
|
import { oneLine } from "./one-line.js";
|
|
58
36
|
export const INITIAL_BACKOFF_MS = 1_000;
|
|
59
37
|
export const MAX_BACKOFF_MS = 30_000;
|
|
@@ -95,18 +73,8 @@ export function invalidEventReason(value) {
|
|
|
95
73
|
if (!isRecord(d))
|
|
96
74
|
return "detail is not an object";
|
|
97
75
|
switch (value.kind) {
|
|
98
|
-
case "comment_added":
|
|
99
|
-
|
|
100
|
-
return "detail.excerpt is not capped text";
|
|
101
|
-
// DX-2906 — `problem` is OPTIONAL (only present when the comment named
|
|
102
|
-
// one); absent is valid, but a present-and-malformed one is not.
|
|
103
|
-
if (d.problem === undefined)
|
|
104
|
-
return null;
|
|
105
|
-
const problem = d.problem;
|
|
106
|
-
return isRecord(problem) && typeof problem.id === "number" && isCappedText(problem.statement)
|
|
107
|
-
? null
|
|
108
|
-
: "detail.problem is not {id, statement}";
|
|
109
|
-
}
|
|
76
|
+
case "comment_added":
|
|
77
|
+
return isCappedText(d.excerpt) ? null : "detail.excerpt is not capped text";
|
|
110
78
|
case "solution_answered": {
|
|
111
79
|
const problem = d.problem;
|
|
112
80
|
if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
|
|
@@ -125,32 +93,6 @@ export function invalidEventReason(value) {
|
|
|
125
93
|
}
|
|
126
94
|
return null;
|
|
127
95
|
}
|
|
128
|
-
// DX-2935 — the operator retracted a problem's answer with no new one yet.
|
|
129
|
-
case "problem_unanswered": {
|
|
130
|
-
const problem = d.problem;
|
|
131
|
-
if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
|
|
132
|
-
return "detail.problem is not {id, statement}";
|
|
133
|
-
}
|
|
134
|
-
return typeof d.decision_id === "number" ? null : "detail.decision_id is not a number";
|
|
135
|
-
}
|
|
136
|
-
// DX-2935 — the operator retracted a problem's answer AND recorded a new
|
|
137
|
-
// one, in one transaction — same either/or solution/freeform shape
|
|
138
|
-
// `solution_answered` carries, plus which decision was retracted.
|
|
139
|
-
case "problem_answer_changed": {
|
|
140
|
-
const problem = d.problem;
|
|
141
|
-
if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
|
|
142
|
-
return "detail.problem is not {id, statement}";
|
|
143
|
-
}
|
|
144
|
-
if (typeof d.retracted_decision_id !== "number")
|
|
145
|
-
return "detail.retracted_decision_id is not a number";
|
|
146
|
-
if (typeof d.decision_id !== "number")
|
|
147
|
-
return "detail.decision_id is not a number";
|
|
148
|
-
if (d.solution === null)
|
|
149
|
-
return isCappedText(d.freeform) ? null : "detail.freeform is not capped text";
|
|
150
|
-
if (!isRecord(d.solution) || typeof d.solution.title !== "string")
|
|
151
|
-
return "detail.solution is not {title, note}";
|
|
152
|
-
return d.solution.note === null || isCappedText(d.solution.note) ? null : "detail.solution.note is not capped text";
|
|
153
|
-
}
|
|
154
96
|
case "blocked":
|
|
155
97
|
return isCappedText(d.reason) ? null : "detail.reason is not capped text";
|
|
156
98
|
default:
|
|
@@ -161,16 +103,8 @@ export function invalidEventReason(value) {
|
|
|
161
103
|
function describe(event) {
|
|
162
104
|
const d = event.detail;
|
|
163
105
|
switch (event.kind) {
|
|
164
|
-
case "comment_added":
|
|
165
|
-
|
|
166
|
-
// answering it) relays distinctly from a plain comment, so an agent
|
|
167
|
-
// listening to the stream can tell which of its open problems just
|
|
168
|
-
// got a reply.
|
|
169
|
-
const problem = d.problem;
|
|
170
|
-
if (problem === undefined)
|
|
171
|
-
return `${event.actor} commented: ${quoted(d.excerpt)}`;
|
|
172
|
-
return `${event.actor} commented on problem ${quoted(problem.statement)}: ${quoted(d.excerpt)}`;
|
|
173
|
-
}
|
|
106
|
+
case "comment_added":
|
|
107
|
+
return `${event.actor} commented: ${quoted(d.excerpt)}`;
|
|
174
108
|
case "solution_answered": {
|
|
175
109
|
// DX-2735: every answer answers ONE problem on a card that may carry several,
|
|
176
110
|
// so the line names the problem — otherwise the agent cannot tell which of its
|
|
@@ -189,22 +123,6 @@ function describe(event) {
|
|
|
189
123
|
const problem = d.problem;
|
|
190
124
|
return `${event.actor} opened a problem: ${quoted(problem.statement)}`;
|
|
191
125
|
}
|
|
192
|
-
case "problem_unanswered": {
|
|
193
|
-
// DX-2935 — MUST read as "a previous answer was withdrawn", never as a
|
|
194
|
-
// brand-new problem: a connected session that already acted on the
|
|
195
|
-
// retracted answer has to stop, not treat this as fresh work to start.
|
|
196
|
-
const problem = d.problem;
|
|
197
|
-
return `${event.actor} retracted the answer to ${quoted(problem.statement)}`;
|
|
198
|
-
}
|
|
199
|
-
case "problem_answer_changed": {
|
|
200
|
-
// DX-2935 — ONE line for the whole retract-and-reanswer (never two
|
|
201
|
-
// separate events), naming the statement so a listener with several
|
|
202
|
-
// open questions knows which one just moved.
|
|
203
|
-
const problem = d.problem;
|
|
204
|
-
const solution = d.solution;
|
|
205
|
-
const now = solution === null ? quoted(d.freeform) : `"${solution.title}"`;
|
|
206
|
-
return `${event.actor} changed the answer to ${quoted(problem.statement)}: now ${now}`;
|
|
207
|
-
}
|
|
208
126
|
case "blocked":
|
|
209
127
|
return `${event.actor} blocked the card: ${quoted(d.reason)}`;
|
|
210
128
|
case "unblocked":
|
|
@@ -222,46 +140,6 @@ export function formatActivityLine(event) {
|
|
|
222
140
|
throw new Error(`malformed activity event: ${invalid}`);
|
|
223
141
|
return oneLine(`[${event.cardId} "${event.cardTitle}" ${event.boardId}] ${describe(event)}`);
|
|
224
142
|
}
|
|
225
|
-
function isNudgeCardRef(value) {
|
|
226
|
-
return isRecord(value) && typeof value.id === "string" && typeof value.title === "string";
|
|
227
|
-
}
|
|
228
|
-
function isNudgeCardRefArray(value) {
|
|
229
|
-
return Array.isArray(value) && value.every(isNudgeCardRef);
|
|
230
|
-
}
|
|
231
|
-
/** Why a nudge cannot be read, or `null` when it can — same checked-before-describing shape as `invalidEventReason`. */
|
|
232
|
-
export function invalidNudgeReason(value) {
|
|
233
|
-
if (!isRecord(value))
|
|
234
|
-
return "the nudge is not an object";
|
|
235
|
-
if (typeof value.idleMinutes !== "number" || !Number.isFinite(value.idleMinutes)) {
|
|
236
|
-
return "idleMinutes is not a number";
|
|
237
|
-
}
|
|
238
|
-
if (!isNudgeCardRefArray(value.startable))
|
|
239
|
-
return "startable is not a {id,title}[] array";
|
|
240
|
-
if (!isNudgeCardRefArray(value.held))
|
|
241
|
-
return "held is not a {id,title}[] array";
|
|
242
|
-
return null;
|
|
243
|
-
}
|
|
244
|
-
function cardList(cards) {
|
|
245
|
-
return cards.map((c) => `${c.id} "${c.title}"`).join(", ");
|
|
246
|
-
}
|
|
247
|
-
/**
|
|
248
|
-
* The one readable line for a nudge (DX-2885). Always names the actual
|
|
249
|
-
* waiting cards — never a generic "keep going" — because that is the whole
|
|
250
|
-
* point of the feature: a session that only sees "you're idle" learns
|
|
251
|
-
* nothing a bare timer couldn't have told it. Always a single line; throws,
|
|
252
|
-
* naming the fault, on a malformed nudge.
|
|
253
|
-
*/
|
|
254
|
-
export function formatNudgeLine(event) {
|
|
255
|
-
const invalid = invalidNudgeReason(event);
|
|
256
|
-
if (invalid !== null)
|
|
257
|
-
throw new Error(`malformed nudge event: ${invalid}`);
|
|
258
|
-
const parts = [];
|
|
259
|
-
if (event.startable.length > 0)
|
|
260
|
-
parts.push(`startable: ${cardList(event.startable)}`);
|
|
261
|
-
if (event.held.length > 0)
|
|
262
|
-
parts.push(`held: ${cardList(event.held)}`);
|
|
263
|
-
return oneLine(`${LINE_PREFIX} this session has been idle ${event.idleMinutes}m with work waiting — ${parts.join("; ")}`);
|
|
264
|
-
}
|
|
265
143
|
/**
|
|
266
144
|
* Incremental SSE parser. Comment lines (keep-alives, the connect marker) and
|
|
267
145
|
* field-less blocks produce no message, which is what keeps them out of the output.
|
|
@@ -303,23 +181,6 @@ function parseBlock(block) {
|
|
|
303
181
|
}
|
|
304
182
|
return sawField ? { id, event, data: data.join("\n") } : null;
|
|
305
183
|
}
|
|
306
|
-
/**
|
|
307
|
-
* DX-2920 (round 4) — the dashboard's own `StreamEndReason` values this
|
|
308
|
-
* client recognizes on the wire, EXCLUDING `scope_narrowed` (parsed by its own
|
|
309
|
-
* dedicated branch in `handle()` below, since it alone carries `boards`).
|
|
310
|
-
* Hand-copied from `StreamEndReason` in `src/issues/plan-session-listeners.ts`
|
|
311
|
-
* — this published package cannot import danxbot source. A wire `reason` this
|
|
312
|
-
* set does not contain (a newer dashboard's reason this build predates)
|
|
313
|
-
* normalizes to `"unknown"` rather than passing the raw string through, which
|
|
314
|
-
* is what lets `Outcome`'s `reason` field below be a closed union instead of
|
|
315
|
-
* `string` — see `ListenStopReason`'s docblock for why that closure is what
|
|
316
|
-
* makes the `scope_narrowed`-requires-`boards` invariant an actual compile
|
|
317
|
-
* error rather than a comment's promise.
|
|
318
|
-
*/
|
|
319
|
-
const KNOWN_WIRE_END_REASONS = new Set(["superseded", "replaced", "revoked", "not_connected"]);
|
|
320
|
-
function isKnownWireEndReason(reason) {
|
|
321
|
-
return KNOWN_WIRE_END_REASONS.has(reason);
|
|
322
|
-
}
|
|
323
184
|
/** Statuses that describe the moment, not the ticket — retry them. */
|
|
324
185
|
const RETRYABLE_CLIENT_STATUSES = new Set([408, 429]);
|
|
325
186
|
/**
|
|
@@ -340,85 +201,14 @@ export async function runListener(options, deps) {
|
|
|
340
201
|
};
|
|
341
202
|
for (const id of options.resumeIds.slice(-DELIVERED_ID_MEMORY))
|
|
342
203
|
remember(id);
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
if (extra?.boards === undefined) {
|
|
346
|
-
throw new Error('stop("scope_narrowed", ...) requires boards — unreachable through the exported overloads above');
|
|
347
|
-
}
|
|
348
|
-
deps.write({ type: "stopped", reason, detail, boards: extra.boards });
|
|
349
|
-
return code;
|
|
350
|
-
}
|
|
351
|
-
deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal });
|
|
204
|
+
const stop = (reason, detail, code) => {
|
|
205
|
+
deps.write({ type: "stopped", reason, detail });
|
|
352
206
|
return code;
|
|
353
|
-
}
|
|
207
|
+
};
|
|
354
208
|
const handle = (message) => {
|
|
355
209
|
if (message.event === "end") {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
// from before this round. Only a `scope_narrowed` reason additionally
|
|
359
|
-
// REQUIRES a valid `boards` string array (matching the server's `end`
|
|
360
|
-
// overload, which cannot emit `scope_narrowed` without one) — a
|
|
361
|
-
// `scope_narrowed` frame that fails that check is a PROTOCOL error
|
|
362
|
-
// (a server too old to have sent boards, or a corrupted frame), never
|
|
363
|
-
// silently downgraded to the generic "the dashboard ended this
|
|
364
|
-
// listener" message the caller cannot act on.
|
|
365
|
-
let parsed;
|
|
366
|
-
try {
|
|
367
|
-
parsed = JSON.parse(message.data);
|
|
368
|
-
}
|
|
369
|
-
catch {
|
|
370
|
-
parsed = null;
|
|
371
|
-
}
|
|
372
|
-
const reasonRaw = isRecord(parsed) ? parsed.reason : undefined;
|
|
373
|
-
const reason = typeof reasonRaw === "string" ? reasonRaw : "unknown";
|
|
374
|
-
if (reason === "scope_narrowed") {
|
|
375
|
-
const boardsRaw = isRecord(parsed) ? parsed.boards : undefined;
|
|
376
|
-
const boards = Array.isArray(boardsRaw) && boardsRaw.length > 0 && boardsRaw.every((b) => typeof b === "string")
|
|
377
|
-
? boardsRaw
|
|
378
|
-
: null;
|
|
379
|
-
if (boards === null) {
|
|
380
|
-
return { kind: "ended", reason: "bad_end_payload" };
|
|
381
|
-
}
|
|
382
|
-
return { kind: "ended", reason, boards };
|
|
383
|
-
}
|
|
384
|
-
// DX-2920 (round 4) — a wire reason this build does not recognize (a
|
|
385
|
-
// newer dashboard's reason an older bridge hasn't shipped yet)
|
|
386
|
-
// normalizes to "unknown" rather than passing the raw string through:
|
|
387
|
-
// `Outcome`'s "ended" reason is now a closed set (`EndedReason`), and an
|
|
388
|
-
// open passthrough here would be exactly the hole that lets a
|
|
389
|
-
// missing-`boards` "scope_narrowed" construction slip past the compiler
|
|
390
|
-
// elsewhere — see `ListenStopReason`'s docblock.
|
|
391
|
-
return { kind: "ended", reason: isKnownWireEndReason(reason) ? reason : "unknown" };
|
|
392
|
-
}
|
|
393
|
-
if (message.event === "nudge") {
|
|
394
|
-
// DX-2885 — a nudge carries no usable id (it is advisory, re-derived
|
|
395
|
-
// fresh every tick, never replayed — see `plan-session-stream.ts`'s
|
|
396
|
-
// module doc), so it rides the SAME `{type:"event", id:null, text}`
|
|
397
|
-
// shape an ordinary activity line uses (`id` is `null` only for a frame
|
|
398
|
-
// that carried no usable id — a nudge is exactly that case). That is
|
|
399
|
-
// what lets `bridge.ts` and the plugin's relay
|
|
400
|
-
// (`danxbot/scripts/plan-event-bridge.mjs`, outside this repo) forward
|
|
401
|
-
// it with NO changes at all: both already forward any `{type:"event",
|
|
402
|
-
// id, text}` line generically, keyed only on `type`.
|
|
403
|
-
//
|
|
404
|
-
// DX-2885 — this frame must stay exempt from DX-2730 D4's digest-
|
|
405
|
-
// batching when that lands (see DX-2730 AC 29939, and the matching note
|
|
406
|
-
// at the dashboard's own emit site): it must keep delivering
|
|
407
|
-
// immediately, never held for a later digest flush.
|
|
408
|
-
let parsed;
|
|
409
|
-
let invalid;
|
|
410
|
-
try {
|
|
411
|
-
parsed = JSON.parse(message.data);
|
|
412
|
-
invalid = invalidNudgeReason(parsed);
|
|
413
|
-
}
|
|
414
|
-
catch (err) {
|
|
415
|
-
invalid = `not JSON: ${err.message}`;
|
|
416
|
-
}
|
|
417
|
-
const text = invalid === null
|
|
418
|
-
? formatNudgeLine(parsed)
|
|
419
|
-
: `${LINE_PREFIX} could not read nudge (${invalid}): ${oneLine(message.data, 300)}`;
|
|
420
|
-
deps.write({ type: "event", id: null, text });
|
|
421
|
-
return null;
|
|
210
|
+
const reason = JSON.parse(message.data).reason;
|
|
211
|
+
return { kind: "ended", reason: typeof reason === "string" ? reason : "unknown" };
|
|
422
212
|
}
|
|
423
213
|
if (message.event !== "activity")
|
|
424
214
|
return null;
|
|
@@ -472,25 +262,7 @@ export async function runListener(options, deps) {
|
|
|
472
262
|
armIdle();
|
|
473
263
|
const response = await deps.fetch(options.streamUrl, { headers, signal: controller.signal });
|
|
474
264
|
if (response.status >= 400 && response.status < 500 && !RETRYABLE_CLIENT_STATUSES.has(response.status)) {
|
|
475
|
-
|
|
476
|
-
// DX-2920 — parsed ONCE, here, into a structured outcome a consumer can
|
|
477
|
-
// classify by status + error code (`bridge.ts` does, matching a specific
|
|
478
|
-
// admission refusal such as the dashboard's own `409 session_not_connected`)
|
|
479
|
-
// rather than re-deriving it from the flattened `detail` string below.
|
|
480
|
-
let body = null;
|
|
481
|
-
try {
|
|
482
|
-
body = text === "" ? null : JSON.parse(text);
|
|
483
|
-
}
|
|
484
|
-
catch {
|
|
485
|
-
body = null;
|
|
486
|
-
}
|
|
487
|
-
return {
|
|
488
|
-
kind: "refused",
|
|
489
|
-
status: response.status,
|
|
490
|
-
errorCode: errorCodeOf(body),
|
|
491
|
-
body,
|
|
492
|
-
detail: `HTTP ${response.status} ${oneLine(text, 300)}`,
|
|
493
|
-
};
|
|
265
|
+
return { kind: "refused", detail: `HTTP ${response.status} ${oneLine(await response.text(), 300)}` };
|
|
494
266
|
}
|
|
495
267
|
if (!response.ok || response.body === null) {
|
|
496
268
|
await response.body?.cancel();
|
|
@@ -524,23 +296,10 @@ export async function runListener(options, deps) {
|
|
|
524
296
|
if (outcome.reason === "superseded" || outcome.reason === "replaced") {
|
|
525
297
|
return stop(outcome.reason, "another listener for this session took over", 0);
|
|
526
298
|
}
|
|
527
|
-
if (outcome.reason === "bad_end_payload") {
|
|
528
|
-
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);
|
|
529
|
-
}
|
|
530
|
-
// DX-2920 (round 4) — split explicitly rather than the old conditional
|
|
531
|
-
// `outcome.boards === undefined ? undefined : {boards}` pass-through:
|
|
532
|
-
// `Outcome`'s "ended" kind is now itself discriminated on `reason`, so
|
|
533
|
-
// `outcome.boards` is GUARANTEED present here (no `?? []`, no optional
|
|
534
|
-
// check needed) once narrowed to `"scope_narrowed"`.
|
|
535
|
-
if (outcome.reason === "scope_narrowed") {
|
|
536
|
-
return stop("scope_narrowed", "the dashboard ended this listener", 1, { boards: outcome.boards });
|
|
537
|
-
}
|
|
538
299
|
return stop(outcome.reason, "the dashboard ended this listener", 1);
|
|
539
300
|
}
|
|
540
301
|
if (outcome.kind === "refused") {
|
|
541
|
-
return stop("refused", `the dashboard refused the listener ticket: ${outcome.detail}`, 1
|
|
542
|
-
refusal: { status: outcome.status, errorCode: outcome.errorCode, body: outcome.body },
|
|
543
|
-
});
|
|
302
|
+
return stop("refused", `the dashboard refused the listener ticket: ${outcome.detail}`, 1);
|
|
544
303
|
}
|
|
545
304
|
if (outcome.healthy) {
|
|
546
305
|
unhealthySince = null;
|
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.88",
|
|
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/json-body.js
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DX-2920 — the ONE place that reads a JSON body's top-level `.error` string.
|
|
3
|
-
*
|
|
4
|
-
* WHY THIS EXISTS. `bridge.ts` (classifying a mint refusal) and `listen.ts`
|
|
5
|
-
* (classifying a stream admission refusal) both need to answer "what error
|
|
6
|
-
* code did this JSON body carry", and before this card each answered it a
|
|
7
|
-
* different way: `bridge.ts` parsed the body directly, `listen.ts` never
|
|
8
|
-
* looked past a flattened, human-readable detail string at all — a caller
|
|
9
|
-
* that wanted to react to a SPECIFIC refusal (the admission-time 409 the
|
|
10
|
-
* mint route also uses) had to regex-scrape that flattened string back apart,
|
|
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",
|
|
13
|
-
* only that a JSON object may carry a top-level string field named `error` —
|
|
14
|
-
* so it can live in BOTH the domain-aware `bridge.ts` and the deliberately
|
|
15
|
-
* generic `listen.ts` (whose own docblock states it carries no domain
|
|
16
|
-
* knowledge of the dashboard's specific error shapes) without either one
|
|
17
|
-
* importing the other.
|
|
18
|
-
*/
|
|
19
|
-
export function errorCodeOf(body) {
|
|
20
|
-
return typeof body === "object" && body !== null && typeof body.error === "string"
|
|
21
|
-
? body.error
|
|
22
|
-
: null;
|
|
23
|
-
}
|
package/dist/plan-state.js
DELETED
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `danx-dashboard-mcp plan-state` — the ONE-SHOT client of
|
|
3
|
-
* `GET /api/plan-sessions/me/turn-state` (DX-2921).
|
|
4
|
-
*
|
|
5
|
-
* WHO RUNS IT. The danxbot Claude Code plugin's DX-2888 turn-start /
|
|
6
|
-
* turn-end hooks, via the plugin's existing `bridgeCommand()` npx path — the
|
|
7
|
-
* SAME package pin, no separate install, no deep import of this package's
|
|
8
|
-
* `dist/` internals (that was DX-2921's whole reason to exist: the DX-2888
|
|
9
|
-
* architecture review found the first build of this logic vendored a private
|
|
10
|
-
* copy of the MCP package and hardcoded the dashboard HTTP contract inside
|
|
11
|
-
* the PLUGIN). The plugin decides what to DO with the answer (inject plan
|
|
12
|
-
* numbers at turn start, refuse to let a turn end while the session could
|
|
13
|
-
* legally start work and has nothing running); this module only fetches it.
|
|
14
|
-
*
|
|
15
|
-
* THE CONTRACT (agreed with the DX-2888 plugin side, issue comment 4245 on
|
|
16
|
-
* DX-2921 — binding, because the plugin is already built against it):
|
|
17
|
-
* - print EXACTLY ONE JSON line on stdout, and ALWAYS exit 0 — success or
|
|
18
|
-
* failure alike, because the hook must stay silent rather than spam the
|
|
19
|
-
* session's own turn with a stack trace;
|
|
20
|
-
* - a session that is not connected to a plan prints
|
|
21
|
-
* `{"ok":false,"reason":"session_not_connected"}` — that EXACT literal,
|
|
22
|
-
* because the plugin keys its no-log-spam rule on it (it is also the
|
|
23
|
-
* dashboard's own 409 error code for this case — see
|
|
24
|
-
* `requireConnectedPlanId`, `plan-session-context.ts`);
|
|
25
|
-
* - on success, every count is a finite number, `plan` carries `id` +
|
|
26
|
-
* `name`, and every `startable` / `held` entry carries a string `id` +
|
|
27
|
-
* `title` — the plugin rejects anything else as `bad_response`, so this
|
|
28
|
-
* module validates the server's response BEFORE printing it, rather than
|
|
29
|
-
* trusting a 200 status to mean a well-shaped body;
|
|
30
|
-
* - human-readable diagnostics go to stderr only, never stdout.
|
|
31
|
-
*
|
|
32
|
-
* THE CREDENTIAL — the SAME resolver `bridge` uses (DX-2862 / DX-2921 AC),
|
|
33
|
-
* via `resolveBridgeOptions` (`bridge.ts`): the dashboard URL, credential
|
|
34
|
-
* source and credential all come from the connection record this session's
|
|
35
|
-
* OWN danx-dashboard MCP server wrote on its last successful `plan_connect`
|
|
36
|
-
* (`session-connection.ts`), never from this process's own ambient env. That
|
|
37
|
-
* is deliberate here for the identical reason it is deliberate for `bridge`:
|
|
38
|
-
* a hook process trusting whatever `DANXBOT_DISPATCH_TOKEN` happens to be in
|
|
39
|
-
* its environment is precisely the DX-2862 bug (a hook's ambient credential
|
|
40
|
-
* silently pointed at a different dashboard from the one the session's tools
|
|
41
|
-
* actually use).
|
|
42
|
-
*
|
|
43
|
-
* A HARD TIMEOUT (AC 30672) — `PLAN_STATE_REQUEST_TIMEOUT_MS`. This call
|
|
44
|
-
* blocks a turn boundary the plugin is holding open, so it must never hang:
|
|
45
|
-
* a slow or wedged dashboard is `{ok:false, reason:"timeout"}`, not a stuck
|
|
46
|
-
* hook.
|
|
47
|
-
*/
|
|
48
|
-
import { SESSION_ID_HEADER, resolveBridgeOptions } from "./bridge.js";
|
|
49
|
-
export const PLAN_STATE_SUBCOMMAND = "plan-state";
|
|
50
|
-
export const TURN_STATE_PATH = "/api/plan-sessions/me/turn-state";
|
|
51
|
-
/** How long this one-shot call may take before it counts as a timeout. */
|
|
52
|
-
export const PLAN_STATE_REQUEST_TIMEOUT_MS = 5_000;
|
|
53
|
-
const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${PLAN_STATE_SUBCOMMAND}`;
|
|
54
|
-
function errorCodeOf(body) {
|
|
55
|
-
return typeof body === "object" && body !== null && typeof body.error === "string"
|
|
56
|
-
? body.error
|
|
57
|
-
: null;
|
|
58
|
-
}
|
|
59
|
-
function isFiniteNumber(v) {
|
|
60
|
-
return typeof v === "number" && Number.isFinite(v);
|
|
61
|
-
}
|
|
62
|
-
function isCardRef(v) {
|
|
63
|
-
return (typeof v === "object" &&
|
|
64
|
-
v !== null &&
|
|
65
|
-
typeof v.id === "string" &&
|
|
66
|
-
typeof v.title === "string");
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* The strict shape check the contract pins (comment 4245): finite counts, a
|
|
70
|
-
* `plan` with `id` + `name`, and a string `id` + `title` on every
|
|
71
|
-
* `startable` / `held` entry. `null` on anything short of that — a 200 whose
|
|
72
|
-
* body does not match is `bad_response`, exactly like a non-2xx.
|
|
73
|
-
*/
|
|
74
|
-
export function parseTurnStateBody(body) {
|
|
75
|
-
if (typeof body !== "object" || body === null)
|
|
76
|
-
return null;
|
|
77
|
-
const b = body;
|
|
78
|
-
const plan = b.plan;
|
|
79
|
-
if (typeof plan !== "object" || plan === null)
|
|
80
|
-
return null;
|
|
81
|
-
const p = plan;
|
|
82
|
-
if (!isFiniteNumber(p.id) || typeof p.name !== "string")
|
|
83
|
-
return null;
|
|
84
|
-
const counts = b.counts;
|
|
85
|
-
if (typeof counts !== "object" || counts === null)
|
|
86
|
-
return null;
|
|
87
|
-
const c = counts;
|
|
88
|
-
if (!isFiniteNumber(c.open) || !isFiniteNumber(c.inProgress) || !isFiniteNumber(c.needsYou))
|
|
89
|
-
return null;
|
|
90
|
-
if (!Array.isArray(b.startable) || !b.startable.every(isCardRef))
|
|
91
|
-
return null;
|
|
92
|
-
if (!Array.isArray(b.held) || !b.held.every(isCardRef))
|
|
93
|
-
return null;
|
|
94
|
-
return {
|
|
95
|
-
plan: { id: p.id, name: p.name },
|
|
96
|
-
counts: { open: c.open, inProgress: c.inProgress, needsYou: c.needsYou },
|
|
97
|
-
startable: b.startable,
|
|
98
|
-
held: b.held,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* One authenticated GET, with a hard timeout, classified into the output
|
|
103
|
-
* shape this subcommand always prints — never a throw, never a non-2xx
|
|
104
|
-
* bubbling past this function.
|
|
105
|
-
*/
|
|
106
|
-
export async function fetchTurnState(options, deps) {
|
|
107
|
-
const controller = new AbortController();
|
|
108
|
-
const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
|
|
109
|
-
let status;
|
|
110
|
-
let text;
|
|
111
|
-
try {
|
|
112
|
-
try {
|
|
113
|
-
const response = await deps.fetch(`${options.dashboardUrl}${TURN_STATE_PATH}`, {
|
|
114
|
-
method: "GET",
|
|
115
|
-
headers: {
|
|
116
|
-
Authorization: `Bearer ${options.token}`,
|
|
117
|
-
Accept: "application/json",
|
|
118
|
-
[SESSION_ID_HEADER]: options.sessionId,
|
|
119
|
-
},
|
|
120
|
-
signal: controller.signal,
|
|
121
|
-
});
|
|
122
|
-
status = response.status;
|
|
123
|
-
text = await response.text();
|
|
124
|
-
}
|
|
125
|
-
catch (err) {
|
|
126
|
-
return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
finally {
|
|
130
|
-
clearTimeout(timer);
|
|
131
|
-
}
|
|
132
|
-
let body = null;
|
|
133
|
-
try {
|
|
134
|
-
body = text === "" ? null : JSON.parse(text);
|
|
135
|
-
}
|
|
136
|
-
catch {
|
|
137
|
-
return { ok: false, reason: "bad_response" };
|
|
138
|
-
}
|
|
139
|
-
// The exact literal the plugin's no-log-spam rule keys on (AC 30672 /
|
|
140
|
-
// comment 4245) — the dashboard's own 409 error code for an unconnected
|
|
141
|
-
// session, passed through verbatim rather than renamed.
|
|
142
|
-
if (status === 409 && errorCodeOf(body) === "session_not_connected") {
|
|
143
|
-
return { ok: false, reason: "session_not_connected" };
|
|
144
|
-
}
|
|
145
|
-
if (status === 401 || status === 403) {
|
|
146
|
-
return { ok: false, reason: "unauthorized" };
|
|
147
|
-
}
|
|
148
|
-
if (status < 200 || status >= 300) {
|
|
149
|
-
return { ok: false, reason: "http_error" };
|
|
150
|
-
}
|
|
151
|
-
const parsed = parseTurnStateBody(body);
|
|
152
|
-
if (parsed === null) {
|
|
153
|
-
return { ok: false, reason: "bad_response" };
|
|
154
|
-
}
|
|
155
|
-
return { ok: true, ...parsed };
|
|
156
|
-
}
|
|
157
|
-
/** The bin's `plan-state` subcommand, wired to the real process. Always resolves to exit code 0. */
|
|
158
|
-
export async function runPlanStateCommand(argv, env = process.env,
|
|
159
|
-
/** Test seam: the home the session's connection record is read from. */
|
|
160
|
-
resolveFrom = {}) {
|
|
161
|
-
const write = (output) => {
|
|
162
|
-
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
163
|
-
};
|
|
164
|
-
if (argv.length !== 0) {
|
|
165
|
-
// A malformed invocation is still a "the hook must stay silent" failure,
|
|
166
|
-
// not a crash — same ok:false/exit-0 contract as every other failure
|
|
167
|
-
// path here, with the usage line reserved for a human reading stderr.
|
|
168
|
-
write({ ok: false, reason: "usage" });
|
|
169
|
-
process.stderr.write(`${USAGE}\n`);
|
|
170
|
-
return 0;
|
|
171
|
-
}
|
|
172
|
-
const sessionId = env.CLAUDE_CODE_SESSION_ID;
|
|
173
|
-
if (!sessionId) {
|
|
174
|
-
write({ ok: false, reason: "no_session_id" });
|
|
175
|
-
process.stderr.write(`${USAGE}\n`);
|
|
176
|
-
return 0;
|
|
177
|
-
}
|
|
178
|
-
let options;
|
|
179
|
-
try {
|
|
180
|
-
options = resolveBridgeOptions({ sessionId, resumeIds: [] }, env, resolveFrom);
|
|
181
|
-
}
|
|
182
|
-
catch (err) {
|
|
183
|
-
const start = err;
|
|
184
|
-
write({ ok: false, reason: start.reason });
|
|
185
|
-
process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
|
|
186
|
-
return 0;
|
|
187
|
-
}
|
|
188
|
-
const output = await fetchTurnState(options, {
|
|
189
|
-
fetch: (input, init) => fetch(input, init),
|
|
190
|
-
requestTimeoutMs: PLAN_STATE_REQUEST_TIMEOUT_MS,
|
|
191
|
-
});
|
|
192
|
-
write(output);
|
|
193
|
-
return 0;
|
|
194
|
-
}
|