@thehammer/danx-dashboard-mcp 0.1.119 → 0.1.121

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
@@ -96,6 +96,13 @@ export async function issueGet(client, args) {
96
96
  if (args.ids !== undefined && args.board !== undefined) {
97
97
  throw new Error("issue_get: board does not apply to the ids batch form — it resolves every id globally; drop board");
98
98
  }
99
+ // DX-3321 — comments paging is a SINGLE-id-route feature
100
+ // (`GET /api/issues/:id?comments_limit=&comments_offset=`); the batch
101
+ // route has no per-card paging at all. Refuse rather than silently drop,
102
+ // same as the `ids` + `board` refusal just above.
103
+ if (args.ids !== undefined && (args.comments_limit !== undefined || args.comments_offset !== undefined)) {
104
+ throw new Error("issue_get: comments_limit/comments_offset do not apply to the ids batch form — it has no per-card comment paging; drop them or use the single-id form");
105
+ }
99
106
  const query = {};
100
107
  if (args.fields !== undefined && args.fields.length > 0) {
101
108
  query.fields = args.fields.join(",");
@@ -112,6 +119,10 @@ export async function issueGet(client, args) {
112
119
  if (args.id === undefined) {
113
120
  throw new Error("issue_get: pass exactly one of id or ids");
114
121
  }
122
+ if (args.comments_limit !== undefined)
123
+ query.comments_limit = args.comments_limit;
124
+ if (args.comments_offset !== undefined)
125
+ query.comments_offset = args.comments_offset;
115
126
  return client.request({
116
127
  method: "GET",
117
128
  path: `/${encodeURIComponent(args.id)}`,
@@ -668,12 +679,13 @@ export async function issueProblem(client, args) {
668
679
  }
669
680
  }
670
681
  /**
671
- * One problem's candidate solutions via
672
- * `/api/issues/:id/problems/:pid/solutions[/:sid]` (DX-2735), action-dispatched
673
- * like `issue_checklist`. A missing required arg for the chosen action throws at
674
- * this boundary (no round-trip); the server's refusal envelopes
675
- * (`stale_solution` with the current row, a second recommendation on the same
676
- * problem, editing a chosen option, a solution id from another problem → 404)
682
+ * One problem's candidate solutions, AND (DX-3310) one solution's individual
683
+ * procedure steps, via `/api/issues/:id/problems/:pid/solutions[/:sid][/steps[/:stepId]]`
684
+ * — action-dispatched like `issue_checklist`. A missing required arg for the
685
+ * chosen action throws at this boundary (no round-trip); the server's
686
+ * refusal envelopes (`stale_solution`/`stale_step` with the current row, a
687
+ * second recommendation on the same problem, editing a chosen option, a
688
+ * solution/step id from the wrong scope → 404, nesting past depth 3 → 400)
677
689
  * pass through verbatim.
678
690
  *
679
691
  * There is deliberately NO answer action, on this tool or any other. Answering
@@ -697,11 +709,22 @@ export async function issueSolution(client, args) {
697
709
  case "add": {
698
710
  // DX-2735: the POST carries the CHECKED title, not a re-read of args.title.
699
711
  const title = need.string(args.title, "title");
712
+ // DX-3310 — omitted `steps` on add means "no steps", not "leave
713
+ // untouched" (there is nothing yet to leave untouched on a brand new
714
+ // solution) — sent through only when the caller actually supplied it,
715
+ // so an existing add-only caller that never heard of `steps` keeps
716
+ // creating exactly what it always created.
717
+ if (args.steps !== undefined)
718
+ content.steps = args.steps;
700
719
  return client.request({ method: "POST", path: base, body: { ...content, title }, board });
701
720
  }
702
721
  case "edit": {
703
722
  const solutionId = need.id(args.solution_id, "solution_id");
704
723
  const baseHash = need.string(args.base_hash, "base_hash");
724
+ // DX-3310 — omitted keeps the stored steps untouched; an explicit
725
+ // (including empty) array replaces them via the server's diff.
726
+ if (args.steps !== undefined)
727
+ content.steps = args.steps;
705
728
  return client.request({
706
729
  method: "PATCH",
707
730
  path: `${base}/${solutionId}`,
@@ -719,6 +742,42 @@ export async function issueSolution(client, args) {
719
742
  board,
720
743
  });
721
744
  }
745
+ // DX-3310 (AC 35250) — granular single-step CUD, so changing one step
746
+ // never requires resending the whole `steps` tree.
747
+ case "add_step": {
748
+ const solutionId = need.id(args.solution_id, "solution_id");
749
+ const title = need.string(args.title, "title");
750
+ const body = { title };
751
+ if (args.description !== undefined)
752
+ body.description = args.description;
753
+ if (args.parent_step_id !== undefined)
754
+ body.parent_step_id = args.parent_step_id;
755
+ if (args.position !== undefined)
756
+ body.position = args.position;
757
+ return client.request({ method: "POST", path: `${base}/${solutionId}/steps`, body, board });
758
+ }
759
+ case "edit_step": {
760
+ const solutionId = need.id(args.solution_id, "solution_id");
761
+ const stepId = need.id(args.step_id, "step_id");
762
+ const baseHash = need.string(args.base_hash, "base_hash");
763
+ const body = { base_hash: baseHash };
764
+ if (args.title !== undefined)
765
+ body.title = args.title;
766
+ if (args.description !== undefined)
767
+ body.description = args.description;
768
+ return client.request({ method: "PATCH", path: `${base}/${solutionId}/steps/${stepId}`, body, board });
769
+ }
770
+ case "remove_step": {
771
+ const solutionId = need.id(args.solution_id, "solution_id");
772
+ const stepId = need.id(args.step_id, "step_id");
773
+ const baseHash = need.string(args.base_hash, "base_hash");
774
+ return client.request({
775
+ method: "DELETE",
776
+ path: `${base}/${solutionId}/steps/${stepId}`,
777
+ body: { base_hash: baseHash },
778
+ board,
779
+ });
780
+ }
722
781
  }
723
782
  }
724
783
  /**
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@
20
20
  * - issue_comment POST/PATCH/DELETE /api/issues/:id/comments[/:cid]
21
21
  * - issue_checklist POST/PATCH/DELETE /api/issues/:id/checklists[/:cid[/items[/:iid]]]
22
22
  * - issue_problem GET/POST/PATCH/DELETE /api/issues/:id/problems[/:pid] (DX-2735)
23
- * - issue_solution POST/PATCH/DELETE /api/issues/:id/problems/:pid/solutions[/:sid] (DX-2735)
23
+ * - issue_solution POST/PATCH/DELETE /api/issues/:id/problems/:pid/solutions[/:sid][/steps[/:stepId]] (DX-2735, steps DX-3310)
24
24
  * - issue_dependency POST/DELETE /api/issues/:id/dependencies[/:did]
25
25
  * - issue_retire_branch POST /api/issues/:id/card-branch-retire (DX-2845)
26
26
  * - issue_quality_gate POST /api/issues/:id/quality-gates/:gate
@@ -384,13 +384,29 @@ strictTool("issue_list",
384
384
  strictTool("issue_get",
385
385
  // DX-2735: trimmed to pay for the problem tools inside the work-profile
386
386
  // injected-surface budget — same facts, no repeated prose.
387
- "Fetch one card (GET /api/issues/:id, `id`) or many (GET /api/issues/batch, `ids`, at most " + ISSUE_BATCH_GET_MAX + " — split larger sets); pass exactly one. Ids are globally unique, so both resolve from any board; the batch form is global and throws with `board`. THE DEFAULT ROW IS MINIMAL — scalars only (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash: the concurrency token issue_edit needs for title/description/checklists). `fields` opts into groups, per card in a batch too: description (body + summary), problems (live problems in order, each {id, statement, content_hash, open} with its solutions[] and decisions[] — a card needs a human exactly when open_problem_count > 0), ac (acceptance criteria + checklists), comments, retro, dependencies (waiting_on/conflict_on/blocked), triage (history + ICE), assignment (dispatch, assigned_agent, lifecycle timestamps), quality_gates (one row per gate {gate, required, status pending|pass|fail, completed_at, message}; a required PRE gate not `pass` pre-empts the work dispatch, and complete refuses while a required POST gate is not `pass`), children (ids + rollups), mirrors (external sync state), code_review_items, plans (every plan this card is on, `{id, ref, name}[]` via `plan_cards` — `ref` is the plan's `PLN-<id>`). Single form: unknown id → 404. Batch form: `{issues: [...], not_found: [...ids]}` — an unknown or deleted id never fails the call.", {
387
+ "Fetch one card (GET /api/issues/:id, `id`) or many (GET /api/issues/batch, `ids`, at most " + ISSUE_BATCH_GET_MAX + " — split larger sets); pass exactly one. Ids are globally unique, so both resolve from any board; the batch form is global and throws with `board`. THE DEFAULT ROW IS MINIMAL — scalars only (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash: the concurrency token issue_edit needs for title/description/checklists). `fields` opts into groups, per card in a batch too: description (body + summary), problems (live problems in order, each {id, statement, content_hash, open} with its solutions[] and decisions[] — a card needs a human exactly when open_problem_count > 0), ac (acceptance criteria + checklists), comments (see `comments_limit`/`comments_offset` below), retro, dependencies (waiting_on/conflict_on/blocked), triage (history + ICE), assignment (dispatch, assigned_agent, lifecycle timestamps), quality_gates (one row per gate {gate, required, status pending|pass|fail, completed_at, message}; a required PRE gate not `pass` pre-empts the work dispatch, and complete refuses while a required POST gate is not `pass`), children (ids + rollups), mirrors (external sync state), code_review_items, plans (every plan this card is on, `{id, ref, name}[]` via `plan_cards` — `ref` is the plan's `PLN-<id>`). The `comments` group returns a PAGE ANCHORED AT THE NEWEST COMMENT: `comments_offset` counts back from the newest comment (offset 0 = the most recent comments_limit comments), and `comments_total` is the card's real total comment count, so `comments_offset + comments.length < comments_total` means older comments remain — page forward with `comments_offset` to reach them; a long-running card's decisive history (what was tried, measured, rejected or reverted) often lives past the first page. WITHIN a page, comments are ordered CHRONOLOGICALLY (oldest first, newest last) — the same order every other comment read in this API uses; only the WINDOW you request is anchored at the newest end, not the array itself. `comments_limit`/`comments_offset` apply to the SINGLE-id form only (no per-card paging in the batch form — naming either alongside `ids` is refused). Single form: unknown id → 404. Batch form: `{issues: [...], not_found: [...ids]}` — an unknown or deleted id never fails the call.", {
388
388
  id: z.string().min(1).optional(),
389
389
  ids: z.array(z.string().min(1)).min(1).max(ISSUE_BATCH_GET_MAX).optional(),
390
390
  fields: z
391
391
  .array(z.enum(GET_FIELD_GROUPS))
392
392
  .optional()
393
393
  .describe("Field groups to add; absent = minimal scalars."),
394
+ comments_limit: z
395
+ .number()
396
+ .int()
397
+ .positive()
398
+ .optional()
399
+ .describe("Single-id form only. How many comments the page holds (server default 20), the window anchored at the " +
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`."),
402
+ comments_offset: z
403
+ .number()
404
+ .int()
405
+ .nonnegative()
406
+ .optional()
407
+ .describe("Single-id form only. Skip this many of the newest comments before the page starts (default 0) — page " +
408
+ "forward while comments_offset + comments.length < comments_total to reach older comments. Requires " +
409
+ "`fields` to include `comments`."),
394
410
  ...boardField,
395
411
  }, async (args) => jsonResult(await issueGet(client, args)));
396
412
  // ---------------- issue_create ----------------
@@ -610,7 +626,7 @@ const SOLUTION_FIELDS = {
610
626
  con: z.string().optional(),
611
627
  recommended: z.boolean().optional(),
612
628
  };
613
- strictTool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pid]: one statement the operator must resolve (a question, or a flaw in the plan) 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 (DX-2830: removing it just means the card no longer needs a human). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. No answer action — the operator answers in the dashboard. DX-2942 — `statement` is capped at 200 characters (a 400 names the actual length otherwise): write it as ONE plain 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.", {
629
+ 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.", {
614
630
  id: z.string().min(1),
615
631
  action: z.enum(["list", "add", "edit", "remove"]),
616
632
  problem_id: z.number().int().positive().optional().describe("edit/remove"),
@@ -643,10 +659,8 @@ strictTool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pi
643
659
  "WHY THE ACTION IS NEEDED AND WHY YOU CANNOT DO IT YOURSELF — that sentence is what tells the operator this " +
644
660
  "is not you being lazy (an add with no summary is refused 400); context is whatever additional detail the " +
645
661
  "person needs to carry it out; solutions[] are the possible ROUTES a person could take (e.g. \"rotate by " +
646
- "hand in the console\" vs \"run the provisioning script\" vs \"ask the vendor\") — each will carry its own " +
647
- "ordered steps to completion once the operator picks one (a later release; for now, describe the route in " +
648
- "the solution's body). Omitted defaults to \"question\" (today's behavior) — but that default exists for " +
649
- "the caller who has never heard of this field, not as permission to skip the choice: decide deliberately " +
662
+ "hand in the console\" vs \"run the provisioning script\" vs \"ask the vendor\"), each carrying its own " +
663
+ "steps to completion once the operator picks one. Omitted defaults to \"question\" — decide deliberately " +
650
664
  "every time."),
651
665
  summary: z
652
666
  .string()
@@ -664,15 +678,61 @@ strictTool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pi
664
678
  .describe("add only; fields as issue_solution add"),
665
679
  ...boardField,
666
680
  }, async (args) => jsonResult(await issueProblem(client, args)));
667
- // ---------------- issue_solution ----------------
668
- strictTool("issue_solution", "One problem's options via /api/issues/:id/problems/:pid/solutions[/:sid]; `problem_id` is REQUIRED (from issue_problem list/add; another problem's solution id → 404). add {title, body?, pro?, con?, recommended?}: title names the option, body is its markdown detail, pro/con the case for and against; edit :sid {base_hash, ...only the changed fields}; remove :sid {base_hash}. A stale base_hash → 409 `stale_solution` with currentHash + currentSolution: merge, then retry. At most ONE live recommended per problem: a second → 409 naming `recommended_solution_id`. A chosen option cannot be edited (409 — add a new one) but can be removed.", {
681
+ const STEP_INPUT = z.lazy(() => z
682
+ .object({
683
+ id: z
684
+ .number()
685
+ .int()
686
+ .positive()
687
+ .optional()
688
+ .describe("present → this node IS an existing step (matched on this id); absent → new, or matched by (parent, ordinal, title)"),
689
+ title: z.string().min(1),
690
+ description: z.string().nullable().optional().describe("markdown detail, or null/omitted for none"),
691
+ steps: z.array(STEP_INPUT).optional().describe("this step's own children, nested — refused past 3 levels total"),
692
+ })
693
+ .strict());
694
+ strictTool("issue_solution", "One problem's options via /api/issues/:id/problems/:pid/solutions[/:sid], AND (DX-3310) one solution's individual " +
695
+ "procedure steps via .../solutions/:sid/steps[/:stepId]; `problem_id` is REQUIRED (from issue_problem list/add; " +
696
+ "another problem's solution id → 404). add {title, body?, pro?, con?, recommended?, steps?}: title names the " +
697
+ "option/route, body is its markdown detail, pro/con the case for and against, `steps` is its WHOLE procedure " +
698
+ "authored in one call — an ordered array of {title, description?, steps?}, nestable to 3 levels (a 4th is refused " +
699
+ "400 naming the offending step and the limit); edit :sid {base_hash, ...only the changed fields, steps?}: `steps` " +
700
+ "omitted leaves the stored procedure untouched, an explicit array (including []) DIFFS against it — send back an " +
701
+ "existing step's `id` (read from the card) to keep it (even while retitling/reordering it) and omit `id` for a " +
702
+ "genuinely new step; any existing step you don't include gets removed. NEVER resend the whole tree just to fix " +
703
+ "one word — see add_step/edit_step/remove_step below. remove :sid {base_hash}. A stale base_hash → 409 " +
704
+ "`stale_solution` with currentHash + currentSolution: merge, then retry. At most ONE live recommended per " +
705
+ "problem: a second → 409 naming `recommended_solution_id`. A chosen option cannot be edited (409 — add a new " +
706
+ "one) but can be removed.\n\n" +
707
+ "Granular single-step actions — change ONE step without resending the tree: add_step {solution_id, title, " +
708
+ "description?, parent_step_id?, position?} — parent_step_id omitted/null = top-level; position is 1-indexed " +
709
+ "among the parent's current live children, omitted = append; nesting past depth 3 → 400. edit_step " +
710
+ "{solution_id, step_id, base_hash, title?, description?} — never moves a step (no parent_step_id/position here). " +
711
+ "remove_step {solution_id, step_id, base_hash} — soft-deletes it AND its own live children. A stale base_hash on " +
712
+ "a step → 409 `stale_step` with currentHash + currentStep (carrying its real derived `label`).", {
669
713
  id: z.string().min(1),
670
- action: z.enum(["add", "edit", "remove"]),
714
+ action: z.enum(["add", "edit", "remove", "add_step", "edit_step", "remove_step"]),
671
715
  problem_id: z.number().int().positive(),
672
- solution_id: z.number().int().positive().optional().describe("edit/remove"),
673
- base_hash: z.string().min(1).optional().describe("content_hash last read; edit/remove"),
674
- title: z.string().min(1).optional().describe("add"),
716
+ solution_id: z.number().int().positive().optional().describe("edit/remove/add_step/edit_step/remove_step"),
717
+ base_hash: z.string().min(1).optional().describe("content_hash last read; edit/remove/edit_step/remove_step"),
718
+ title: z.string().min(1).optional().describe("add; also add_step/edit_step"),
675
719
  ...SOLUTION_FIELDS,
720
+ steps: z.array(STEP_INPUT).optional().describe("add/edit only — see this tool's own description"),
721
+ description: z.string().nullable().optional().describe("add_step/edit_step; markdown detail, or null/omitted for none"),
722
+ step_id: z.number().int().positive().optional().describe("edit_step/remove_step"),
723
+ parent_step_id: z
724
+ .number()
725
+ .int()
726
+ .positive()
727
+ .nullable()
728
+ .optional()
729
+ .describe("add_step only; omitted/null = top-level step"),
730
+ position: z
731
+ .number()
732
+ .int()
733
+ .positive()
734
+ .optional()
735
+ .describe("add_step only; 1-indexed among the parent's current live children, omitted = append"),
676
736
  ...boardField,
677
737
  }, async (args) => jsonResult(await issueSolution(client, args)));
678
738
  // ---------------- issue_dependency ----------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.119",
3
+ "version": "0.1.121",
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",