@thehammer/danx-dashboard-mcp 0.1.108 → 0.1.112
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/dist/handlers.js +4 -0
- package/dist/index.js +115 -59
- package/package.json +1 -1
package/dist/handlers.js
CHANGED
|
@@ -230,6 +230,10 @@ export async function issueCreate(client, args, defaultBoard) {
|
|
|
230
230
|
body.ac = args.ac;
|
|
231
231
|
if (args.effort_level !== undefined)
|
|
232
232
|
body.effort_level = args.effort_level;
|
|
233
|
+
// DX-3238 — same tier-word → numeric midpoint resolution `issueEdit` does;
|
|
234
|
+
// the route only ever accepts a number (`parsePriority`, `create.ts:545`).
|
|
235
|
+
if (args.priority !== undefined)
|
|
236
|
+
body.priority = resolvePriority(args.priority);
|
|
233
237
|
if (args.list_id !== undefined)
|
|
234
238
|
body.list_id = args.list_id;
|
|
235
239
|
if (args.quality_gates !== undefined)
|
package/dist/index.js
CHANGED
|
@@ -227,6 +227,30 @@ function jsonResult(value) {
|
|
|
227
227
|
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
228
228
|
};
|
|
229
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* DX-3238 — every tool on this server MUST reject an undeclared/mistyped
|
|
232
|
+
* argument instead of silently stripping it. `server.tool(name, description,
|
|
233
|
+
* shape, cb)` builds its input schema as a bare `z.object(shape)` internally
|
|
234
|
+
* (via the SDK's own `objectFromShape`), and zod's default behaviour for an
|
|
235
|
+
* object schema is to STRIP any key not in `shape` — so a caller's typo (or a
|
|
236
|
+
* field the route accepts but this tool never declared, e.g. `issue_create`'s
|
|
237
|
+
* `priority` before this card) vanished before the HTTP request was even
|
|
238
|
+
* built, and the server's own fail-loud checks (e.g. `parseFilterObject`'s
|
|
239
|
+
* "Unknown filter key" 400) never got a chance to run.
|
|
240
|
+
*
|
|
241
|
+
* `strictTool` is the ONE place that fixes this for every tool at once: it
|
|
242
|
+
* wraps `shape` in `z.object(shape).strict()` itself and registers it via
|
|
243
|
+
* `registerTool` (the SDK preserves a pre-built Zod object schema's `.strict()`
|
|
244
|
+
* verbatim through `normalizeObjectSchema` — passing the same `shape` to the
|
|
245
|
+
* deprecated `server.tool(...)` overload instead would NOT: that overload only
|
|
246
|
+
* accepts a raw shape, which the SDK rebuilds as a plain `z.object(shape)`
|
|
247
|
+
* with no strict flag). A zod-rejected extra/misspelled key now throws
|
|
248
|
+
* `McpError(InvalidParams, ...)` naming the offending key and the accepted
|
|
249
|
+
* set, exactly like the dashboard's own `parseFilterObject` refusal.
|
|
250
|
+
*/
|
|
251
|
+
function strictTool(name, description, shape, cb) {
|
|
252
|
+
return server.registerTool(name, { description, inputSchema: z.object(shape).strict() }, cb);
|
|
253
|
+
}
|
|
230
254
|
// Effort + verdict + action tuples kept in lockstep with the v2 write
|
|
231
255
|
// handlers. Drift surfaces at runtime as a server 400 — not silently
|
|
232
256
|
// wrong data — but pinning them here gets the failure caught at the
|
|
@@ -277,7 +301,7 @@ const sortField = z
|
|
|
277
301
|
.array(z.object({
|
|
278
302
|
column: z.string().min(1),
|
|
279
303
|
order: z.enum(SORT_ORDERS),
|
|
280
|
-
}))
|
|
304
|
+
}).strict())
|
|
281
305
|
.optional()
|
|
282
306
|
.describe("Multi-column sort — ordered list of {column, order}. Absent → the server's default order (priority desc (higher priority=most urgent first), repo_name asc, with a numeric-id tiebreaker always appended).");
|
|
283
307
|
// DX-1290 — the uniform checklist-item status, extended DX-2653 with
|
|
@@ -323,7 +347,7 @@ const TITLE_DESCRIBE = 'Short, specific label naming the domain, so a reader rec
|
|
|
323
347
|
const SUMMARY_DESCRIBE = "1–3 plain-language sentences, no markdown/jargon, for someone new to this codebase: what the card is and why it matters. Always shown, never collapsed — not a second title, not a teaser for the description.";
|
|
324
348
|
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.';
|
|
325
349
|
// ---------------- issue_list ----------------
|
|
326
|
-
|
|
350
|
+
strictTool("issue_list",
|
|
327
351
|
// DX-2735: trimmed to pay for the problem tools inside the work-profile
|
|
328
352
|
// injected-surface budget — same facts, no repeated prose.
|
|
329
353
|
"List cards via GET /api/issues. Board-scoped; see `board`. `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 — a card needs a human exactly when this is > 0), ac, comments, retro, dependencies, triage, 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 (highest priority=most urgent first), repo_name asc, numeric-id tiebreaker. `limit`/`offset` page (uncapped by default). DX-3113 — `include_closed` defaults to FALSE: a bare call silently excludes every Done/Cancelled card (leaf AND container alike). The response always carries `total` (the full count matching every filter except limit/offset — compare against `issues.length` to tell an exhausted list from a truncated one) and, whenever `include_closed` was not explicitly `true`, `closed_excluded` (how many additional terminal cards the default withheld — re-call with `include_closed: true` to see them). issue_get reads one card in full.", {
|
|
@@ -339,6 +363,13 @@ server.tool("issue_list",
|
|
|
339
363
|
include_closed: z.boolean().optional(),
|
|
340
364
|
include_deleted: z.boolean().optional(),
|
|
341
365
|
})
|
|
366
|
+
// DX-3238 — the card's own repro: a mistyped key here (`dispatchable`
|
|
367
|
+
// for `dispatchable_derived`) was silently stripped by this NESTED
|
|
368
|
+
// object's own bare `z.object`, so the query ran unfiltered — the
|
|
369
|
+
// outer `strictTool` wrapper (below) only guards the TOP-LEVEL shape,
|
|
370
|
+
// never a nested one, so every nested z.object() in this file needs
|
|
371
|
+
// its own `.strict()`.
|
|
372
|
+
.strict()
|
|
342
373
|
.optional(),
|
|
343
374
|
fields: z
|
|
344
375
|
.array(z.enum(LIST_FIELD_GROUPS))
|
|
@@ -350,7 +381,7 @@ server.tool("issue_list",
|
|
|
350
381
|
...boardField,
|
|
351
382
|
}, async (args) => jsonResult(await issueList(client, args)));
|
|
352
383
|
// ---------------- issue_get ----------------
|
|
353
|
-
|
|
384
|
+
strictTool("issue_get",
|
|
354
385
|
// DX-2735: trimmed to pay for the problem tools inside the work-profile
|
|
355
386
|
// injected-surface budget — same facts, no repeated prose.
|
|
356
387
|
"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[] — a card needs a human exactly when open_problem_count > 0), ac (acceptance criteria + checklists), comments, retro, dependencies (waiting_on/conflict_on/blocked), triage (history + ICE), 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, plans (every plan this card is on, `{id, ref, name}[]` via `plan_cards` — `ref` is the plan's `PLN-<id>`). Single form: unknown id → 404. Batch form: `{issues: [...], not_found: [...ids]}` — an unknown or deleted id never fails the call.", {
|
|
@@ -363,7 +394,7 @@ server.tool("issue_get",
|
|
|
363
394
|
...boardField,
|
|
364
395
|
}, async (args) => jsonResult(await issueGet(client, args)));
|
|
365
396
|
// ---------------- issue_create ----------------
|
|
366
|
-
|
|
397
|
+
strictTool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass "mine" to put the card on THIS session\'s connected plan, or null when it deliberately belongs to no plan. There is no default and no inference — a card that names no plan is one nobody following the work can see, which is why the answer has to be given rather than omitted. "mine" while this session is on no plan is refused (409 session_not_connected) and creates NO card; a plan id is not accepted (a card is only ever created onto your own connected plan). This replaces the plan_add_card follow-up at creation time; plan_add_card remains for putting an EXISTING card on a plan. ' +
|
|
367
398
|
'Create a card via POST /api/issues. Board-scoped; see `board`. type=Epic REQUIRES non-empty phase_children[] (epic and phases inserted in one transaction; children get the epic as parent); other types refuse phase_children[] (400). Status starts at Review. `list_id` places the card straight into a column — a board_lists id or the list\'s display NAME (case-insensitive, emoji-tolerant): a `ready`-type queue lands it in ToDo, a `completed` list in Done, with no follow-up transition. Not valid on Epic; unknown name/id → 400. `quality_gates` names the gates this card carries BEYOND the board\'s default set for its type — one `{gate, note?}` each. Omit it for just the board defaults; a gate you do not name simply is not on the card (there is no optional gate and nothing fails closed for going unnamed). Add one later with `issue_quality_gate`. ALWAYS pass `triage_enabled` explicitly on the root card and every phase child: true only when it should enter automatic triage/dispatch without human review; absent → false.', {
|
|
368
399
|
type: z.enum(ISSUE_TYPES),
|
|
369
400
|
title: z.string().min(1).describe(TITLE_DESCRIBE),
|
|
@@ -378,7 +409,7 @@ server.tool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass
|
|
|
378
409
|
.nullable()
|
|
379
410
|
.describe('"mine" = attach to the plan THIS session is connected to; null = deliberately no plan. REQUIRED — decide per card. A plan id is not accepted: a card is only ever created onto your own connected plan, so there is no id to name.'),
|
|
380
411
|
parent_id: z.string().nullable().optional(),
|
|
381
|
-
ac: z.array(z.object({ title: z.string().min(1) })).optional(),
|
|
412
|
+
ac: z.array(z.object({ title: z.string().min(1) }).strict()).optional(),
|
|
382
413
|
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
383
414
|
list_id: z.string().min(1).nullable().optional(),
|
|
384
415
|
quality_gates: z
|
|
@@ -386,7 +417,7 @@ server.tool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass
|
|
|
386
417
|
gate: z.string().min(1),
|
|
387
418
|
note: z.string().optional(),
|
|
388
419
|
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
389
|
-
}))
|
|
420
|
+
}).strict())
|
|
390
421
|
.optional()
|
|
391
422
|
.describe("The gates this card carries BEYOND the board's default set for its type — one {gate, note?} each, `note` = why it applies here. Every gate on a card is required; a gate you do not name is not on the card at all (not displayed, not counted, never run), and omitting this field entirely is normal. Optional `effort_level` overrides a `plan-*` gate's reviewer rung."),
|
|
392
423
|
phase_children: z
|
|
@@ -399,30 +430,51 @@ server.tool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass
|
|
|
399
430
|
.optional()
|
|
400
431
|
.describe(`${SUMMARY_DESCRIBE} This child's OWN summary — never inherited from the root card.`),
|
|
401
432
|
description: z.string().describe(DESCRIPTION_DESCRIBE),
|
|
402
|
-
ac: z.array(z.object({ title: z.string().min(1) })).optional(),
|
|
433
|
+
ac: z.array(z.object({ title: z.string().min(1) }).strict()).optional(),
|
|
403
434
|
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
404
435
|
quality_gates: z
|
|
405
436
|
.array(z.object({
|
|
406
437
|
gate: z.string().min(1),
|
|
407
438
|
note: z.string().optional(),
|
|
408
439
|
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
409
|
-
}))
|
|
440
|
+
}).strict())
|
|
410
441
|
.optional()
|
|
411
442
|
.describe("Same as the root quality_gates, resolved against THIS child's type."),
|
|
412
443
|
triage_enabled: z
|
|
413
444
|
.boolean()
|
|
414
445
|
.optional()
|
|
415
446
|
.describe("ALWAYS pass per child; absent → false (never auto-triaged). Not inherited from the root."),
|
|
416
|
-
}))
|
|
447
|
+
}).strict())
|
|
417
448
|
.optional(),
|
|
418
449
|
triage_enabled: z
|
|
419
450
|
.boolean()
|
|
420
451
|
.optional()
|
|
421
452
|
.describe("ALWAYS pass explicitly. true = enters automatic triage/dispatch without further human review; absent → false. Operator POST /api/triage and issue_triage ignore it."),
|
|
453
|
+
// DX-3238 — was absent from this schema entirely (not merely optional),
|
|
454
|
+
// so a caller's value was silently stripped before the request was built
|
|
455
|
+
// even though the route already honoured it (ALLOWED_CREATE_KEYS,
|
|
456
|
+
// create.ts:39-56; parsePriority, create.ts:545). Same shape as
|
|
457
|
+
// issue_edit's `priority` above.
|
|
458
|
+
priority: z
|
|
459
|
+
.union([z.enum(PRIORITY_TIER_WORDS), z.number()])
|
|
460
|
+
.optional()
|
|
461
|
+
.describe('A tier word ("lowest"=0–1…"critical"=5–6; higher number = more urgent, prefer tier word) or number in [0,6); higher numbers are more urgent. Omit for the route\'s own default.'),
|
|
462
|
+
// DX-3238 — `assigned_agent` is DELIBERATELY left undeclared here, unlike
|
|
463
|
+
// `priority` above. The route accepts it (`ALLOWED_CREATE_KEYS`,
|
|
464
|
+
// create.ts:39-56) but it only matters for the create→`list_id`-lands-
|
|
465
|
+
// in-progress placement path (create.ts:918-955), a dispatched-worker
|
|
466
|
+
// internal-bookkeeping concern this MCP tool never exposes (no MCP
|
|
467
|
+
// caller can pick an in-progress-mapped `list_id`) — so there is no
|
|
468
|
+
// legitimate agent-facing use for it. Before this card that gap was
|
|
469
|
+
// silent (the key vanished with no signal); now, with every schema
|
|
470
|
+
// here strict, a caller that tries it gets a clear "Invalid arguments"
|
|
471
|
+
// refusal naming `assigned_agent` instead — that refusal IS the fix for
|
|
472
|
+
// this field, not a schema addition. Revisit only if a future card
|
|
473
|
+
// wires the in-progress-placement path up to this tool on purpose.
|
|
422
474
|
...boardField,
|
|
423
475
|
}, async (args) => jsonResult(await issueCreate(client, args, config.board)));
|
|
424
476
|
// ---------------- issue_edit ----------------
|
|
425
|
-
|
|
477
|
+
strictTool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED keys: title, summary, description, ac, checklists, effort_level, parent_id, priority, list_id, triage_enabled, type, content_hash. Any other key (lifecycle, triage, dependencies, retro, blocked) is refused 400 with offending_keys[] naming the right tool: issue_transition / issue_triage / issue_comment / issue_dependency / issue_problem / issue_retro. `type`: Story/Bug/Chore = eligible for autonomous pickup; Task or a container (Epic/Feature) removes that eligibility — how a planning item becomes work. `priority` (tier word: "lowest"–"critical", or number 0–6; higher = more urgent, prefer tier word) is the ONLY way to set priority; a "Priority:" line does nothing. CHECKLISTS: each item has one status `incomplete|failing|passing|cancelled|deferred`; `deferred` (work done, a real-world/post-deploy check outstanding) REQUIRES `detail`, and a `📡`-prefixed item can never be `passing`. `ac` edits the default "Acceptance Criteria" checklist (items matched by check_item_id, else exact title); `checklists` REPLACES every named checklist with full status control (`{name, items:[{label, detail?, status}]}`). Send `ac` OR `checklists`, not both (400). `list_id` pins the card to a list by id or display NAME; its type must match the card\'s current derived status (400 otherwise — e.g. ready the card first before pinning it to a `ready`-type list); null clears the pin. `content_hash` (DX-2741) is the card\'s optimistic-concurrency token — REQUIRED whenever the edit touches `title` / `description` / `checklists` (NOT `ac`, which keeps its own check_item_id/title diffing): missing → 400, stale → 409 `stale_issue_content` carrying `currentHash` + `currentTitle` + `currentDescription`. Read it off `issue_get`/`issue_list`\'s `content_hash` scalar (present even minimal); on 409, re-`issue_get` and retry with the fresh hash — never blindly.', {
|
|
426
478
|
id: z.string().min(1),
|
|
427
479
|
title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
|
|
428
480
|
summary: z
|
|
@@ -452,7 +504,7 @@ server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED
|
|
|
452
504
|
.union([z.string(), z.number()])
|
|
453
505
|
.optional()
|
|
454
506
|
.describe("Optional id from issue_get's ac[].check_item_id. Only needed to tell apart two items with identical titles; otherwise items match by title."),
|
|
455
|
-
}))
|
|
507
|
+
}).strict())
|
|
456
508
|
.optional()
|
|
457
509
|
.describe("The default Acceptance Criteria checklist: checked true → passing, false → incomplete, or pass `status`. Diffed against live items — unchanged items keep their id, new titles are inserted, missing ones removed."),
|
|
458
510
|
checklists: z
|
|
@@ -462,8 +514,8 @@ server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED
|
|
|
462
514
|
label: z.string().min(1),
|
|
463
515
|
detail: z.string().optional(),
|
|
464
516
|
status: z.enum(CHECKLIST_ITEM_STATUSES),
|
|
465
|
-
})),
|
|
466
|
-
}))
|
|
517
|
+
}).strict()),
|
|
518
|
+
}).strict())
|
|
467
519
|
.optional(),
|
|
468
520
|
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
469
521
|
parent_id: z.string().nullable().optional(),
|
|
@@ -484,7 +536,7 @@ server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED
|
|
|
484
536
|
...boardField,
|
|
485
537
|
}, async (args) => jsonResult(await issueEdit(client, args)));
|
|
486
538
|
// ---------------- issue_transition ----------------
|
|
487
|
-
|
|
539
|
+
strictTool("issue_transition",
|
|
488
540
|
// DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
|
|
489
541
|
"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, open_problem_count (a card needs a human exactly when this is > 0), 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 EXCEPT open_problem_count, which never lets a card start, and is never auto-rolled-back); rollback_pickup (`keep_assignment:true` releases the card to ready WITHOUT clearing its assignment — use this to hand a manually-held card back to `ready` while you keep holding it, instead of a follow-up assigned-agent call); 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 add; 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.", {
|
|
490
542
|
id: z.string().min(1),
|
|
@@ -505,14 +557,14 @@ server.tool("issue_transition",
|
|
|
505
557
|
...boardField,
|
|
506
558
|
}, async (args) => jsonResult(await issueTransition(client, args)));
|
|
507
559
|
// ---------------- issue_triage ----------------
|
|
508
|
-
|
|
560
|
+
strictTool("issue_triage", "Record a triage confidence score via POST /api/issues/:id/triage (DX-2086). Caller sends a single `confidence` integer 0-5 plus a required non-empty `reason` — the server computes the verdict by comparing `confidence` against the board's configured thresholds (all band edges inclusive on the low side): confidence <= cancelThreshold -> cancel (stamps cancelled_at, terminal); cancelThreshold < confidence <= archiveThreshold -> defer (stamps archived_at AND ready_at:null); archiveThreshold < confidence <= reviewThreshold -> keep (no column stamp); confidence > reviewThreshold -> approve (stamps ready_at). REFUSES 409 on terminal cards. DX-2782 / DX-2830 — a keep/defer verdict does NOT block the card: it opens a problem asking the reason with three real choices (approve and ready / defer / cancel, one recommended) — opening it IS what puts the card in front of a human, the same escalation shape every other machine writer uses (the dashboard's Needs You tab reads only open_problem_count). Answering that problem applies the chosen outcome through the normal issue_transition actions automatically. It is not a cross-card ordering gate; use issue_dependency (kind: depends_on) to sequence one card after another.", {
|
|
509
561
|
id: z.string().min(1),
|
|
510
562
|
confidence: z.number().int().min(0).max(5),
|
|
511
563
|
reason: z.string().min(1),
|
|
512
564
|
...boardField,
|
|
513
565
|
}, async (args) => jsonResult(await issueTriage(client, args)));
|
|
514
566
|
// ---------------- issue_comment ----------------
|
|
515
|
-
|
|
567
|
+
strictTool("issue_comment", "Comment CRUD via /api/issues/:id/comments[/:cid]. action=add → POST {text, metadata?, problem_id?} (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). `problem_id` (DX-2906, action=add only) OPTIONALLY threads the comment as a follow-up question under one of this SAME card's problems (from `issue_problem` list/add) WITHOUT answering it — commenting never changes open_problem_count, blocked, or records a decision; use issue_problem's answer route for that. An unknown id, another card's id, or a removed problem's id all 404 naming the problem id — an ANSWERED (but not removed) problem still accepts a follow-up comment, since the thread continues after a decision.", {
|
|
516
568
|
id: z.string().min(1),
|
|
517
569
|
action: z.enum(["add", "edit", "delete"]),
|
|
518
570
|
comment_id: z.number().int().positive().optional(),
|
|
@@ -522,12 +574,14 @@ server.tool("issue_comment", "Comment CRUD via /api/issues/:id/comments[/:cid].
|
|
|
522
574
|
...boardField,
|
|
523
575
|
}, async (args) => jsonResult(await issueComment(client, args)));
|
|
524
576
|
// ---------------- issue_checklist ----------------
|
|
525
|
-
const CHECKLIST_ITEM_INPUT = z
|
|
577
|
+
const CHECKLIST_ITEM_INPUT = z
|
|
578
|
+
.object({
|
|
526
579
|
label: z.string().min(1),
|
|
527
580
|
detail: z.string().optional(),
|
|
528
581
|
status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
|
|
529
|
-
})
|
|
530
|
-
|
|
582
|
+
})
|
|
583
|
+
.strict();
|
|
584
|
+
strictTool("issue_checklist", "Targeted checklist CUD via /api/issues/:id/checklists[/:cid[/items[/:iid]]] (DX-1362). Mutates ONE checklist/item without the wholesale `issue_edit({checklists})` replace, which DROPS any checklist you omit and churns every item id (orphaning its Trello mirror) — prefer this for single-item changes. Action-dispatched: add_list (POST :id/checklists {name, items?}) — create a named checklist, optionally with initial items; update_list (PATCH :id/checklists/:cid {name}) — rename; remove_list (DELETE :id/checklists/:cid) — soft-delete the checklist (audit trail preserved); add_item (POST :id/checklists/:cid/items {label, detail?, status?}) — append an item (status defaults `incomplete`); update_item (PATCH :id/checklists/:cid/items/:iid {label?, detail?, status?}) — change ONLY the fields you pass, in place (keeps id + Trello link; ≥1 field required); remove_item (DELETE :id/checklists/:cid/items/:iid) — soft-delete one item. Status: incomplete|failing|passing|cancelled|deferred (terminal = passing|cancelled|deferred; DX-2653 added `deferred` — the honest disposition for a criterion whose work is done but names a real-world/post-deploy check still outstanding, REQUIRES a non-empty `detail`). checklist_id is required for every action except add_list; item_id for update_item/remove_item. Each returns the {ok,status,body} envelope; unknown card/checklist/item → 404, invalid status → 400. ADDITIVE — `issue_edit({checklists})` still works for bulk authoring.", {
|
|
531
585
|
id: z.string().min(1),
|
|
532
586
|
action: z.enum([
|
|
533
587
|
"add_list",
|
|
@@ -556,7 +610,7 @@ const SOLUTION_FIELDS = {
|
|
|
556
610
|
con: z.string().optional(),
|
|
557
611
|
recommended: z.boolean().optional(),
|
|
558
612
|
};
|
|
559
|
-
|
|
613
|
+
strictTool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pid]: one statement the operator must resolve (a question, or a flaw in the plan), each with its own solutions/answers — one problem per question. OPEN = not yet answered; the card needs a human exactly while open_problem_count > 0 — there is no separate flag to set, adding a problem IS putting the card in front of a human. list → live problems in order, each {id, statement, context, content_hash, open, solutions[], decisions[]}; add {statement, context?, solutions?} → problem_id + solution_ids in one transaction (zero solutions is valid: the operator answers free-form) plus `problems_reminder: {open_problem_count, instruction}` naming each open problem's solution count; edit :pid {base_hash, statement, context?}; remove :pid {base_hash} — always allowed, even as the card's last open problem (DX-2830: removing it just means the card no longer needs a human). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. No answer action — the operator answers in the dashboard. DX-2942 — `statement` is capped at 200 characters (a 400 names the actual length otherwise): write it as ONE plain question, and put any investigation detail in `context` (markdown, no cap) instead of running it on. Good: statement \"Which cache should we use?\", context \"Redis fits the read-heavy path; see benchmark in #123. Memcached is simpler ops but no persistence.\" Bad: statement \"We looked at Redis vs Memcached, ran benchmarks showing Redis 3x faster on reads, but Memcached has simpler ops and we're not sure persistence matters here since the cache is fully rebuildable from Postgres...\" (too long, refused — move it to context).", {
|
|
560
614
|
id: z.string().min(1),
|
|
561
615
|
action: z.enum(["list", "add", "edit", "remove"]),
|
|
562
616
|
problem_id: z.number().int().positive().optional().describe("edit/remove"),
|
|
@@ -568,13 +622,13 @@ server.tool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:p
|
|
|
568
622
|
.optional()
|
|
569
623
|
.describe("add/edit; markdown detail behind statement — file:line refs, code excerpts, root-cause writeups. add: sent only when given (null/omit means none). edit: omit to keep the stored context, null to clear it."),
|
|
570
624
|
solutions: z
|
|
571
|
-
.array(z.object({ title: z.string().min(1), ...SOLUTION_FIELDS }))
|
|
625
|
+
.array(z.object({ title: z.string().min(1), ...SOLUTION_FIELDS }).strict())
|
|
572
626
|
.optional()
|
|
573
627
|
.describe("add only; fields as issue_solution add"),
|
|
574
628
|
...boardField,
|
|
575
629
|
}, async (args) => jsonResult(await issueProblem(client, args)));
|
|
576
630
|
// ---------------- issue_solution ----------------
|
|
577
|
-
|
|
631
|
+
strictTool("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.", {
|
|
578
632
|
id: z.string().min(1),
|
|
579
633
|
action: z.enum(["add", "edit", "remove"]),
|
|
580
634
|
problem_id: z.number().int().positive(),
|
|
@@ -585,7 +639,7 @@ server.tool("issue_solution", "One problem's options via /api/issues/:id/problem
|
|
|
585
639
|
...boardField,
|
|
586
640
|
}, async (args) => jsonResult(await issueSolution(client, args)));
|
|
587
641
|
// ---------------- issue_dependency ----------------
|
|
588
|
-
|
|
642
|
+
strictTool("issue_dependency", 'Dependency CRUD via /api/issues/:id/dependencies[/:did]. action=add → POST {kind, target_id, reason} where kind ∈ {depends_on, conflict_on}. depends_on adds are CYCLE-CHECKED (BFS from target back to source — 409 if loop). Idempotent: re-adding a live triple returns the existing id. Self-loops refuse 409. action=remove → DELETE /:did. The server REQUIRES the literal reason="recorded_in_error" on removal (encodes "removal means NOT related, never satisfied") — this MCP boundary hardcodes it, so callers do not pass reason on remove. This is the ONLY mechanism the dispatch picker enforces to sequence one card after another — leaving a card at a Review/held status (e.g. an issue_triage "keep" verdict) is NOT a substitute and provides no cross-card ordering protection.', {
|
|
589
643
|
id: z.string().min(1),
|
|
590
644
|
action: z.enum(["add", "remove"]),
|
|
591
645
|
kind: z.enum(["depends_on", "conflict_on"]).optional(),
|
|
@@ -595,13 +649,13 @@ server.tool("issue_dependency", 'Dependency CRUD via /api/issues/:id/dependencie
|
|
|
595
649
|
...boardField,
|
|
596
650
|
}, async (args) => jsonResult(await issueDependency(client, args)));
|
|
597
651
|
// ---------------- issue_retire_branch ----------------
|
|
598
|
-
|
|
652
|
+
strictTool("issue_retire_branch", "Mark a card's own `card/<id>` branch RETIRED (unsafe to merge) via POST /api/issues/:id/card-branch-retire {reason} (DX-2845). NO status/terminal gate — settable the moment a branch is judged unsafe (an audit rejected it, the card was split into fresh slices, ...), whether the card is ToDo, In Progress, or anything else; this is deliberately NOT the same as captureAndDeleteCardBranch, which only fires once the card itself reaches Done/Cancelled. `by` is server-stamped from the resolved writing identity, never client-supplied. The dispatched worker reads this at its next bootstrap and forces `origin/main` as the checkout start point instead of re-attaching to the retired content — the origin `card/<id>` ref itself is separately backed up then deleted by the worker as a lazy hygiene step. Idempotent: retiring an already-retired branch just re-stamps reason/actor/timestamp.", {
|
|
599
653
|
id: z.string().min(1),
|
|
600
654
|
reason: z.string().min(1),
|
|
601
655
|
...boardField,
|
|
602
656
|
}, async (args) => jsonResult(await issueRetireBranch(client, args)));
|
|
603
657
|
// ---------------- issue_quality_gate ----------------
|
|
604
|
-
|
|
658
|
+
strictTool("issue_quality_gate", "Put one quality gate ON a card, or take it OFF, via POST /api/issues/:id/quality-gates/:gate {action} — the only post-create way (issue_create names gates in quality_gates; issue_edit refuses gate keys). A gate is on the card or it does not exist for it; every gate on a card is required, so `add` means it now runs and `remove` means it is gone (not displayed, not counted). Adding a gate at any time is fully supported — that is what this tool is for. PRE `plan-*` gates run before the work dispatch; POST `code-*` gates block complete. Unknown gate → 400; a never-gated card type (Epic/Feature/Task) → 400. `note` (why it applies) and `effort_level` (overrides a `plan-*` gate's reviewer rung; null clears it) are `add`-only — passing either with `remove` → 400. Re-adding a gate the card already has updates note/effort and KEEPS its verdict; `remove` discards the row and any verdict on it. Board-scoped; see `board`.", {
|
|
605
659
|
id: z.string().min(1),
|
|
606
660
|
gate: z.enum([
|
|
607
661
|
"plan-dependency",
|
|
@@ -622,7 +676,7 @@ server.tool("issue_quality_gate", "Put one quality gate ON a card, or take it OF
|
|
|
622
676
|
...boardField,
|
|
623
677
|
}, async (args) => jsonResult(await issueQualityGate(client, args)));
|
|
624
678
|
// ---------------- issue_quality_gate_verdict ----------------
|
|
625
|
-
|
|
679
|
+
strictTool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate VERDICT via PATCH /api/issues/:id/quality-gates/:gate {status, message} — the same write the dashboard Gates-tab Pass / Fail / Revert controls perform (DX-1373). SIBLING of `issue_quality_gate`, not a replacement: that one puts a gate ON or OFF the card (does this gate apply at all), THIS one records the VERDICT (did it pass) — POST vs PATCH on the same resource, neither substitutes for the other. **Use this to close out a card you picked up with `issue_transition pickup {manual:true}`** (DX-946 operator-session self-pickup): `issue_transition complete` REFUSES 409 (`failed_gate: \"quality_gate_post\"`, `failed_post_gates[]`) while any required POST gate (`code-quality` / `code-test-quality` / `code-architecture`) is not `pass`, so without a verdict a manually-claimed card can never reach Done — it strands In Progress and its `conflict_on` / `waiting_on` edges then stall OTHER cards' dispatch. `status`: `pass` | `fail` | `pending` (revert a prior verdict, clears the message). `message` is the accountability record for the override, REQUIRED at >= 20 characters for `pass`/`fail` (shorter → 400), ignored for `pending`. Record the REAL reviewer finding, not a rubber stamp — a human-attributed override, stamped with the operator actor, standing in for a reviewer dispatch. A manual verdict is a PURE row write: no side effects — a manual `fail` never blocks the card and a manual `pass` never releases a dispatch. Unknown gate → 400; bad status → 400; unknown card → 404. Board-scoped; see `board`.", {
|
|
626
680
|
id: z.string().min(1),
|
|
627
681
|
gate: z.enum([
|
|
628
682
|
"plan-dependency",
|
|
@@ -637,7 +691,7 @@ server.tool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate
|
|
|
637
691
|
...boardField,
|
|
638
692
|
}, async (args) => jsonResult(await issueQualityGateVerdict(client, args)));
|
|
639
693
|
// ---------------- issue_retro ----------------
|
|
640
|
-
|
|
694
|
+
strictTool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retro. Body: {good, bad, correctable_danxbot_problem, correctable_danxbot_problem_description, action_item_ids[], commits[], tests[]}. REFUSES 409 unless the card is terminal (completed_at OR cancelled_at) — retro ships when work concludes. Replace semantics: good/bad upsert; action_item_ids[] + commits[] + tests[] soft-delete prior live rows and insert with fresh ordinals. action_item_ids[] entries MUST match <PREFIX>-N. commits[] entries take {sha, subject?}. tests[] (DX-1646) is REQUIRED (empty array allowed — the \"ran no tests\" case): one row per test GROUP that ran (a whole suite/class — name the group, do NOT list individual unit tests) or per individual e2e test (kind:'e2e', few + expensive so listed explicitly). Each row: {name, kind:'group'|'e2e', num_tests, num_passing_tests, duration_ms} required; num_assertions + num_passing_assertions NULLABLE (vitest has no assertion totals — pass null or omit). `correctable_danxbot_problem` (DX-2794) is REQUIRED on every write, like tests[] — ANSWER IT HONESTLY: did THIS dispatch hit a problem in danxbot's own code or configuration (not merely \"this card was hard\") that danxbot could change so it stops happening? true REQUIRES a non-empty `correctable_danxbot_problem_description` naming the problem; false REQUIRES the description be empty. A true+described retro is read by a deterministic, no-LLM check and starts exactly one automated repair (fixes the problem or files a ready card) — this is the ONLY reliable channel for a danxbot defect found mid-dispatch to actually get fixed, so do not default to false out of haste.", {
|
|
641
695
|
id: z.string().min(1),
|
|
642
696
|
good: z.string(),
|
|
643
697
|
bad: z.string(),
|
|
@@ -651,7 +705,7 @@ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retr
|
|
|
651
705
|
commits: z.array(z.object({
|
|
652
706
|
sha: z.string().min(1),
|
|
653
707
|
subject: z.string().optional(),
|
|
654
|
-
})),
|
|
708
|
+
}).strict()),
|
|
655
709
|
tests: z.array(z.object({
|
|
656
710
|
name: z.string().min(1),
|
|
657
711
|
kind: z.enum(["group", "e2e"]),
|
|
@@ -665,7 +719,7 @@ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retr
|
|
|
665
719
|
.nullable()
|
|
666
720
|
.optional(),
|
|
667
721
|
duration_ms: z.number().int().nonnegative(),
|
|
668
|
-
})),
|
|
722
|
+
}).strict()),
|
|
669
723
|
...boardField,
|
|
670
724
|
}, async (args) => jsonResult(await issueRetro(client, args)));
|
|
671
725
|
// ---------------- issue_attach ----------------
|
|
@@ -673,7 +727,7 @@ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retr
|
|
|
673
727
|
// route's MAX_DECODED_BYTES (src/issues/write/attachments.ts). This package is
|
|
674
728
|
// a separate published artifact and cannot import that constant, so the number
|
|
675
729
|
// is restated here as prose — keep the two in sync if the backend ceiling moves.
|
|
676
|
-
|
|
730
|
+
strictTool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/issues/:id/attachments. Pass `id` (the card) and `file_path` (an ABSOLUTE path to a file on the dispatch's shared filesystem — e.g. a screenshot, exported CSV, or diagram you wrote). This MCP server reads the bytes, infers the MIME type from the extension, and uploads through the dashboard, which: stores the bytes in S3, inserts ONE danxbot-origin issue_attachments row, and auto-mirrors the file to the card's linked Trello card AND its Slack card-view thread (DX-1122 outbound projection) — no extra step needed. Board-scoped; see `board`. Fail-loud: a relative/empty path is rejected at the MCP boundary, and a missing/unreadable file throws BEFORE any upload (no partial S3 object, no row). 25 MB decoded ceiling (413). Returns `{issue, attachment_id, s3_key}` — find the new attachment in `issue.attachments` by `attachment_id` to read its public, long-lived `url` (no expiry — never a presigned link). EMBEDDING: that `url` is not only a card-level attachment — paste it into ANY markdown-bearing field (a problem's `statement`/`context` via `issue_problem`, a comment via `issue_comment`, a plan record's `context`, a solution's `body`) as `` and the dashboard renders it inline, capped to a compact thumbnail with click-to-enlarge. Use this whenever a screenshot would let a human judge something faster than prose — a UI bug, a before/after, a broken layout. Example: after uploading and reading the attachment's url (say `https://dx-issues.s3.us-east-1.amazonaws.com/abc123.png`), call `issue_comment` with text `Before the fix, the sidebar overlapped the header:\\n\\n`.", {
|
|
677
731
|
id: z.string().min(1),
|
|
678
732
|
file_path: z
|
|
679
733
|
.string()
|
|
@@ -682,11 +736,11 @@ server.tool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/
|
|
|
682
736
|
...boardField,
|
|
683
737
|
}, async (args) => jsonResult(await issueAttach(client, args)));
|
|
684
738
|
// ---------------- repo_knowledge_get ----------------
|
|
685
|
-
|
|
739
|
+
strictTool("repo_knowledge_get", "Fetch the board's working-knowledge markdown doc via GET /api/repo-knowledge (DX-1128, Story 2). Board-scoped; see `board`. Returns `{ok, status, body: {content, contentHash, updatedAt, updatedBy, boardId}}` — an unset doc reads as the empty view (`content: \"\"`, `contentHash: \"\"`), NOT a 404. Ground exploratory answers in `content`; before `repo_knowledge_set`, ALWAYS `repo_knowledge_get` immediately first and pass its `contentHash` back as `base_hash` — the server's optimistic-concurrency guard rejects a stale write.", {
|
|
686
740
|
...boardField,
|
|
687
741
|
}, async (args) => jsonResult(await repoKnowledgeGet(client, args)));
|
|
688
742
|
// ---------------- repo_knowledge_set ----------------
|
|
689
|
-
|
|
743
|
+
strictTool("repo_knowledge_set", 'Write the board\'s working-knowledge markdown doc via PUT /api/repo-knowledge (DX-1128, Story 2). Board-scoped; see `board`. `base_hash` MUST be the `contentHash` from the immediately-prior `repo_knowledge_get` call ("" for the true first write, when the board has no doc yet) — the server compares it against the CURRENT hash and, on mismatch, fails loud with `{ok: false, body: {error: "stale_repo_knowledge", currentHash}}` rather than silently overwriting a concurrent write. On that refusal: re-`repo_knowledge_get`, re-merge your insight into the fresh content, and retry `repo_knowledge_set` with the new hash. On success, persists to the DB, publishes `repo-knowledge:updated` over SSE (live in the dashboard editor), and returns the new view.', {
|
|
690
744
|
content: z.string(),
|
|
691
745
|
base_hash: z
|
|
692
746
|
.string()
|
|
@@ -695,11 +749,11 @@ server.tool("repo_knowledge_set", 'Write the board\'s working-knowledge markdown
|
|
|
695
749
|
...boardField,
|
|
696
750
|
}, async (args) => jsonResult(await repoKnowledgeSet(client, args)));
|
|
697
751
|
// ---------------- brief_list ----------------
|
|
698
|
-
|
|
752
|
+
strictTool("brief_list", "List the board's named Brief pages via GET /api/brief (DX-2083 / DX-2484). Board-scoped; see `board`. Returns `{boardId, pages: [{slug, title, contentHash, sortOrder, updatedAt, updatedBy}]}` — metadata only, no page content (use `brief_get_page` for that). This is the list+page-shaped sibling of `repo_knowledge_get`/`repo_knowledge_set` (one board-level doc) — Brief pages are MANY named pages per board (the Goals / Architecture / Rules / Caveats tabs), keyed by `(board, slug)`. The reserved `index` slug always exists — every board carries exactly one.", {
|
|
699
753
|
...boardField,
|
|
700
754
|
}, async (args) => jsonResult(await briefList(client, args)));
|
|
701
755
|
// ---------------- brief_get_page ----------------
|
|
702
|
-
|
|
756
|
+
strictTool("brief_get_page", 'Fetch one Brief page by slug via GET /api/brief/page?slug=<slug> (DX-2083 / DX-2484). Board-scoped; see `board`. Returns `{boardId, slug, title, content, contentHash, sortOrder, updatedAt, updatedBy}` — a missing/not-yet-created page reads as the empty view (`content: ""`, `contentHash: ""`), NOT a 404, matching `repo_knowledge_get`\'s convention. Before `brief_set_page`, ALWAYS `brief_get_page` immediately first and pass its `contentHash` back as `base_hash` — the server\'s optimistic-concurrency guard rejects a stale write.', {
|
|
703
757
|
slug: z
|
|
704
758
|
.string()
|
|
705
759
|
.min(1)
|
|
@@ -707,7 +761,7 @@ server.tool("brief_get_page", 'Fetch one Brief page by slug via GET /api/brief/p
|
|
|
707
761
|
...boardField,
|
|
708
762
|
}, async (args) => jsonResult(await briefGetPage(client, args)));
|
|
709
763
|
// ---------------- brief_set_page ----------------
|
|
710
|
-
|
|
764
|
+
strictTool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug=<slug> (DX-2083 / DX-2484). Board-scoped; see `board`. Body: `{content, title?, sortOrder?, base_hash?}` — mirrors `repo_knowledge_set`\'s optimistic-concurrency shape but targets one named page instead of the board\'s single working-knowledge doc. `base_hash` MUST be the `contentHash` from the immediately-prior `brief_get_page` call ("" for a true first write, when the page doesn\'t exist yet) — the server compares it against the CURRENT hash and, on mismatch, fails loud with `{ok: false, body: {error: "stale_brief_page", currentHash}}` rather than silently overwriting a concurrent write — re-get, re-merge, and retry on that refusal, never retry blindly or overwrite. On success, persists to the DB, publishes `brief:updated` over SSE, and returns the new view. NO delete tool is exposed on this surface — the reserved `index` slug can never be deleted through the tool surface, matching the route\'s own refusal; deleting a non-index page is dashboard-UI-only for now.', {
|
|
711
765
|
slug: z
|
|
712
766
|
.string()
|
|
713
767
|
.min(1)
|
|
@@ -731,13 +785,13 @@ server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug
|
|
|
731
785
|
// plan id — and it can only ever bind the caller's own session. `plan_create`
|
|
732
786
|
// also takes no plan id, but for a different reason: it MAKES a plan rather
|
|
733
787
|
// than acting on one, so there is no existing plan for an id to name yet.
|
|
734
|
-
|
|
788
|
+
strictTool("plan_list", "List every plan via GET /api/plans (DX-2683), and learn which plan THIS session is connected to. Plans are GLOBAL, not board-scoped: a plan is a named, dated set of cards an operator assembled by hand, and its cards may come from any repository. Returns `{ok, status, body: {plans: [{id, ref, name, createdAt, cardCount, boards, status}], session, sessionListenerAttached}}` — `ref` is the plan's short reference (`PLN-<id>`), the same thing a card's own id is for a card; cite it rather than a bare id. Each plan's `status` (DX-2834) is COMPUTED fresh on every read, never stored — one of `awaiting-session` (no session is live on it — a `plan_sessions` row is never released when a session merely ends, so this is a real liveness check, not just \"has anyone ever connected\"), `planning` (no cards, or only Review/Backlog/terminal cards with at least one not Done/Cancelled), `building` (a live session AND at least one card ToDo/In Progress or in an active-but-stuck state — Blocked, Needs Help), `complete` (at least one card and every one Done/Cancelled — wins even with no session). Pass `status` to filter to one of them. `session` is your own registration — `{sessionId, title, planId, planName, firstSeenAt, lastActiveAt}` — or `null` if this process is not running inside a Claude Code session. A `planId` of null means you are connected to no plan: read any plan with `plan_get`, then `plan_connect` to the one you are working on (or ask the operator to connect you from the Plans list). `sessionListenerAttached` says whether your session's event stream is attached (the danxbot plugin's plan event bridge holds it). It is `false` for a few seconds right after `plan_connect` while the bridge starts; still `false` after that while connected means its card events are NOT reaching you — tell the operator. There is nothing to arm. NOTE this is NOT the board Brief (`brief_list`), which is a different feature entirely.", {
|
|
735
789
|
status: z
|
|
736
790
|
.enum(PLAN_STATUSES)
|
|
737
791
|
.optional()
|
|
738
792
|
.describe("Filter to one computed status: awaiting-session, planning, building, complete. Omit for every plan."),
|
|
739
793
|
}, async (args) => jsonResult(await planList(client, args)));
|
|
740
|
-
|
|
794
|
+
strictTool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id` to read ANY plan; OMIT to read the plan this session is connected to — omitting while connected to none fails loud `{error: \"session_not_connected\"}`, connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{plan, boards, cardCount, bucketCounts, status, session, sessionListenerAttached, available_field_groups}` — no cards, records, or architecture body. `plan` carries `{id, ref, name, createdAt}`; `ref` is the plan's short reference (`PLN-<id>`) — cite that, not the bare id. `status` (DX-2834) is computed fresh on every read, never stored — see `plan_list` for the four values and what each means. Pass `fields` to opt into: `cards` (member cards, PAGED: `cards_offset` (default 0) and `cards_limit` (1.." + LIST_PAGE_MAX_LIMIT + ", default " + PLAN_GET_CARDS_DEFAULT_LIMIT + ") pick the page, in stable card-reference order (board prefix, then card number — pages never repeat/skip unless membership changes between reads); response carries `cards_total`/`cards_offset` — page while cards_offset + cards.length < cards_total; either paging arg without `fields: [\"cards\"]` is a 400), `records` (every goal+rule+caveat, keyed by kind) or `records:goal` / `records:rule` / `records:caveat` (one kind, cheaper), `architecture` (`{sections: [{id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}]}`), `sessions` (every session connected to the plan), `events` (DX-2987/DX-3027 — the plan's durable event ledger: every human action and every bridge message recorded on it, cursor-paged newest first via `events_limit` (1.." + PLAN_GET_EVENTS_MAX_LIMIT + ", default " + PLAN_GET_EVENTS_DEFAULT_LIMIT + ") and `events_before` (an opaque cursor — pass a previous page's `next_cursor` to read older; omit for the newest page); filter with `events_kinds` (one or more of " + PLAN_EVENT_KINDS.join(", ") + "), `events_origin` (one of " + PLAN_EVENT_ORIGINS.join(", ") + "), `events_writer` (exact writer name); every `events_*` param without `fields: [\"events\"]` is a 400, same as the `cards_*` params above; response carries `events: {items: [{id, at, kind, writer, origin, originSessionId, targetSessionId, cardId, cardTitle, boardId, detail}], next_cursor}` — `next_cursor` is `null` on the last page; an event on a card whose board you cannot read is left out, plan-level events are always visible). `session`/`sessionListenerAttached` and `available_field_groups` ride every response regardless. `sessionListenerAttached` is `false` for a few seconds right after `plan_connect` while the plugin's event bridge starts; still `false` after that while connected means the plan's card events are not reaching you — tell the operator. ALWAYS `plan_get`/`plan_get_architecture_section` immediately before `plan_update_architecture_section`/`plan_delete_architecture_section` and pass the returned `contentHash` back as `base_hash`.", {
|
|
741
795
|
plan_id: z
|
|
742
796
|
.number()
|
|
743
797
|
.int()
|
|
@@ -787,12 +841,12 @@ server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id
|
|
|
787
841
|
.optional()
|
|
788
842
|
.describe("DX-3027 — only events with this exact writer name. Omit for every writer. Requires `fields` to include `events`."),
|
|
789
843
|
}, async (args) => jsonResult(await planGet(client, args)));
|
|
790
|
-
|
|
844
|
+
strictTool("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, ref, name, createdAt}}}` — `ref` is the plan's short reference (`PLN-<id>`). Use the returned `plan.id` with `plan_connect` to start working on it, or with `plan_get({plan_id})` to browse it.", {
|
|
791
845
|
name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
|
|
792
846
|
}, async (args) => jsonResult(await planCreate(client, args)));
|
|
793
|
-
|
|
847
|
+
strictTool("plan_connect",
|
|
794
848
|
// DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
|
|
795
|
-
"Connect THIS session to a plan via POST /api/plan-sessions/me/plan — the same binding the operator's Connect action writes. ONE CALL IS ENOUGH TO START (DX-2859): the reply carries `{session, movedFrom, briefing, listenerHealth}` — `briefing` is every goal/rule/caveat (ref+body), every architecture section (id+title), the plan's own ref/name/status, a first page of open cards (id/type/status/title/openProblemCount/assignedAgent) with a `morePagesHint` when more exist, and the closed-card count — usually replacing the `plan_get({fields:[...]})` + card batch-read a fresh session used to need. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and `movedFrom: {id, name} | null` says which plan it left (null = no plan, or already this one). 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. `listenerHealth` reports the event bridge's state — `null` (no session), or `{attached, state: \"unattached\"|\"credential_mismatch\"|\"board_scope_narrowed\"|\"healthy\", nextStep}` naming a concrete fix for every unhealthy state (the SAME shape `plan_list`/`plan_get` report). Once healthy, every comment, answer, problem added and block/unblock on this plan's cards reaches the session on its own, relayed by the danxbot plugin's plan event bridge as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<problem statement>\": chose \"Pause E2E\"`. Nothing to arm; never poll for these. DX-2816: pass `title` (call `get_session({session_id:\"self\"})` first and forward its `title` verbatim) so the dashboard shows the same name Claude does — this server has no way to read it itself.", {
|
|
849
|
+
"Connect THIS session to a plan via POST /api/plan-sessions/me/plan — the same binding the operator's Connect action writes. ONE CALL IS ENOUGH TO START (DX-2859): the reply carries `{session, movedFrom, browserInstruction, briefing, listenerHealth}` — `browserInstruction` (DX-3276) is a server-built action to take NOW, not just informational text: it names the plan's URL and tells you to open it (your in-app browser if you have one, else your default browser), keep that tab open for the whole session, never navigate it away, and use a different tab for your own browsing — it is the operator's tab for following and talking to you through the plan. Returned on EVERY connect, including a re-connect, so it doubles as the reminder after a context loss — act on it again each time. `briefing` is every goal/rule/caveat (ref+body), every architecture section (id+title), the plan's own ref/name/status, a first page of open cards (id/type/status/title/openProblemCount/assignedAgent) with a `morePagesHint` when more exist, and the closed-card count — usually replacing the `plan_get({fields:[...]})` + card batch-read a fresh session used to need. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and `movedFrom: {id, name} | null` says which plan it left (null = no plan, or already this one). 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. `listenerHealth` reports the event bridge's state — `null` (no session), or `{attached, state: \"unattached\"|\"credential_mismatch\"|\"board_scope_narrowed\"|\"healthy\", nextStep}` naming a concrete fix for every unhealthy state (the SAME shape `plan_list`/`plan_get` report). Once healthy, every comment, answer, problem added and block/unblock on this plan's cards reaches the session on its own, relayed by the danxbot plugin's plan event bridge as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<problem statement>\": chose \"Pause E2E\"`. Nothing to arm; never poll for these. DX-2816: pass `title` (call `get_session({session_id:\"self\"})` first and forward its `title` verbatim) so the dashboard shows the same name Claude does — this server has no way to read it itself.", {
|
|
796
850
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
797
851
|
title: z
|
|
798
852
|
.string()
|
|
@@ -816,15 +870,15 @@ async (args) => {
|
|
|
816
870
|
}),
|
|
817
871
|
});
|
|
818
872
|
});
|
|
819
|
-
|
|
873
|
+
strictTool("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. DX-3072 — returns the created record plus `records_count` (that kind's live count, not the whole list, which can grow unboundedly over a plan's life); read the list itself with `plan_get({fields:[\"records:<kind>\"]})` or the dedicated GET.", {
|
|
820
874
|
kind: z.enum(["goal", "rule", "caveat"]).describe("goal = outcome, rule = constraint, caveat = architecture trade-off."),
|
|
821
875
|
body: z.string().min(1).describe("One plain statement, at most 250 characters. Details go in `context`."),
|
|
822
876
|
context: z.string().optional().describe("Markdown detail behind the statement: evidence, history, examples."),
|
|
823
877
|
}, async (args) => jsonResult(await planAddRecord(client, args)));
|
|
824
|
-
|
|
878
|
+
strictTool("plan_get_record", "Read one goal/rule/caveat of your connected plan (GET /api/plans/mine/records/:rid) without pulling the whole plan. Takes no plan id; an unknown or another plan's record id → 404. Returns `{record: {id, planId, kind, refNum, ref, body, context, contentHash, createdAt, updatedAt}}` — `context` is the markdown detail, or null.", {
|
|
825
879
|
record_id: z.number().int().positive().describe("A record id, from `plan_add_record`, `plan_get` or `plan_get_record` itself."),
|
|
826
880
|
}, async (args) => jsonResult(await planGetRecord(client, args)));
|
|
827
|
-
|
|
881
|
+
strictTool("plan_update_record", 'Edit a goal/rule/caveat of your connected plan (PATCH /api/plans/mine/records/:rid). The reference never moves. `body` stays ONE plain statement of at most 250 characters (400 otherwise); detail belongs in markdown `context`. `content_hash` must be the record\'s `contentHash` from your last read, and covers body AND context. On a mismatch nothing is written and you get `{error: "stale_plan_record", currentHash, currentBody, currentContext}`: merge into those and retry with `content_hash: currentHash`, never blindly. Takes no plan id. DX-3072 — returns the edited record plus `records_count` (that kind\'s live count, not the whole list — see `plan_add_record`).', {
|
|
828
882
|
record_id: z.number().int().positive().describe("The record id to edit."),
|
|
829
883
|
content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
|
|
830
884
|
body: z.string().min(1).describe("The new statement, at most 250 characters. Plain text."),
|
|
@@ -834,11 +888,11 @@ server.tool("plan_update_record", 'Edit a goal/rule/caveat of your connected pla
|
|
|
834
888
|
.optional()
|
|
835
889
|
.describe("New markdown detail. Omit to keep the stored context; null clears it."),
|
|
836
890
|
}, async (args) => jsonResult(await planUpdateRecord(client, args)));
|
|
837
|
-
|
|
891
|
+
strictTool("plan_delete_record", 'Soft-delete a goal/rule/caveat of your connected plan (DELETE /api/plans/mine/records/:rid). Its reference is retired permanently, never reused. `content_hash` must be the record\'s `contentHash` from your last read; a stale hash deletes nothing and returns `{error: "stale_plan_record", currentHash, currentBody, currentContext}`. Takes no plan id. Unknown or already-deleted id → 404. DX-3072 — returns `{records_count}`, that kind\'s remaining live count, not the whole list (see `plan_add_record`).', {
|
|
838
892
|
record_id: z.number().int().positive().describe("The record id to delete."),
|
|
839
893
|
content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
|
|
840
894
|
}, async (args) => jsonResult(await planDeleteRecord(client, args)));
|
|
841
|
-
|
|
895
|
+
strictTool("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. DX-3072 — returns the new note plus `notes_count` (the plan's total live note count, not the latest page); read the timeline itself with `plan_get({fields:[\"notes\"]})` or the dedicated GET.", {
|
|
842
896
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
843
897
|
title: z.string().min(1).describe("At most 60 characters."),
|
|
844
898
|
body: z.string().min(1).describe("The wrap-up, at most 250 characters."),
|
|
@@ -849,7 +903,7 @@ server.tool("plan_add_note", "Write a milestone note to a plan's timeline, via P
|
|
|
849
903
|
.describe("Goal/rule/caveat references this note announces, e.g. `[\"G-1\", \"R-3\", \"CAV-2\"]`."),
|
|
850
904
|
section_ids: z.array(z.number().int().positive()).optional().describe("Architecture section ids this note announces."),
|
|
851
905
|
}, async (args) => jsonResult(await planAddNote(client, args)));
|
|
852
|
-
|
|
906
|
+
strictTool("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. DX-3072 — returns the edited note plus `notes_count` (see `plan_add_note`), not the latest page.', {
|
|
853
907
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
854
908
|
note_id: z.number().int().positive().describe("The note id to edit."),
|
|
855
909
|
content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
|
|
@@ -859,40 +913,40 @@ server.tool("plan_update_note", 'Edit a plan note, via PATCH /api/plans/:plan_id
|
|
|
859
913
|
record_refs: z.array(z.string().min(1)).optional().describe("REPLACES the whole link set when sent (with card_ids/section_ids)."),
|
|
860
914
|
section_ids: z.array(z.number().int().positive()).optional().describe("REPLACES the whole link set when sent (with card_ids/record_refs)."),
|
|
861
915
|
}, async (args) => jsonResult(await planUpdateNote(client, args)));
|
|
862
|
-
|
|
916
|
+
strictTool("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. DX-3072 — returns `{notes_count}`, the plan\'s remaining live note count, not the latest page.', {
|
|
863
917
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
864
918
|
note_id: z.number().int().positive().describe("The note id to delete."),
|
|
865
919
|
content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
|
|
866
920
|
}, async (args) => jsonResult(await planDeleteNote(client, args)));
|
|
867
|
-
|
|
921
|
+
strictTool("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. DX-3072 — returns `{card_id, member: true, cards_count}` (the attachment's own confirmation plus the plan's total member-card count), not the full member list — a plan can hold hundreds of cards, and the whole list was a ~500KB reply that a caller could not read. Read the list itself with `plan_get({fields:[\"cards\"]})` (paged) when you actually need it.", {
|
|
868
922
|
card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
|
|
869
923
|
}, async (args) => jsonResult(await planAddCard(client, args)));
|
|
870
|
-
|
|
924
|
+
strictTool("plan_remove_card", "Remove a card from a plan via DELETE /api/plans/:plan_id/cards/:card_id (DX-2740) — the sibling of `plan_add_card`. The card may live on ANY board. Idempotent: removing a card that was never a member is a no-op, not an error — the same idempotent-toggle contract `issue_dependency` add/remove uses. This removes MEMBERSHIP only; it never edits or deletes the card itself, and its membership in every OTHER plan is untouched. Unlike `plan_add_card`, this takes an EXPLICIT `plan_id` rather than acting on your connected session's plan — you may remove a card from any plan you can name. Unknown plan → 404. DX-3072 — returns `{card_id, member: false, cards_count}`, the plan's remaining member-card count, not the full list (see `plan_add_card`).", {
|
|
871
925
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
872
926
|
card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
|
|
873
927
|
}, async (args) => jsonResult(await planRemoveCard(client, args)));
|
|
874
|
-
|
|
928
|
+
strictTool("plan_rename", "Rename a plan via PATCH /api/plans/:plan_id (DX-2740) — the ONLY way to change a plan's `name`; nothing else in this tool surface can fix a stale name. Takes an EXPLICIT `plan_id`, not your connected session's plan, so you may rename any plan you can name. `name` must be a non-empty string (400 otherwise). The new name is visible immediately in a follow-up `plan_list` or `plan_get`. Unknown plan → 404. Returns the renamed plan `{id, ref, name, createdAt}`.", {
|
|
875
929
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
876
930
|
name: z.string().min(1).describe("The plan's new name."),
|
|
877
931
|
}, async (args) => jsonResult(await planRename(client, args)));
|
|
878
|
-
|
|
932
|
+
strictTool("plan_get_architecture_section", "Read ONE section of the plan this session is connected to, via GET /api/plans/mine/architecture/sections/:sid (DX-2726). TAKES NO PLAN ID: the plan is resolved from your connected session, same as `plan_add_record`. Not connected → `{error: \"session_not_connected\"}`. Unknown or another plan's section id → 404. Returns `{section: {id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}}`.", {
|
|
879
933
|
section_id: z.number().int().positive().describe("A section id, from `plan_get` or `plan_add_architecture_section`."),
|
|
880
934
|
}, async (args) => jsonResult(await planGetArchitectureSection(client, args)));
|
|
881
|
-
|
|
935
|
+
strictTool("plan_add_architecture_section", "Append a section to your connected plan's architecture, via POST /api/plans/mine/architecture/sections (DX-2726). Architecture is SECTIONS, not one document — each section is independently editable and hash-guarded, so fixing one never stales a concurrent edit to another. The new section sorts after every existing live section; use `plan_reorder_architecture_section` to move it. `title` is the heading shown in the auto-generated navigation index; `content` is its markdown. Takes no plan id; `session_not_connected` → `plan_connect` first. Returns the new section plus the plan's full live section list.", {
|
|
882
936
|
title: z.string().min(1).describe("The section's heading, shown in the navigation index."),
|
|
883
937
|
content: z.string().describe("The section's markdown. May be empty — a section awaiting its first draft is a real state."),
|
|
884
938
|
}, async (args) => jsonResult(await planAddArchitectureSection(client, args)));
|
|
885
|
-
|
|
939
|
+
strictTool("plan_update_architecture_section", 'Edit a section\'s title and/or content, via PATCH /api/plans/mine/architecture/sections/:sid (DX-2726). Both `title` and `content` are OPTIONAL — send only whichever changed. `base_hash` MUST be the section\'s `contentHash` from your last read; the server compares it against the current hash and, on a mismatch, fails loud with `{ok: false, body: {error: "stale_plan_architecture_section", currentHash, currentTitle, currentContent}}` rather than overwriting whoever wrote in between — merge into those and retry with `base_hash: currentHash`, never blindly. Takes no plan id. Returns the edited section plus the plan\'s full live section list.', {
|
|
886
940
|
section_id: z.number().int().positive().describe("The section id to edit."),
|
|
887
941
|
base_hash: z.string().describe("The section's `contentHash` from your last read. Required."),
|
|
888
942
|
title: z.string().min(1).optional().describe("New heading. Omit to keep the stored title."),
|
|
889
943
|
content: z.string().optional().describe("New markdown. Omit to keep the stored content."),
|
|
890
944
|
}, async (args) => jsonResult(await planUpdateArchitectureSection(client, args)));
|
|
891
|
-
|
|
945
|
+
strictTool("plan_delete_architecture_section", 'Soft-delete a section of your connected plan\'s architecture, via DELETE /api/plans/mine/architecture/sections/:sid (DX-2726). `base_hash` must be the section\'s `contentHash` from your last read; a stale hash deletes nothing and returns `{error: "stale_plan_architecture_section", currentHash, currentTitle, currentContent}` — on that refusal, re-fetch and confirm this is still the section you meant to remove before retrying, never blindly re-send with the fresh hash. Takes no plan id. Unknown or already-deleted section id → 404. Returns the plan\'s remaining live section list.', {
|
|
892
946
|
section_id: z.number().int().positive().describe("The section id to delete."),
|
|
893
947
|
base_hash: z.string().describe("The section's `contentHash` from your last read. Required."),
|
|
894
948
|
}, async (args) => jsonResult(await planDeleteArchitectureSection(client, args)));
|
|
895
|
-
|
|
949
|
+
strictTool("plan_reorder_architecture_section", "Reassign your connected plan's section display order, via PUT /api/plans/mine/architecture/sections/reorder (DX-2726). UNGUARDED by content hash, by design: moving a section never changes its (or any other section's) `contentHash`, so no `base_hash` is needed. `order` must name EXACTLY the plan's current live section ids, each once — a partial or foreign list is refused with a 400 rather than silently reordering a subset or dropping a section from view. Takes no plan id. Returns the plan's full live section list in its new order.", {
|
|
896
950
|
order: z
|
|
897
951
|
.array(z.number().int().positive())
|
|
898
952
|
.min(1)
|
|
@@ -912,17 +966,19 @@ const matcherField = z
|
|
|
912
966
|
.optional()
|
|
913
967
|
.describe("Regex source tested against the normalized excerpt (paths/UUIDs/timestamps/ports already stripped)."),
|
|
914
968
|
})
|
|
969
|
+
.strict()
|
|
915
970
|
.describe("At least one of sourceKind/tool/regexPattern must be set — an empty matcher is refused.");
|
|
916
971
|
const expectedRateField = z
|
|
917
972
|
.object({
|
|
918
973
|
count: z.number().int().min(0).describe("N — how many failures are expected."),
|
|
919
974
|
overDispatches: z.number().int().positive().describe("X — over how many dispatches."),
|
|
920
975
|
})
|
|
976
|
+
.strict()
|
|
921
977
|
.nullable()
|
|
922
978
|
.optional()
|
|
923
979
|
.describe("Both fields together, or omit/null entirely — never a half-specified rate.");
|
|
924
|
-
|
|
925
|
-
|
|
980
|
+
strictTool("failure_category_list", "List every failure category via GET /api/failure-categories (DX-2791/DX-2792). Board-less — a category applies across the whole install, not one board. Returns `{categories: [{id, name, description, matchers, ignore, ignoreReason, expectedRate, matchedCount, lastSeenMs, createdAtMs, createdBy, updatedAtMs, updatedBy}]}`, id ascending. A fresh install returns `{categories: []}`. Read this immediately before `failure_category_create`/`failure_category_update` so your overlap/expand decision is against the CURRENT set, not a stale snapshot from earlier in the dispatch.", {}, async () => jsonResult(await failureCategoryList(client)));
|
|
981
|
+
strictTool("failure_category_create", 'Create a new failure category via POST /api/failure-categories (DX-2791/DX-2792). Board-less. `matchers` (at least one) is an OR-across-matchers set — a category matches an occurrence when ANY ONE matcher\'s fields all hold. `ignore: true` REQUIRES a non-empty `ignoreReason` (400 otherwise) — use this for an expected, non-actionable failure rather than leaving it uncategorized. A matcher set overlapping an EXISTING category is refused 400 naming the conflict — call `failure_category_list` first and EXPAND that category (`failure_category_update`) instead of creating a near-duplicate. On success, the dashboard re-matches every existing uncategorized occurrence against the new category before responding.', {
|
|
926
982
|
name: z.string().min(1).describe("The category's name."),
|
|
927
983
|
description: z.string().optional().describe("Optional free-text description. Defaults to empty."),
|
|
928
984
|
matchers: z.array(matcherField).min(1).describe("At least one matcher; a category matches an occurrence when ANY ONE matches (OR across matchers)."),
|
|
@@ -930,7 +986,7 @@ server.tool("failure_category_create", 'Create a new failure category via POST /
|
|
|
930
986
|
ignoreReason: z.string().nullable().optional().describe("Required (non-empty) when ignore is true."),
|
|
931
987
|
expectedRate: expectedRateField,
|
|
932
988
|
}, async (args) => jsonResult(await failureCategoryCreate(client, args)));
|
|
933
|
-
|
|
989
|
+
strictTool("failure_category_update", "Patch an existing failure category via PATCH /api/failure-categories/:id (DX-2791/DX-2792). Board-less. This is the tool for BOTH actions: expanding an existing category's matchers (send the FULL replacement `matchers` array — it REPLACES, not appends, so include every matcher you want to keep alongside the new one) and marking a category ignored (`ignore: true` + a non-empty `ignoreReason`). At least one field besides `id` is required (400 otherwise). Same overlap refusal as create, excluding this category's own prior matchers. On success, re-matches every uncategorized occurrence against the updated matcher set before responding.", {
|
|
934
990
|
id: z.number().int().positive().describe("The category id to patch — from failure_category_list."),
|
|
935
991
|
name: z.string().min(1).optional(),
|
|
936
992
|
description: z.string().optional(),
|
|
@@ -940,7 +996,7 @@ server.tool("failure_category_update", "Patch an existing failure category via P
|
|
|
940
996
|
expectedRate: expectedRateField,
|
|
941
997
|
}, async (args) => jsonResult(await failureCategoryUpdate(client, args)));
|
|
942
998
|
// ---------------- dispatch_transcript_search (DX-3221) ----------------
|
|
943
|
-
|
|
999
|
+
strictTool("dispatch_transcript_search", "Search or tail ANOTHER dispatch's stored JSONL session transcript via the existing durable GET /api/dispatches/:id/logs sink (DX-1682/DX-1484) — every worker dispatch's raw transcript lines are already captured there today, so this adds no new server route, only in-process search/windowing. Use this instead of trying to Read a session transcript file directly: it needs no filesystem access to `~/.claude/projects/` (an ordinary authenticated HTTP call, so it never touches worktree-guard or CLAUDE.md Core Principle 5's worktree boundary), and it fixes what Read structurally cannot — Read paginates by LINE, and one persisted JSONL entry can itself be a single line far past Read's 25000-token cap with no way to sub-page inside it; this tool does the string/regex search itself and only ever returns a bounded excerpt AROUND a match, never the whole line. With `pattern`: returns up to `maxMatches` matching lines (case-insensitive regex), each as a windowed excerpt of up to `contextChars` characters centered on the first match. Without `pattern`: returns the most recent `tail` lines instead, each capped at `contextChars` characters (a `truncated: true` flag marks a capped excerpt either way — never a caller-visible error the way an oversized Read would throw). `dispatchId` is a real `dispatches.id` — exactly the ids a failure-repair card's own body already lists under \"dispatches that hit it\".", {
|
|
944
1000
|
dispatchId: z.string().min(1).describe("The dispatch id whose transcript to search — from a failure-repair card's own body, or any other dispatch id you already have."),
|
|
945
1001
|
pattern: z.string().min(1).optional().describe("Case-insensitive regex tested against each raw JSONL line. Omit to get a tail read of the most recent lines instead."),
|
|
946
1002
|
tail: z.number().int().positive().optional().describe("Only used when `pattern` is omitted. How many of the most recent lines to return. Defaults to 20, capped at 200."),
|
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.112",
|
|
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",
|