@thehammer/danx-dashboard-mcp 0.1.125 → 0.1.128

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
@@ -432,9 +432,15 @@ const STRUCTURAL_ACTION_FIELDS = ["id", "action", "board"];
432
432
  * always allowed) is named in one error, so a caller fixes every offending
433
433
  * field in one round trip rather than one at a time.
434
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.
435
440
  function refuseInapplicableFields(tool, mode, args, allowed) {
436
- const offending = Object.keys(args)
437
- .filter((key) => args[key] !== undefined && !STRUCTURAL_ACTION_FIELDS.includes(key) && !allowed.has(key))
441
+ const offending = Object.entries(args)
442
+ .filter(([key, value]) => value !== undefined && !STRUCTURAL_ACTION_FIELDS.includes(key) && !allowed.has(key))
443
+ .map(([key]) => key)
438
444
  .sort();
439
445
  if (offending.length > 0) {
440
446
  throw new Error(`${tool} ${mode} refuses fields that do not apply to this action: ${offending.join(", ")}`);
package/dist/index.js CHANGED
@@ -338,14 +338,14 @@ const boardField = {
338
338
  .string()
339
339
  .min(1)
340
340
  .optional()
341
- .describe("Target another board by its qualified id `<repo>:<slug>`; omit for this dispatch's board. Unknown → 404."),
341
+ .describe("Board 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, 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.';
346
+ const TITLE_DESCRIBE = 'Short, specific label naming the domain (e.g. "Guest checkout rejects carts holding a gift card"). 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 for the description.";
348
+ const DESCRIPTION_DESCRIBE = 'The full body ("Context"): evidence, examples, technical detail; markdown, collapsed by default. Operator questions go in issue_problem, not here.';
349
349
  // ---------------- issue_list ----------------
350
350
  strictTool("issue_list",
351
351
  // DX-2735: trimmed to pay for the problem tools inside the work-profile
@@ -398,7 +398,7 @@ strictTool("issue_get",
398
398
  .optional()
399
399
  .describe("Single-id form only. How many comments the page holds (server default 20), the window anchored at the " +
400
400
  "NEWEST comment. An out-of-range value (server max 200) is refused by the server with its own 400, " +
401
- "never silently clamped here. Requires `fields` to include `comments`."),
401
+ "never silently clamped here. Requires `fields:[\"comments\"]`."),
402
402
  comments_offset: z
403
403
  .number()
404
404
  .int()
@@ -410,8 +410,8 @@ strictTool("issue_get",
410
410
  ...boardField,
411
411
  }, async (args) => jsonResult(await issueGet(client, args)));
412
412
  // ---------------- issue_create ----------------
413
- 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. ' +
414
- '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.', {
413
+ strictTool("issue_create", '`plan` is 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. ' +
414
+ '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 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.', {
415
415
  type: z.enum(ISSUE_TYPES),
416
416
  title: z.string().min(1).describe(TITLE_DESCRIBE),
417
417
  summary: z.string().min(1).optional().describe(SUMMARY_DESCRIBE),
@@ -423,7 +423,7 @@ strictTool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass "
423
423
  plan: z
424
424
  .literal("mine")
425
425
  .nullable()
426
- .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.'),
426
+ .describe('REQUIRED, decide per card — see tool description. "mine" = this session\'s connected plan; null = deliberately none; no plan id accepted.'),
427
427
  parent_id: z.string().nullable().optional(),
428
428
  ac: z.array(z.object({ title: z.string().min(1) }).strict()).optional(),
429
429
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
@@ -474,7 +474,7 @@ strictTool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass "
474
474
  priority: z
475
475
  .union([z.enum(PRIORITY_TIER_WORDS), z.number()])
476
476
  .optional()
477
- .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.'),
477
+ .describe('Tier word ("lowest"…"critical", prefer this) or number in [0,6); higher = more urgent. Omit for the route\'s own default.'),
478
478
  // DX-3238 — `assigned_agent` is DELIBERATELY left undeclared here, unlike
479
479
  // `priority` above. The route accepts it (`ALLOWED_CREATE_KEYS`,
480
480
  // create.ts:39-56) but it only matters for the create→`list_id`-lands-
@@ -490,7 +490,7 @@ strictTool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass "
490
490
  ...boardField,
491
491
  }, async (args) => jsonResult(await issueCreate(client, args, config.board)));
492
492
  // ---------------- issue_edit ----------------
493
- 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.', {
493
+ 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`, 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.', {
494
494
  id: z.string().min(1),
495
495
  title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
496
496
  summary: z
@@ -511,7 +511,7 @@ strictTool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED k
511
511
  status: z
512
512
  .enum(CHECKLIST_ITEM_STATUSES)
513
513
  .optional()
514
- .describe("Optional full status; omitted → from `checked`. `deferred` REQUIRES a non-empty `detail`; a 📡 item can never be `passing`."),
514
+ .describe("Optional full status; omitted → from `checked`. See tool description for the deferred/📡 rules."),
515
515
  detail: z
516
516
  .string()
517
517
  .optional()
@@ -538,7 +538,7 @@ strictTool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED k
538
538
  priority: z
539
539
  .union([z.enum(PRIORITY_TIER_WORDS), z.number()])
540
540
  .optional()
541
- .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.'),
541
+ .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.'),
542
542
  list_id: z.string().min(1).nullable().optional(),
543
543
  triage_enabled: z
544
544
  .boolean()
@@ -597,7 +597,7 @@ const CHECKLIST_ITEM_INPUT = z
597
597
  status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
598
598
  })
599
599
  .strict();
600
- 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.", {
600
+ 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.", {
601
601
  id: z.string().min(1),
602
602
  action: z.enum([
603
603
  "add_list",
@@ -634,11 +634,11 @@ const STEP_INPUT = z.lazy(() => z
634
634
  .positive()
635
635
  .optional()
636
636
  .describe("present -> this node IS an existing step (matched on this id, 400 if it does not resolve to a live " +
637
- "child of this exact parent scope — a supplied id is NEVER claimed by the positional/title fallback " +
638
- "below); absent -> matched against unclaimed siblings by TITLE (first unclaimed match, in order), else " +
639
- "created new. An id-less MIDDLE insert recreates every later id-less sibling's row unless you send ids " +
640
- "for the ones you want preserved — send ids whenever an existing step's identity matters (checked " +
641
- "progress, a later single-step edit)."),
637
+ "child of this exact parent scope — a supplied id is NEVER claimed by the fallback below); absent -> " +
638
+ "id-less nodes match the first unclaimed sibling with the same title under the same parent, else " +
639
+ "created new. This title-based match (never positional) is what lets an id-less MIDDLE insert keep " +
640
+ "every later id-less sibling's id — send ids anyway whenever an existing step's identity matters " +
641
+ "(checked progress, a later single-step edit), or when two siblings under the same parent share a title."),
642
642
  title: z
643
643
  .string()
644
644
  .min(1)
@@ -648,7 +648,7 @@ const STEP_INPUT = z.lazy(() => z
648
648
  steps: z.array(STEP_INPUT).optional().describe("this step's own children, nested — refused past 3 levels total"),
649
649
  })
650
650
  .strict());
651
- 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 `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). 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.", {
651
+ 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 `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). 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, and put any investigation detail in `context` (markdown, no cap) instead of running it on — e.g. statement \"Which cache should we use?\", context \"Redis fits the read-heavy path; see benchmark in #123.\" not a run-on statement listing the whole investigation. Every problem is also a `type` with a `summary` distinct from `context` — read both fields' own descriptions below before your first `add`.", {
652
652
  id: z.string().min(1),
653
653
  action: z.enum(["list", "add", "edit", "remove"]),
654
654
  problem_id: z.number().int().positive().optional().describe("edit/remove"),
@@ -657,9 +657,7 @@ strictTool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pi
657
657
  .string()
658
658
  .min(1)
659
659
  .optional()
660
- .describe("add/edit; ONE plain sentence, at most 200 characters. For a question: the question itself. For an action: " +
661
- "WHAT IS TO BE DONE — the deed itself, never phrased as a question (\"Rotate the staging DB credential\", " +
662
- "not \"Should we rotate it?\")."),
660
+ .describe("add/edit; ONE plain sentence, at most 200 characters — see `type` below for question vs action phrasing."),
663
661
  context: z
664
662
  .string()
665
663
  .nullable()
@@ -671,33 +669,28 @@ strictTool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pi
671
669
  type: z
672
670
  .enum(["question", "action"])
673
671
  .optional()
674
- .describe("add only. Before you raise this problem, apply the test: could I do this myself if I tried harder, and is the " +
675
- "only thing missing a decision? If yes, this is a \"question\": statement is the question itself (one plain " +
676
- "sentence); summary (optional) is why it matters; context is the evidence behind it; solutions[] are " +
677
- "candidate ANSWERS, each with its own pro/con, and the operator is done the moment they pick one. If the " +
678
- "blocker is access, credentials, hardware, a human's authority, or a system you genuinely cannot reach, " +
679
- "this is an \"action\": statement is WHAT IS TO BE DONE — the deed itself, never phrased as a question " +
680
- "(\"Rotate the staging DB credential\", not \"Should we rotate it?\"); summary is REQUIRED and MUST say " +
681
- "WHY THE ACTION IS NEEDED AND WHY YOU CANNOT DO IT YOURSELF — that sentence is what tells the operator this " +
682
- "is not you being lazy (an add with no summary is refused 400); context is whatever additional detail the " +
683
- "person needs to carry it out; solutions[] are the possible ROUTES a person could take (e.g. \"rotate by " +
684
- "hand in the console\" vs \"run the provisioning script\" vs \"ask the vendor\"), and EACH ONE MUST CARRY " +
685
- "AT LEAST ONE STEP (refused 400 otherwise) — an action with no procedure is not yet actionable. Omitted " +
686
- "defaults to \"question\" — decide deliberately every time."),
672
+ .describe("add only. Test: could I do this myself if I tried harder, missing only a decision? If yes, \"question\": " +
673
+ "statement is the question (one plain sentence); summary optional (why it matters); context is the evidence; " +
674
+ "solutions[] are candidate ANSWERS with pro/con, done the moment the operator picks one. If the blocker is " +
675
+ "access, credentials, hardware, a human's authority, or a system you genuinely cannot reach, \"action\": " +
676
+ "statement is WHAT IS TO BE DONE — the deed itself, never phrased as a question (\"Rotate the staging DB " +
677
+ "credential\", not \"Should we rotate it?\"); summary is REQUIRED, saying WHY THE ACTION IS NEEDED AND WHY " +
678
+ "YOU CANNOT DO IT YOURSELF (an add with no summary is refused 400 — proves this isn't you being lazy); " +
679
+ "context is whatever detail the person needs to carry it out; solutions[] are the possible ROUTES a person " +
680
+ "could take, and EACH MUST CARRY AT LEAST ONE STEP (refused 400 otherwise). Omitted defaults to \"question\" " +
681
+ "— decide deliberately every time."),
687
682
  summary: z
688
683
  .string()
689
684
  .nullable()
690
685
  .optional()
691
- .describe("add/edit. Plain text, 1-3 sentences — never markdown detail (that's `context`'s job). For a question: " +
692
- "optional, why it matters. For an action: REQUIRED and non-empty — why the action is needed AND why you " +
693
- "(the agent raising it) cannot do it yourself; omitting it on an action add is refused 400 naming this " +
694
- "field, and an edit that would clear it on a live action problem is refused the same way. add: sent only " +
695
- "when given (omit/null on a question means none). edit: omit to keep the stored summary, null to clear it " +
696
- "(refused if the problem is a live action)."),
686
+ .describe("add/edit. Plain text, 1-3 sentences — never markdown (that's `context`'s job). Required+non-empty for an " +
687
+ "action problem (why needed + why you can't do it yourself — see `type`; refused 400 if missing on add or " +
688
+ "cleared on a live action edit); optional for a question. add: sent only when given (omit/null on a " +
689
+ "question means none). edit: omit keeps the stored summary, null clears it."),
697
690
  solutions: z
698
691
  .array(z.object({ title: z.string().min(1), ...SOLUTION_FIELDS, steps: z.array(STEP_INPUT).optional() }).strict())
699
692
  .optional()
700
- .describe("add only; fields as issue_solution add, INCLUDING steps (S6/AC 35332) — an ACTION problem's inline " +
693
+ .describe("add only; fields as issue_solution add, INCLUDING steps — an ACTION problem's inline " +
701
694
  "solutions must each carry at least one step (refused 400 otherwise); a QUESTION's may carry any number, " +
702
695
  "including none."),
703
696
  ...boardField,
@@ -708,14 +701,15 @@ strictTool("issue_solution", "One problem's options via /api/issues/:id/problems
708
701
  "another problem's solution id → 404). add {title, body?, pro?, con?, recommended?, steps?}: title names the " +
709
702
  "option/route, body is its markdown detail, pro/con the case for and against, `steps` is its WHOLE procedure " +
710
703
  "authored in one call — an ordered array of {title, description?, steps?}, nestable to 3 levels (a 4th is refused " +
711
- "400 naming the offending step and the limit). AC 35332 — under an ACTION problem, `steps` must be non-empty " +
704
+ "400 naming the offending step and the limit). Under an ACTION problem, `steps` must be non-empty " +
712
705
  "(refused 400 otherwise); under a QUESTION it may be omitted or empty. Labels (\"1\", \"2a\", \"2a.i\") are " +
713
706
  "ALWAYS DERIVED on read from position — never put one in a title yourself. edit :sid {base_hash, ...only the " +
714
707
  "changed fields, steps?, steps_base_hash?}: `steps` omitted leaves the stored procedure untouched, an explicit " +
715
708
  "array (including [], refused if it would leave an action's solution with zero steps) DIFFS against it — send " +
716
709
  "back an existing step's `id` (read from the card) to keep it (even while retitling/reordering it) and omit " +
717
- "`id` for a genuinely new step; any existing step you don't include gets removed, and an id-less MIDDLE insert " +
718
- "recreates every later id-less sibling's row unless you send ids for the ones whose identity matters. " +
710
+ "`id` for a genuinely new step; any existing step you don't include gets removed. An id-less node matches the " +
711
+ "first unclaimed sibling with the same title under the same parent (never positional), so an id-less MIDDLE " +
712
+ "insert keeps every later id-less sibling's id — send ids anyway whenever an existing step's identity matters. " +
719
713
  "`steps_base_hash` is REQUIRED whenever `steps` is sent — the solution's current `steps_hash` (read it off the " +
720
714
  "card first); a stale value → 409 `stale_steps` carrying the current tree, so a step added/removed since your " +
721
715
  "read is never silently dropped. NEVER resend the whole tree just to fix one word — see add_step/edit_step/" +
@@ -724,8 +718,8 @@ strictTool("issue_solution", "One problem's options via /api/issues/:id/problems
724
718
  "`recommended_solution_id`. A CHOSEN option's words are frozen (409 — add a new one instead), and so is its " +
725
719
  "WHOLE PROCEDURE — every steps route below also refuses once a decision has chosen this solution.\n\n" +
726
720
  "Granular single-step actions — change ONE step without resending the tree: add_step {solution_id, title, " +
727
- "description?, parent_step_id?, position?} — title is ONE imperative line, never a label like \"1.\" or \"2a\" " +
728
- "(derived on read); parent_step_id omitted/null = top-level; position is 1-indexed among the parent's current " +
721
+ "description?, parent_step_id?, position?} — title is ONE imperative line (labels are derived, never typed, " +
722
+ "same rule as above); parent_step_id omitted/null = top-level; position is 1-indexed among the parent's current " +
729
723
  "live children, omitted = append, and a position beyond the current sibling count is refused (400), never " +
730
724
  "silently clamped to append; nesting past depth 3 → 400. edit_step {solution_id, step_id, base_hash, title?, " +
731
725
  "description?} — never moves a step (no parent_step_id/position here). remove_step {solution_id, step_id, " +
@@ -763,7 +757,7 @@ strictTool("issue_solution", "One problem's options via /api/issues/:id/problems
763
757
  ...boardField,
764
758
  }, async (args) => jsonResult(await issueSolution(client, args)));
765
759
  // ---------------- issue_dependency ----------------
766
- 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.', {
760
+ 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; 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.', {
767
761
  id: z.string().min(1),
768
762
  action: z.enum(["add", "remove"]),
769
763
  kind: z.enum(["depends_on", "conflict_on"]).optional(),
@@ -800,7 +794,7 @@ strictTool("issue_quality_gate", "Put one quality gate ON a card, or take it OFF
800
794
  ...boardField,
801
795
  }, async (args) => jsonResult(await issueQualityGate(client, args)));
802
796
  // ---------------- issue_quality_gate_verdict ----------------
803
- 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`.", {
797
+ 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`.", {
804
798
  id: z.string().min(1),
805
799
  gate: z.enum([
806
800
  "plan-dependency",
@@ -821,7 +815,7 @@ strictTool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retro
821
815
  bad: z.string(),
822
816
  correctable_danxbot_problem: z
823
817
  .boolean()
824
- .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."),
818
+ .describe("Required every time, answered honestly — see tool description. Determines whether the description field below must be filled or must be empty."),
825
819
  correctable_danxbot_problem_description: z
826
820
  .string()
827
821
  .describe("Required. Non-empty (naming the concrete problem) when correctable_danxbot_problem is true; must be empty when it is false."),
@@ -915,7 +909,7 @@ strictTool("plan_list", "List every plan via GET /api/plans (DX-2683), and learn
915
909
  .optional()
916
910
  .describe("Filter to one computed status: awaiting-session, planning, building, complete. Omit for every plan."),
917
911
  }, async (args) => jsonResult(await planList(client, args)));
918
- 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`.", {
912
+ 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 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, see its own enum), `events_origin` (one, see its own enum), `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`.", {
919
913
  plan_id: z
920
914
  .number()
921
915
  .int()
@@ -931,39 +925,39 @@ strictTool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id`
931
925
  .int()
932
926
  .nonnegative()
933
927
  .optional()
934
- .describe("Where the `cards` page starts (default 0). Requires `fields` to include `cards`. Page with cards_offset while cards_offset + cards.length < cards_total."),
928
+ .describe("Where the `cards` page starts (default 0). Requires `fields:[\"cards\"]`. Page with cards_offset while cards_offset + cards.length < cards_total."),
935
929
  cards_limit: z
936
930
  .number()
937
931
  .int()
938
932
  .positive()
939
933
  .max(LIST_PAGE_MAX_LIMIT)
940
934
  .optional()
941
- .describe("How many cards one page holds, 1.." + LIST_PAGE_MAX_LIMIT + " (default " + PLAN_GET_CARDS_DEFAULT_LIMIT + "). Requires `fields` to include `cards`."),
935
+ .describe("How many cards one page holds, 1.." + LIST_PAGE_MAX_LIMIT + " (default " + PLAN_GET_CARDS_DEFAULT_LIMIT + "). Requires `fields:[\"cards\"]`."),
942
936
  events_limit: z
943
937
  .number()
944
938
  .int()
945
939
  .positive()
946
940
  .max(PLAN_GET_EVENTS_MAX_LIMIT)
947
941
  .optional()
948
- .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`."),
942
+ .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\"]`."),
949
943
  events_before: z
950
944
  .string()
951
945
  .min(1)
952
946
  .optional()
953
- .describe("DX-3027 — an opaque cursor from a previous page's `next_cursor`. Omit for the newest page. Requires `fields` to include `events`."),
947
+ .describe("an opaque cursor from a previous page's `next_cursor`. Omit for the newest page. Requires `fields:[\"events\"]`."),
954
948
  events_kinds: z
955
949
  .array(z.enum(PLAN_EVENT_KINDS))
956
950
  .optional()
957
- .describe("DX-3027 — only these event kinds. Omit for every kind. Requires `fields` to include `events`."),
951
+ .describe("only these event kinds. Omit for every kind. Requires `fields:[\"events\"]`."),
958
952
  events_origin: z
959
953
  .enum(PLAN_EVENT_ORIGINS)
960
954
  .optional()
961
- .describe("DX-3027 — only events with this origin. Omit for every origin. Requires `fields` to include `events`."),
955
+ .describe("only events with this origin. Omit for every origin. Requires `fields:[\"events\"]`."),
962
956
  events_writer: z
963
957
  .string()
964
958
  .min(1)
965
959
  .optional()
966
- .describe("DX-3027 — only events with this exact writer name. Omit for every writer. Requires `fields` to include `events`."),
960
+ .describe("only events with this exact writer name. Omit for every writer. Requires `fields:[\"events\"]`."),
967
961
  }, async (args) => jsonResult(await planGet(client, args)));
968
962
  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.", {
969
963
  name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
@@ -1033,9 +1027,9 @@ strictTool("plan_update_note", 'Edit a plan note, via PATCH /api/plans/:plan_id/
1033
1027
  content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
1034
1028
  title: z.string().min(1).optional().describe("New title, at most 60 characters. Omit to keep the stored title."),
1035
1029
  body: z.string().min(1).optional().describe("New wrap-up, at most 250 characters. Omit to keep the stored body."),
1036
- card_ids: z.array(z.string().min(1)).optional().describe("REPLACES the whole link set when sent (with record_refs/section_ids)."),
1037
- record_refs: z.array(z.string().min(1)).optional().describe("REPLACES the whole link set when sent (with card_ids/section_ids)."),
1038
- section_ids: z.array(z.number().int().positive()).optional().describe("REPLACES the whole link set when sent (with card_ids/record_refs)."),
1030
+ card_ids: z.array(z.string().min(1)).optional().describe("Part of the link-set group — see tool description."),
1031
+ record_refs: z.array(z.string().min(1)).optional().describe("Part of the link-set group — see tool description."),
1032
+ section_ids: z.array(z.number().int().positive()).optional().describe("Part of the link-set group — see tool description."),
1039
1033
  }, async (args) => jsonResult(await planUpdateNote(client, args)));
1040
1034
  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.', {
1041
1035
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
@@ -1120,7 +1114,7 @@ strictTool("failure_category_update", "Patch an existing failure category via PA
1120
1114
  expectedRate: expectedRateField,
1121
1115
  }, async (args) => jsonResult(await failureCategoryUpdate(client, args)));
1122
1116
  // ---------------- dispatch_transcript_search (DX-3221) ----------------
1123
- 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\".", {
1117
+ 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\".", {
1124
1118
  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."),
1125
1119
  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."),
1126
1120
  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.125",
3
+ "version": "0.1.128",
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",