@thehammer/danx-dashboard-mcp 0.1.246 → 0.1.247
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 -0
- package/dist/index.js +9 -1
- package/dist/ready-cards.js +176 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,6 +67,10 @@ CLAUDE_CODE_SESSION_ID=<session> npx -y @thehammer/danx-dashboard-mcp@<version>
|
|
|
67
67
|
|
|
68
68
|
Run by the danxbot plugin's SessionStart hook for a session that is **not** plan-connected. A restarted Claude Code session gets a new session id and no plan binding, so nothing told it that the plan it had been working was unattended. `plan_connect` writes the session's working directory into its connection record (`projectKey`, record schema v2); this subcommand finds the newest OTHER session's record for the same project within `STALE_RECORD_MS`, resolves that record's own credential, and asks `GET /api/plan-sessions/<predecessor>/restart-notice` which plan it was on and how many operator events wait there (the connect digest's own query, read-only). Exit 0 with text on stdout = say it (the registry's `plan_restart.waiting_events` text — this package carries no wording); exit 0 with nothing = nothing to say (never connected from this project, plan archived or deleted, record stale); exit 1 = the lookup failed. A record from an older server (schema v1) is skipped here and refused, with the fix, everywhere a session reads its OWN record.
|
|
69
69
|
|
|
70
|
+
## `ready-cards` — the plan's cards anyone could pick up now (DX-4534)
|
|
71
|
+
|
|
72
|
+
Run by the danxbot plugin's Stop hook for a plan-connected session. `GET /api/plans/mine` answers the session's plan and the boards its cards live on (a session on no plan is a 409 `session_not_connected`, read as an empty answer); then, for each board, `GET /api/issues?board=` with `filter.plan_id` + `filter.status_derived` returns the plan's ToDo cards, and the ready ones are kept: a Story, Bug or Chore with no assigned agent, no live dispatch, no block, no open problem, no unmet `depends_on` and no active `conflict_on`. No dashboard route was added. Exit 0 prints one JSON line `{"plan_id": <n|null>, "cards": [{"id", "title"}]}`; exit 1 prints one stderr line that starts with its reason (`credential_unavailable`, `no_connection_record`, `http_error`, ...) and nothing on stdout.
|
|
73
|
+
|
|
70
74
|
## `subagents-live` — the plan pane's local live sub-agent feed (DX-4508)
|
|
71
75
|
|
|
72
76
|
`danx-dashboard-mcp subagents-live <absolute path to the session's main transcript .jsonl>`. Spawned by the danxbot plugin's plan pane, which cannot tail files itself; the dashboard's sub-agent read lags the transcript by about 80s, this by about one second. It tails the session's transcript tree (the main file and `<session-id>/subagents/agent-*.jsonl` beside it) with the same `SessionLogWatcher` the bridge runs, from byte 0 once and then only appended bytes, polling every second, and computes each sub-agent through `@thehammer/danx-session-telemetry`'s `createSubagentLiveView` (the same observer, ledger counter and pricing rule the dashboard uses). stdout carries only `{"subagents":[...]}` lines: the first, after the catch-up read, holds every sub-agent (an empty array when there are none); each later one holds only the sub-agents a poll changed. No network, no credential. A missing, relative or non-`.jsonl` path exits 2 with one stderr line; SIGTERM, SIGINT or a closed stdout exits 0.
|
package/dist/index.js
CHANGED
|
@@ -87,6 +87,7 @@ import { BACKGROUND_WORK_SUBCOMMAND, runBackgroundWorkCommand } from "./backgrou
|
|
|
87
87
|
import { ACTIVITY_SUBCOMMAND, runActivityCommand } from "./activity.js";
|
|
88
88
|
import { EVENT_TEXT_SUBCOMMAND, runEventTextCommand } from "./event-text.js";
|
|
89
89
|
import { RESTART_NOTICE_SUBCOMMAND, runRestartNoticeCommand } from "./restart-notice.js";
|
|
90
|
+
import { READY_CARDS_SUBCOMMAND, runReadyCardsCommand } from "./ready-cards.js";
|
|
90
91
|
import { SUBAGENTS_LIVE_SUBCOMMAND, runSubagentsLiveCommand } from "./subagents-live.js";
|
|
91
92
|
import { resolveBootCredential } from "./credential.js";
|
|
92
93
|
import { SessionKeyUnusableError, deleteSessionKey } from "./session-key.js";
|
|
@@ -687,6 +688,13 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
|
|
|
687
688
|
process.exit(1);
|
|
688
689
|
});
|
|
689
690
|
}
|
|
691
|
+
else if (subcommand === READY_CARDS_SUBCOMMAND) {
|
|
692
|
+
// DX-4534 — the plugin Stop hook's read; the event-text exit-code contract: 0 (one JSON line) / 1 (read failed).
|
|
693
|
+
runReadyCardsCommand(rest).then((code) => process.exit(code), (err) => {
|
|
694
|
+
console.error(`[danx-dashboard-mcp] ${READY_CARDS_SUBCOMMAND} fatal: ${err.message}`);
|
|
695
|
+
process.exit(1);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
690
698
|
else if (subcommand === SUBAGENTS_LIVE_SUBCOMMAND) {
|
|
691
699
|
// DX-4508 — the plugin pane's local live sub-agent feed: stdout is its JSON Lines channel, so a fatal goes to
|
|
692
700
|
// stderr only and exits 1; a refused path exits 2 from the command itself.
|
|
@@ -698,7 +706,7 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
|
|
|
698
706
|
else if (subcommand !== undefined) {
|
|
699
707
|
console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only ones are ` +
|
|
700
708
|
`"${BRIDGE_SUBCOMMAND}", "${BACKGROUND_WORK_SUBCOMMAND}", "${ACTIVITY_SUBCOMMAND}", "${EVENT_TEXT_SUBCOMMAND}", ` +
|
|
701
|
-
`"${RESTART_NOTICE_SUBCOMMAND}", and "${SUBAGENTS_LIVE_SUBCOMMAND}")`);
|
|
709
|
+
`"${RESTART_NOTICE_SUBCOMMAND}", "${READY_CARDS_SUBCOMMAND}", and "${SUBAGENTS_LIVE_SUBCOMMAND}")`);
|
|
702
710
|
process.exit(2);
|
|
703
711
|
}
|
|
704
712
|
else {
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `danx-dashboard-mcp ready-cards` — DX-4534. The ONE-SHOT client the claude-plugins `danxbot`
|
|
3
|
+
* plugin's Stop hook runs for a plan-connected session, to learn which of its plan's cards
|
|
4
|
+
* anyone could pick up right now.
|
|
5
|
+
*
|
|
6
|
+
* WHY IT EXISTS. An orchestrating plan session must keep every unblocked card moving, and
|
|
7
|
+
* nothing enforced it: on 2026-10-04 one builder ran while two readied, unblocked cards sat
|
|
8
|
+
* untouched, because the session only looked for work when a builder reported back. The Stop
|
|
9
|
+
* hook turns that check into something the harness runs. This is its read.
|
|
10
|
+
*
|
|
11
|
+
* NO DASHBOARD ROUTE IS ADDED. It composes existing reads on the session's own credential:
|
|
12
|
+
* 1. `GET /api/plans/mine` answers the session's plan (`id`) and the boards its cards live on
|
|
13
|
+
* (`boards`) in one read; a session on no plan is a 409 `session_not_connected`.
|
|
14
|
+
* 2. `GET /api/issues?board=<b>` with `filter.plan_id` + `filter.status_derived=ToDo` and the
|
|
15
|
+
* judged fields, once per board: a plan can span boards (PLAN-11 spans danxbot and
|
|
16
|
+
* gpt-manager), and an issues read without `board` sees only the default one.
|
|
17
|
+
* Server-side filtering narrows the read; `isReadyCard` is the whole definition of "ready".
|
|
18
|
+
*
|
|
19
|
+
* READY means a card a dispatcher or a session could claim this minute: a Story, Bug or Chore
|
|
20
|
+
* (Epic and Feature are containers, a Task is a leaf nobody dispatches), derived status ToDo,
|
|
21
|
+
* nobody holding it (no assigned agent, no live dispatch), and nothing in its way: no hold
|
|
22
|
+
* (`blocked`), no open problem (a human is needed), no unmet `depends_on`, no active
|
|
23
|
+
* `conflict_on`. A card whose reason it cannot run is recorded on it therefore stops counting,
|
|
24
|
+
* which is how the hook's "dispatch it, or record why not" instruction is satisfiable.
|
|
25
|
+
*
|
|
26
|
+
* THE CONTRACT, which the hook reads by exit code:
|
|
27
|
+
* - exit 0, one JSON line on stdout: `{"plan_id": <n|null>, "cards": [{"id","title"}...]}`;
|
|
28
|
+
* `plan_id` null (the dashboard has the session on no plan) comes with an empty list.
|
|
29
|
+
* - exit 1, one reason on stderr, nothing on stdout: credential, network, non-2xx, bad shape.
|
|
30
|
+
*/
|
|
31
|
+
import { resolveSessionOptions, sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
|
|
32
|
+
export const READY_CARDS_SUBCOMMAND = "ready-cards";
|
|
33
|
+
export const READY_CARDS_REQUEST_TIMEOUT_MS = 5_000;
|
|
34
|
+
/** `GET /api/issues` page size (its maximum): a plan's ToDo cards never approach it. */
|
|
35
|
+
const CARDS_PAGE_SIZE = 1000;
|
|
36
|
+
/** The dispatchable card types: Epic and Feature are containers, a Task is a leaf nobody dispatches. */
|
|
37
|
+
const DISPATCHABLE_TYPES = new Set(["Story", "Bug", "Chore"]);
|
|
38
|
+
/**
|
|
39
|
+
* The fields `isReadyCard` judges beyond the always-returned row, each with the check a row's value
|
|
40
|
+
* must pass: the one list the request's `fields` tree and the row parse both come from.
|
|
41
|
+
*/
|
|
42
|
+
const JUDGED_FIELDS = {
|
|
43
|
+
dispatch: (v) => v !== undefined,
|
|
44
|
+
blocked: (v) => v !== undefined,
|
|
45
|
+
open_problem_count: (v) => typeof v === "number",
|
|
46
|
+
waiting_on: (v) => typeof v === "boolean",
|
|
47
|
+
conflict_on_active_count: (v) => typeof v === "number",
|
|
48
|
+
};
|
|
49
|
+
const CARD_FIELDS = Object.fromEntries(Object.keys(JUDGED_FIELDS).map((name) => [name, true]));
|
|
50
|
+
const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${READY_CARDS_SUBCOMMAND}`;
|
|
51
|
+
/** The row, or null when any field `isReadyCard` judges is missing or the wrong type: a card never counts on a guess. */
|
|
52
|
+
function parseCardRow(raw) {
|
|
53
|
+
if (typeof raw !== "object" || raw === null)
|
|
54
|
+
return null;
|
|
55
|
+
const r = raw;
|
|
56
|
+
const assigned = r.assigned_agent;
|
|
57
|
+
if (typeof r.id !== "string" || typeof r.type !== "string" || typeof r.title !== "string")
|
|
58
|
+
return null;
|
|
59
|
+
if (!(assigned === null || typeof assigned === "string"))
|
|
60
|
+
return null;
|
|
61
|
+
if (!Object.entries(JUDGED_FIELDS).every(([name, valid]) => valid(r[name])))
|
|
62
|
+
return null;
|
|
63
|
+
return r;
|
|
64
|
+
}
|
|
65
|
+
/** Whether a ToDo card could be claimed right now (see the module banner). */
|
|
66
|
+
export function isReadyCard(card) {
|
|
67
|
+
return (DISPATCHABLE_TYPES.has(card.type) &&
|
|
68
|
+
card.assigned_agent === null &&
|
|
69
|
+
card.dispatch === null &&
|
|
70
|
+
card.blocked === null &&
|
|
71
|
+
card.open_problem_count === 0 &&
|
|
72
|
+
!card.waiting_on &&
|
|
73
|
+
card.conflict_on_active_count === 0);
|
|
74
|
+
}
|
|
75
|
+
function classifyStatus(status) {
|
|
76
|
+
if (status === 401 || status === 403)
|
|
77
|
+
return "unauthorized";
|
|
78
|
+
if (status === 404)
|
|
79
|
+
return "not_found";
|
|
80
|
+
if (status < 200 || status >= 300)
|
|
81
|
+
return "http_error";
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
/** The session's plan and its boards, `null` when the dashboard has the session on no plan, or a failure. */
|
|
85
|
+
async function readSessionPlan(options, deps) {
|
|
86
|
+
const sent = await sendSessionRequest(options, "/api/plans/mine", { method: "GET" }, deps);
|
|
87
|
+
if ("ok" in sent)
|
|
88
|
+
return sent;
|
|
89
|
+
const parsed = tryParseJsonBody(sent.text);
|
|
90
|
+
const body = parsed.ok ? parsed.body : null;
|
|
91
|
+
// A session on no plan is the ordinary answer, not a fault: a 409 `session_not_connected`.
|
|
92
|
+
if (sent.status === 409 && body?.error === "session_not_connected")
|
|
93
|
+
return { ok: true, plan: null };
|
|
94
|
+
const failed = classifyStatus(sent.status);
|
|
95
|
+
if (failed !== null)
|
|
96
|
+
return { ok: false, reason: failed };
|
|
97
|
+
if (body === null ||
|
|
98
|
+
typeof body.id !== "number" ||
|
|
99
|
+
!Array.isArray(body.boards) ||
|
|
100
|
+
!body.boards.every((b) => typeof b === "string")) {
|
|
101
|
+
return { ok: false, reason: "bad_response" };
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, plan: { planId: body.id, boards: body.boards } };
|
|
104
|
+
}
|
|
105
|
+
/** The plan's ready cards on one board. */
|
|
106
|
+
async function readBoardReadyCards(options, deps, planId, board) {
|
|
107
|
+
const filter = encodeURIComponent(JSON.stringify({ plan_id: planId, status_derived: "ToDo" }));
|
|
108
|
+
const fields = encodeURIComponent(JSON.stringify(CARD_FIELDS));
|
|
109
|
+
const sent = await sendSessionRequest(options, `/api/issues?board=${encodeURIComponent(board)}&filter=${filter}&fields=${fields}&limit=${CARDS_PAGE_SIZE}&sort=priority`, { method: "GET" }, deps);
|
|
110
|
+
if ("ok" in sent)
|
|
111
|
+
return sent;
|
|
112
|
+
const failed = classifyStatus(sent.status);
|
|
113
|
+
if (failed !== null)
|
|
114
|
+
return { ok: false, reason: failed };
|
|
115
|
+
const parsed = tryParseJsonBody(sent.text);
|
|
116
|
+
const body = parsed.ok ? parsed.body : null;
|
|
117
|
+
if (body === null || !Array.isArray(body.issues) || typeof body.total !== "number")
|
|
118
|
+
return { ok: false, reason: "bad_response" };
|
|
119
|
+
// A truncated read would silently miss ready cards: fail loudly instead.
|
|
120
|
+
if (body.total > body.issues.length)
|
|
121
|
+
return { ok: false, reason: "too_many_cards" };
|
|
122
|
+
const cards = [];
|
|
123
|
+
for (const raw of body.issues) {
|
|
124
|
+
const row = parseCardRow(raw);
|
|
125
|
+
if (row === null)
|
|
126
|
+
return { ok: false, reason: "bad_response" };
|
|
127
|
+
if (isReadyCard(row))
|
|
128
|
+
cards.push({ id: row.id, title: row.title });
|
|
129
|
+
}
|
|
130
|
+
return { ok: true, cards };
|
|
131
|
+
}
|
|
132
|
+
/** One authenticated read of the session's plan's ready cards, across every board the plan spans. Never throws. */
|
|
133
|
+
export async function fetchReadyCards(options, deps) {
|
|
134
|
+
const read = await readSessionPlan(options, deps);
|
|
135
|
+
if (!read.ok)
|
|
136
|
+
return read;
|
|
137
|
+
if (read.plan === null)
|
|
138
|
+
return { ok: true, planId: null, cards: [] };
|
|
139
|
+
const { planId } = read.plan;
|
|
140
|
+
const perBoard = await Promise.all(read.plan.boards.map((board) => readBoardReadyCards(options, deps, planId, board)));
|
|
141
|
+
const cards = [];
|
|
142
|
+
for (const onBoard of perBoard) {
|
|
143
|
+
if (!onBoard.ok)
|
|
144
|
+
return onBoard;
|
|
145
|
+
cards.push(...onBoard.cards);
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, planId: read.plan.planId, cards };
|
|
148
|
+
}
|
|
149
|
+
/** The bin's `ready-cards` subcommand, wired to the real process. */
|
|
150
|
+
export async function runReadyCardsCommand(argv, env = process.env,
|
|
151
|
+
/** Test seams: the home the connection record is read from, and the HTTP client. */
|
|
152
|
+
seams = {}) {
|
|
153
|
+
if (argv.length !== 0) {
|
|
154
|
+
process.stderr.write(`unexpected arguments\n${USAGE}\n`);
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
// `resolveSessionOptions` words a missing session id as the usage text alone; every stderr line here starts with its reason.
|
|
158
|
+
if (!env.CLAUDE_CODE_SESSION_ID) {
|
|
159
|
+
process.stderr.write(`no_session_id: CLAUDE_CODE_SESSION_ID is not set\n${USAGE}\n`);
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
const resolved = await resolveSessionOptions(env, { home: seams.home }, USAGE);
|
|
163
|
+
if (!resolved.ok) {
|
|
164
|
+
// `detail` names its reason except for a missing session id, where it is only the usage text.
|
|
165
|
+
const named = resolved.detail.startsWith(`${resolved.output.reason}:`);
|
|
166
|
+
process.stderr.write(named ? `${resolved.detail}\n` : `${resolved.output.reason}: CLAUDE_CODE_SESSION_ID is not set\n${resolved.detail}\n`);
|
|
167
|
+
return 1;
|
|
168
|
+
}
|
|
169
|
+
const output = await fetchReadyCards(resolved.options, seams.deps ?? { fetch: (input, init) => fetch(input, init), requestTimeoutMs: READY_CARDS_REQUEST_TIMEOUT_MS });
|
|
170
|
+
if (!output.ok) {
|
|
171
|
+
process.stderr.write(`${output.reason}: could not read the plan's ready cards from the dashboard\n`);
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
process.stdout.write(`${JSON.stringify({ plan_id: output.planId, cards: output.cards })}\n`);
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
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.247",
|
|
4
4
|
"description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, danxbot_complete, and other per-dispatch lifecycle callbacks) (DX-3465, PLN-11 R-25).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|