@thehammer/danx-dashboard-mcp 0.1.129 → 0.1.131

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
@@ -413,39 +413,6 @@ function argCheckers(tool, mode) {
413
413
  },
414
414
  };
415
415
  }
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
416
  export async function issueComment(client, args) {
450
417
  const idEnc = encodeURIComponent(args.id);
451
418
  const board = args.board;
@@ -647,25 +614,10 @@ export async function issueChecklist(client, args) {
647
614
  *
648
615
  * No answer action, for the reason `issueSolution` gives.
649
616
  */
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
617
  export async function issueProblem(client, args) {
665
618
  const idEnc = encodeURIComponent(args.id);
666
619
  const board = args.board;
667
620
  const need = argCheckers("issue_problem", `action=${args.action}`);
668
- refuseInapplicableFields("issue_problem", `action=${args.action}`, args, new Set(PROBLEM_ACTION_FIELDS[args.action]));
669
621
  switch (args.action) {
670
622
  case "list":
671
623
  return client.request({ method: "GET", path: `/${idEnc}/problems`, board });
@@ -741,24 +693,11 @@ export async function issueProblem(client, args) {
741
693
  * question could release the very stop it set to wait for a human. The operator
742
694
  * answers in the dashboard.
743
695
  */
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
696
  export async function issueSolution(client, args) {
753
697
  const need = argCheckers("issue_solution", `action=${args.action}`);
754
698
  // DX-2735: checked at runtime too, not only by the schema — a caller that skips
755
699
  // the MCP boundary must never build `/problems/undefined/solutions`.
756
700
  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
701
  const base = `/${encodeURIComponent(args.id)}/problems/${problemId}/solutions`;
763
702
  const board = args.board;
764
703
  const content = {};
package/dist/index.js CHANGED
@@ -338,14 +338,19 @@ 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 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.';
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
+ // DX-3335 — condensed from the now-deleted danxbot:comment-style skill (PLN-11
350
+ // R-16: fold a skill's rule into the tool description it governs rather than
351
+ // require a separate load). Shared by every prose-writing field below so an
352
+ // agent sees the same rules wherever it writes card markdown.
353
+ const MARKDOWN_STYLE_DESCRIBE = "Renders as markdown here. Use `##`/`###` headers, fenced code blocks with a language tag, inline code for paths/symbols/flags, `-`/`1.` lists, and GFM tables (`|---|`) for 2D data — never ASCII tables. Never escape markdown characters (`\\*`, `\\_`) to \"play it safe\"; they render wrong. Don't repeat the same content as both prose and a list. Structure the content itself per `base:convey` (concept-first headline, diff, caveats, verify) — this rule only governs the markdown syntax.";
349
354
  // ---------------- issue_list ----------------
350
355
  strictTool("issue_list",
351
356
  // DX-2735: trimmed to pay for the problem tools inside the work-profile
@@ -398,7 +403,7 @@ strictTool("issue_get",
398
403
  .optional()
399
404
  .describe("Single-id form only. How many comments the page holds (server default 20), the window anchored at the " +
400
405
  "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:[\"comments\"]`."),
406
+ "never silently clamped here. Requires `fields` to include `comments`."),
402
407
  comments_offset: z
403
408
  .number()
404
409
  .int()
@@ -410,8 +415,8 @@ strictTool("issue_get",
410
415
  ...boardField,
411
416
  }, async (args) => jsonResult(await issueGet(client, args)));
412
417
  // ---------------- issue_create ----------------
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.', {
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.', {
415
420
  type: z.enum(ISSUE_TYPES),
416
421
  title: z.string().min(1).describe(TITLE_DESCRIBE),
417
422
  summary: z.string().min(1).optional().describe(SUMMARY_DESCRIBE),
@@ -423,7 +428,7 @@ strictTool("issue_create", '`plan` is REQUIRED on every create (DX-3006, no defa
423
428
  plan: z
424
429
  .literal("mine")
425
430
  .nullable()
426
- .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.'),
427
432
  parent_id: z.string().nullable().optional(),
428
433
  ac: z.array(z.object({ title: z.string().min(1) }).strict()).optional(),
429
434
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
@@ -474,7 +479,7 @@ strictTool("issue_create", '`plan` is REQUIRED on every create (DX-3006, no defa
474
479
  priority: z
475
480
  .union([z.enum(PRIORITY_TIER_WORDS), z.number()])
476
481
  .optional()
477
- .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.'),
478
483
  // DX-3238 — `assigned_agent` is DELIBERATELY left undeclared here, unlike
479
484
  // `priority` above. The route accepts it (`ALLOWED_CREATE_KEYS`,
480
485
  // create.ts:39-56) but it only matters for the create→`list_id`-lands-
@@ -490,7 +495,7 @@ strictTool("issue_create", '`plan` is REQUIRED on every create (DX-3006, no defa
490
495
  ...boardField,
491
496
  }, async (args) => jsonResult(await issueCreate(client, args, config.board)));
492
497
  // ---------------- 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. `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.', {
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.', {
494
499
  id: z.string().min(1),
495
500
  title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
496
501
  summary: z
@@ -511,7 +516,7 @@ strictTool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED k
511
516
  status: z
512
517
  .enum(CHECKLIST_ITEM_STATUSES)
513
518
  .optional()
514
- .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`."),
515
520
  detail: z
516
521
  .string()
517
522
  .optional()
@@ -538,7 +543,7 @@ strictTool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED k
538
543
  priority: z
539
544
  .union([z.enum(PRIORITY_TIER_WORDS), z.number()])
540
545
  .optional()
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.'),
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.'),
542
547
  list_id: z.string().min(1).nullable().optional(),
543
548
  triage_enabled: z
544
549
  .boolean()
@@ -584,7 +589,7 @@ strictTool("issue_comment", "Comment CRUD via /api/issues/:id/comments[/:cid]. a
584
589
  id: z.string().min(1),
585
590
  action: z.enum(["add", "edit", "delete"]),
586
591
  comment_id: z.number().int().positive().optional(),
587
- text: z.string().min(1).optional(),
592
+ text: z.string().min(1).optional().describe(MARKDOWN_STYLE_DESCRIBE),
588
593
  metadata: z.record(z.unknown()).optional(),
589
594
  problem_id: z.number().int().positive().optional().describe("action=add only — thread this comment as a follow-up under a LIVE problem of this same card, without answering it"),
590
595
  ...boardField,
@@ -597,7 +602,7 @@ const CHECKLIST_ITEM_INPUT = z
597
602
  status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
598
603
  })
599
604
  .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; `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.", {
601
606
  id: z.string().min(1),
602
607
  action: z.enum([
603
608
  "add_list",
@@ -634,11 +639,11 @@ const STEP_INPUT = z.lazy(() => z
634
639
  .positive()
635
640
  .optional()
636
641
  .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 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
+ "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)."),
642
647
  title: z
643
648
  .string()
644
649
  .min(1)
@@ -648,7 +653,7 @@ const STEP_INPUT = z.lazy(() => z
648
653
  steps: z.array(STEP_INPUT).optional().describe("this step's own children, nested — refused past 3 levels total"),
649
654
  })
650
655
  .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, 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`.", {
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 `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.", {
652
657
  id: z.string().min(1),
653
658
  action: z.enum(["list", "add", "edit", "remove"]),
654
659
  problem_id: z.number().int().positive().optional().describe("edit/remove"),
@@ -657,7 +662,9 @@ strictTool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pi
657
662
  .string()
658
663
  .min(1)
659
664
  .optional()
660
- .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?\")."),
661
668
  context: z
662
669
  .string()
663
670
  .nullable()
@@ -669,28 +676,33 @@ strictTool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pi
669
676
  type: z
670
677
  .enum(["question", "action"])
671
678
  .optional()
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."),
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."),
682
692
  summary: z
683
693
  .string()
684
694
  .nullable()
685
695
  .optional()
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."),
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)."),
690
702
  solutions: z
691
703
  .array(z.object({ title: z.string().min(1), ...SOLUTION_FIELDS, steps: z.array(STEP_INPUT).optional() }).strict())
692
704
  .optional()
693
- .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 " +
694
706
  "solutions must each carry at least one step (refused 400 otherwise); a QUESTION's may carry any number, " +
695
707
  "including none."),
696
708
  ...boardField,
@@ -701,15 +713,14 @@ strictTool("issue_solution", "One problem's options via /api/issues/:id/problems
701
713
  "another problem's solution id → 404). add {title, body?, pro?, con?, recommended?, steps?}: title names the " +
702
714
  "option/route, body is its markdown detail, pro/con the case for and against, `steps` is its WHOLE procedure " +
703
715
  "authored in one call — an ordered array of {title, description?, steps?}, nestable to 3 levels (a 4th is refused " +
704
- "400 naming the offending step and the limit). Under an ACTION problem, `steps` must be non-empty " +
716
+ "400 naming the offending step and the limit). AC 35332 — under an ACTION problem, `steps` must be non-empty " +
705
717
  "(refused 400 otherwise); under a QUESTION it may be omitted or empty. Labels (\"1\", \"2a\", \"2a.i\") are " +
706
718
  "ALWAYS DERIVED on read from position — never put one in a title yourself. edit :sid {base_hash, ...only the " +
707
719
  "changed fields, steps?, steps_base_hash?}: `steps` omitted leaves the stored procedure untouched, an explicit " +
708
720
  "array (including [], refused if it would leave an action's solution with zero steps) DIFFS against it — send " +
709
721
  "back an existing step's `id` (read from the card) to keep it (even while retitling/reordering it) and omit " +
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. " +
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. " +
713
724
  "`steps_base_hash` is REQUIRED whenever `steps` is sent — the solution's current `steps_hash` (read it off the " +
714
725
  "card first); a stale value → 409 `stale_steps` carrying the current tree, so a step added/removed since your " +
715
726
  "read is never silently dropped. NEVER resend the whole tree just to fix one word — see add_step/edit_step/" +
@@ -718,8 +729,8 @@ strictTool("issue_solution", "One problem's options via /api/issues/:id/problems
718
729
  "`recommended_solution_id`. A CHOSEN option's words are frozen (409 — add a new one instead), and so is its " +
719
730
  "WHOLE PROCEDURE — every steps route below also refuses once a decision has chosen this solution.\n\n" +
720
731
  "Granular single-step actions — change ONE step without resending the tree: add_step {solution_id, title, " +
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 " +
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 " +
723
734
  "live children, omitted = append, and a position beyond the current sibling count is refused (400), never " +
724
735
  "silently clamped to append; nesting past depth 3 → 400. edit_step {solution_id, step_id, base_hash, title?, " +
725
736
  "description?} — never moves a step (no parent_step_id/position here). remove_step {solution_id, step_id, " +
@@ -757,7 +768,7 @@ strictTool("issue_solution", "One problem's options via /api/issues/:id/problems
757
768
  ...boardField,
758
769
  }, async (args) => jsonResult(await issueSolution(client, args)));
759
770
  // ---------------- issue_dependency ----------------
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 (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.', {
761
772
  id: z.string().min(1),
762
773
  action: z.enum(["add", "remove"]),
763
774
  kind: z.enum(["depends_on", "conflict_on"]).optional(),
@@ -794,7 +805,7 @@ strictTool("issue_quality_gate", "Put one quality gate ON a card, or take it OFF
794
805
  ...boardField,
795
806
  }, async (args) => jsonResult(await issueQualityGate(client, args)));
796
807
  // ---------------- issue_quality_gate_verdict ----------------
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`.", {
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`.", {
798
809
  id: z.string().min(1),
799
810
  gate: z.enum([
800
811
  "plan-dependency",
@@ -811,11 +822,11 @@ strictTool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate
811
822
  // ---------------- issue_retro ----------------
812
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.", {
813
824
  id: z.string().min(1),
814
- good: z.string(),
815
- bad: z.string(),
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."),
816
827
  correctable_danxbot_problem: z
817
828
  .boolean()
818
- .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."),
819
830
  correctable_danxbot_problem_description: z
820
831
  .string()
821
832
  .describe("Required. Non-empty (naming the concrete problem) when correctable_danxbot_problem is true; must be empty when it is false."),
@@ -909,7 +920,7 @@ strictTool("plan_list", "List every plan via GET /api/plans (DX-2683), and learn
909
920
  .optional()
910
921
  .describe("Filter to one computed status: awaiting-session, planning, building, complete. Omit for every plan."),
911
922
  }, async (args) => jsonResult(await planList(client, args)));
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`.", {
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`.", {
913
924
  plan_id: z
914
925
  .number()
915
926
  .int()
@@ -925,39 +936,39 @@ strictTool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id`
925
936
  .int()
926
937
  .nonnegative()
927
938
  .optional()
928
- .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."),
929
940
  cards_limit: z
930
941
  .number()
931
942
  .int()
932
943
  .positive()
933
944
  .max(LIST_PAGE_MAX_LIMIT)
934
945
  .optional()
935
- .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`."),
936
947
  events_limit: z
937
948
  .number()
938
949
  .int()
939
950
  .positive()
940
951
  .max(PLAN_GET_EVENTS_MAX_LIMIT)
941
952
  .optional()
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\"]`."),
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`."),
943
954
  events_before: z
944
955
  .string()
945
956
  .min(1)
946
957
  .optional()
947
- .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`."),
948
959
  events_kinds: z
949
960
  .array(z.enum(PLAN_EVENT_KINDS))
950
961
  .optional()
951
- .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`."),
952
963
  events_origin: z
953
964
  .enum(PLAN_EVENT_ORIGINS)
954
965
  .optional()
955
- .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`."),
956
967
  events_writer: z
957
968
  .string()
958
969
  .min(1)
959
970
  .optional()
960
- .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`."),
961
972
  }, async (args) => jsonResult(await planGet(client, args)));
962
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.", {
963
974
  name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
@@ -1027,9 +1038,9 @@ strictTool("plan_update_note", 'Edit a plan note, via PATCH /api/plans/:plan_id/
1027
1038
  content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
1028
1039
  title: z.string().min(1).optional().describe("New title, at most 60 characters. Omit to keep the stored title."),
1029
1040
  body: z.string().min(1).optional().describe("New wrap-up, at most 250 characters. Omit to keep the stored body."),
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."),
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)."),
1033
1044
  }, async (args) => jsonResult(await planUpdateNote(client, args)));
1034
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.', {
1035
1046
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
@@ -1114,7 +1125,7 @@ strictTool("failure_category_update", "Patch an existing failure category via PA
1114
1125
  expectedRate: expectedRateField,
1115
1126
  }, async (args) => jsonResult(await failureCategoryUpdate(client, args)));
1116
1127
  // ---------------- dispatch_transcript_search (DX-3221) ----------------
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\".", {
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\".", {
1118
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."),
1119
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."),
1120
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.129",
3
+ "version": "0.1.131",
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",