@thehammer/danx-dashboard-mcp 0.1.106 → 0.1.110

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 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)
@@ -1383,3 +1387,136 @@ export async function failureCategoryUpdate(client, args) {
1383
1387
  body: patch,
1384
1388
  });
1385
1389
  }
1390
+ // ---------------- dispatch_transcript_search (DX-3221) ----------------
1391
+ /**
1392
+ * DX-3221 — search or tail ANOTHER dispatch's stored JSONL transcript
1393
+ * without ever touching the filesystem. Wraps the existing durable
1394
+ * `GET /api/dispatches/:id/logs` route (DX-1682, reading the `dispatch_logs`
1395
+ * sink every worker already streams its raw JSONL lines into per DX-1484 —
1396
+ * no dashboard-side change needed, that data is already captured today for
1397
+ * every worker dispatch).
1398
+ *
1399
+ * Why this exists instead of a filesystem path: CLAUDE.md Core Principle 5
1400
+ * makes the worktree boundary binary (inside allow, outside reject, no
1401
+ * per-card opt-in) — a Grep/Glob tool pointed at `~/.claude/projects/`, or a
1402
+ * worktree-guard carve-out for that path, both widen that boundary the same
1403
+ * way a Bash `grep` would. This tool needs no such carve-out: it is an
1404
+ * ordinary HTTP call authenticated the same way every other
1405
+ * `mcp__danx-dashboard__*` tool already is, so worktree-guard never enters
1406
+ * the picture and CP5 stays untouched.
1407
+ *
1408
+ * Why this ALSO fixes the underlying complaint `Read` can't: `Read`
1409
+ * paginates by LINE, and a persisted JSONL entry can itself be one giant
1410
+ * line (a large MCP tool-result payload, escaped-newline JSON) that exceeds
1411
+ * the 25000-token cap with no way to sub-page inside it (`offset`/`limit`
1412
+ * are line-granular). This handler does the string/regex search ITSELF,
1413
+ * server-side of the model (inside this Node process), and only ever hands
1414
+ * back a bounded excerpt around a match — never the whole line — so a
1415
+ * single oversized line is no longer un-searchable.
1416
+ */
1417
+ const DISPATCHES_BASE_PATH = "/api/dispatches";
1418
+ /** Tail lines returned when no `pattern` is given. */
1419
+ const DEFAULT_TAIL_LINES = 20;
1420
+ /** Hard ceiling on `tail` — keeps a mistaken huge request bounded. */
1421
+ const MAX_TAIL_LINES = 200;
1422
+ /** Characters of context returned around (or up to, for tail) each line. */
1423
+ const DEFAULT_CONTEXT_CHARS = 1000;
1424
+ /** Hard ceiling on `contextChars` — this is what keeps a single giant line from blowing the model's context the way `Read` does today. */
1425
+ const MAX_CONTEXT_CHARS = 4000;
1426
+ /** Matches returned before search stops scanning further lines. */
1427
+ const DEFAULT_MAX_MATCHES = 10;
1428
+ /** Hard ceiling on `maxMatches`. */
1429
+ const MAX_MATCHES_CEILING = 50;
1430
+ function clampPositiveInt(value, fallback, ceiling) {
1431
+ const n = value ?? fallback;
1432
+ if (!Number.isFinite(n) || n <= 0)
1433
+ return fallback;
1434
+ return Math.min(Math.floor(n), ceiling);
1435
+ }
1436
+ /**
1437
+ * `GET /api/dispatches/:id/logs` (DX-1682) → search or tail, in-process.
1438
+ * Proxies the SAME durable sink `handleGetDispatchLogs` already serves —
1439
+ * this tool adds no new dashboard route, only client-side windowing.
1440
+ */
1441
+ export async function dispatchTranscriptSearch(client, args) {
1442
+ const hasPattern = args.pattern !== undefined && args.pattern !== "";
1443
+ // Validate the regex BEFORE the network call — a malformed `pattern` is a
1444
+ // caller input error, not a reason to spend an HTTP round-trip first.
1445
+ let re = null;
1446
+ if (hasPattern) {
1447
+ try {
1448
+ re = new RegExp(args.pattern, "gi");
1449
+ }
1450
+ catch (err) {
1451
+ return {
1452
+ ok: false,
1453
+ status: 400,
1454
+ body: {
1455
+ error: `dispatch_transcript_search: invalid \`pattern\` regex: ${err instanceof Error ? err.message : String(err)}`,
1456
+ },
1457
+ };
1458
+ }
1459
+ }
1460
+ const logsResult = await client.request({
1461
+ method: "GET",
1462
+ path: `/${encodeURIComponent(args.dispatchId)}/logs`,
1463
+ basePath: DISPATCHES_BASE_PATH,
1464
+ });
1465
+ if (!logsResult.ok) {
1466
+ return logsResult;
1467
+ }
1468
+ const body = logsResult.body;
1469
+ const lines = body?.lines ?? [];
1470
+ const contextChars = clampPositiveInt(args.contextChars, DEFAULT_CONTEXT_CHARS, MAX_CONTEXT_CHARS);
1471
+ if (re) {
1472
+ const maxMatches = clampPositiveInt(args.maxMatches, DEFAULT_MAX_MATCHES, MAX_MATCHES_CEILING);
1473
+ const results = [];
1474
+ for (const { ordinal, line } of lines) {
1475
+ re.lastIndex = 0;
1476
+ const match = re.exec(line);
1477
+ if (!match)
1478
+ continue;
1479
+ const half = Math.floor(contextChars / 2);
1480
+ const start = Math.max(0, match.index - half);
1481
+ const end = Math.min(line.length, match.index + match[0].length + half);
1482
+ const truncated = start > 0 || end < line.length;
1483
+ results.push({
1484
+ ordinal,
1485
+ excerpt: `${start > 0 ? "…" : ""}${line.slice(start, end)}${end < line.length ? "…" : ""}`,
1486
+ truncated,
1487
+ });
1488
+ if (results.length >= maxMatches)
1489
+ break;
1490
+ }
1491
+ return {
1492
+ ok: true,
1493
+ status: 200,
1494
+ body: {
1495
+ dispatch_id: args.dispatchId,
1496
+ pattern: args.pattern,
1497
+ total_lines: lines.length,
1498
+ matched_lines: results.length,
1499
+ results,
1500
+ },
1501
+ };
1502
+ }
1503
+ const tail = clampPositiveInt(args.tail, DEFAULT_TAIL_LINES, MAX_TAIL_LINES);
1504
+ const results = lines.slice(-tail).map(({ ordinal, line }) => {
1505
+ const truncated = line.length > contextChars;
1506
+ return {
1507
+ ordinal,
1508
+ excerpt: truncated ? `${line.slice(0, contextChars)}…` : line,
1509
+ truncated,
1510
+ };
1511
+ });
1512
+ return {
1513
+ ok: true,
1514
+ status: 200,
1515
+ body: {
1516
+ dispatch_id: args.dispatchId,
1517
+ total_lines: lines.length,
1518
+ returned_lines: results.length,
1519
+ results,
1520
+ },
1521
+ };
1522
+ }
package/dist/index.js CHANGED
@@ -52,6 +52,7 @@
52
52
  * - failure_category_list GET /api/failure-categories (DX-2791/DX-2792, board-less)
