@thehammer/danx-dashboard-mcp 0.1.132 → 0.1.134

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
@@ -23,7 +23,6 @@
23
23
  */
24
24
  import { readFile } from "node:fs/promises";
25
25
  import { basename, extname, isAbsolute } from "node:path";
26
- import { oneLine } from "./one-line.js";
27
26
  import { resolvePriority } from "./priority.js";
28
27
  /**
29
28
  * `filter`/`fields`/`sort` are JSON/CSV-encoded onto the query string (the
@@ -297,82 +296,6 @@ export async function issueTransition(client, args) {
297
296
  board,
298
297
  });
299
298
  }
300
- /** How much of a problem statement the reminder quotes — enough to recognise it. */
301
- const REMINDER_STATEMENT_MAX = 120;
302
- function isReminderProblem(value) {
303
- const p = value;
304
- return (typeof p === "object" &&
305
- p !== null &&
306
- typeof p.id === "number" &&
307
- typeof p.statement === "string" &&
308
- typeof p.open === "boolean" &&
309
- Array.isArray(p.solutions));
310
- }
311
- /**
312
- * Attach the problems reminder to a SUCCESSFUL `issue_problem add`.
313
- *
314
- * DX-2830 — an added problem IS the moment a card is put in front of a human;
315
- * there is no separate gate to set or refuse. Whether each open problem lists
316
- * its viable solutions is feedback, never refusal: a problem with zero
317
- * solutions is valid (the operator answers free-form), and refusing it would
318
- * push the agent into inventing options. So the reminder rides the success
319
- * response and names the STILL-OPEN problems only — an answered sibling needs
320
- * nothing more.
321
- *
322
- * The write's own envelope is returned untouched beside it. A refused write
323
- * gets no reminder and no extra request. A failure READING the problems is
324
- * reported inside the reminder rather than thrown: the add already succeeded,
325
- * and throwing would tell the agent it failed when it did not.
326
- */
327
- export async function withProblemsReminder(client, id, board, result) {
328
- if (!result.ok)
329
- return result;
330
- let listed;
331
- try {
332
- listed = await client.request({
333
- method: "GET",
334
- path: `/${encodeURIComponent(id)}/problems`,
335
- board,
336
- });
337
- }
338
- catch (err) {
339
- return { ...result, problems_reminder: unreadableReminder(id, err instanceof Error ? err.message : String(err)) };
340
- }
341
- // `body` is the server's JSON verbatim — `null` is valid JSON, so read it null-safely;
342
- // a throw here would land OUTSIDE the try above and misreport the successful write.
343
- const problems = listed.ok ? listed.body?.problems : undefined;
344
- if (!Array.isArray(problems) || !problems.every(isReminderProblem)) {
345
- // A 2xx whose body is not the problem list is a contract break, not a refusal —
346
- // say so, rather than leaving a bare "HTTP 200" that reads like success.
347
- const detail = listed.ok ? `HTTP ${listed.status}, unexpected shape` : `HTTP ${listed.status}`;
348
- return { ...result, problems_reminder: unreadableReminder(id, detail) };
349
- }
350
- const open = problems.filter((p) => p.open);
351
- return { ...result, problems_reminder: { open_problem_count: open.length, instruction: openProblemsInstruction(id, open) } };
352
- }
353
- function openProblemsInstruction(id, open) {
354
- if (open.length === 0) {
355
- // Only reachable when every problem was answered between the add and this
356
- // read — the card no longer needs a human.
357
- return `No problem on ${id} is open any more — each was answered already. Re-read the card before you stop.`;
358
- }
359
- const listing = open
360
- .map((p) => {
361
- const statement = oneLine(p.statement, REMINDER_STATEMENT_MAX);
362
- const count = p.solutions.length;
363
- return `problem ${p.id} "${statement}" — ${count === 0 ? "NO SOLUTIONS LISTED" : `${count} solution${count === 1 ? "" : "s"}`}`;
364
- })
365
- .join("; ");
366
- return (`${open.length} open problem${open.length === 1 ? "" : "s"} on ${id}: ${listing}. ` +
367
- `Before you stop, list EVERY viable solution to each with issue_solution({id: "${id}", problem_id, action: "add", title, body, pro, con}) and mark the one you recommend with recommended: true, ` +
368
- `and make every other question the operator must answer its own problem with issue_problem. The card needs a human until every open problem is answered.`);
369
- }
370
- function unreadableReminder(id, detail) {
371
- return {
372
- open_problem_count: null,
373
- instruction: `Could not read the problems on ${id} (${detail}). Check with issue_problem({id: "${id}", action: "list"}) and make sure EVERY viable solution to each open problem is listed before you stop.`,
374
- };
375
- }
376
299
  export async function issueTriage(client, args) {
377
300
  const { id, board, ...body } = args;
378
301
  return client.request({
@@ -413,39 +336,6 @@ function argCheckers(tool, mode) {
413
336
  },
414
337
  };
415
338
  }
416
- /**
417
- * Every action-dispatched tool's args carry these three regardless of
418
- * `action` — never action-specific, so every per-action allow-list passed to
419
- * `refuseInapplicableFields` is unioned with this rather than repeating it.
420
- */
421
- const STRUCTURAL_ACTION_FIELDS = ["id", "action", "board"];
422
- /**
423
- * DX-3310 (code-review nit) — a field the caller sent that means nothing for
424
- * the chosen `action` used to be silently ignored: `issue_solution`'s `add`
425
- * built its `content` object from `title`/`body`/`pro`/`con`/`recommended`
426
- * regardless of action, then simply never read it on `remove`/`add_step`/etc,
427
- * and `issue_problem`'s `remove` never looked at `statement`/`context`/
428
- * `solutions` at all. Either way the caller got a 200 with the field quietly
429
- * discarded — no different from a typo. Refused loud instead, mirroring
430
- * `argCheckers`' own wording: every ACTUALLY-SUPPLIED (`!== undefined`) key
431
- * outside the action's own allow-list (plus `STRUCTURAL_ACTION_FIELDS`,
432
- * always allowed) is named in one error, so a caller fixes every offending
433
- * field in one round trip rather than one at a time.
434
- */
435
- // A9 (code-review fix) — takes `object` rather than `Record<string, unknown>`
436
- // so every caller (each with its own specific `*Args` interface, no index
437
- // signature) passes `args` directly, with no `as unknown as Record<...>`
438
- // bridge cast at the call site. `Object.entries` reads the key/value pairs
439
- // without needing an index signature on the input type either.
440
- function refuseInapplicableFields(tool, mode, args, allowed) {
441
- const offending = Object.entries(args)
442
- .filter(([key, value]) => value !== undefined && !STRUCTURAL_ACTION_FIELDS.includes(key) && !allowed.has(key))
443
- .map(([key]) => key)
444
- .sort();
445
- if (offending.length > 0) {
446
- throw new Error(`${tool} ${mode} refuses fields that do not apply to this action: ${offending.join(", ")}`);
447
- }
448
- }
449
339
  export async function issueComment(client, args) {
450
340
  const idEnc = encodeURIComponent(args.id);
451
341
  const board = args.board;
@@ -647,25 +537,10 @@ export async function issueChecklist(client, args) {
647
537
  *
648
538
  * No answer action, for the reason `issueSolution` gives.
649
539
  */
650
- /**
651
- * DX-3310 (nit) — per-action allow-list for `refuseInapplicableFields`.
652
- * `id`/`action`/`board` are structural and never listed (every action takes
653
- * them). `type` is listed under `edit` even though it can never actually be
654
- * SENT there (see the dedicated throw below) — that keeps the generic
655
- * refusal from firing first with a less specific message than the one that
656
- * already names the real reason.
657
- */
658
- const PROBLEM_ACTION_FIELDS = {
659
- list: [],
660
- add: ["statement", "context", "type", "summary", "solutions"],
661
- edit: ["problem_id", "base_hash", "statement", "context", "summary", "type"],
662
- remove: ["problem_id", "base_hash"],
663
- };
664
540
  export async function issueProblem(client, args) {
665
541
  const idEnc = encodeURIComponent(args.id);
666
542
  const board = args.board;
667
543
  const need = argCheckers("issue_problem", `action=${args.action}`);
668
- refuseInapplicableFields("issue_problem", `action=${args.action}`, args, new Set(PROBLEM_ACTION_FIELDS[args.action]));
669
544
  switch (args.action) {
670
545
  case "list":
671
546
  return client.request({ method: "GET", path: `/${idEnc}/problems`, board });
@@ -685,8 +560,13 @@ export async function issueProblem(client, args) {
685
560
  body.summary = args.summary;
686
561
  if (args.solutions !== undefined)
687
562
  body.solutions = args.solutions;
688
- const result = await client.request({ method: "POST", path: `/${idEnc}/problems`, body, board });
689
- return withProblemsReminder(client, args.id, board, result);
563
+ // DX-3365 — the server's own response now carries the reminder (a
564
+ // `reminders: [{key, text}]` field appended by the registry
565
+ // middleware, `src/issues/reminders/middleware.ts`); this used to be an
566
+ // EXTRA client-side GET (`withProblemsReminder`, deleted) computing the
567
+ // identical text a request round-trip later. Passed through verbatim —
568
+ // no dual path.
569
+ return client.request({ method: "POST", path: `/${idEnc}/problems`, body, board });
690
570
  }
691
571
  case "edit": {
692
572
  const problemId = need.id(args.problem_id, "problem_id");
@@ -741,24 +621,11 @@ export async function issueProblem(client, args) {
741
621
  * question could release the very stop it set to wait for a human. The operator
742
622
  * answers in the dashboard.
743
623
  */
744
- const SOLUTION_ACTION_FIELDS = {
745
- add: ["title", "body", "pro", "con", "recommended", "steps"],
746
- edit: ["solution_id", "base_hash", "title", "body", "pro", "con", "recommended", "steps", "steps_base_hash"],
747
- remove: ["solution_id", "base_hash"],
748
- add_step: ["solution_id", "title", "description", "parent_step_id", "position"],
749
- edit_step: ["solution_id", "step_id", "base_hash", "title", "description"],
750
- remove_step: ["solution_id", "step_id", "base_hash"],
751
- };
752
624
  export async function issueSolution(client, args) {
753
625
  const need = argCheckers("issue_solution", `action=${args.action}`);
754
626
  // DX-2735: checked at runtime too, not only by the schema — a caller that skips
755
627
  // the MCP boundary must never build `/problems/undefined/solutions`.
756
628
  const problemId = need.id(args.problem_id, "problem_id");
757
- // DX-3310 (nit) — `problem_id` is unioned in here rather than listed in
758
- // every `SOLUTION_ACTION_FIELDS` entry: unlike `issue_problem` (where it
759
- // only applies to edit/remove), `issue_solution` requires it for every
760
- // action (checked above), so it is structural FOR THIS TOOL specifically.
761
- refuseInapplicableFields("issue_solution", `action=${args.action}`, args, new Set(["problem_id", ...SOLUTION_ACTION_FIELDS[args.action]]));
762
629
  const base = `/${encodeURIComponent(args.id)}/problems/${problemId}/solutions`;
763
630
  const board = args.board;
764
631
  const content = {};
package/dist/index.js CHANGED
@@ -338,14 +338,14 @@ const boardField = {
338
338
  .string()
339
339
  .min(1)
340
340
  .optional()
341
- .describe("Board id `<repo>:<slug>`; omit for this dispatch's board. Unknown → 404."),
341
+ .describe("Target another board by its qualified id `<repo>:<slug>`; omit for this dispatch's board. Unknown → 404."),
342
342
  };
343
343
  // The three prose fields of a card, each with ONE job. Shared by issue_create
344
344
  // (root + phase children) and issue_edit so the guidance an agent reads is
345
345
  // identical wherever it writes the field.
346
- const TITLE_DESCRIBE = 'Short, specific label naming the domain (e.g. "Guest checkout rejects gift-card carts"). Never generic ("Fix bug", "Follow-up").';
347
- const SUMMARY_DESCRIBE = "1–3 plain-language sentences, no markdown/jargon: what the card is and why it matters. Always shown, never collapsed — not a second title, not a teaser.";
348
- const DESCRIPTION_DESCRIBE = 'The full body ("Context"): evidence, examples, technical detail; markdown, collapsed by default. Operator questions go in issue_problem. Style: see issue_comment\'s `text` param.';
346
+ const TITLE_DESCRIBE = 'Short, specific label naming the domain, so a reader recognises the card unopened (e.g. "Guest checkout rejects carts holding a gift card"). Never generic ("2 real decisions needed", "Fix bug", "Follow-up").';
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.";
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. Style rules: see issue_comment\'s `text` param.';
349
349
  // DX-3335 — condensed from the now-deleted danxbot:comment-style skill (PLN-11
350
350
  // R-16: fold a skill's rule into the tool description it governs rather than
351
351
  // require a separate load). Shared by every prose-writing field below so an
@@ -355,7 +355,7 @@ const MARKDOWN_STYLE_DESCRIBE = "Renders as markdown here. Use `##`/`###` header
355
355
  strictTool("issue_list",
356
356
  // DX-2735: trimmed to pay for the problem tools inside the work-profile
357
357
  // injected-surface budget — same facts, no repeated prose.
358
- "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=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.", {
358
+ "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.", {
359
359
  filter: z
360
360
  .object({
361
361
  q: z.string().optional(),
@@ -389,7 +389,7 @@ strictTool("issue_list",
389
389
  strictTool("issue_get",
390
390
  // DX-2735: trimmed to pay for the problem tools inside the work-profile
391
391
  // injected-surface budget — same facts, no repeated prose.
392
- "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 (see `comments_limit`/`comments_offset` below), 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>`). The `comments` group PAGES ANCHORED AT THE NEWEST COMMENT: `comments_offset` counts back from it (offset 0 = the most recent comments_limit comments); `comments_total` is the real total, so `comments_offset + comments.length < comments_total` means older comments remain — page forward to reach them; a long-running card's decisive history often lives past the first page. WITHIN a page, comments stay CHRONOLOGICAL (oldest first) — same order as every other comment read; only the requested WINDOW is anchored at the newest end. `comments_limit`/`comments_offset` apply to the SINGLE-id form only (naming either alongside `ids` is refused). Single form: unknown id → 404. Batch form: `{issues: [...], not_found: [...ids]}` — an unknown or deleted id never fails the call.", {
392
+ "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 (see `comments_limit`/`comments_offset` below), 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>`). The `comments` group returns a PAGE ANCHORED AT THE NEWEST COMMENT: `comments_offset` counts back from the newest comment (offset 0 = the most recent comments_limit comments), and `comments_total` is the card's real total comment count, so `comments_offset + comments.length < comments_total` means older comments remain — page forward with `comments_offset` to reach them; a long-running card's decisive history (what was tried, measured, rejected or reverted) often lives past the first page. WITHIN a page, comments are ordered CHRONOLOGICALLY (oldest first, newest last) — the same order every other comment read in this API uses; only the WINDOW you request is anchored at the newest end, not the array itself. `comments_limit`/`comments_offset` apply to the SINGLE-id form only (no per-card paging in the batch form — naming either alongside `ids` is refused). Single form: unknown id → 404. Batch form: `{issues: [...], not_found: [...ids]}` — an unknown or deleted id never fails the call.", {
393
393
  id: z.string().min(1).optional(),
394
394
  ids: z.array(z.string().min(1)).min(1).max(ISSUE_BATCH_GET_MAX).optional(),
395
395
  fields: z
@@ -403,7 +403,7 @@ strictTool("issue_get",
403
403
  .optional()
404
404
  .describe("Single-id form only. How many comments the page holds (server default 20), the window anchored at the " +
405
405
  "NEWEST comment. An out-of-range value (server max 200) is refused by the server with its own 400, " +
406
- "never silently clamped here. Requires `fields:[\"comments\"]`."),
406
+ "never silently clamped here. Requires `fields` to include `comments`."),
407
407
  comments_offset: z
408
408
  .number()
409
409
  .int()
@@ -415,8 +415,8 @@ strictTool("issue_get",
415
415
  ...boardField,
416
416
  }, async (args) => jsonResult(await issueGet(client, args)));
417
417
  // ---------------- issue_create ----------------
418
- strictTool("issue_create", '`plan` REQUIRED on every create (DX-3006, no default/inference): "mine" puts the card on THIS session\'s connected plan; null = deliberately no plan. "mine" while connected to none is refused (409 session_not_connected), creating NO card; a plan id is not accepted — a card is only ever created onto your own connected plan. Replaces the plan_add_card follow-up at creation time; plan_add_card still exists for putting an EXISTING card on a plan. ' +
419
- 'Create a card via POST /api/issues. Board-scoped; see `board`. type=Epic REQUIRES non-empty phase_children[] (epic + 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, no follow-up transition needed. 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 — `{gate, note?}` each; omit for just the board defaults (an unnamed gate is simply not on the card; 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.', {
418
+ 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. ' +
419
+ '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.', {
420
420
  type: z.enum(ISSUE_TYPES),
421
421
  title: z.string().min(1).describe(TITLE_DESCRIBE),
422
422
  summary: z.string().min(1).optional().describe(SUMMARY_DESCRIBE),
@@ -428,7 +428,7 @@ strictTool("issue_create", '`plan` REQUIRED on every create (DX-3006, no default
428
428
  plan: z
429
429
  .literal("mine")
430
430
  .nullable()
431
- .describe('REQUIRED, decide per card — see tool description. "mine" = this session\'s connected plan; null = deliberately none; no plan id accepted.'),
431
+ .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.'),
432
432
  parent_id: z.string().nullable().optional(),
433
433
  ac: z.array(z.object({ title: z.string().min(1) }).strict()).optional(),
434
434
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
@@ -479,7 +479,7 @@ strictTool("issue_create", '`plan` REQUIRED on every create (DX-3006, no default
479
479
  priority: z
480
480
  .union([z.enum(PRIORITY_TIER_WORDS), z.number()])
481
481
  .optional()
482
- .describe('Tier word ("lowest"…"critical", prefer this) or number in [0,6); higher = more urgent. Omit for the route\'s own default.'),
482
+ .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.'),
483
483
  // DX-3238 — `assigned_agent` is DELIBERATELY left undeclared here, unlike
484
484
  // `priority` above. The route accepts it (`ALLOWED_CREATE_KEYS`,
485
485
  // create.ts:39-56) but it only matters for the create→`list_id`-lands-
@@ -495,7 +495,7 @@ strictTool("issue_create", '`plan` REQUIRED on every create (DX-3006, no default
495
495
  ...boardField,
496
496
  }, async (args) => jsonResult(await issueCreate(client, args, config.board)));
497
497
  // ---------------- issue_edit ----------------
498
- 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. `priority` (tier word "lowest"–"critical", or number 0–6, higher = more urgent) 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, real-world check outstanding) REQUIRES `detail`; 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) — the card\'s optimistic-concurrency token — is 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` with `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.', {
498
+ 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.', {
499
499
  id: z.string().min(1),
500
500
  title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
501
501
  summary: z
@@ -516,7 +516,7 @@ strictTool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED k
516
516
  status: z
517
517
  .enum(CHECKLIST_ITEM_STATUSES)
518
518
  .optional()
519
- .describe("Optional full status; omitted → from `checked`. See tool description for the deferred/📡 rules."),
519
+ .describe("Optional full status; omitted → from `checked`. `deferred` REQUIRES a non-empty `detail`; a 📡 item can never be `passing`."),
520
520
  detail: z
521
521
  .string()
522
522
  .optional()
@@ -543,7 +543,7 @@ strictTool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED k
543
543
  priority: z
544
544
  .union([z.enum(PRIORITY_TIER_WORDS), z.number()])
545
545
  .optional()
546
- .describe('Tier word ("lowest"…"critical", prefer this) or number in [0,6); higher = more urgent — see tool description for why this is the only way to set it.'),
546
+ .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. The only way to set priority.'),
547
547
  list_id: z.string().min(1).nullable().optional(),
548
548
  triage_enabled: z
549
549
  .boolean()
@@ -602,7 +602,7 @@ const CHECKLIST_ITEM_INPUT = z
602
602
  status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
603
603
  })
604
604
  .strict();
605
- 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; `deferred` = work done but 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.", {
605
+ 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.", {
606
606
  id: z.string().min(1),
607
607
  action: z.enum([
608
608
  "add_list",
@@ -638,20 +638,22 @@ const STEP_INPUT = z.lazy(() => z
638
638
  .int()
639
639
  .positive()
640
640
  .optional()
641
- .describe("present -> matches an existing step by this id (400 if not a live child of this exact parent scope, " +
642
- "never claimed by the fallback below); absent -> matches the first unclaimed same-titled sibling " +
643
- "under the same parent, else creates new (title-based, never positional — keeps later id-less " +
644
- "siblings' ids intact on a middle insert). Send ids anyway when identity matters (checked progress, " +
645
- "a later edit) or siblings share a title."),
641
+ .describe("present -> this node IS an existing step (matched on this id, 400 if it does not resolve to a live " +
642
+ "child of this exact parent scope — a supplied id is NEVER claimed by the positional/title fallback " +
643
+ "below); absent -> matched against unclaimed siblings by TITLE (first unclaimed match, in order), else " +
644
+ "created new. An id-less MIDDLE insert recreates every later id-less sibling's row unless you send ids " +
645
+ "for the ones you want preserved — send ids whenever an existing step's identity matters (checked " +
646
+ "progress, a later single-step edit)."),
646
647
  title: z
647
648
  .string()
648
649
  .min(1)
649
- .describe("one imperative line — labels (\"1.\", \"2a\") are DERIVED on read, never put one in the title."),
650
+ .describe("one imperative line, the thing to do — labels (\"1.\", \"2a\") are DERIVED on read and must never appear " +
651
+ "in the title itself."),
650
652
  description: z.string().nullable().optional().describe("markdown detail, or null/omitted for none"),
651
653
  steps: z.array(STEP_INPUT).optional().describe("this step's own children, nested — refused past 3 levels total"),
652
654
  })
653
655
  .strict());
654
- 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) OR an action only a person can carry out, 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 — no separate flag, adding a problem IS putting the card in front of a human. list → live problems in order, each {id, statement, context, type, summary, content_hash, open, solutions[], decisions[]}; add {statement, context?, type?, summary?, 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?, summary?}; remove :pid {base_hash} — always allowed, even as the card's last open problem (removing it just means the card no longer needs a human). Stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. No answer action — the operator answers in the dashboard. `statement` capped at 200 characters (400 names the actual length otherwise): ONE plain sentence, with investigation detail in `context` (markdown, no cap) instead — e.g. statement \"Which cache should we use?\", context \"Redis fits the read-heavy path; see benchmark in #123.\" not a run-on statement. Every problem also has a `type` with a `summary` distinct from `context` — read both fields' descriptions before your first `add`.", {
656
+ 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) OR an action only a person can carry out, 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, type, summary, content_hash, open, solutions[], decisions[]}; add {statement, context?, type?, summary?, solutions?} → problem_id + solution_ids in one transaction (zero solutions is valid: the operator answers free-form) plus a `reminders: [{key, text}]` array (DX-3365 — every MCP-response reminder rides this ONE field now, DB-registry-driven and operator-overridable from the dashboard) naming each still-open problem's solution count; edit :pid {base_hash, statement, context?, summary?}; remove :pid {base_hash} — always allowed, even as the card's last open problem (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. `statement` is capped at 200 characters (a 400 names the actual length otherwise): write it as ONE plain sentence (a question for a question, the deed itself for an action), 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). Every problem is also a `type`, with a `summary` distinct from `context`: read BOTH fields' own descriptions below before your first `add` — together they teach which type this is, and the three-field split (`statement` / `summary` / `context`) an action actually needs.", {
655
657
  id: z.string().min(1),
656
658
  action: z.enum(["list", "add", "edit", "remove"]),
657
659
  problem_id: z.number().int().positive().optional().describe("edit/remove"),
@@ -660,7 +662,9 @@ strictTool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pi
660
662
  .string()
661
663
  .min(1)
662
664
  .optional()
663
- .describe("add/edit; ONE plain sentence, at most 200 characters — see `type` below for question vs action phrasing."),
665
+ .describe("add/edit; ONE plain sentence, at most 200 characters. For a question: the question itself. For an action: " +
666
+ "WHAT IS TO BE DONE — the deed itself, never phrased as a question (\"Rotate the staging DB credential\", " +
667
+ "not \"Should we rotate it?\")."),
664
668
  context: z
665
669
  .string()
666
670
  .nullable()
@@ -672,58 +676,67 @@ strictTool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pi
672
676
  type: z
673
677
  .enum(["question", "action"])
674
678
  .optional()
675
- .describe("add only. Test: could I do this myself if I tried harder, missing only a decision? Yes → \"question\": " +
676
- "statement is the question (one plain sentence); summary optional; context is the evidence; solutions[] " +
677
- "are candidate ANSWERS with pro/con, done once the operator picks one. Blocker is access, credentials, " +
678
- "hardware, a human's authority, or a system you genuinely cannot reach → \"action\": statement is WHAT " +
679
- "IS TO BE DONE — the deed, never a question (\"Rotate the staging DB credential\", not \"Should we " +
680
- "rotate it?\"); summary REQUIRED — WHY NEEDED AND WHY YOU CANNOT DO IT YOURSELF (no summary → 400, " +
681
- "proves this isn't laziness); context is detail for the person carrying it out; solutions[] are the " +
682
- "possible ROUTES, EACH MUST CARRY AT LEAST ONE STEP (400 otherwise). Omitted defaults to \"question\" " +
683
- "— decide deliberately."),
679
+ .describe("add only. Before you raise this problem, apply the test: could I do this myself if I tried harder, and is the " +
680
+ "only thing missing a decision? If yes, this is a \"question\": statement is the question itself (one plain " +
681
+ "sentence); summary (optional) is why it matters; context is the evidence behind it; solutions[] are " +
682
+ "candidate ANSWERS, each with its own pro/con, and the operator is done the moment they pick one. If the " +
683
+ "blocker is access, credentials, hardware, a human's authority, or a system you genuinely cannot reach, " +
684
+ "this is an \"action\": statement is WHAT IS TO BE DONE — the deed itself, never phrased as a question " +
685
+ "(\"Rotate the staging DB credential\", not \"Should we rotate it?\"); summary is REQUIRED and MUST say " +
686
+ "WHY THE ACTION IS NEEDED AND WHY YOU CANNOT DO IT YOURSELF — that sentence is what tells the operator this " +
687
+ "is not you being lazy (an add with no summary is refused 400); context is whatever additional detail the " +
688
+ "person needs to carry it out; solutions[] are the possible ROUTES a person could take (e.g. \"rotate by " +
689
+ "hand in the console\" vs \"run the provisioning script\" vs \"ask the vendor\"), and EACH ONE MUST CARRY " +
690
+ "AT LEAST ONE STEP (refused 400 otherwise) — an action with no procedure is not yet actionable. Omitted " +
691
+ "defaults to \"question\" — decide deliberately every time."),
684
692
  summary: z
685
693
  .string()
686
694
  .nullable()
687
695
  .optional()
688
- .describe("add/edit. Plain text, 1-3 sentences — never markdown (that's `context`'s job). Required+non-empty for an " +
689
- "action problem (why needed + why you can't do it yourself — see `type`; refused 400 if missing on add or " +
690
- "cleared on a live action edit); optional for a question. add: sent only when given (omit/null on a " +
691
- "question means none). edit: omit keeps the stored summary, null clears it."),
696
+ .describe("add/edit. Plain text, 1-3 sentences — never markdown detail (that's `context`'s job). For a question: " +
697
+ "optional, why it matters. For an action: REQUIRED and non-empty — why the action is needed AND why you " +
698
+ "(the agent raising it) cannot do it yourself; omitting it on an action add is refused 400 naming this " +
699
+ "field, and an edit that would clear it on a live action problem is refused the same way. add: sent only " +
700
+ "when given (omit/null on a question means none). edit: omit to keep the stored summary, null to clear it " +
701
+ "(refused if the problem is a live action)."),
692
702
  solutions: z
693
703
  .array(z.object({ title: z.string().min(1), ...SOLUTION_FIELDS, steps: z.array(STEP_INPUT).optional() }).strict())
694
704
  .optional()
695
- .describe("add only; fields as issue_solution add, INCLUDING steps — an ACTION problem's inline " +
705
+ .describe("add only; fields as issue_solution add, INCLUDING steps (S6/AC 35332) — an ACTION problem's inline " +
696
706
  "solutions must each carry at least one step (refused 400 otherwise); a QUESTION's may carry any number, " +
697
707
  "including none."),
698
708
  ...boardField,
699
709
  }, async (args) => jsonResult(await issueProblem(client, args)));
700
710
  // ---------------- issue_solution ----------------
701
- strictTool("issue_solution", "One problem's options via /api/issues/:id/problems/:pid/solutions[/:sid], plus (DX-3310) a solution's own " +
702
- "procedure steps via .../solutions/:sid/steps[/:stepId]; `problem_id` REQUIRED (from issue_problem list/add; " +
711
+ strictTool("issue_solution", "One problem's options via /api/issues/:id/problems/:pid/solutions[/:sid], AND (DX-3310) one solution's individual " +
712
+ "procedure steps via .../solutions/:sid/steps[/:stepId]; `problem_id` is REQUIRED (from issue_problem list/add; " +
703
713
  "another problem's solution id → 404). add {title, body?, pro?, con?, recommended?, steps?}: title names the " +
704
- "option, body/pro/con its markdown detail and case for/against; `steps` authors the WHOLE procedure in one " +
705
- "call — {title, description?, steps?}[], nestable to 3 levels (4th refused 400, naming the step + limit). " +
706
- "ACTION problem: `steps` must be non-empty (400 otherwise); QUESTION: may be omitted/empty. Labels " +
707
- "(\"1\", \"2a\", \"2a.i\") are DERIVED on read from position — never put one in a title. edit :sid {base_hash, " +
708
- "...changed fields, steps?, steps_base_hash?}: `steps` omitted leaves the procedure untouched; an explicit " +
709
- "array (incl. [], refused if it leaves an action's solution with zero steps) DIFFS against it — send an " +
710
- "existing step's `id` to keep it (even while retitling/reordering), omit `id` for a new step; any existing " +
711
- "step left out is removed. An id-less node matches the first unclaimed same-titled sibling under the same " +
712
- "parent (never positional), so an id-less MIDDLE insert keeps later id-less siblings' ids — send ids anyway " +
713
- "when identity matters. `steps_base_hash` REQUIRED whenever `steps` is sent — the solution's current " +
714
- "`steps_hash`; stale → 409 `stale_steps` with the current tree. Never resend the whole tree to fix one word " +
715
- "— use add_step/edit_step/remove_step below. remove :sid {base_hash}: stale → 409 `stale_solution` with " +
716
- "currentHash + currentSolution, merge and retry. At most ONE live recommended per problem — a second → 409 " +
717
- "naming `recommended_solution_id`. A CHOSEN option's words and WHOLE PROCEDURE are frozen (409 — add a new " +
718
- "one instead); every steps route below refuses once a decision has chosen this solution.\n\n" +
719
- "Granular single-step actions, no tree resend: add_step {solution_id, title, description?, parent_step_id?, " +
720
- "position?} — title is one imperative line (labels derived, never typed); parent_step_id omitted/null = " +
721
- "top-level; position 1-indexed among current live children, omitted = append, beyond sibling count → 400 " +
722
- "(never clamped); nesting past depth 3 → 400. edit_step {solution_id, step_id, base_hash, title?, " +
723
- "description?} — never moves a step. remove_step {solution_id, step_id, base_hash} — soft-deletes it and " +
724
- "its live children; refused 409 if this solution belongs to an ACTION problem and removing it leaves zero " +
725
- "live steps. Stale base_hash on a step → 409 `stale_step` with currentHash + currentStep (real derived " +
726
- "`label`).", {
714
+ "option/route, body is its markdown detail, pro/con the case for and against, `steps` is its WHOLE procedure " +
715
+ "authored in one call — an ordered array of {title, description?, steps?}, nestable to 3 levels (a 4th is refused " +
716
+ "400 naming the offending step and the limit). AC 35332 — under an ACTION problem, `steps` must be non-empty " +
717
+ "(refused 400 otherwise); under a QUESTION it may be omitted or empty. Labels (\"1\", \"2a\", \"2a.i\") are " +
718
+ "ALWAYS DERIVED on read from position — never put one in a title yourself. edit :sid {base_hash, ...only the " +
719
+ "changed fields, steps?, steps_base_hash?}: `steps` omitted leaves the stored procedure untouched, an explicit " +
720
+ "array (including [], refused if it would leave an action's solution with zero steps) DIFFS against it — send " +
721
+ "back an existing step's `id` (read from the card) to keep it (even while retitling/reordering it) and omit " +
722
+ "`id` for a genuinely new step; any existing step you don't include gets removed, and an id-less MIDDLE insert " +
723
+ "recreates every later id-less sibling's row unless you send ids for the ones whose identity matters. " +
724
+ "`steps_base_hash` is REQUIRED whenever `steps` is sent — the solution's current `steps_hash` (read it off the " +
725
+ "card first); a stale value → 409 `stale_steps` carrying the current tree, so a step added/removed since your " +
726
+ "read is never silently dropped. NEVER resend the whole tree just to fix one word — see add_step/edit_step/" +
727
+ "remove_step below. remove :sid {base_hash}. A stale base_hash → 409 `stale_solution` with currentHash + " +
728
+ "currentSolution: merge, then retry. At most ONE live recommended per problem: a second → 409 naming " +
729
+ "`recommended_solution_id`. A CHOSEN option's words are frozen (409 — add a new one instead), and so is its " +
730
+ "WHOLE PROCEDURE — every steps route below also refuses once a decision has chosen this solution.\n\n" +
731
+ "Granular single-step actions — change ONE step without resending the tree: add_step {solution_id, title, " +
732
+ "description?, parent_step_id?, position?} — title is ONE imperative line, never a label like \"1.\" or \"2a\" " +
733
+ "(derived on read); parent_step_id omitted/null = top-level; position is 1-indexed among the parent's current " +
734
+ "live children, omitted = append, and a position beyond the current sibling count is refused (400), never " +
735
+ "silently clamped to append; nesting past depth 3 → 400. edit_step {solution_id, step_id, base_hash, title?, " +
736
+ "description?} — never moves a step (no parent_step_id/position here). remove_step {solution_id, step_id, " +
737
+ "base_hash} — soft-deletes it AND its own live children; refused (409) if this solution belongs to an ACTION " +
738
+ "problem and removing it would leave zero live steps. A stale base_hash on a step → 409 `stale_step` with " +
739
+ "currentHash + currentStep (carrying its real derived `label`).", {
727
740
  id: z.string().min(1),
728
741
  action: z.enum(["add", "edit", "remove", "add_step", "edit_step", "remove_step"]),
729
742
  problem_id: z.number().int().positive(),
@@ -755,7 +768,7 @@ strictTool("issue_solution", "One problem's options via /api/issues/:id/problems
755
768
  ...boardField,
756
769
  }, async (args) => jsonResult(await issueSolution(client, args)));
757
770
  // ---------------- issue_dependency ----------------
758
- 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 Review/held (e.g. an issue_triage "keep" verdict) is NOT a substitute.', {
771
+ 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.', {
759
772
  id: z.string().min(1),
760
773
  action: z.enum(["add", "remove"]),
761
774
  kind: z.enum(["depends_on", "conflict_on"]).optional(),
@@ -792,7 +805,7 @@ strictTool("issue_quality_gate", "Put one quality gate ON a card, or take it OFF
792
805
  ...boardField,
793
806
  }, async (args) => jsonResult(await issueQualityGate(client, args)));
794
807
  // ---------------- issue_quality_gate_verdict ----------------
795
- 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}`**: `issue_transition complete` REFUSES 409 (`failed_gate: \"quality_gate_post\"`, `failed_post_gates[]`) while any required POST gate is not `pass` — without a verdict a manually-claimed card strands In Progress, stalling other cards via its `conflict_on`/`waiting_on` edges. `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 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`.", {
808
+ 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`.", {
796
809
  id: z.string().min(1),
797
810
  gate: z.enum([
798
811
  "plan-dependency",
@@ -807,13 +820,13 @@ strictTool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate
807
820
  ...boardField,
808
821
  }, async (args) => jsonResult(await issueQualityGateVerdict(client, args)));
809
822
  // ---------------- issue_retro ----------------
810
- 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, never 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 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) — the ONLY reliable channel for a danxbot defect found mid-dispatch to get fixed, so do not default to false out of haste.", {
823
+ 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.", {
811
824
  id: z.string().min(1),
812
- good: z.string().describe("Free-form markdown (prose or list, they render identically). Style: see issue_comment's `text` param."),
813
- bad: z.string().describe("Free-form markdown (prose or list, they render identically). Style: see issue_comment's `text` param."),
825
+ good: z.string().describe("Free-form markdown (prose or a list — pick whichever reads fastest, they render identically). Style rules: see issue_comment's `text` param."),
826
+ bad: z.string().describe("Free-form markdown (prose or a list — pick whichever reads fastest, they render identically). Style rules: see issue_comment's `text` param."),
814
827
  correctable_danxbot_problem: z
815
828
  .boolean()
816
- .describe("Required every time, answered honestly — see tool description. Determines whether the description field below must be filled or must be empty."),
829
+ .describe("Required. Did this dispatch hit a problem in danxbot's OWN code/configuration (not \"this card was hard\") that danxbot could change so it stops happening? Answer explicitly every time — never left at a default."),
817
830
  correctable_danxbot_problem_description: z
818
831
  .string()
819
832
  .describe("Required. Non-empty (naming the concrete problem) when correctable_danxbot_problem is true; must be empty when it is false."),
@@ -907,7 +920,7 @@ strictTool("plan_list", "List every plan via GET /api/plans (DX-2683), and learn
907
920
  .optional()
908
921
  .describe("Filter to one computed status: awaiting-session, planning, building, complete. Omit for every plan."),
909
922
  }, async (args) => jsonResult(await planList(client, args)));
910
- 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); 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 + bridge message, cursor-paged newest first via `events_limit` (1.." + PLAN_GET_EVENTS_MAX_LIMIT + ", default " + PLAN_GET_EVENTS_DEFAULT_LIMIT + ") and `events_before` (a previous page's `next_cursor`; omit for the newest page); filter with `events_kinds` (one+, see its enum), `events_origin` (one, see its enum), `events_writer` (exact name); every `events_*` param without `fields: [\"events\"]` is a 400, same as `cards_*` above; response carries `events: {items: [{id, at, kind, writer, origin, originSessionId, targetSessionId, cardId, cardTitle, boardId, detail}], next_cursor}` — `next_cursor` null on the last page; an event on an unreadable board is left out, plan-level events always visible). `session`/`sessionListenerAttached`/`available_field_groups` ride every response regardless. `sessionListenerAttached` is `false` for a few seconds right after `plan_connect` while the event bridge starts; still `false` after that while connected means plan events aren't 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`.", {
923
+ 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`.", {
911
924
  plan_id: z
912
925
  .number()
913
926
  .int()
@@ -923,39 +936,39 @@ strictTool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id`
923
936
  .int()
924
937
  .nonnegative()
925
938
  .optional()
926
- .describe("Where the `cards` page starts (default 0). Requires `fields:[\"cards\"]`. Page with cards_offset while cards_offset + cards.length < cards_total."),
939
+ .describe("Where the `cards` page starts (default 0). Requires `fields` to include `cards`. Page with cards_offset while cards_offset + cards.length < cards_total."),
927
940
  cards_limit: z
928
941
  .number()
929
942
  .int()
930
943
  .positive()
931
944
  .max(LIST_PAGE_MAX_LIMIT)
932
945
  .optional()
933
- .describe("How many cards one page holds, 1.." + LIST_PAGE_MAX_LIMIT + " (default " + PLAN_GET_CARDS_DEFAULT_LIMIT + "). Requires `fields:[\"cards\"]`."),
946
+ .describe("How many cards one page holds, 1.." + LIST_PAGE_MAX_LIMIT + " (default " + PLAN_GET_CARDS_DEFAULT_LIMIT + "). Requires `fields` to include `cards`."),
934
947
  events_limit: z
935
948
  .number()
936
949
  .int()
937
950
  .positive()
938
951
  .max(PLAN_GET_EVENTS_MAX_LIMIT)
939
952
  .optional()
940
- .describe("DX-3027 — how many ledger events one page holds, 1.." + PLAN_GET_EVENTS_MAX_LIMIT + " (default " + PLAN_GET_EVENTS_DEFAULT_LIMIT + "). Requires `fields:[\"events\"]`."),
953
+ .describe("DX-3027 — how many ledger events one page holds, 1.." + PLAN_GET_EVENTS_MAX_LIMIT + " (default " + PLAN_GET_EVENTS_DEFAULT_LIMIT + "). Requires `fields` to include `events`."),
941
954
  events_before: z
942
955
  .string()
943
956
  .min(1)
944
957
  .optional()
945
- .describe("an opaque cursor from a previous page's `next_cursor`. Omit for the newest page. Requires `fields:[\"events\"]`."),
958
+ .describe("DX-3027 — an opaque cursor from a previous page's `next_cursor`. Omit for the newest page. Requires `fields` to include `events`."),
946
959
  events_kinds: z
947
960
  .array(z.enum(PLAN_EVENT_KINDS))
948
961
  .optional()
949
- .describe("only these event kinds. Omit for every kind. Requires `fields:[\"events\"]`."),
962
+ .describe("DX-3027 — only these event kinds. Omit for every kind. Requires `fields` to include `events`."),
950
963
  events_origin: z
951
964
  .enum(PLAN_EVENT_ORIGINS)
952
965
  .optional()
953
- .describe("only events with this origin. Omit for every origin. Requires `fields:[\"events\"]`."),
966
+ .describe("DX-3027 — only events with this origin. Omit for every origin. Requires `fields` to include `events`."),
954
967
  events_writer: z
955
968
  .string()
956
969
  .min(1)
957
970
  .optional()
958
- .describe("only events with this exact writer name. Omit for every writer. Requires `fields:[\"events\"]`."),
971
+ .describe("DX-3027 — only events with this exact writer name. Omit for every writer. Requires `fields` to include `events`."),
959
972
  }, async (args) => jsonResult(await planGet(client, args)));
960
973
  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.", {
961
974
  name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
@@ -1025,9 +1038,9 @@ strictTool("plan_update_note", 'Edit a plan note, via PATCH /api/plans/:plan_id/
1025
1038
  content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
1026
1039
  title: z.string().min(1).optional().describe("New title, at most 60 characters. Omit to keep the stored title."),
1027
1040
  body: z.string().min(1).optional().describe("New wrap-up, at most 250 characters. Omit to keep the stored body."),
1028
- card_ids: z.array(z.string().min(1)).optional().describe("Part of the link-set group — see tool description."),
1029
- record_refs: z.array(z.string().min(1)).optional().describe("Part of the link-set group — see tool description."),
1030
- section_ids: z.array(z.number().int().positive()).optional().describe("Part of the link-set group — see tool description."),
1041
+ card_ids: z.array(z.string().min(1)).optional().describe("REPLACES the whole link set when sent (with record_refs/section_ids)."),
1042
+ record_refs: z.array(z.string().min(1)).optional().describe("REPLACES the whole link set when sent (with card_ids/section_ids)."),
1043
+ section_ids: z.array(z.number().int().positive()).optional().describe("REPLACES the whole link set when sent (with card_ids/record_refs)."),
1031
1044
  }, async (args) => jsonResult(await planUpdateNote(client, args)));
1032
1045
  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.', {
1033
1046
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
@@ -1112,7 +1125,7 @@ strictTool("failure_category_update", "Patch an existing failure category via PA
1112
1125
  expectedRate: expectedRateField,
1113
1126
  }, async (args) => jsonResult(await failureCategoryUpdate(client, args)));
1114
1127
  // ---------------- dispatch_transcript_search (DX-3221) ----------------
1115
- strictTool("dispatch_transcript_search", "Search or tail ANOTHER dispatch's stored JSONL session transcript via GET /api/dispatches/:id/logs (DX-1682/DX-1484/DX-3221) — no new server route, only in-process search/windowing over the transcript already captured there. Prefer this over reading the transcript file directly: no filesystem access needed, and a single JSONL entry can be far longer than a line-based file read can sub-page — this tool searches the string itself and returns only a bounded excerpt AROUND a match, never the whole line. With `pattern`: returns up to `maxMatches` matching lines (case-insensitive regex), each 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`. `truncated: true` marks a capped excerpt either way, never a thrown error. `dispatchId` is a real `dispatches.id` — exactly the ids a failure-repair card's own body already lists under \"dispatches that hit it\".", {
1128
+ 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\".", {
1116
1129
  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."),
1117
1130
  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."),
1118
1131
  tail: z.number().int().positive().optional().describe("Only used when `pattern` is omitted. How many of the most recent lines to return. Defaults to 20, capped at 200."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.132",
3
+ "version": "0.1.134",
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",