@thehammer/danx-dashboard-mcp 0.1.285 → 0.1.287
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 +3 -9
- package/dist/credential.js +5 -5
- package/dist/digest-hold.js +2 -1
- package/dist/heartbeat-reporter.js +2 -2
- package/dist/index.js +16 -44
- package/dist/listen.js +11 -6
- package/dist/plan-events-relay.js +4 -3
- package/dist/request-bound.js +1 -2
- package/dist/restart-notice.js +50 -4
- package/dist/session-connection.js +9 -10
- package/dist/session-options.js +6 -6
- package/dist/stop-fixes.js +4 -4
- package/dist/urgent-records.js +34 -0
- package/package.json +1 -1
- package/dist/activity.js +0 -260
- package/dist/background-work.js +0 -162
- package/dist/one-shot-session-request.js +0 -105
- package/dist/ready-cards.js +0 -176
package/dist/ready-cards.js
DELETED
|
@@ -1,176 +0,0 @@
|
|
|
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
|
-
}
|