53
53
  * - failure_category_create POST /api/failure-categories (DX-2791/DX-2792, board-less)
54
54
  * - failure_category_update PATCH /api/failure-categories/:id (DX-2791/DX-2792, board-less)
55
+ * - dispatch_transcript_search GET /api/dispatches/:id/logs (DX-3221 — search/tail, no new route)
55
56
  *
56
57
  * DX-2683 — THE PLAN TOOLS ARE SESSION-BOUND, and asymmetrically so. Reads
57
58
  * may name any plan; WRITES take no plan id at all and act on the plan this
@@ -101,7 +102,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
101
102
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
102
103
  import { z } from "zod";
103
104
  import { DashboardHttpClient } from "./http-client.js";
104
- import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, planAddArchitectureSection, planAddCard, planAddNote, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteNote, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, PLAN_EVENT_KINDS, PLAN_EVENT_ORIGINS, PLAN_STATUSES, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, PLAN_GET_EVENTS_DEFAULT_LIMIT, PLAN_GET_EVENTS_MAX_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateNote, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
105
+ import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, dispatchTranscriptSearch, planAddArchitectureSection, planAddCard, planAddNote, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteNote, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, PLAN_EVENT_KINDS, PLAN_EVENT_ORIGINS, PLAN_STATUSES, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, PLAN_GET_EVENTS_DEFAULT_LIMIT, PLAN_GET_EVENTS_MAX_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateNote, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
105
106
  import { PRIORITY_TIER_WORDS } from "./priority.js";
