@thehammer/danx-dashboard-mcp 0.1.64 → 0.1.65

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.
Files changed (2) hide show
  1. package/dist/index.js +18 -18
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -275,19 +275,19 @@ const boardField = {
275
275
  .string()
276
276
  .min(1)
277
277
  .optional()
278
- .describe("Target another board by its qualified id `<repo>:<slug>` (e.g. `platform:the-supply-operations-hub`); omit to use this dispatch's board. Unknown board → 404."),
278
+ .describe("Target another board by its qualified id `<repo>:<slug>`; omit for this dispatch's board. Unknown → 404."),
279
279
  };
280
280
  // The three prose fields of a card, each with ONE job. Shared by issue_create
281
281
  // (root + phase children) and issue_edit so the guidance an agent reads is
282
282
  // identical wherever it writes the field.
283
- const TITLE_DESCRIBE = 'Short, specific label that names the domain, so a reader recognises the card without opening it (e.g. "Guest checkout rejects carts holding a gift card"). Never a generic phrase like "2 real decisions needed", "Fix bug" or "Follow-up".';
284
- const SUMMARY_DESCRIBE = "1–3 plain-language sentences — no markdown, no jargon — for someone who has never seen this codebase: what the card is about and why it matters. Always shown, never collapsed. It must stand on its own: not a second title, not a teaser for the description.";
283
+ 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").';
284
+ 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.";
285
285
  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.';
286
286
  // ---------------- issue_list ----------------
