@thehammer/danx-dashboard-mcp 0.1.63 → 0.1.64
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 +5 -4
- package/dist/handlers.js +227 -137
- package/dist/index.js +54 -35
- package/dist/listen.js +81 -10
- package/dist/one-line.js +18 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,12 +25,13 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
|
|
|
25
25
|
| `issue_get` | `GET /api/issues/:id` or `GET /api/issues/batch` | Pass `id` for one card, or `ids[]` (DX-2727, at most 100) to resolve many across boards in ONE call — global, so `ids` with `board` throws; per-id `not_found` rather than a whole-call 404. Minimal scalars by default; `fields` opts in |
|
|
26
26
|
| `issue_create` | `POST /api/issues` | Epic REQUIRES non-empty `phase_children[]` (atomic insert). `title` = short domain-naming label; `summary` = 1–3 plain-language sentences, always shown; `description` = the collapsed "Context" body. Root and every phase child take their own `summary` |
|
|
27
27
|
| `issue_edit` | `PATCH /api/issues/:id/edit` | Prose + structured keys (`title`, `summary` (null clears), `description`, `ac`, `checklists`, `effort_level`, `parent_id`, `priority`, `list_id`); semantic keys refused with 400 + pointer to dedicated handler. `priority` (DX-1532) takes a tier word (`low`/`high`/…) or a number in `[0,6)` — the ONLY way to set the numeric column the Trello label + dashboard badge read; never set priority via description prose |
|
|
28
|
-
| `issue_transition` | `POST /api/issues/:id/transition` | Actions: ready, pickup, rollback_pickup, complete, cancel, block, unblock, archive, reopen.
|
|
29
|
-
| `
|
|
28
|
+
| `issue_transition` | `POST /api/issues/:id/transition` | Actions: ready, pickup, rollback_pickup, complete, cancel, block, unblock, archive, reopen. `block` is a dispatch hold only — it never marks the card as needing a human; use `issue_problem` + `issue_requires_human` for that |
|
|
29
|
+
| `issue_problem` | `GET/POST/PATCH/DELETE /api/issues/:id/problems[/:pid]` | Actions list / add / edit / remove. A problem is one statement the operator must resolve (a question or a plan flaw) with its own solutions and answers; `add` takes `statement` + optional `solutions[]` in one transaction. Edit + remove are hash-guarded (`base_hash`, 409 `stale_problem`); removing the last open problem while `requires_human` is set is refused 409 `last_open_problem` |
|
|
30
|
+
| `issue_solution` | `POST/PATCH/DELETE /api/issues/:id/problems/:pid/solutions[/:sid]` | Actions add / edit / remove, `problem_id` required (list via `issue_problem list`). Edit + remove are hash-guarded (`base_hash`, 409 `stale_solution` with the current row); at most one live `recommended` per problem; a chosen option cannot be edited. No answer action on any tool — the operator answers in the dashboard |
|
|
30
31
|
| `issue_triage` | `POST /api/issues/:id/triage` | Send `{confidence, reason}` — an integer 0-5 score; the server computes the verdict (approve/cancel/keep/defer) against the board's configured thresholds (DX-2086). `keep`/`defer` now block the card. None of these are a cross-card ordering gate; use `issue_dependency` to sequence cards |
|
|
31
32
|
| `issue_comment` | `POST/PATCH/DELETE /api/issues/:id/comments[/:cid]` | Author server-stamped, soft-delete preserved |
|
|
32
33
|
| `issue_dependency` | `POST/DELETE /api/issues/:id/dependencies[/:did]` | `depends_on` cycle-checked; remove hardcodes `reason: "recorded_in_error"`. The only mechanism the dispatch picker enforces to sequence one card after another — status alone is not a substitute |
|
|
33
|
-
| `issue_requires_human` | `POST/DELETE /api/issues/:id/requires-human` | Set replaces step rows atomically; clear soft-deletes them. A successful set also returns `
|
|
34
|
+
| `issue_requires_human` | `POST/DELETE /api/issues/:id/requires-human` | Set REQUIRES an open problem (409 `no_open_problem` with the server's `fix` otherwise) and replaces step rows atomically; clear soft-deletes them. The gate clears itself when the last open problem is answered. A successful set also returns `problems_reminder: {open_problem_count, instruction}` |
|
|
34
35
|
| `issue_retro` | `PUT /api/issues/:id/retro` | Requires terminal card; replace semantics |
|
|
35
36
|
|
|
36
37
|
## `plan_get` — cheap by default, opt-in for the rest (DX-2727)
|
|
@@ -48,7 +49,7 @@ npx -y @thehammer/danx-dashboard-mcp@<version> listen --stream <dashboard>/api/p
|
|
|
48
49
|
All three flags come from the dashboard's own ticket response; `plan_connect` assembles the command, so never build it by hand.
|
|
49
50
|
|
|
50
51
|
- **Credential.** `--ticket` is a listener ticket minted by `POST /api/plan-sessions/me/stream-ticket` for THIS session only. It authorizes reading that one session's event stream and nothing else, and stops the moment the credential that minted it is revoked — the MCP server's own token never appears in the command. Minting a new one (calling `plan_connect` again) ends the previous listener; a second connection with the same ticket replaces the first.
|
|
51
|
-
- **Output.** Exactly one line per event on the connected plan's cards — `[DX-8 "Title" repo:board] newms87 answered: chose "Pause E2E" — note: "only this week"`, `… commented: "…"`, `… set requires_human: "…"`, `… cleared requires_human`, `… blocked the card: "…"`, `… unblocked the card`. Nothing for keep-alives or reconnects. The session's own writes are never echoed back to it. An event it cannot read still produces one `could not read event` line.
|
|
52
|
+
- **Output.** Exactly one line per event on the connected plan's cards — `[DX-8 "Title" repo:board] newms87 answered "Which rollout order?": chose "Pause E2E" — note: "only this week"`, `… answered "…": "<free-form answer>"`, `… commented: "…"`, `… set requires_human: "…"`, `… cleared requires_human`, `… blocked the card: "…"`, `… unblocked the card`. Nothing for keep-alives or reconnects. The session's own writes are never echoed back to it. An event it cannot read still produces one `could not read event` line.
|
|
52
53
|
- **Reconnect.** 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 printed twice. One final `[danx-dashboard listen] …` line and exit when the ticket is refused or revoked (exit 1), when a newer listener takes over (exit 0), or after `--lease-ms` without a healthy connection (exit 1) — the remedy is always `plan_connect` again.
|
|
53
54
|
|
|
54
55
|
## Build + test
|
package/dist/handlers.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { readFile } from "node:fs/promises";
|
|
25
25
|
import { basename, extname, isAbsolute } from "node:path";
|
|
26
|
+
import { oneLine } from "./one-line.js";
|
|
26
27
|
import { resolvePriority } from "./priority.js";
|
|
27
28
|
/**
|
|
28
29
|
* `filter`/`fields`/`sort` are JSON/CSV-encoded onto the query string (the
|
|
@@ -255,62 +256,92 @@ export async function issueEdit(client, args) {
|
|
|
255
256
|
}
|
|
256
257
|
export async function issueTransition(client, args) {
|
|
257
258
|
const { id, board, ...body } = args;
|
|
258
|
-
|
|
259
|
+
// DX-2735: no reminder on block. A block is a dispatch HOLD only — it never
|
|
260
|
+
// puts the card in front of a human — so there is no question to list
|
|
261
|
+
// options for. A card that needs a human uses issue_problem + issue_requires_human.
|
|
262
|
+
return client.request({
|
|
259
263
|
method: "POST",
|
|
260
264
|
path: `/${encodeURIComponent(id)}/transition`,
|
|
261
265
|
body,
|
|
262
266
|
board,
|
|
263
267
|
});
|
|
264
|
-
|
|
268
|
+
}
|
|
269
|
+
/** How much of a problem statement the reminder quotes — enough to recognise it. */
|
|
270
|
+
const REMINDER_STATEMENT_MAX = 120;
|
|
271
|
+
function isReminderProblem(value) {
|
|
272
|
+
const p = value;
|
|
273
|
+
return (typeof p === "object" &&
|
|
274
|
+
p !== null &&
|
|
275
|
+
typeof p.id === "number" &&
|
|
276
|
+
typeof p.statement === "string" &&
|
|
277
|
+
typeof p.open === "boolean" &&
|
|
278
|
+
Array.isArray(p.solutions));
|
|
265
279
|
}
|
|
266
280
|
/**
|
|
267
|
-
* Attach the
|
|
281
|
+
* Attach the problems reminder to a SUCCESSFUL requires-human set.
|
|
268
282
|
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
283
|
+
* DX-2735 — THE SERVER NOW ENFORCES THE QUESTION, THIS ENFORCES THE OPTIONS.
|
|
284
|
+
* The set route refuses 409 `no_open_problem` unless the card has an open
|
|
285
|
+
* problem, so a card can no longer be put in front of a human without saying
|
|
286
|
+
* what the human must decide. Whether each open problem lists its viable
|
|
287
|
+
* solutions is still feedback, not refusal: a problem with zero solutions is
|
|
288
|
+
* valid (the operator answers free-form), and refusing it would push the agent
|
|
289
|
+
* into inventing options. So the reminder rides the success response and names
|
|
290
|
+
* the STILL-OPEN problems only — an answered sibling needs nothing more.
|
|
276
291
|
*
|
|
277
|
-
* The
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
292
|
+
* The write's own envelope is returned untouched beside it. A refused write
|
|
293
|
+
* (including `no_open_problem`, whose `fix` text passes through verbatim) gets
|
|
294
|
+
* no reminder and no extra request. A failure READING the problems is reported
|
|
295
|
+
* inside the reminder rather than thrown: the set already succeeded, and
|
|
296
|
+
* throwing would tell the agent it failed when it did not.
|
|
282
297
|
*/
|
|
283
|
-
export async function
|
|
298
|
+
export async function withProblemsReminder(client, id, board, result) {
|
|
284
299
|
if (!result.ok)
|
|
285
300
|
return result;
|
|
286
|
-
const nextStep = `issue_solution({id: "${id}", action: "add", title, body, pro, con})`;
|
|
287
301
|
let listed;
|
|
288
302
|
try {
|
|
289
303
|
listed = await client.request({
|
|
290
304
|
method: "GET",
|
|
291
|
-
path: `/${encodeURIComponent(id)}/
|
|
305
|
+
path: `/${encodeURIComponent(id)}/problems`,
|
|
292
306
|
board,
|
|
293
307
|
});
|
|
294
308
|
}
|
|
295
309
|
catch (err) {
|
|
296
|
-
return { ...result,
|
|
310
|
+
return { ...result, problems_reminder: unreadableReminder(id, err instanceof Error ? err.message : String(err)) };
|
|
297
311
|
}
|
|
298
312
|
// `body` is the server's JSON verbatim — `null` is valid JSON, so read it null-safely;
|
|
299
313
|
// a throw here would land OUTSIDE the try above and misreport the successful write.
|
|
300
|
-
const
|
|
301
|
-
if (!Array.isArray(
|
|
302
|
-
|
|
314
|
+
const problems = listed.ok ? listed.body?.problems : undefined;
|
|
315
|
+
if (!Array.isArray(problems) || !problems.every(isReminderProblem)) {
|
|
316
|
+
// A 2xx whose body is not the problem list is a contract break, not a refusal —
|
|
317
|
+
// say so, rather than leaving a bare "HTTP 200" that reads like success.
|
|
318
|
+
const detail = listed.ok ? `HTTP ${listed.status}, unexpected shape` : `HTTP ${listed.status}`;
|
|
319
|
+
return { ...result, problems_reminder: unreadableReminder(id, detail) };
|
|
320
|
+
}
|
|
321
|
+
const open = problems.filter((p) => p.open);
|
|
322
|
+
return { ...result, problems_reminder: { open_problem_count: open.length, instruction: openProblemsInstruction(id, open) } };
|
|
323
|
+
}
|
|
324
|
+
function openProblemsInstruction(id, open) {
|
|
325
|
+
if (open.length === 0) {
|
|
326
|
+
// Only reachable when every problem was answered between the set and this
|
|
327
|
+
// read — the gate has already released itself.
|
|
328
|
+
return `No problem on ${id} is open any more — each was answered after you set requires_human, which releases the gate. Re-read the card before you stop.`;
|
|
303
329
|
}
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
330
|
+
const listing = open
|
|
331
|
+
.map((p) => {
|
|
332
|
+
const statement = oneLine(p.statement, REMINDER_STATEMENT_MAX);
|
|
333
|
+
const count = p.solutions.length;
|
|
334
|
+
return `problem ${p.id} "${statement}" — ${count === 0 ? "NO SOLUTIONS LISTED" : `${count} solution${count === 1 ? "" : "s"}`}`;
|
|
335
|
+
})
|
|
336
|
+
.join("; ");
|
|
337
|
+
return (`${open.length} open problem${open.length === 1 ? "" : "s"} on ${id}: ${listing}. ` +
|
|
338
|
+
`Before you stop, list EVERY viable solution to each with issue_solution({id: "${id}", problem_id, action: "add", title, body, pro, con}) and mark the one you recommend with recommended: true, ` +
|
|
339
|
+
`and make every other question the operator must answer its own problem with issue_problem. The card needs a human until every open problem is answered.`);
|
|
309
340
|
}
|
|
310
341
|
function unreadableReminder(id, detail) {
|
|
311
342
|
return {
|
|
312
|
-
|
|
313
|
-
instruction: `Could not read the
|
|
343
|
+
open_problem_count: null,
|
|
344
|
+
instruction: `Could not read the problems on ${id} (${detail}). Check with issue_problem({id: "${id}", action: "list"}) and make sure EVERY viable solution to each open problem is listed before you stop.`,
|
|
314
345
|
};
|
|
315
346
|
}
|
|
316
347
|
export async function issueTriage(client, args) {
|
|
@@ -322,72 +353,104 @@ export async function issueTriage(client, args) {
|
|
|
322
353
|
board,
|
|
323
354
|
});
|
|
324
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* DX-2735 — the ONE boundary check every action-dispatched handler uses for its
|
|
358
|
+
* required args. The MCP schema already validates, but a handler can be called
|
|
359
|
+
* without it (tests, the composition E2E, a future caller), so each required arg
|
|
360
|
+
* is checked by TYPE, not merely for presence: `null`, a number where text
|
|
361
|
+
* belongs, or a string where a row id belongs all throw here, before any request
|
|
362
|
+
* is built, as `<tool> <mode> requires <arg> (<what it must be>)`.
|
|
363
|
+
*
|
|
364
|
+
* Returns the checkers rather than checking anything itself. `mode` is the
|
|
365
|
+
* condition the args are required under — `action=<action>` for the
|
|
366
|
+
* action-dispatched tools, `set=true` for issue_requires_human — so every tool
|
|
367
|
+
* refuses in the same wording.
|
|
368
|
+
*/
|
|
369
|
+
function argCheckers(tool, mode) {
|
|
370
|
+
const fail = (name, expected) => {
|
|
371
|
+
throw new Error(`${tool} ${mode} requires ${name} (${expected})`);
|
|
372
|
+
};
|
|
373
|
+
return {
|
|
374
|
+
/** Text, a content hash, a name, an enum value. */
|
|
375
|
+
string(value, name) {
|
|
376
|
+
if (typeof value !== "string")
|
|
377
|
+
fail(name, "a string");
|
|
378
|
+
return value;
|
|
379
|
+
},
|
|
380
|
+
/** A database row id. */
|
|
381
|
+
id(value, name) {
|
|
382
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0)
|
|
383
|
+
fail(name, "a positive integer id");
|
|
384
|
+
return value;
|
|
385
|
+
},
|
|
386
|
+
/**
|
|
387
|
+
* A list of human-written lines (e.g. requires_human steps). DX-2735: every
|
|
388
|
+
* element must be a non-blank string, matching the server, which refuses a
|
|
389
|
+
* blank step — refused here before any request is built. An empty list is
|
|
390
|
+
* allowed, as the server allows it.
|
|
391
|
+
*/
|
|
392
|
+
stringArray(value, name) {
|
|
393
|
+
const valid = Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim() !== "");
|
|
394
|
+
if (!valid)
|
|
395
|
+
fail(name, "an array of non-blank strings");
|
|
396
|
+
return value;
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
}
|
|
325
400
|
export async function issueComment(client, args) {
|
|
326
401
|
const idEnc = encodeURIComponent(args.id);
|
|
327
402
|
const board = args.board;
|
|
403
|
+
const need = argCheckers("issue_comment", `action=${args.action}`);
|
|
328
404
|
if (args.action === "add") {
|
|
329
|
-
|
|
330
|
-
throw new Error("issue_comment action=add requires text");
|
|
331
|
-
}
|
|
405
|
+
const text = need.string(args.text, "text");
|
|
332
406
|
return client.request({
|
|
333
407
|
method: "POST",
|
|
334
408
|
path: `/${idEnc}/comments`,
|
|
335
|
-
body: { text
|
|
409
|
+
body: { text, ...(args.metadata !== undefined ? { metadata: args.metadata } : {}) },
|
|
336
410
|
board,
|
|
337
411
|
});
|
|
338
412
|
}
|
|
339
413
|
if (args.action === "edit") {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
if (typeof args.text !== "string") {
|
|
344
|
-
throw new Error("issue_comment action=edit requires text");
|
|
345
|
-
}
|
|
414
|
+
const commentId = need.id(args.comment_id, "comment_id");
|
|
415
|
+
const text = need.string(args.text, "text");
|
|
346
416
|
return client.request({
|
|
347
417
|
method: "PATCH",
|
|
348
|
-
path: `/${idEnc}/comments/${
|
|
349
|
-
body: { text
|
|
418
|
+
path: `/${idEnc}/comments/${commentId}`,
|
|
419
|
+
body: { text },
|
|
350
420
|
board,
|
|
351
421
|
});
|
|
352
422
|
}
|
|
353
423
|
// delete
|
|
354
|
-
|
|
355
|
-
throw new Error("issue_comment action=delete requires comment_id");
|
|
356
|
-
}
|
|
424
|
+
const commentId = need.id(args.comment_id, "comment_id");
|
|
357
425
|
return client.request({
|
|
358
426
|
method: "DELETE",
|
|
359
|
-
path: `/${idEnc}/comments/${
|
|
427
|
+
path: `/${idEnc}/comments/${commentId}`,
|
|
360
428
|
board,
|
|
361
429
|
});
|
|
362
430
|
}
|
|
363
431
|
export async function issueDependency(client, args) {
|
|
364
432
|
const idEnc = encodeURIComponent(args.id);
|
|
365
433
|
const board = args.board;
|
|
434
|
+
const need = argCheckers("issue_dependency", `action=${args.action}`);
|
|
366
435
|
if (args.action === "add") {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
370
|
-
if (args.target_id === undefined) {
|
|
371
|
-
throw new Error("issue_dependency action=add requires target_id");
|
|
372
|
-
}
|
|
436
|
+
const kind = need.string(args.kind, "kind");
|
|
437
|
+
const targetId = need.string(args.target_id, "target_id");
|
|
373
438
|
return client.request({
|
|
374
439
|
method: "POST",
|
|
375
440
|
path: `/${idEnc}/dependencies`,
|
|
376
441
|
body: {
|
|
377
|
-
kind
|
|
378
|
-
target_id:
|
|
442
|
+
kind,
|
|
443
|
+
target_id: targetId,
|
|
379
444
|
reason: args.reason ?? "",
|
|
380
445
|
},
|
|
381
446
|
board,
|
|
382
447
|
});
|
|
383
448
|
}
|
|
384
449
|
// remove
|
|
385
|
-
|
|
386
|
-
throw new Error("issue_dependency action=remove requires dependency_id");
|
|
387
|
-
}
|
|
450
|
+
const dependencyId = need.id(args.dependency_id, "dependency_id");
|
|
388
451
|
return client.request({
|
|
389
452
|
method: "DELETE",
|
|
390
|
-
path: `/${idEnc}/dependencies/${
|
|
453
|
+
path: `/${idEnc}/dependencies/${dependencyId}`,
|
|
391
454
|
// Server demands literal "recorded_in_error" — any other value is
|
|
392
455
|
// a 400. The MCP boundary hardcodes it to remove a footgun: an
|
|
393
456
|
// agent that types the wrong reason gets a refusal here at MCP
|
|
@@ -407,12 +470,10 @@ export async function issueDependency(client, args) {
|
|
|
407
470
|
export async function issueChecklist(client, args) {
|
|
408
471
|
const idEnc = encodeURIComponent(args.id);
|
|
409
472
|
const board = args.board;
|
|
473
|
+
const need = argCheckers("issue_checklist", `action=${args.action}`);
|
|
410
474
|
switch (args.action) {
|
|
411
475
|
case "add_list": {
|
|
412
|
-
|
|
413
|
-
throw new Error("issue_checklist action=add_list requires name");
|
|
414
|
-
}
|
|
415
|
-
const body = { name: args.name };
|
|
476
|
+
const body = { name: need.string(args.name, "name") };
|
|
416
477
|
if (args.items !== undefined)
|
|
417
478
|
body.items = args.items;
|
|
418
479
|
return client.request({
|
|
@@ -423,55 +484,40 @@ export async function issueChecklist(client, args) {
|
|
|
423
484
|
});
|
|
424
485
|
}
|
|
425
486
|
case "update_list": {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
}
|
|
429
|
-
if (typeof args.name !== "string") {
|
|
430
|
-
throw new Error("issue_checklist action=update_list requires name");
|
|
431
|
-
}
|
|
487
|
+
const checklistId = need.id(args.checklist_id, "checklist_id");
|
|
488
|
+
const name = need.string(args.name, "name");
|
|
432
489
|
return client.request({
|
|
433
490
|
method: "PATCH",
|
|
434
|
-
path: `/${idEnc}/checklists/${
|
|
435
|
-
body: { name
|
|
491
|
+
path: `/${idEnc}/checklists/${checklistId}`,
|
|
492
|
+
body: { name },
|
|
436
493
|
board,
|
|
437
494
|
});
|
|
438
495
|
}
|
|
439
496
|
case "remove_list": {
|
|
440
|
-
|
|
441
|
-
throw new Error("issue_checklist action=remove_list requires checklist_id");
|
|
442
|
-
}
|
|
497
|
+
const checklistId = need.id(args.checklist_id, "checklist_id");
|
|
443
498
|
return client.request({
|
|
444
499
|
method: "DELETE",
|
|
445
|
-
path: `/${idEnc}/checklists/${
|
|
500
|
+
path: `/${idEnc}/checklists/${checklistId}`,
|
|
446
501
|
board,
|
|
447
502
|
});
|
|
448
503
|
}
|
|
449
504
|
case "add_item": {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
if (typeof args.label !== "string") {
|
|
454
|
-
throw new Error("issue_checklist action=add_item requires label");
|
|
455
|
-
}
|
|
456
|
-
const body = { label: args.label };
|
|
505
|
+
const checklistId = need.id(args.checklist_id, "checklist_id");
|
|
506
|
+
const body = { label: need.string(args.label, "label") };
|
|
457
507
|
if (args.detail !== undefined)
|
|
458
508
|
body.detail = args.detail;
|
|
459
509
|
if (args.status !== undefined)
|
|
460
510
|
body.status = args.status;
|
|
461
511
|
return client.request({
|
|
462
512
|
method: "POST",
|
|
463
|
-
path: `/${idEnc}/checklists/${
|
|
513
|
+
path: `/${idEnc}/checklists/${checklistId}/items`,
|
|
464
514
|
body,
|
|
465
515
|
board,
|
|
466
516
|
});
|
|
467
517
|
}
|
|
468
518
|
case "update_item": {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}
|
|
472
|
-
if (args.item_id === undefined) {
|
|
473
|
-
throw new Error("issue_checklist action=update_item requires item_id");
|
|
474
|
-
}
|
|
519
|
+
const checklistId = need.id(args.checklist_id, "checklist_id");
|
|
520
|
+
const itemId = need.id(args.item_id, "item_id");
|
|
475
521
|
const body = {};
|
|
476
522
|
if (args.label !== undefined)
|
|
477
523
|
body.label = args.label;
|
|
@@ -481,39 +527,92 @@ export async function issueChecklist(client, args) {
|
|
|
481
527
|
body.status = args.status;
|
|
482
528
|
return client.request({
|
|
483
529
|
method: "PATCH",
|
|
484
|
-
path: `/${idEnc}/checklists/${
|
|
530
|
+
path: `/${idEnc}/checklists/${checklistId}/items/${itemId}`,
|
|
485
531
|
body,
|
|
486
532
|
board,
|
|
487
533
|
});
|
|
488
534
|
}
|
|
489
535
|
case "remove_item": {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
536
|
+
const checklistId = need.id(args.checklist_id, "checklist_id");
|
|
537
|
+
const itemId = need.id(args.item_id, "item_id");
|
|
538
|
+
return client.request({
|
|
539
|
+
method: "DELETE",
|
|
540
|
+
path: `/${idEnc}/checklists/${checklistId}/items/${itemId}`,
|
|
541
|
+
board,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* A card's PROBLEMS via `/api/issues/:id/problems[/:pid]` (DX-2735),
|
|
548
|
+
* action-dispatched like `issue_checklist`. A problem is one statement the
|
|
549
|
+
* operator must resolve — a question or a flaw in the plan — owning its own
|
|
550
|
+
* solutions and its own decisions. A missing required arg throws at this
|
|
551
|
+
* boundary (no round-trip); the server's refusals (`stale_problem` with the
|
|
552
|
+
* current row, `last_open_problem`) pass through verbatim.
|
|
553
|
+
*
|
|
554
|
+
* `add` carries `solutions[]` in the SAME request so a problem and its options
|
|
555
|
+
* land in one server transaction — never a problem briefly visible to the
|
|
556
|
+
* operator with none of the options it was created with.
|
|
557
|
+
*
|
|
558
|
+
* No answer action, for the reason `issueSolution` gives.
|
|
559
|
+
*/
|
|
560
|
+
export async function issueProblem(client, args) {
|
|
561
|
+
const idEnc = encodeURIComponent(args.id);
|
|
562
|
+
const board = args.board;
|
|
563
|
+
const need = argCheckers("issue_problem", `action=${args.action}`);
|
|
564
|
+
switch (args.action) {
|
|
565
|
+
case "list":
|
|
566
|
+
return client.request({ method: "GET", path: `/${idEnc}/problems`, board });
|
|
567
|
+
case "add": {
|
|
568
|
+
const body = { statement: need.string(args.statement, "statement") };
|
|
569
|
+
if (args.solutions !== undefined)
|
|
570
|
+
body.solutions = args.solutions;
|
|
571
|
+
return client.request({ method: "POST", path: `/${idEnc}/problems`, body, board });
|
|
572
|
+
}
|
|
573
|
+
case "edit": {
|
|
574
|
+
const problemId = need.id(args.problem_id, "problem_id");
|
|
575
|
+
const baseHash = need.string(args.base_hash, "base_hash");
|
|
576
|
+
const statement = need.string(args.statement, "statement");
|
|
577
|
+
return client.request({
|
|
578
|
+
method: "PATCH",
|
|
579
|
+
path: `/${idEnc}/problems/${problemId}`,
|
|
580
|
+
body: { base_hash: baseHash, statement },
|
|
581
|
+
board,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
case "remove": {
|
|
585
|
+
const problemId = need.id(args.problem_id, "problem_id");
|
|
586
|
+
const baseHash = need.string(args.base_hash, "base_hash");
|
|
496
587
|
return client.request({
|
|
497
588
|
method: "DELETE",
|
|
498
|
-
path: `/${idEnc}/
|
|
589
|
+
path: `/${idEnc}/problems/${problemId}`,
|
|
590
|
+
body: { base_hash: baseHash },
|
|
499
591
|
board,
|
|
500
592
|
});
|
|
501
593
|
}
|
|
502
594
|
}
|
|
503
595
|
}
|
|
504
596
|
/**
|
|
505
|
-
*
|
|
506
|
-
* action-dispatched
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
597
|
+
* One problem's candidate solutions via
|
|
598
|
+
* `/api/issues/:id/problems/:pid/solutions[/:sid]` (DX-2735), action-dispatched
|
|
599
|
+
* like `issue_checklist`. A missing required arg for the chosen action throws at
|
|
600
|
+
* this boundary (no round-trip); the server's refusal envelopes
|
|
601
|
+
* (`stale_solution` with the current row, a second recommendation on the same
|
|
602
|
+
* problem, editing a chosen option, a solution id from another problem → 404)
|
|
603
|
+
* pass through verbatim.
|
|
510
604
|
*
|
|
511
|
-
* There is deliberately NO answer action
|
|
512
|
-
*
|
|
513
|
-
* very stop it set to wait for a human. The operator
|
|
605
|
+
* There is deliberately NO answer action, on this tool or any other. Answering
|
|
606
|
+
* releases the human gate on a card — an agent that could answer its own
|
|
607
|
+
* question could release the very stop it set to wait for a human. The operator
|
|
608
|
+
* answers in the dashboard.
|
|
514
609
|
*/
|
|
515
610
|
export async function issueSolution(client, args) {
|
|
516
|
-
const
|
|
611
|
+
const need = argCheckers("issue_solution", `action=${args.action}`);
|
|
612
|
+
// DX-2735: checked at runtime too, not only by the schema — a caller that skips
|
|
613
|
+
// the MCP boundary must never build `/problems/undefined/solutions`.
|
|
614
|
+
const problemId = need.id(args.problem_id, "problem_id");
|
|
615
|
+
const base = `/${encodeURIComponent(args.id)}/problems/${problemId}/solutions`;
|
|
517
616
|
const board = args.board;
|
|
518
617
|
const content = {};
|
|
519
618
|
for (const key of ["title", "body", "pro", "con", "recommended"]) {
|
|
@@ -521,58 +620,49 @@ export async function issueSolution(client, args) {
|
|
|
521
620
|
content[key] = args[key];
|
|
522
621
|
}
|
|
523
622
|
switch (args.action) {
|
|
524
|
-
case "
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
if (args.solution_id === undefined) {
|
|
533
|
-
throw new Error("issue_solution action=edit requires solution_id");
|
|
534
|
-
}
|
|
535
|
-
if (typeof args.base_hash !== "string") {
|
|
536
|
-
throw new Error("issue_solution action=edit requires base_hash");
|
|
537
|
-
}
|
|
623
|
+
case "add": {
|
|
624
|
+
// DX-2735: the POST carries the CHECKED title, not a re-read of args.title.
|
|
625
|
+
const title = need.string(args.title, "title");
|
|
626
|
+
return client.request({ method: "POST", path: base, body: { ...content, title }, board });
|
|
627
|
+
}
|
|
628
|
+
case "edit": {
|
|
629
|
+
const solutionId = need.id(args.solution_id, "solution_id");
|
|
630
|
+
const baseHash = need.string(args.base_hash, "base_hash");
|
|
538
631
|
return client.request({
|
|
539
632
|
method: "PATCH",
|
|
540
|
-
path: `${base}/${
|
|
541
|
-
body: { base_hash:
|
|
633
|
+
path: `${base}/${solutionId}`,
|
|
634
|
+
body: { base_hash: baseHash, ...content },
|
|
542
635
|
board,
|
|
543
636
|
});
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
if (typeof args.base_hash !== "string") {
|
|
549
|
-
throw new Error("issue_solution action=remove requires base_hash");
|
|
550
|
-
}
|
|
637
|
+
}
|
|
638
|
+
case "remove": {
|
|
639
|
+
const solutionId = need.id(args.solution_id, "solution_id");
|
|
640
|
+
const baseHash = need.string(args.base_hash, "base_hash");
|
|
551
641
|
return client.request({
|
|
552
642
|
method: "DELETE",
|
|
553
|
-
path: `${base}/${
|
|
554
|
-
body: { base_hash:
|
|
643
|
+
path: `${base}/${solutionId}`,
|
|
644
|
+
body: { base_hash: baseHash },
|
|
555
645
|
board,
|
|
556
646
|
});
|
|
647
|
+
}
|
|
557
648
|
}
|
|
558
649
|
}
|
|
559
650
|
export async function issueRequiresHuman(client, args) {
|
|
560
651
|
const idEnc = encodeURIComponent(args.id);
|
|
561
652
|
const board = args.board;
|
|
562
653
|
if (args.set) {
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}
|
|
654
|
+
// DX-2735: the same checkers every action-dispatched tool uses, so the refusal
|
|
655
|
+
// wording and type checks match — `issue_requires_human set=true requires ...`.
|
|
656
|
+
const need = argCheckers("issue_requires_human", "set=true");
|
|
657
|
+
const reason = need.string(args.reason, "reason");
|
|
658
|
+
const steps = need.stringArray(args.steps, "steps");
|
|
569
659
|
const result = await client.request({
|
|
570
660
|
method: "POST",
|
|
571
661
|
path: `/${idEnc}/requires-human`,
|
|
572
|
-
body: { reason
|
|
662
|
+
body: { reason, steps },
|
|
573
663
|
board,
|
|
574
664
|
});
|
|
575
|
-
return
|
|
665
|
+
return withProblemsReminder(client, args.id, board, result);
|
|
576
666
|
}
|
|
577
667
|
return client.request({
|
|
578
668
|
method: "DELETE",
|
package/dist/index.js
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* - issue_triage POST /api/issues/:id/triage
|
|
20
20
|
* - issue_comment POST/PATCH/DELETE /api/issues/:id/comments[/:cid]
|
|
21
21
|
* - issue_checklist POST/PATCH/DELETE /api/issues/:id/checklists[/:cid[/items[/:iid]]]
|
|
22
|
-
* -
|
|
22
|
+
* - issue_problem GET/POST/PATCH/DELETE /api/issues/:id/problems[/:pid] (DX-2735)
|
|
23
|
+
* - issue_solution POST/PATCH/DELETE /api/issues/:id/problems/:pid/solutions[/:sid] (DX-2735)
|
|
23
24
|
* - issue_dependency POST/DELETE /api/issues/:id/dependencies[/:did]
|
|
24
25
|
* - issue_requires_human POST/DELETE /api/issues/:id/requires-human
|
|
25
26
|
* - issue_quality_gate POST /api/issues/:id/quality-gates/:gate
|
|
@@ -88,7 +89,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
88
89
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
89
90
|
import { z } from "zod";
|
|
90
91
|
import { DashboardHttpClient } from "./http-client.js";
|
|
91
|
-
import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueQualityGate, issueQualityGateVerdict, issueRequiresHuman, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, planAddArchitectureSection, planAddCard, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, 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";
|
|
92
|
+
import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRequiresHuman, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, planAddArchitectureSection, planAddCard, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, 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";
|
|
92
93
|
import { PRIORITY_TIER_WORDS } from "./priority.js";
|
|
93
94
|
function readEnvOrDie(name) {
|
|
94
95
|
const v = process.env[name];
|
|
@@ -214,7 +215,8 @@ const NON_EPIC_TYPES = ["Bug", "Feature", "Story", "Chore", "Task"];
|
|
|
214
215
|
// server 400, not silently.
|
|
215
216
|
const LIST_FIELD_GROUPS = [
|
|
216
217
|
"description",
|
|
217
|
-
"solutions",
|
|
218
|
+
// DX-2735: replaced the flat "solutions" group (hard cut, no alias).
|
|
219
|
+
"problems",
|
|
218
220
|
"ac",
|
|
219
221
|
"comments",
|
|
220
222
|
"retro",
|
|
@@ -280,9 +282,12 @@ const boardField = {
|
|
|
280
282
|
// identical wherever it writes the field.
|
|
281
283
|
const TITLE_DESCRIBE = 'Short, specific label that names the domain, so a reader recognises the card without opening it (e.g. "Guest checkout rejects carts holding a gift card"). Never a generic phrase like "2 real decisions needed", "Fix bug" or "Follow-up".';
|
|
282
284
|
const SUMMARY_DESCRIBE = "1–3 plain-language sentences — no markdown, no jargon — for someone who has never seen this codebase: what the card is about and why it matters. Always shown, never collapsed. It must stand on its own: not a second title, not a teaser for the description.";
|
|
283
|
-
const DESCRIPTION_DESCRIBE = 'The full body ("Context"): evidence, examples, technical detail
|
|
285
|
+
const DESCRIPTION_DESCRIBE = 'The full body ("Context"): evidence, examples, technical detail; markdown, collapsed by default. A question for the operator and its options go in issue_problem, not here.';
|
|
284
286
|
// ---------------- issue_list ----------------
|
|
285
|
-
server.tool("issue_list",
|
|
287
|
+
server.tool("issue_list",
|
|
288
|
+
// DX-2735: trimmed to pay for the problem tools inside the work-profile
|
|
289
|
+
// injected-surface budget — same facts, no repeated prose.
|
|
290
|
+
"List cards via GET /api/issues on this dispatch's board, or `board` (`<repo>:<slug>`; unknown → 404). `filter`: type, parent_id, dispatchable_derived, status_derived[], self_dispatchable_derived, assigned_agent, include_closed, include_deleted, q (free text over id+title+description). THE DEFAULT ROW IS MINIMAL — scalars only (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash), no joins; opt into heavy data with `fields` groups: description (+ summary), problems (open_problem_count), ac, comments, retro, dependencies, triage, requires_human, assignment, quality_gates, children, effort. `sort`: [{column, order}] over id|priority|repo_name|title|type|status_derived|triage_ice_total|created_at|updated_at; default priority desc, repo_name asc, numeric-id tiebreaker. `limit`/`offset` page (uncapped by default). issue_get reads one card in full.", {
|
|
286
291
|
filter: z
|
|
287
292
|
.object({
|
|
288
293
|
q: z.string().optional(),
|
|
@@ -295,30 +300,27 @@ server.tool("issue_list", "List issues for the dispatch's board by default via G
|
|
|
295
300
|
include_closed: z.boolean().optional(),
|
|
296
301
|
include_deleted: z.boolean().optional(),
|
|
297
302
|
})
|
|
298
|
-
.optional()
|
|
299
|
-
.describe("Nested list filters (DX-935) — the hard-cut replacement for the old flat top-level params, including the former standalone `q` (now `filter.q`). Omit entirely for no filtering."),
|
|
303
|
+
.optional(),
|
|
300
304
|
fields: z
|
|
301
305
|
.array(z.enum(LIST_FIELD_GROUPS))
|
|
302
306
|
.optional()
|
|
303
|
-
.describe("
|
|
307
|
+
.describe("Field groups to add; absent = minimal scalars."),
|
|
304
308
|
sort: sortField,
|
|
305
309
|
limit: z.number().int().positive().max(LIST_PAGE_MAX_LIMIT).optional(),
|
|
306
310
|
offset: z.number().int().nonnegative().optional(),
|
|
307
311
|
...boardField,
|
|
308
312
|
}, async (args) => jsonResult(await issueList(client, args)));
|
|
309
313
|
// ---------------- issue_get ----------------
|
|
310
|
-
server.tool("issue_get",
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
.optional()
|
|
317
|
-
.describe(`Batch form (DX-2727): every requested id (at most ${ISSUE_BATCH_GET_MAX}), resolved globally in ONE call. Mutually exclusive with \`id\`, and never combined with \`board\` (the batch is global; passing both throws). Reports not_found per id rather than failing the whole call.`),
|
|
314
|
+
server.tool("issue_get",
|
|
315
|
+
// DX-2735: trimmed to pay for the problem tools inside the work-profile
|
|
316
|
+
// injected-surface budget — same facts, no repeated prose.
|
|
317
|
+
"Fetch one card (GET /api/issues/:id, `id`) or many (GET /api/issues/batch, `ids`, at most " + ISSUE_BATCH_GET_MAX + " — split larger sets); pass exactly one. Ids are globally unique, so both resolve from any board; the batch form is global and throws with `board`. THE DEFAULT ROW IS MINIMAL — scalars only (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash: the concurrency token issue_edit needs for title/description/checklists). `fields` opts into groups, per card in a batch too: description (body + summary), problems (live problems in order, each {id, statement, content_hash, open} with its solutions[] and decisions[]), ac (acceptance criteria + checklists), comments, retro, dependencies (waiting_on/conflict_on/blocked), triage (history + ICE), requires_human (gate + steps), assignment (dispatch, assigned_agent, lifecycle timestamps), quality_gates (one row per gate {gate, required, status pending|pass|fail, completed_at, message}; a required PRE gate not `pass` pre-empts the work dispatch, and complete refuses while a required POST gate is not `pass`), children (ids + rollups), mirrors (external sync state), code_review_items. Single form: unknown id → 404. Batch form: `{issues: [...], not_found: [...ids]}` — an unknown or deleted id never fails the call.", {
|
|
318
|
+
id: z.string().min(1).optional(),
|
|
319
|
+
ids: z.array(z.string().min(1)).min(1).max(ISSUE_BATCH_GET_MAX).optional(),
|
|
318
320
|
fields: z
|
|
319
321
|
.array(z.enum(GET_FIELD_GROUPS))
|
|
320
322
|
.optional()
|
|
321
|
-
.describe("
|
|
323
|
+
.describe("Field groups to add; absent = minimal scalars."),
|
|
322
324
|
...boardField,
|
|
323
325
|
}, async (args) => jsonResult(await issueGet(client, args)));
|
|
324
326
|
// ---------------- issue_create ----------------
|
|
@@ -436,7 +438,9 @@ server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED
|
|
|
436
438
|
...boardField,
|
|
437
439
|
}, async (args) => jsonResult(await issueEdit(client, args)));
|
|
438
440
|
// ---------------- issue_transition ----------------
|
|
439
|
-
server.tool("issue_transition",
|
|
441
|
+
server.tool("issue_transition",
|
|
442
|
+
// DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
|
|
443
|
+
"Move a card's lifecycle via POST /api/issues/:id/transition — the ONLY way; `danxbot_complete` never moves a card, so call this first. Actions: ready (Review→ToDo); pickup (ToDo→In Progress; checks every dispatch gate — ready, blocked, requires_human, depends_on terminal, conflict_on idle — and refuses 409 with failed_gate naming the cause; `manual:true` is a self-pickup for work in YOUR session: it bypasses card-flow gates and is never auto-rolled-back); rollback_pickup; complete (your explicit decision; 409 on an Epic with non-terminal children (non_terminal_phases[]) or while a required POST quality gate is not pass (failed_gate 'quality_gate_post' + failed_post_gates[])); cancel (terminal); block (non-empty reason; only holds dispatch, never asks a human — for that use issue_problem then issue_requires_human; env faults use `danxbot_complete({status:'failed'})`); unblock; archive (to Backlog, clears ready_at); reopen (terminal→active). Terminal cards refuse all but reopen; forward stamps never clear earlier ones. A dispatched agent's manual pickup MUST pass `assigned_agent` = your agent/profile name (409 otherwise); one that loses a race is refused 409 `failed_gate: \"dispatch_id\"` — re-read assigned_agent/dispatch_id, never retry blindly.", {
|
|
440
444
|
id: z.string().min(1),
|
|
441
445
|
action: z.enum(TRANSITION_ACTIONS),
|
|
442
446
|
reason: z.string().optional(),
|
|
@@ -491,24 +495,37 @@ server.tool("issue_checklist", "Targeted checklist CUD via /api/issues/:id/check
|
|
|
491
495
|
status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
|
|
492
496
|
...boardField,
|
|
493
497
|
}, async (args) => jsonResult(await issueChecklist(client, args)));
|
|
494
|
-
// ----------------
|
|
495
|
-
|
|
498
|
+
// ---------------- issue_problem ----------------
|
|
499
|
+
// DX-2735: the option fields, shared by `issue_problem` add's inline solutions[]
|
|
500
|
+
// and `issue_solution`. Their meaning is stated ONCE, in issue_solution's
|
|
501
|
+
// description — per-field describes repeated it on every call's context.
|
|
502
|
+
const SOLUTION_FIELDS = {
|
|
503
|
+
body: z.string().optional(),
|
|
504
|
+
pro: z.string().optional(),
|
|
505
|
+
con: z.string().optional(),
|
|
506
|
+
recommended: z.boolean().optional(),
|
|
507
|
+
};
|
|
508
|
+
server.tool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pid]: each is one statement the operator must resolve (a question, or a flaw in the plan) with its own solutions and answers — one problem per question. OPEN = not yet answered; the card needs a human until none is open, and issue_requires_human set is refused 409 `no_open_problem` until one is. list → live problems in order, each {id, statement, content_hash, open, solutions[], decisions[]}; add {statement, solutions?} → problem_id + solution_ids in one transaction (zero solutions is valid: the operator answers free-form); edit :pid {base_hash, statement}; remove :pid {base_hash} (409 `last_open_problem` while requires_human is set). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. No answer action — the operator answers in the dashboard.", {
|
|
496
509
|
id: z.string().min(1),
|
|
497
510
|
action: z.enum(["list", "add", "edit", "remove"]),
|
|
498
|
-
|
|
499
|
-
base_hash: z
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
.
|
|
503
|
-
.describe("The solution's content_hash from your last read — required for edit / remove."),
|
|
504
|
-
title: z.string().min(1).optional().describe("Short name for the option — required for add."),
|
|
505
|
-
body: z.string().optional().describe("Markdown detail of what this option actually does."),
|
|
506
|
-
pro: z.string().optional().describe("The case FOR this option."),
|
|
507
|
-
con: z.string().optional().describe("The case AGAINST this option."),
|
|
508
|
-
recommended: z
|
|
509
|
-
.boolean()
|
|
511
|
+
problem_id: z.number().int().positive().optional().describe("edit/remove"),
|
|
512
|
+
base_hash: z.string().min(1).optional().describe("content_hash last read; edit/remove"),
|
|
513
|
+
statement: z.string().min(1).optional().describe("add/edit"),
|
|
514
|
+
solutions: z
|
|
515
|
+
.array(z.object({ title: z.string().min(1), ...SOLUTION_FIELDS }))
|
|
510
516
|
.optional()
|
|
511
|
-
.describe("
|
|
517
|
+
.describe("add only; fields as issue_solution add"),
|
|
518
|
+
...boardField,
|
|
519
|
+
}, async (args) => jsonResult(await issueProblem(client, args)));
|
|
520
|
+
// ---------------- issue_solution ----------------
|
|
521
|
+
server.tool("issue_solution", "One problem's options via /api/issues/:id/problems/:pid/solutions[/:sid]; `problem_id` is REQUIRED (from issue_problem list/add; another problem's solution id → 404). add {title, body?, pro?, con?, recommended?}: title names the option, body is its markdown detail, pro/con the case for and against; edit :sid {base_hash, ...only the changed fields}; remove :sid {base_hash}. A stale base_hash → 409 `stale_solution` with currentHash + currentSolution: merge, then retry. At most ONE live recommended per problem: a second → 409 naming `recommended_solution_id`. A chosen option cannot be edited (409 — add a new one) but can be removed.", {
|
|
522
|
+
id: z.string().min(1),
|
|
523
|
+
action: z.enum(["add", "edit", "remove"]),
|
|
524
|
+
problem_id: z.number().int().positive(),
|
|
525
|
+
solution_id: z.number().int().positive().optional().describe("edit/remove"),
|
|
526
|
+
base_hash: z.string().min(1).optional().describe("content_hash last read; edit/remove"),
|
|
527
|
+
title: z.string().min(1).optional().describe("add"),
|
|
528
|
+
...SOLUTION_FIELDS,
|
|
512
529
|
...boardField,
|
|
513
530
|
}, async (args) => jsonResult(await issueSolution(client, args)));
|
|
514
531
|
// ---------------- issue_dependency ----------------
|
|
@@ -522,7 +539,7 @@ server.tool("issue_dependency", 'Dependency CRUD via /api/issues/:id/dependencie
|
|
|
522
539
|
...boardField,
|
|
523
540
|
}, async (args) => jsonResult(await issueDependency(client, args)));
|
|
524
541
|
// ---------------- issue_requires_human ----------------
|
|
525
|
-
server.tool("issue_requires_human", "Set
|
|
542
|
+
server.tool("issue_requires_human", "Set/clear the requires_human gate via /api/issues/:id/requires-human — the ONLY flag that puts a card in front of a human (block only holds dispatch). Escalate in order: 1) issue_problem add (statement + every viable solution, one recommended); 2) set=true {reason, steps[]} — refused 409 `no_open_problem` (with the server's `fix`) while no problem is open. Set stamps requires_human_reason (no pickup while non-null) and replaces the steps; it clears itself when the last open problem is answered. set=false → DELETE clears it and its steps. Terminal cards refuse 409. Success returns `problems_reminder: {open_problem_count, instruction}` naming each open problem's solution count.", {
|
|
526
543
|
id: z.string().min(1),
|
|
527
544
|
set: z.boolean(),
|
|
528
545
|
reason: z.string().optional(),
|
|
@@ -677,7 +694,9 @@ server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id
|
|
|
677
694
|
server.tool("plan_create", "Create a new, empty plan via POST /api/plans (DX-2531). GLOBAL — a plan is not board-scoped, and this call adds no cards, no records, and no architecture sections; it does NOT connect any session to the new plan (call `plan_connect` separately, exactly as adding a card to a plan is its own separate step). Returns `{ok, status, body: {plan: {id, name, createdAt}}}`. Use the returned `plan.id` with `plan_connect` to start working on it, or with `plan_get({plan_id})` to browse it.", {
|
|
678
695
|
name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
|
|
679
696
|
}, async (args) => jsonResult(await planCreate(client, args)));
|
|
680
|
-
server.tool("plan_connect",
|
|
697
|
+
server.tool("plan_connect",
|
|
698
|
+
// DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
|
|
699
|
+
"Connect THIS session to a plan via POST /api/plan-sessions/me/plan — the same binding the operator's Connect action writes. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and the reply says which plan it left: `{session, movedFrom: {id, name} | null}` (null = no plan, or already this one). It binds only your OWN session, resolved from the session id this server forwards. Afterwards every plan WRITE tool acts on this plan and takes no plan id. THE REPLY ALSO CARRIES `listener: {command, persistent: true, instruction}` — arm it IMMEDIATELY with the Monitor tool (`persistent: true`): every comment, answer, requires_human change and block/unblock on this plan's cards then arrives as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<problem statement>\": chose \"Pause E2E\" — note: \"…\"`. Never poll for these. The command holds a narrow stream ticket, not a credential; calling plan_connect again (same plan is fine) issues a new one and ends the old listener — how you re-arm after a restart or a give-up. If no ticket can be issued the call fails `listener_not_armed`, though the connect happened.", {
|
|
681
700
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
682
701
|
}, async (args) => jsonResult(await planConnect(client, args, { baseUrl: config.baseUrl, packageSpec: PACKAGE_SPEC })));
|
|
683
702
|
server.tool("plan_add_record", "Add a goal, rule or caveat to your connected plan (POST /api/plans/mine/records). A GOAL is an outcome the work is measured against. A RULE is a constraint that must hold while it is worked. A CAVEAT is a lasting trade-off or limitation of the ARCHITECTURE — never progress, status or a session note (those are comments on the card). `body` is ONE plain statement of at most 250 characters; the evidence, history and detail go in `context` (markdown). An overlong body is refused with a 400 naming its length. The server allocates a permanent reference (`G-1`, `R-4`, `CAV-12`). Takes no plan id; `session_not_connected` → `plan_connect` first. Returns the record plus that kind's list.", {
|
package/dist/listen.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
* (`src/issues/db/issue-activity.ts`); this published package cannot import
|
|
34
34
|
* danxbot source.
|
|
35
35
|
*/
|
|
36
|
+
import { oneLine } from "./one-line.js";
|
|
36
37
|
export const LISTEN_SUBCOMMAND = "listen";
|
|
37
38
|
export const INITIAL_BACKOFF_MS = 1_000;
|
|
38
39
|
export const MAX_BACKOFF_MS = 30_000;
|
|
@@ -66,17 +67,71 @@ export function parseListenArgs(argv) {
|
|
|
66
67
|
function quoted(value) {
|
|
67
68
|
return `"${value.text}${value.truncated ? "…" : ""}"`;
|
|
68
69
|
}
|
|
70
|
+
function isRecord(value) {
|
|
71
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
72
|
+
}
|
|
73
|
+
function isCappedText(value) {
|
|
74
|
+
return isRecord(value) && typeof value.text === "string" && typeof value.truncated === "boolean";
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* DX-2735 — why an event cannot be described, or `null` when it can.
|
|
78
|
+
*
|
|
79
|
+
* Checked BEFORE describing, so a malformed event is reported by what is
|
|
80
|
+
* actually wrong with it — never by whichever TypeError a missing field happens
|
|
81
|
+
* to raise (whose wording is the JS engine's, not ours). The envelope is checked
|
|
82
|
+
* for every kind; the detail only for the kinds `describe` reads, so a kind newer
|
|
83
|
+
* than this listener still produces its generic line.
|
|
84
|
+
*/
|
|
85
|
+
export function invalidEventReason(value) {
|
|
86
|
+
if (!isRecord(value))
|
|
87
|
+
return "the event is not an object";
|
|
88
|
+
if (typeof value.id !== "number" || !Number.isSafeInteger(value.id))
|
|
89
|
+
return "id is not an integer";
|
|
90
|
+
for (const field of ["cardId", "cardTitle", "boardId", "actor", "kind"]) {
|
|
91
|
+
if (typeof value[field] !== "string")
|
|
92
|
+
return `${field} is not a string`;
|
|
93
|
+
}
|
|
94
|
+
const d = value.detail;
|
|
95
|
+
if (!isRecord(d))
|
|
96
|
+
return "detail is not an object";
|
|
97
|
+
switch (value.kind) {
|
|
98
|
+
case "comment_added":
|
|
99
|
+
return isCappedText(d.excerpt) ? null : "detail.excerpt is not capped text";
|
|
100
|
+
case "solution_answered": {
|
|
101
|
+
const problem = d.problem;
|
|
102
|
+
if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
|
|
103
|
+
return "detail.problem is not {id, statement}";
|
|
104
|
+
}
|
|
105
|
+
if (d.solution === null)
|
|
106
|
+
return isCappedText(d.freeform) ? null : "detail.freeform is not capped text";
|
|
107
|
+
if (!isRecord(d.solution) || typeof d.solution.title !== "string")
|
|
108
|
+
return "detail.solution is not {title, note}";
|
|
109
|
+
return d.solution.note === null || isCappedText(d.solution.note) ? null : "detail.solution.note is not capped text";
|
|
110
|
+
}
|
|
111
|
+
case "requires_human_set":
|
|
112
|
+
case "blocked":
|
|
113
|
+
return isCappedText(d.reason) ? null : "detail.reason is not capped text";
|
|
114
|
+
default:
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Only ever called on an event `invalidEventReason` accepted, so its casts are checked. */
|
|
69
119
|
function describe(event) {
|
|
70
120
|
const d = event.detail;
|
|
71
121
|
switch (event.kind) {
|
|
72
122
|
case "comment_added":
|
|
73
123
|
return `${event.actor} commented: ${quoted(d.excerpt)}`;
|
|
74
124
|
case "solution_answered": {
|
|
125
|
+
// DX-2735: every answer answers ONE problem on a card that may carry several,
|
|
126
|
+
// so the line names the problem — otherwise the agent cannot tell which of its
|
|
127
|
+
// questions just closed.
|
|
128
|
+
const problem = d.problem;
|
|
129
|
+
const answered = `${event.actor} answered ${quoted(problem.statement)}:`;
|
|
75
130
|
const solution = d.solution;
|
|
76
131
|
if (solution === null)
|
|
77
|
-
return `${
|
|
132
|
+
return `${answered} ${quoted(d.freeform)}`;
|
|
78
133
|
const note = solution.note === null ? "" : ` — note: ${quoted(solution.note)}`;
|
|
79
|
-
return `${
|
|
134
|
+
return `${answered} chose "${solution.title}"${note}`;
|
|
80
135
|
}
|
|
81
136
|
case "requires_human_set":
|
|
82
137
|
return `${event.actor} set requires_human: ${quoted(d.reason)}`;
|
|
@@ -92,9 +147,12 @@ function describe(event) {
|
|
|
92
147
|
return `${event.actor}: ${event.kind}`;
|
|
93
148
|
}
|
|
94
149
|
}
|
|
95
|
-
/** The one notification line for an event. Always a single line; throws on a malformed event. */
|
|
150
|
+
/** The one notification line for an event. Always a single line; throws, naming the fault, on a malformed event. */
|
|
96
151
|
export function formatActivityLine(event) {
|
|
97
|
-
|
|
152
|
+
const invalid = invalidEventReason(event);
|
|
153
|
+
if (invalid !== null)
|
|
154
|
+
throw new Error(`malformed activity event: ${invalid}`);
|
|
155
|
+
return oneLine(`[${event.cardId} "${event.cardTitle}" ${event.boardId}] ${describe(event)}`);
|
|
98
156
|
}
|
|
99
157
|
/**
|
|
100
158
|
* Incremental SSE parser. Comment lines (keep-alives, the connect marker) and
|
|
@@ -165,14 +223,27 @@ export async function runListener(options, deps) {
|
|
|
165
223
|
const id = Number(message.id);
|
|
166
224
|
if (Number.isSafeInteger(id) && printed.has(id))
|
|
167
225
|
return null;
|
|
226
|
+
// DX-2735: an event that is not JSON, or JSON of the wrong shape, is reported
|
|
227
|
+
// from an explicit check naming the fault — never from a TypeError thrown
|
|
228
|
+
// partway through describing it. Never silence, and never a reconnect loop on
|
|
229
|
+
// the same bad event: say so in one line and move past it.
|
|
230
|
+
let parsed;
|
|
231
|
+
let invalid;
|
|
168
232
|
try {
|
|
169
|
-
|
|
233
|
+
parsed = JSON.parse(message.data);
|
|
234
|
+
invalid = invalidEventReason(parsed);
|
|
170
235
|
}
|
|
171
236
|
catch (err) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
237
|
+
invalid = `not JSON: ${err.message}`;
|
|
238
|
+
}
|
|
239
|
+
if (invalid === null) {
|
|
240
|
+
deps.write(formatActivityLine(parsed));
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
deps.write(`${LINE_PREFIX} could not read event ${message.id ?? "(no id)"} (${invalid}): ` +
|
|
244
|
+
// DX-2735: oneLine caps by code point, so a bad event carrying an emoji
|
|
245
|
+
// at the cut can never leave a lone surrogate in the line.
|
|
246
|
+
oneLine(message.data, 300));
|
|
176
247
|
}
|
|
177
248
|
if (Number.isSafeInteger(id))
|
|
178
249
|
remember(id);
|
|
@@ -198,7 +269,7 @@ export async function runListener(options, deps) {
|
|
|
198
269
|
armIdle();
|
|
199
270
|
const response = await deps.fetch(options.streamUrl, { headers, signal: controller.signal });
|
|
200
271
|
if (response.status >= 400 && response.status < 500 && !RETRYABLE_CLIENT_STATUSES.has(response.status)) {
|
|
201
|
-
return { kind: "refused", detail: `HTTP ${response.status} ${(await response.text()
|
|
272
|
+
return { kind: "refused", detail: `HTTP ${response.status} ${oneLine(await response.text(), 300)}` };
|
|
202
273
|
}
|
|
203
274
|
if (!response.ok || response.body === null) {
|
|
204
275
|
await response.body?.cancel();
|
package/dist/one-line.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-2735 — the ONE way this package renders human text into an agent-facing
|
|
3
|
+
* line: the listen notification line and the requires_human problems reminder
|
|
4
|
+
* both go through it, so they can never disagree about how a statement looks.
|
|
5
|
+
*
|
|
6
|
+
* Every whitespace run (newlines included) collapses to a single space, because
|
|
7
|
+
* a notification is one line and a reminder quotes statements inline. The
|
|
8
|
+
* optional cap counts CODE POINTS, never UTF-16 units: `String#slice` can cut
|
|
9
|
+
* an emoji or any astral character in half and leave a lone surrogate, which
|
|
10
|
+
* renders as garbage. A capped string ends with "…" so truncation is visible.
|
|
11
|
+
*/
|
|
12
|
+
export function oneLine(text, maxCodePoints) {
|
|
13
|
+
const collapsed = text.replace(/\s+/g, " ");
|
|
14
|
+
if (maxCodePoints === undefined)
|
|
15
|
+
return collapsed;
|
|
16
|
+
const points = Array.from(collapsed);
|
|
17
|
+
return points.length > maxCodePoints ? `${points.slice(0, maxCodePoints).join("")}…` : collapsed;
|
|
18
|
+
}
|
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.64",
|
|
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",
|