106
107
  function readEnvOrDie(name) {
107
108
  const v = process.env[name];
@@ -226,6 +227,30 @@ function jsonResult(value) {
226
227
  content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
227
228
  };
228
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
+ }
229
254
  // Effort + verdict + action tuples kept in lockstep with the v2 write
230
255
  // handlers. Drift surfaces at runtime as a server 400 — not silently
231
256
  // wrong data — but pinning them here gets the failure caught at the
@@ -276,7 +301,7 @@ const sortField = z
276
301
  .array(z.object({
277
302
  column: z.string().min(1),
278
303
  order: z.enum(SORT_ORDERS),
279
- }))
304
+ }).strict())
280
305
  .optional()
281
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).");
282
307
  // DX-1290 — the uniform checklist-item status, extended DX-2653 with
@@ -322,7 +347,7 @@ const TITLE_DESCRIBE = 'Short, specific label naming the domain, so a reader rec
322
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.";
323
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.';
324
349
  // ---------------- issue_list ----------------
325
- server.tool("issue_list",
350
+ strictTool("issue_list",
326
351
  // DX-2735: trimmed to pay for the problem tools inside the work-profile
327
352
  // injected-surface budget — same facts, no repeated prose.
328
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.", {
@@ -338,6 +363,13 @@ server.tool("issue_list",
338
363
  include_closed: z.boolean().optional(),
339
364
  include_deleted: z.boolean().optional(),
340
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()
341
373
  .optional(),
342
374
  fields: z
343
375
  .array(z.enum(LIST_FIELD_GROUPS))
@@ -349,7 +381,7 @@ server.tool("issue_list",
349
381
  ...boardField,
350
382
  }, async (args) => jsonResult(await issueList(client, args)));
351
383
  // ---------------- issue_get ----------------
352
- server.tool("issue_get",
384
+ strictTool("issue_get",
353
385
  // DX-2735: trimmed to pay for the problem tools inside the work-profile
354
386
  // injected-surface budget — same facts, no repeated prose.
355
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.", {
@@ -362,7 +394,7 @@ server.tool("issue_get",
362
394
  ...boardField,
363
395
  }, async (args) => jsonResult(await issueGet(client, args)));
364
396
  // ---------------- issue_create ----------------
365
- server.tool("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. ' +
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. ' +
366
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.', {
367
399
  type: z.enum(ISSUE_TYPES),
368
400
  title: z.string().min(1).describe(TITLE_DESCRIBE),
@@ -377,7 +409,7 @@ server.tool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass
377
409
  .nullable()
378
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.'),
379
411
  parent_id: z.string().nullable().optional(),
380
- ac: z.array(z.object({ title: z.string().min(1) })).optional(),
412
+ ac: z.array(z.object({ title: z.string().min(1) }).strict()).optional(),
381
413
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
382
414
  list_id: z.string().min(1).nullable().optional(),
383
415
  quality_gates: z
@@ -385,7 +417,7 @@ server.tool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass
385
417
  gate: z.string().min(1),
386
418
  note: z.string().optional(),
387
419
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
388
- }))
420
+ }).strict())
389
421
  .optional()
390
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."),
391
423
  phase_children: z
@@ -398,30 +430,51 @@ server.tool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass
398
430
  .optional()
399
431
  .describe(`${SUMMARY_DESCRIBE} This child's OWN summary — never inherited from the root card.`),
400
432
  description: z.string().describe(DESCRIPTION_DESCRIBE),
401
- ac: z.array(z.object({ title: z.string().min(1) })).optional(),
433
+ ac: z.array(z.object({ title: z.string().min(1) }).strict()).optional(),
402
434
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
403
435
  quality_gates: z
404
436
  .array(z.object({
405
437
  gate: z.string().min(1),
406
438
  note: z.string().optional(),
407
439
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
408
- }))
440
+ }).strict())
409
441
  .optional()
410
442
  .describe("Same as the root quality_gates, resolved against THIS child's type."),
411
443
  triage_enabled: z
412
444
  .boolean()
413
445
  .optional()