287
287
  server.tool("issue_list",
288
288
  // DX-2735: trimmed to pay for the problem tools inside the work-profile
289
289
  // injected-surface budget — same facts, no repeated prose.
290
- "List cards via GET /api/issues on this dispatch's board, or `board` (`<repo>:<slug>`; unknown → 404). `filter`: type, parent_id, dispatchable_derived, status_derived[], self_dispatchable_derived, assigned_agent, include_closed, include_deleted, q (free text over id+title+description). THE DEFAULT ROW IS MINIMAL — scalars only (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash), no joins; opt into heavy data with `fields` groups: description (+ summary), problems (open_problem_count), ac, comments, retro, dependencies, triage, requires_human, assignment, quality_gates, children, effort. `sort`: [{column, order}] over id|priority|repo_name|title|type|status_derived|triage_ice_total|created_at|updated_at; default priority desc, repo_name asc, numeric-id tiebreaker. `limit`/`offset` page (uncapped by default). issue_get reads one card in full.", {
290
+ "List cards via GET /api/issues. Board-scoped; see `board`. `filter`: type, parent_id, dispatchable_derived, status_derived[], self_dispatchable_derived, assigned_agent, include_closed, include_deleted, q (free text over id+title+description). THE DEFAULT ROW IS MINIMAL — scalars only (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash), no joins; opt into heavy data with `fields` groups: description (+ summary), problems (open_problem_count), ac, comments, retro, dependencies, triage, requires_human, assignment, quality_gates, children, effort. `sort`: [{column, order}] over id|priority|repo_name|title|type|status_derived|triage_ice_total|created_at|updated_at; default priority desc, repo_name asc, numeric-id tiebreaker. `limit`/`offset` page (uncapped by default). issue_get reads one card in full.", {
291
291
  filter: z
292
292
  .object({
293
293
  q: z.string().optional(),
@@ -324,7 +324,7 @@ server.tool("issue_get",
324
324
  ...boardField,
325
325
  }, async (args) => jsonResult(await issueGet(client, args)));
326
326
  // ---------------- issue_create ----------------
327
- server.tool("issue_create", 'Create a card via POST /api/issues on this dispatch\'s board, or another via `board` (`<repo>:<slug>`; unknown → 404). 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. `gate_decisions` is REQUIRED when the board has an OPTIONAL quality gate for the card\'s type: a missing one fails closed with 400 `{error, required_gate_decisions:[...]}` naming each gate — retry with one `{gate, enabled, note}` per listed 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.', {
327
+ server.tool("issue_create", '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. `gate_decisions` is REQUIRED when the board has an OPTIONAL quality gate for the card\'s type: a missing one fails closed with 400 `{error, required_gate_decisions:[...]}` naming each gate — retry with one `{gate, enabled, note}` per listed 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.', {
328
328
  type: z.enum(ISSUE_TYPES),
329
329
  title: z.string().min(1).describe(TITLE_DESCRIBE),
330
330
  summary: z.string().min(1).optional().describe(SUMMARY_DESCRIBE),
@@ -376,7 +376,7 @@ server.tool("issue_create", 'Create a card via POST /api/issues on this dispatch
376
376
  ...boardField,
377
377
  }, async (args) => jsonResult(await issueCreate(client, args, config.board)));
378
378
  // ---------------- issue_edit ----------------
379
- server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED keys: title, summary, description, ac, checklists, effort_level, parent_id, priority, list_id, triage_enabled, type, content_hash. Any other key (lifecycle, triage, dependencies, retro, requires_human, blocked) is refused 400 with offending_keys[] naming the right tool: issue_transition / issue_triage / issue_comment / issue_dependency / issue_requires_human / issue_retro. `type`: Story/Bug/Chore makes a card eligible for autonomous pickup; Task or a container (Epic/Feature) removes that eligibility — how a planning item becomes work. `priority` (a tier word or a number) is the ONLY way to set priority; a "Priority:" line in the description changes 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 — ready a card before pinning it to a `ready` queue); null clears the pin. `content_hash` (DX-2741) is the card\'s optimistic-concurrency token — REQUIRED (never optional/defaulted) whenever the edit touches `title` / `description` / `checklists` (NOT `ac`, which keeps its own check_item_id/title diffing): a missing hash on one of those fields 400s, a stale one 409s `stale_issue_content` carrying `currentHash` + `currentTitle` + `currentDescription`. Read it off `issue_get`/`issue_list`\'s `content_hash` scalar field (present even in the minimal response) immediately before editing; on a 409, re-`issue_get` and retry with the fresh hash — never blindly.', {
379
+ server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED keys: title, summary, description, ac, checklists, effort_level, parent_id, priority, list_id, triage_enabled, type, content_hash. Any other key (lifecycle, triage, dependencies, retro, requires_human, blocked) is refused 400 with offending_keys[] naming the right tool: issue_transition / issue_triage / issue_comment / issue_dependency / issue_requires_human / 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` (a tier word or a number) is the ONLY way to set priority; a "Priority:" line in the description 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.', {
380
380
  id: z.string().min(1),
381
381
  title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
382
382
  summary: z
@@ -476,7 +476,7 @@ const CHECKLIST_ITEM_INPUT = z.object({
476
476
  detail: z.string().optional(),
477
477
  status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
478
478
  });
479
- server.tool("issue_checklist", "Targeted checklist CUD via /api/issues/:id/checklists[/:cid[/items[/:iid]]] (DX-1362). Mutates ONE checklist or item WITHOUT the wholesale `issue_edit({checklists})` replace — use this for the common case (flip an item's status, add/rename a checklist, add/edit/remove an item); the wholesale path silently DROPS any checklist you omit and churns every item id (orphaning its Trello mirror), so 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 (the item keeps its id + Trello linkage; at least one 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 — the wholesale `issue_edit({checklists})` path stays for bulk authoring.", {
479
+ server.tool("issue_checklist", "Targeted checklist CUD via /api/issues/:id/checklists[/:cid[/items[/:iid]]] (DX-1362). Mutates ONE checklist/item without the wholesale `issue_edit({checklists})` replace, which DROPS any checklist you omit and churns every item id (orphaning its Trello mirror) — prefer this for single-item changes. Action-dispatched: add_list (POST :id/checklists {name, items?}) — create a named checklist, optionally with initial items; update_list (PATCH :id/checklists/:cid {name}) — rename; remove_list (DELETE :id/checklists/:cid) — soft-delete the checklist (audit trail preserved); add_item (POST :id/checklists/:cid/items {label, detail?, status?}) — append an item (status defaults `incomplete`); update_item (PATCH :id/checklists/:cid/items/:iid {label?, detail?, status?}) — change ONLY the fields you pass, in place (keeps id + Trello link; ≥1 field required); remove_item (DELETE :id/checklists/:cid/items/:iid) — soft-delete one item. Status: incomplete|failing|passing|cancelled|deferred (terminal = passing|cancelled|deferred; DX-2653 added `deferred` — the honest disposition for a criterion whose work is done but names a real-world/post-deploy check still outstanding, REQUIRES a non-empty `detail`). checklist_id is required for every action except add_list; item_id for update_item/remove_item. Each returns the {ok,status,body} envelope; unknown card/checklist/item → 404, invalid status → 400. ADDITIVE — `issue_edit({checklists})` still works for bulk authoring.", {
480
480
  id: z.string().min(1),
481
481
  action: z.enum([
482
482
  "add_list",
@@ -505,7 +505,7 @@ const SOLUTION_FIELDS = {
505
505
  con: z.string().optional(),
506
506
  recommended: z.boolean().optional(),
507
507
  };
508
- server.tool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pid]: each is one statement the operator must resolve (a question, or a flaw in the plan) with its own solutions and answers — one problem per question. OPEN = not yet answered; the card needs a human until none is open, and issue_requires_human set is refused 409 `no_open_problem` until one is. list → live problems in order, each {id, statement, content_hash, open, solutions[], decisions[]}; add {statement, solutions?} → problem_id + solution_ids in one transaction (zero solutions is valid: the operator answers free-form); edit :pid {base_hash, statement}; remove :pid {base_hash} (409 `last_open_problem` while requires_human is set). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. No answer action — the operator answers in the dashboard.", {
508
+ server.tool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pid]: one statement the operator must resolve (a question, or a flaw in the plan), each with its own solutions/answers — one problem per question. OPEN = not yet answered; the card needs a human until none is open, and issue_requires_human set is refused 409 `no_open_problem` until one is. list → live problems in order, each {id, statement, content_hash, open, solutions[], decisions[]}; add {statement, solutions?} → problem_id + solution_ids in one transaction (zero solutions is valid: the operator answers free-form); edit :pid {base_hash, statement}; remove :pid {base_hash} (409 `last_open_problem` while requires_human is set). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. No answer action — the operator answers in the dashboard.", {
509
509
  id: z.string().min(1),
510
510
  action: z.enum(["list", "add", "edit", "remove"]),
511
511
  problem_id: z.number().int().positive().optional().describe("edit/remove"),
@@ -547,7 +547,7 @@ server.tool("issue_requires_human", "Set/clear the requires_human gate via /api/
547
547
  ...boardField,
548
548
  }, async (args) => jsonResult(await issueRequiresHuman(client, args)));
549
549
  // ---------------- issue_quality_gate ----------------
550
- server.tool("issue_quality_gate", "Set one card's per-gate `required` flag via POST /api/issues/:id/quality-gates/:gate {required} — the only post-create way (issue_create takes gate_decisions; issue_edit refuses gate keys). PRE `plan-*` gates run before the work dispatch; POST `code-*` gates block complete. Unknown gate → 400. The board state per gate is tri-state: `required` always runs, `optional` runs WHEN this flag is true (optional is NOT off), `disabled` never runs. Optional `effort_level` overrides a `plan-*` gate's reviewer rung (null clears it). The write always succeeds and returns `{issue, applied: true, effective, reason}` — read `effective` (does the gate now run) and `reason` (why the board overrode your value), not just the 200. Pass `board` to target another board.", {
550
+ server.tool("issue_quality_gate", "Set one card's per-gate `required` flag via POST /api/issues/:id/quality-gates/:gate {required} — the only post-create way (issue_create takes gate_decisions; issue_edit refuses gate keys). PRE `plan-*` gates run before the work dispatch; POST `code-*` gates block complete. Unknown gate → 400. The board state per gate is tri-state: `required` always runs, `optional` runs WHEN this flag is true (optional is NOT off), `disabled` never runs. Optional `effort_level` overrides a `plan-*` gate's reviewer rung (null clears it). The write always succeeds and returns `{issue, applied: true, effective, reason}` — read `effective` (does the gate now run) and `reason` (why the board overrode your value), not just the 200. Board-scoped; see `board`.", {
551
551
  id: z.string().min(1),
552
552
  gate: z.enum([
553
553
  "plan-dependency",
@@ -562,7 +562,7 @@ server.tool("issue_quality_gate", "Set one card's per-gate `required` flag via P
562
562
  ...boardField,
563
563
  }, async (args) => jsonResult(await issueQualityGate(client, args)));
564
564
  // ---------------- issue_quality_gate_verdict ----------------
565
- server.tool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate VERDICT via PATCH /api/issues/:id/quality-gates/:gate {status, message} — the same write the dashboard Gates-tab Pass / Fail / Revert controls perform (DX-1373). SIBLING of `issue_quality_gate`, not a replacement: that one flips the per-card `required` FLAG (does this gate run at all), THIS one records the VERDICT (did it pass) — the server exposes them as POST vs PATCH on the same resource and 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 and is REQUIRED at >= 20 characters for `pass`/`fail` (shorter → 400); it is ignored for `pending`. Record the REAL reviewer finding here, not a rubber stamp — this is a human-attributed override, stamped with the operator actor, and it is what a later reader sees instead of a reviewer dispatch. A manual verdict is a PURE row write: unlike the worker's in-dispatch gate route it fires NO side effects — a manual `fail` never blocks the card and a manual `pass` never releases a dispatch. Unknown gate → 400; status outside the three values → 400; unknown card → 404. Board-scoped; pass `board` (`<repo>:<slug>`) to target another board.", {
565
+ server.tool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate VERDICT via PATCH /api/issues/:id/quality-gates/:gate {status, message} — the same write the dashboard Gates-tab Pass / Fail / Revert controls perform (DX-1373). SIBLING of `issue_quality_gate`, not a replacement: that one flips the per-card `required` FLAG (does this gate run 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`.", {
566
566
  id: z.string().min(1),
567
567
  gate: z.enum([
568
568
  "plan-dependency",
@@ -577,7 +577,7 @@ server.tool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate
577
577
  ...boardField,
578
578
  }, async (args) => jsonResult(await issueQualityGateVerdict(client, args)));
579
579
  // ---------------- issue_retro ----------------
580
- server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retro. Body: {good, bad, 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', listed explicitly since they are few + expensive). Each row: {name, kind:'group'|'e2e', num_tests, num_passing_tests, duration_ms} required; num_assertions + num_passing_assertions NULLABLE (vitest surfaces no assertion totals — pass null or omit).", {
580
+ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retro. Body: {good, bad, 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).", {
581
581
  id: z.string().min(1),
582
582
  good: z.string(),
583
583
  bad: z.string(),
@@ -607,7 +607,7 @@ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retr
607
607
  // route's MAX_DECODED_BYTES (src/issues/write/attachments.ts). This package is
608
608
  // a separate published artifact and cannot import that constant, so the number
609
609
  // is restated here as prose — keep the two in sync if the backend ceiling moves.
610
- server.tool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/issues/:id/attachments. Pass `id` (the card) and `file_path` (an ABSOLUTE path to a file on the dispatch's shared filesystem — e.g. a screenshot, exported CSV, or diagram you wrote). This MCP server reads the bytes, infers the MIME type from the extension, and uploads through the dashboard, which: stores the bytes in S3, inserts ONE danxbot-origin issue_attachments row, and auto-mirrors the file to the card's linked Trello card AND its Slack card-view thread (DX-1122 outbound projection) — no extra step needed. Board-scoped; defaults to the dispatch's board. Pass `board` (a qualified id `<repo>:<slug>`) to attach on another board (unknown board → 404). Fail-loud: a relative/empty path is rejected at the MCP boundary, and a missing/unreadable file throws BEFORE any upload (no partial S3 object, no row). 25 MB decoded ceiling (413). Returns the hydrated issue plus the new attachment id.", {
610
+ server.tool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/issues/:id/attachments. Pass `id` (the card) and `file_path` (an ABSOLUTE path to a file on the dispatch's shared filesystem — e.g. a screenshot, exported CSV, or diagram you wrote). This MCP server reads the bytes, infers the MIME type from the extension, and uploads through the dashboard, which: stores the bytes in S3, inserts ONE danxbot-origin issue_attachments row, and auto-mirrors the file to the card's linked Trello card AND its Slack card-view thread (DX-1122 outbound projection) — no extra step needed. Board-scoped; see `board`. Fail-loud: a relative/empty path is rejected at the MCP boundary, and a missing/unreadable file throws BEFORE any upload (no partial S3 object, no row). 25 MB decoded ceiling (413). Returns the hydrated issue plus the new attachment id.", {
611
611
  id: z.string().min(1),
612
612
  file_path: z
613
613
  .string()
@@ -616,11 +616,11 @@ server.tool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/
616
616
  ...boardField,
617
617
  }, async (args) => jsonResult(await issueAttach(client, args)));
618
618
  // ---------------- repo_knowledge_get ----------------
619
- server.tool("repo_knowledge_get", "Fetch the board's working-knowledge markdown doc via GET /api/repo-knowledge (DX-1128, Story 2). Board-scoped; defaults to the dispatch's board. Pass `board` (a qualified id `<repo>:<slug>`) to read another board's doc. Returns `{ok, status, body: {content, contentHash, updatedAt, updatedBy, boardId}}` — an unset doc reads as the empty view (`content: \"\"`, `contentHash: \"\"`), NOT a 404. Ground exploratory answers in `content`; before `repo_knowledge_set`, ALWAYS `repo_knowledge_get` immediately first and pass its `contentHash` back as `base_hash` — the server's optimistic-concurrency guard rejects a stale write.", {
619
+ server.tool("repo_knowledge_get", "Fetch the board's working-knowledge markdown doc via GET /api/repo-knowledge (DX-1128, Story 2). Board-scoped; see `board`. Returns `{ok, status, body: {content, contentHash, updatedAt, updatedBy, boardId}}` — an unset doc reads as the empty view (`content: \"\"`, `contentHash: \"\"`), NOT a 404. Ground exploratory answers in `content`; before `repo_knowledge_set`, ALWAYS `repo_knowledge_get` immediately first and pass its `contentHash` back as `base_hash` — the server's optimistic-concurrency guard rejects a stale write.", {
620
620
  ...boardField,
621
621
  }, async (args) => jsonResult(await repoKnowledgeGet(client, args)));
622
622
  // ---------------- repo_knowledge_set ----------------
623
- server.tool("repo_knowledge_set", 'Write the board\'s working-knowledge markdown doc via PUT /api/repo-knowledge (DX-1128, Story 2). Board-scoped; defaults to the dispatch\'s board. `base_hash` MUST be the `contentHash` from the immediately-prior `repo_knowledge_get` call ("" for the true first write, when the board has no doc yet) — the server compares it against the CURRENT hash and, on mismatch, fails loud with `{ok: false, body: {error: "stale_repo_knowledge", currentHash}}` rather than silently overwriting a concurrent write. On that refusal: re-`repo_knowledge_get`, re-merge your insight into the fresh content, and retry `repo_knowledge_set` with the new hash. On success, persists to the DB, publishes `repo-knowledge:updated` over SSE (live in the dashboard editor), and returns the new view.', {
623
+ server.tool("repo_knowledge_set", 'Write the board\'s working-knowledge markdown doc via PUT /api/repo-knowledge (DX-1128, Story 2). Board-scoped; see `board`. `base_hash` MUST be the `contentHash` from the immediately-prior `repo_knowledge_get` call ("" for the true first write, when the board has no doc yet) — the server compares it against the CURRENT hash and, on mismatch, fails loud with `{ok: false, body: {error: "stale_repo_knowledge", currentHash}}` rather than silently overwriting a concurrent write. On that refusal: re-`repo_knowledge_get`, re-merge your insight into the fresh content, and retry `repo_knowledge_set` with the new hash. On success, persists to the DB, publishes `repo-knowledge:updated` over SSE (live in the dashboard editor), and returns the new view.', {
624
624
  content: z.string(),
625
625
  base_hash: z
626
626
  .string()
@@ -629,11 +629,11 @@ server.tool("repo_knowledge_set", 'Write the board\'s working-knowledge markdown
629
629
  ...boardField,
630
630
  }, async (args) => jsonResult(await repoKnowledgeSet(client, args)));
631
631
  // ---------------- brief_list ----------------
632
- server.tool("brief_list", "List the board's named Brief pages via GET /api/brief (DX-2083 / DX-2484). Board-scoped; defaults to the dispatch's board. Pass `board` (a qualified id `<repo>:<slug>`) to list another board's pages. Returns `{boardId, pages: [{slug, title, contentHash, sortOrder, updatedAt, updatedBy}]}` — metadata only, no page content (use `brief_get_page` for that). This is the list+page-shaped sibling of `repo_knowledge_get`/`repo_knowledge_set` (one board-level doc) — Brief pages are MANY named pages per board (the Goals / Architecture / Rules / Caveats tabs), keyed by `(board, slug)`. The reserved `index` slug always exists — every board carries exactly one.", {
632
+ server.tool("brief_list", "List the board's named Brief pages via GET /api/brief (DX-2083 / DX-2484). Board-scoped; see `board`. Returns `{boardId, pages: [{slug, title, contentHash, sortOrder, updatedAt, updatedBy}]}` — metadata only, no page content (use `brief_get_page` for that). This is the list+page-shaped sibling of `repo_knowledge_get`/`repo_knowledge_set` (one board-level doc) — Brief pages are MANY named pages per board (the Goals / Architecture / Rules / Caveats tabs), keyed by `(board, slug)`. The reserved `index` slug always exists — every board carries exactly one.", {
633
633
  ...boardField,
634
634
  }, async (args) => jsonResult(await briefList(client, args)));
635
635
  // ---------------- brief_get_page ----------------
636
- server.tool("brief_get_page", 'Fetch one Brief page by slug via GET /api/brief/page?slug=<slug> (DX-2083 / DX-2484). Board-scoped; defaults to the dispatch\'s board. Returns `{boardId, slug, title, content, contentHash, sortOrder, updatedAt, updatedBy}` — a missing/not-yet-created page reads as the empty view (`content: ""`, `contentHash: ""`), NOT a 404, matching `repo_knowledge_get`\'s convention. Before `brief_set_page`, ALWAYS `brief_get_page` immediately first and pass its `contentHash` back as `base_hash` — the server\'s optimistic-concurrency guard rejects a stale write.', {
636
+ server.tool("brief_get_page", 'Fetch one Brief page by slug via GET /api/brief/page?slug=<slug> (DX-2083 / DX-2484). Board-scoped; see `board`. Returns `{boardId, slug, title, content, contentHash, sortOrder, updatedAt, updatedBy}` — a missing/not-yet-created page reads as the empty view (`content: ""`, `contentHash: ""`), NOT a 404, matching `repo_knowledge_get`\'s convention. Before `brief_set_page`, ALWAYS `brief_get_page` immediately first and pass its `contentHash` back as `base_hash` — the server\'s optimistic-concurrency guard rejects a stale write.', {
637
637
  slug: z
638
638
  .string()
639
639
  .min(1)
@@ -641,7 +641,7 @@ server.tool("brief_get_page", 'Fetch one Brief page by slug via GET /api/brief/p
641
641
  ...boardField,
642
642
  }, async (args) => jsonResult(await briefGetPage(client, args)));
643
643
  // ---------------- brief_set_page ----------------
644
- server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug=<slug> (DX-2083 / DX-2484). Board-scoped; defaults to the dispatch\'s board. Body: `{content, title?, sortOrder?, base_hash?}` — mirrors `repo_knowledge_set`\'s optimistic-concurrency shape but targets one named page instead of the board\'s single working-knowledge doc. `base_hash` MUST be the `contentHash` from the immediately-prior `brief_get_page` call ("" for a true first write, when the page doesn\'t exist yet) — the server compares it against the CURRENT hash and, on mismatch, fails loud with `{ok: false, body: {error: "stale_brief_page", currentHash}}` rather than silently overwriting a concurrent write — re-get, re-merge, and retry on that refusal, never retry blindly or overwrite. On success, persists to the DB, publishes `brief:updated` over SSE, and returns the new view. NO delete tool is exposed on this surface — the reserved `index` slug can never be deleted through the tool surface, matching the route\'s own refusal; deleting a non-index page is dashboard-UI-only for now.', {
644
+ server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug=<slug> (DX-2083 / DX-2484). Board-scoped; see `board`. Body: `{content, title?, sortOrder?, base_hash?}` — mirrors `repo_knowledge_set`\'s optimistic-concurrency shape but targets one named page instead of the board\'s single working-knowledge doc. `base_hash` MUST be the `contentHash` from the immediately-prior `brief_get_page` call ("" for a true first write, when the page doesn\'t exist yet) — the server compares it against the CURRENT hash and, on mismatch, fails loud with `{ok: false, body: {error: "stale_brief_page", currentHash}}` rather than silently overwriting a concurrent write — re-get, re-merge, and retry on that refusal, never retry blindly or overwrite. On success, persists to the DB, publishes `brief:updated` over SSE, and returns the new view. NO delete tool is exposed on this surface — the reserved `index` slug can never be deleted through the tool surface, matching the route\'s own refusal; deleting a non-index page is dashboard-UI-only for now.', {
645
645
  slug: z
646
646
  .string()
647
647
  .min(1)
@@ -666,7 +666,7 @@ server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug
666
666
  // also takes no plan id, but for a different reason: it MAKES a plan rather
667
667
  // than acting on one, so there is no existing plan for an id to name yet.
668
668
  server.tool("plan_list", "List every plan via GET /api/plans (DX-2683), and learn which plan THIS session is connected to. Plans are GLOBAL, not board-scoped: a plan is a named, dated set of cards an operator assembled by hand, and its cards may come from any repository. Returns `{ok, status, body: {plans: [{id, name, createdAt, cardCount, boards}], session, sessionListenerAttached}}`. `session` is your own registration — `{sessionId, title, planId, planName, firstSeenAt, lastActiveAt}` — or `null` if this process is not running inside a Claude Code session. A `planId` of null means you are connected to no plan: read any plan with `plan_get`, then `plan_connect` to the one you are working on (or ask the operator to connect you from the Plans list). `sessionListenerAttached` says whether your event listener is running: `false` while connected to a plan means you will NOT hear about its cards — call `plan_connect` again and arm the Monitor it returns. NOTE this is NOT the board Brief (`brief_list`), which is a different feature entirely.", {}, async () => jsonResult(await planList(client)));
669
- server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id` to read ANY plan (browsing another plan is useful and changes nothing); OMIT it to read the plan this session is connected to. Omitting it while connected to no plan fails loud with `{error: \"session_not_connected\"}` — connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{plan, boards, cardCount, bucketCounts, session, sessionListenerAttached, available_field_groups}` — no member cards, no goals/rules/caveats, no architecture body. Pass `fields` to opt into the rest, one call at a time: `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 card-reference order (board prefix, then card number — stable while cards are edited, so pages never repeat or skip a card unless the plan's membership changes between reads), and the response carries `cards_total` and `cards_offset` — page with cards_offset 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` (just that one kind — cheaper than the full union), `architecture` (`{sections: [{id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}]}`), `sessions` (every session connected to the plan). `session`/`sessionListenerAttached` (your own connection state) and `available_field_groups` ride EVERY response, gated or not. `sessionListenerAttached: false` while connected means your event listener is not running; call `plan_connect` again and arm the Monitor it returns. 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`.", {
669
+ server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id` to read ANY plan; OMIT to read the plan this session is connected to — omitting while connected to none fails loud `{error: \"session_not_connected\"}`, connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{plan, boards, cardCount, bucketCounts, session, sessionListenerAttached, available_field_groups}` — no cards, records, or architecture body. 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). `session`/`sessionListenerAttached` and `available_field_groups` ride every response regardless. `sessionListenerAttached: false` while connected means your event listener is not running; call `plan_connect` again and arm the Monitor it returns. 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`.", {
670
670
  plan_id: z
671
671
  .number()
672
672
  .int()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.64",
3
+ "version": "0.1.65",
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",