414
446
  .describe("ALWAYS pass per child; absent → false (never auto-triaged). Not inherited from the root."),
415
- }))
447
+ }).strict())
416
448
  .optional(),
417
449
  triage_enabled: z
418
450
  .boolean()
419
451
  .optional()
420
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.
421
474
  ...boardField,
422
475
  }, async (args) => jsonResult(await issueCreate(client, args, config.board)));
423
476
  // ---------------- issue_edit ----------------
424
- server.tool("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.', {
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.', {
425
478
  id: z.string().min(1),
426
479
  title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
427
480
  summary: z
@@ -451,7 +504,7 @@ server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED
451
504
  .union([z.string(), z.number()])
452
505
  .optional()
453
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."),
454
- }))
507
+ }).strict())
455
508
  .optional()
456
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."),
457
510
  checklists: z
@@ -461,8 +514,8 @@ server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED
461
514
  label: z.string().min(1),
462
515
  detail: z.string().optional(),
463
516
  status: z.enum(CHECKLIST_ITEM_STATUSES),
464
- })),
465
- }))
517
+ }).strict()),
518
+ }).strict())
466
519
  .optional(),
467
520
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
468
521
  parent_id: z.string().nullable().optional(),
@@ -483,7 +536,7 @@ server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED
483
536
  ...boardField,
484
537
  }, async (args) => jsonResult(await issueEdit(client, args)));
485
538
  // ---------------- issue_transition ----------------
486
- server.tool("issue_transition",
539
+ strictTool("issue_transition",
487
540
  // DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
488
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.", {
489
542
  id: z.string().min(1),
@@ -504,14 +557,14 @@ server.tool("issue_transition",
504
557
  ...boardField,
505
558
  }, async (args) => jsonResult(await issueTransition(client, args)));
506
559
  // ---------------- issue_triage ----------------
507
- server.tool("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.", {
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.", {
508
561
  id: z.string().min(1),
509
562
  confidence: z.number().int().min(0).max(5),
510
563
  reason: z.string().min(1),
511
564
  ...boardField,
512
565
  }, async (args) => jsonResult(await issueTriage(client, args)));
513
566
  // ---------------- issue_comment ----------------
514
- server.tool("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.", {
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.", {
515
568
  id: z.string().min(1),
516
569
  action: z.enum(["add", "edit", "delete"]),
517
570
  comment_id: z.number().int().positive().optional(),
@@ -521,12 +574,14 @@ server.tool("issue_comment", "Comment CRUD via /api/issues/:id/comments[/:cid].
521
574
  ...boardField,
522
575
  }, async (args) => jsonResult(await issueComment(client, args)));
523
576
  // ---------------- issue_checklist ----------------
524
- const CHECKLIST_ITEM_INPUT = z.object({
577
+ const CHECKLIST_ITEM_INPUT = z
578
+ .object({
525
579
  label: z.string().min(1),
526
580
  detail: z.string().optional(),
527
581
  status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
528
- });
529
- server.tool("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.", {
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.", {
530
585
  id: z.string().min(1),
531
586
  action: z.enum([
532
587
  "add_list",
@@ -555,7 +610,7 @@ const SOLUTION_FIELDS = {
555
610
  con: z.string().optional(),
556
611
  recommended: z.boolean().optional(),
557
612
  };
558
- server.tool("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).", {
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).", {
559
614
  id: z.string().min(1),
560
615
  action: z.enum(["list", "add", "edit", "remove"]),
561
616
  problem_id: z.number().int().positive().optional().describe("edit/remove"),
@@ -567,13 +622,13 @@ server.tool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:p
567
622
  .optional()
568
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."),
569
624
  solutions: z
570
- .array(z.object({ title: z.string().min(1), ...SOLUTION_FIELDS }))
625
+ .array(z.object({ title: z.string().min(1), ...SOLUTION_FIELDS }).strict())
571
626
  .optional()
572
627
  .describe("add only; fields as issue_solution add"),
573
628
  ...boardField,
574
629
  }, async (args) => jsonResult(await issueProblem(client, args)));
575
630
  // ---------------- issue_solution ----------------
576
- 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.", {
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.", {
577
632
  id: z.string().min(1),
578
633
  action: z.enum(["add", "edit", "remove"]),
579
634
  problem_id: z.number().int().positive(),
@@ -584,7 +639,7 @@ server.tool("issue_solution", "One problem's options via /api/issues/:id/problem
584
639
  ...boardField,
585
640
  }, async (args) => jsonResult(await issueSolution(client, args)));
586
641
  // ---------------- issue_dependency ----------------
587
- server.tool("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.', {
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.', {
588
643
  id: z.string().min(1),
589
644
  action: z.enum(["add", "remove"]),
590
645
  kind: z.enum(["depends_on", "conflict_on"]).optional(),
@@ -594,13 +649,13 @@ server.tool("issue_dependency", 'Dependency CRUD via /api/issues/:id/dependencie
594
649
  ...boardField,
595
650
  }, async (args) => jsonResult(await issueDependency(client, args)));
596
651
  // ---------------- issue_retire_branch ----------------
597
- server.tool("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.", {
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.", {
598
653
  id: z.string().min(1),
599
654
  reason: z.string().min(1),
600
655
  ...boardField,
601
656
  }, async (args) => jsonResult(await issueRetireBranch(client, args)));
602
657
  // ---------------- issue_quality_gate ----------------
603
- server.tool("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`.", {
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`.", {
604
659
  id: z.string().min(1),
605
660
  gate: z.enum([
606
661
  "plan-dependency",
@@ -621,7 +676,7 @@ server.tool("issue_quality_gate", "Put one quality gate ON a card, or take it OF
621
676
  ...boardField,
622
677
  }, async (args) => jsonResult(await issueQualityGate(client, args)));
623
678
  // ---------------- issue_quality_gate_verdict ----------------
624
- server.tool("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`.", {
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`.", {
625
680
  id: z.string().min(1),
626
681
  gate: z.enum([
627
682
  "plan-dependency",
@@ -636,7 +691,7 @@ server.tool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate
636
691
  ...boardField,
637
692
  }, async (args) => jsonResult(await issueQualityGateVerdict(client, args)));
638
693
  // ---------------- issue_retro ----------------
639
- server.tool("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.", {
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.", {
640
695
  id: z.string().min(1),
641
696
  good: z.string(),
642
697
  bad: z.string(),
@@ -650,7 +705,7 @@ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retr
650
705
  commits: z.array(z.object({
651
706
  sha: z.string().min(1),
652
707
  subject: z.string().optional(),
653
- })),
708
+ }).strict()),
654
709
  tests: z.array(z.object({
655
710
  name: z.string().min(1),
656
711
  kind: z.enum(["group", "e2e"]),
@@ -664,7 +719,7 @@ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retr
664
719
  .nullable()
665
720
  .optional(),
666
721
  duration_ms: z.number().int().nonnegative(),
667
- })),
722
+ }).strict()),
668
723
  ...boardField,
669
724
  }, async (args) => jsonResult(await issueRetro(client, args)));
670
725
  // ---------------- issue_attach ----------------
@@ -672,7 +727,7 @@ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retr
672
727
  // route's MAX_DECODED_BYTES (src/issues/write/attachments.ts). This package is
673
728
  // a separate published artifact and cannot import that constant, so the number
674
729
  // is restated here as prose — keep the two in sync if the backend ceiling moves.
675
- server.tool("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 `![description](url)` 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![sidebar overlapping header](https://dx-issues.s3.us-east-1.amazonaws.com/abc123.png)`.", {
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 `![description](url)` 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![sidebar overlapping header](https://dx-issues.s3.us-east-1.amazonaws.com/abc123.png)`.", {
676
731
  id: z.string().min(1),
677
732
  file_path: z
678
733
  .string()
@@ -681,11 +736,11 @@ server.tool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/
681
736
  ...boardField,
682
737
  }, async (args) => jsonResult(await issueAttach(client, args)));
683
738
  // ---------------- repo_knowledge_get ----------------
684
- server.tool("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.", {
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.", {
685
740
  ...boardField,
686
741
  }, async (args) => jsonResult(await repoKnowledgeGet(client, args)));
687
742
  // ---------------- repo_knowledge_set ----------------
688
- server.tool("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.', {
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.', {
689
744
  content: z.string(),
690
745
  base_hash: z
691
746
  .string()
@@ -694,11 +749,11 @@ server.tool("repo_knowledge_set", 'Write the board\'s working-knowledge markdown
694
749
  ...boardField,
695
750
  }, async (args) => jsonResult(await repoKnowledgeSet(client, args)));
696
751
  // ---------------- brief_list ----------------
697
- server.tool("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.", {
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.", {
698
753
  ...boardField,
699
754
  }, async (args) => jsonResult(await briefList(client, args)));
700
755
  // ---------------- brief_get_page ----------------
701
- server.tool("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.', {
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.', {
702
757
  slug: z
703
758
  .string()
704
759
  .min(1)
@@ -706,7 +761,7 @@ server.tool("brief_get_page", 'Fetch one Brief page by slug via GET /api/brief/p
706
761
  ...boardField,
707
762
  }, async (args) => jsonResult(await briefGetPage(client, args)));
708
763
  // ---------------- brief_set_page ----------------
709
- server.tool("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.', {
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.', {
710
765
  slug: z
711
766
  .string()
712
767
  .min(1)
@@ -730,13 +785,13 @@ server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug
730
785
  // plan id — and it can only ever bind the caller's own session. `plan_create`
731
786
  // also takes no plan id, but for a different reason: it MAKES a plan rather
732
787
  // than acting on one, so there is no existing plan for an id to name yet.
733
- server.tool("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.", {
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.", {
734
789
  status: z
735
790
  .enum(PLAN_STATUSES)
736
791
  .optional()
737
792
  .describe("Filter to one computed status: awaiting-session, planning, building, complete. Omit for every plan."),
738
793
  }, async (args) => jsonResult(await planList(client, args)));
739
- server.tool("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`.", {
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`.", {
740
795
  plan_id: z
741
796
  .number()
742
797
  .int()
@@ -786,10 +841,10 @@ server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id
786
841
  .optional()
787
842
  .describe("DX-3027 — only events with this exact writer name. Omit for every writer. Requires `fields` to include `events`."),
788
843
  }, async (args) => jsonResult(await planGet(client, args)));
789
- 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, 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.", {
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.", {
790
845
  name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
791
846
  }, async (args) => jsonResult(await planCreate(client, args)));
792
- server.tool("plan_connect",
847
+ strictTool("plan_connect",
793
848
  // DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
794
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, 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.", {
795
850
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
@@ -815,15 +870,15 @@ async (args) => {
815
870
  }),
816
871
  });
817
872
  });
818
- 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. 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.", {
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.", {
819
874
  kind: z.enum(["goal", "rule", "caveat"]).describe("goal = outcome, rule = constraint, caveat = architecture trade-off."),
820
875
  body: z.string().min(1).describe("One plain statement, at most 250 characters. Details go in `context`."),
821
876
  context: z.string().optional().describe("Markdown detail behind the statement: evidence, history, examples."),
822
877
  }, async (args) => jsonResult(await planAddRecord(client, args)));
823
- server.tool("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.", {
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.", {
824
879
  record_id: z.number().int().positive().describe("A record id, from `plan_add_record`, `plan_get` or `plan_get_record` itself."),
825
880
  }, async (args) => jsonResult(await planGetRecord(client, args)));
826
- server.tool("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`).', {
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`).', {
827
882
  record_id: z.number().int().positive().describe("The record id to edit."),
828
883
  content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
829
884
  body: z.string().min(1).describe("The new statement, at most 250 characters. Plain text."),
@@ -833,11 +888,11 @@ server.tool("plan_update_record", 'Edit a goal/rule/caveat of your connected pla
833
888
  .optional()
834
889
  .describe("New markdown detail. Omit to keep the stored context; null clears it."),
835
890
  }, async (args) => jsonResult(await planUpdateRecord(client, args)));
836
- server.tool("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`).', {
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`).', {
837
892
  record_id: z.number().int().positive().describe("The record id to delete."),
838
893
  content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
839
894
  }, async (args) => jsonResult(await planDeleteRecord(client, args)));
840
- server.tool("plan_add_note", "Write a milestone note to a plan's timeline, via POST /api/plans/:plan_id/notes (DX-2915). A note is a MILESTONE, not a log — write one for a card (or related group of cards) finishing, an important decision, or a meaningful goal/rule/caveat/architecture-section change; routine step progress stays a card comment, never a note. Terse tone: `title` at most 60 characters, `body` (the wrap-up) at most 250 (both 400 if too long, naming the limit and actual length). Links resolve on read into what they point at (a card's title, a record's ref+body, a section's title): an unknown card, an unparseable or foreign record ref, or an unknown or foreign section id is refused 400 naming exactly which one. A card link does NOT require the card to be a member of this plan; a record/section link MUST belong to THIS plan. `author` is stamped from your identity server-side — there is no field for it. TAKES AN EXPLICIT `plan_id` (like `plan_remove_card`/`plan_rename`), so a dispatched worker with no plan connection can still write. Unknown plan → 404. 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.", {
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.", {
841
896
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
842
897
  title: z.string().min(1).describe("At most 60 characters."),
843
898
  body: z.string().min(1).describe("The wrap-up, at most 250 characters."),
@@ -848,7 +903,7 @@ server.tool("plan_add_note", "Write a milestone note to a plan's timeline, via P
848
903
  .describe("Goal/rule/caveat references this note announces, e.g. `[\"G-1\", \"R-3\", \"CAV-2\"]`."),
849
904
  section_ids: z.array(z.number().int().positive()).optional().describe("Architecture section ids this note announces."),
850
905
  }, async (args) => jsonResult(await planAddNote(client, args)));
851
- server.tool("plan_update_note", 'Edit a plan note, via PATCH /api/plans/:plan_id/notes/:note_id (DX-2915). `content_hash` MUST be the note\'s `contentHash` from your last read; a mismatch writes NOTHING and returns `{error: "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}` — merge into those and retry with `content_hash: currentHash`, never blindly. `title`/`body` are each optional and keep their stored value when omitted. The link fields (`card_ids`/`record_refs`/`section_ids`) are all-or-nothing AS A GROUP: omit all three to leave the stored link set untouched; send ANY one of them to REPLACE THE WHOLE SET — there is no per-link add/remove. The hash covers title, body AND the link set. TAKES AN EXPLICIT `plan_id`, same reason as `plan_add_note`. Unknown plan or note id → 404. DX-3072 — returns the edited note plus `notes_count` (see `plan_add_note`), not the latest page.', {
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.', {
852
907
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
853
908
  note_id: z.number().int().positive().describe("The note id to edit."),
854
909
  content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
@@ -858,40 +913,40 @@ server.tool("plan_update_note", 'Edit a plan note, via PATCH /api/plans/:plan_id
858
913
  record_refs: z.array(z.string().min(1)).optional().describe("REPLACES the whole link set when sent (with card_ids/section_ids)."),
859
914
  section_ids: z.array(z.number().int().positive()).optional().describe("REPLACES the whole link set when sent (with card_ids/record_refs)."),
860
915
  }, async (args) => jsonResult(await planUpdateNote(client, args)));
861
- server.tool("plan_delete_note", 'Soft-delete a plan note, via DELETE /api/plans/:plan_id/notes/:note_id (DX-2915). `content_hash` must be the note\'s `contentHash` from your last read; a stale hash deletes nothing and returns `{error: "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}` — the same shape `plan_update_note` uses. TAKES AN EXPLICIT `plan_id`, same reason as `plan_add_note`. Unknown plan, unknown note, or an already-deleted note → 404. DX-3072 — returns `{notes_count}`, the plan\'s remaining live note count, not the latest page.', {
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.', {
862
917
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
863
918
  note_id: z.number().int().positive().describe("The note id to delete."),
864
919
  content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
865
920
  }, async (args) => jsonResult(await planDeleteNote(client, args)));
866
- server.tool("plan_add_card", "Add an existing card to the plan this session is connected to, via POST /api/plans/mine/cards (DX-2683). The card may live on ANY board — that is what a plan is for. Idempotent: re-adding a card already on the plan is a no-op, not an error, and a card may sit in several plans at once. This adds MEMBERSHIP only; it never edits the card. TAKES NO PLAN ID: the plan is resolved from your connected session. Not connected → `{error: \"session_not_connected\"}`. Unknown card → 404. 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.", {
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.", {
867
922
  card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
868
923
  }, async (args) => jsonResult(await planAddCard(client, args)));
869
- server.tool("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`).", {
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`).", {
870
925
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
871
926
  card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
872
927
  }, async (args) => jsonResult(await planRemoveCard(client, args)));
873
- server.tool("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}`.", {
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}`.", {
874
929
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
875
930
  name: z.string().min(1).describe("The plan's new name."),
876
931
  }, async (args) => jsonResult(await planRename(client, args)));
877
- server.tool("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}}`.", {
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}}`.", {
878
933
  section_id: z.number().int().positive().describe("A section id, from `plan_get` or `plan_add_architecture_section`."),
879
934
  }, async (args) => jsonResult(await planGetArchitectureSection(client, args)));
880
- server.tool("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.", {
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.", {
881
936
  title: z.string().min(1).describe("The section's heading, shown in the navigation index."),
882
937
  content: z.string().describe("The section's markdown. May be empty — a section awaiting its first draft is a real state."),
883
938
  }, async (args) => jsonResult(await planAddArchitectureSection(client, args)));
884
- server.tool("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.', {
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.', {
885
940
  section_id: z.number().int().positive().describe("The section id to edit."),
886
941
  base_hash: z.string().describe("The section's `contentHash` from your last read. Required."),
887
942
  title: z.string().min(1).optional().describe("New heading. Omit to keep the stored title."),
888
943
  content: z.string().optional().describe("New markdown. Omit to keep the stored content."),
889
944
  }, async (args) => jsonResult(await planUpdateArchitectureSection(client, args)));
890
- server.tool("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.', {
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.', {
891
946
  section_id: z.number().int().positive().describe("The section id to delete."),
892
947
  base_hash: z.string().describe("The section's `contentHash` from your last read. Required."),
893
948
  }, async (args) => jsonResult(await planDeleteArchitectureSection(client, args)));
894
- server.tool("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.", {
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.", {
895
950
  order: z
896
951
  .array(z.number().int().positive())
897
952
  .min(1)
@@ -911,17 +966,19 @@ const matcherField = z
911
966
  .optional()
912
967
  .describe("Regex source tested against the normalized excerpt (paths/UUIDs/timestamps/ports already stripped)."),
913
968
  })
969
+ .strict()
914
970
  .describe("At least one of sourceKind/tool/regexPattern must be set — an empty matcher is refused.");
915
971
  const expectedRateField = z
916
972
  .object({
917
973
  count: z.number().int().min(0).describe("N — how many failures are expected."),
918
974
  overDispatches: z.number().int().positive().describe("X — over how many dispatches."),
919
975
  })
976
+ .strict()
920
977
  .nullable()
921
978
  .optional()
922
979
  .describe("Both fields together, or omit/null entirely — never a half-specified rate.");
923
- server.tool("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)));
924
- server.tool("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.', {
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.', {
925
982
  name: z.string().min(1).describe("The category's name."),
926
983
  description: z.string().optional().describe("Optional free-text description. Defaults to empty."),
927
984
  matchers: z.array(matcherField).min(1).describe("At least one matcher; a category matches an occurrence when ANY ONE matches (OR across matchers)."),
@@ -929,7 +986,7 @@ server.tool("failure_category_create", 'Create a new failure category via POST /
929
986
  ignoreReason: z.string().nullable().optional().describe("Required (non-empty) when ignore is true."),
930
987
  expectedRate: expectedRateField,
931
988
  }, async (args) => jsonResult(await failureCategoryCreate(client, args)));
932
- server.tool("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.", {
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.", {
933
990
  id: z.number().int().positive().describe("The category id to patch — from failure_category_list."),
934
991
  name: z.string().min(1).optional(),
935
992
  description: z.string().optional(),
@@ -938,6 +995,14 @@ server.tool("failure_category_update", "Patch an existing failure category via P
938
995
  ignoreReason: z.string().nullable().optional(),
939
996
  expectedRate: expectedRateField,
940
997
  }, async (args) => jsonResult(await failureCategoryUpdate(client, args)));
998
+ // ---------------- dispatch_transcript_search (DX-3221) ----------------
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\".", {
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."),
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."),
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."),
1003
+ contextChars: z.number().int().positive().optional().describe("Characters of context per returned line/match. Defaults to 1000, capped at 4000 — this cap is what keeps even a single oversized line searchable instead of erroring the way Read does."),
1004
+ maxMatches: z.number().int().positive().optional().describe("Only used with `pattern`. Stop after this many matching lines. Defaults to 10, capped at 50."),
1005
+ }, async (args) => jsonResult(await dispatchTranscriptSearch(client, args)));
941
1006
  // ---------------- main ----------------
942
1007
  async function main() {
943
1008
  boot();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.106",
3
+ "version": "0.1.110",
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",