@thehammer/danx-dashboard-mcp 0.1.53 → 0.1.54

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
@@ -808,13 +808,17 @@ export async function planConnect(client, args, listener) {
808
808
  },
809
809
  };
810
810
  }
811
- /** Add a goal, rule or caveat to the connected plan. */
811
+ /** Add a goal, rule or caveat to the connected plan. `context` is sent only when given. */
812
812
  export async function planAddRecord(client, args) {
813
813
  return client.request({
814
814
  method: "POST",
815
815
  path: "/mine/records",
816
816
  basePath: PLANS_BASE_PATH,
817
- body: { kind: args.kind, body: args.body },
817
+ body: {
818
+ kind: args.kind,
819
+ body: args.body,
820
+ ...(args.context === undefined ? {} : { context: args.context }),
821
+ },
818
822
  });
819
823
  }
820
824
  /** Add an existing card to the connected plan. */
@@ -883,7 +887,9 @@ export async function planGetRecord(client, args) {
883
887
  * `plan_get_record`; the server compares it against the row's current hash
884
888
  * and, on a mismatch, refuses the write ENTIRELY and fails loud with
885
889
  * `{ok: false, status: 409, body: {error: "stale_plan_record", currentHash,
886
- * currentBody}}` rather than overwriting whoever wrote in between.
890
+ * currentBody, currentContext}}` rather than overwriting whoever wrote in
891
+ * between. DX-2734: the hash covers body AND context, and `context` is sent
892
+ * only when given (omitted keeps the stored context, `null` clears it).
887
893
  * `currentBody` rides the SAME refusal — unlike the architecture document's
888
894
  * `stale_plan_architecture`, which carries only `currentHash` — so you can
889
895
  * merge and retry in ONE round trip without a second `plan_get_record` call.
@@ -895,7 +901,11 @@ export async function planUpdateRecord(client, args) {
895
901
  method: "PATCH",
896
902
  path: `/mine/records/${args.record_id}`,
897
903
  basePath: PLANS_BASE_PATH,
898
- body: { body: args.body, content_hash: args.content_hash },
904
+ body: {
905
+ body: args.body,
906
+ content_hash: args.content_hash,
907
+ ...(args.context === undefined ? {} : { context: args.context }),
908
+ },
899
909
  });
900
910
  }
901
911
  /**
package/dist/index.js CHANGED
@@ -310,7 +310,7 @@ server.tool("issue_get", "Fetch a single issue via GET /api/issues/:id. Board-sc
310
310
  ...boardField,
311
311
  }, async (args) => jsonResult(await issueGet(client, args)));
312
312
  // ---------------- issue_create ----------------
313
- server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-scoped; defaults to the dispatch\'s board. Pass `board` (a qualified id `<repo>:<slug>`) to create the card on another board (forwarded into body.board + ?board=; unknown board → 404). INVARIANT: type=Epic REQUIRES non-empty phase_children[] (epic-with-phases atomicity per DX-575) and the route atomically inserts the epic + every phase in ONE transaction. Non-Epic types REFUSE phase_children[] with 400. Status defaults to Review (no lifecycle timestamps stamped on create). parent_id optional. ac items take {title}; phase children inherit the new epic\'s id as parent_id. Optional list_id PLACES the card directly into a column in ONE call. **Pass EITHER a board_lists id OR the list\'s display NAME (case-insensitive, emoji-tolerant — e.g. a queue name like "⚙️ Fulfillment Queue" or just "Fulfillment Queue") — the server resolves a name to its id.** The card lands DIRECTLY in that column with the matching lifecycle stamped automatically — a `ready`-type queue → ToDo, a `completed` list → Done, etc. **You do NOT need a separate issue_transition(ready) + issue_edit(list_id) afterward — just pass the queue name here and the card is created already in that column.** Omit list_id for the default (Review). NOT valid on type=Epic (Epic status derives from children) → 400. Unknown name/id → 400. **gate_decisions is REQUIRED whenever the board has any OPTIONAL quality gate for the card\'s type** (DX-1594): supply one `{gate, enabled, note}` per board-optional gate. The create FAILS CLOSED — a missing decision returns 400 `{error, required_gate_decisions:[...]}` enumerating exactly which gates to answer, so just retry with a decision for each listed gate. `required`/`disabled` board gates take no decision; a board with no optional gates needs no gate_decisions at all. **ALWAYS pass `triage_enabled` explicitly** (root card AND every phase_children[] entry): decide per card whether it should enter the automatic triage/dispatch pipeline — `true` only when auto-triage is expected without further human review; absent → false, the card is NEVER auto-triaged (explicit-only, reverting DX-1928 — auto-created cards must never silently enter the dispatch pipeline).', {
313
+ 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.', {
314
314
  type: z.enum(ISSUE_TYPES),
315
315
  title: z.string().min(1).describe(TITLE_DESCRIBE),
316
316
  summary: z.string().min(1).optional().describe(SUMMARY_DESCRIBE),
@@ -327,7 +327,7 @@ server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-sco
327
327
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
328
328
  }))
329
329
  .optional()
330
- .describe('REQUIRED fail-closed quality-gate decisions (DX-1594 — replaces required_gates). One {gate, enabled, note} per board-OPTIONAL gate of the card\'s type: `enabled` answers whether the gate runs on this card, `note` records the rationale (persisted as the decision rationale, distinct from the reviewer verdict). The board requirement is TRI-STATE per gate (`board_quality_gate_settings.default_state`): `required` runs always (NO decision — auto-on); `optional` REQUIRES a decision here (unanswered → the create 400s); `disabled` never runs (NO decision). Omit this only on a board with no optional gates; otherwise the 400 body\'s `required_gate_decisions` lists exactly which gates to answer — retry with {enabled, note} for each. A decision naming a non-optional gate is rejected 400. DX-1760: each entry also takes an OPTIONAL `effort_level` — a per-`(card, gate)` reviewer-rung override for a `plan-*` gate, persisted at seed time; omit for no override.'),
330
+ .describe("One {gate, enabled, note} per board-OPTIONAL gate of the card's type: `enabled` = does it run on this card, `note` = why. Board `required` gates always run and `disabled` never do; neither takes a decision (naming one → 400). Optional `effort_level` overrides a `plan-*` gate's reviewer rung."),
331
331
  phase_children: z
332
332
  .array(z.object({
333
333
  type: z.enum(NON_EPIC_TYPES),
@@ -348,21 +348,21 @@ server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-sco
348
348
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
349
349
  }))
350
350
  .optional()
351
- .describe("Per-child fail-closed gate decisions — same shape + rule as the root gate_decisions (incl. the optional per-gate effort_level, DX-1760), resolved against THIS child's own type. Required when the child's type has board-optional gates."),
351
+ .describe("Same as the root gate_decisions, resolved against THIS child's type."),
352
352
  triage_enabled: z
353
353
  .boolean()
354
354
  .optional()
355
- .describe("Per-child auto-triage opt-in (explicit-only, reverting DX-1928). ALWAYS pass explicitly per child — decide whether THIS child should enter the automatic triage pipeline. Absent → false (never auto-triaged). Never inherited from the root card."),
355
+ .describe("ALWAYS pass per child; absent → false (never auto-triaged). Not inherited from the root."),
356
356
  }))
357
357
  .optional(),
358
358
  triage_enabled: z
359
359
  .boolean()
360
360
  .optional()
361
- .describe("DX-1895 (explicit-only — reverting DX-1928): per-card opt-in for the automatic triage dispatcher. ALWAYS pass this explicitly — decide per card whether auto-triage is expected. true = the card enters the automatic triage/dispatch pipeline without further human review; absent → false, the card is NEVER auto-triaged. Operator-directed POST /api/triage and direct issue_triage calls are NOT gated by this flag."),
361
+ .describe("ALWAYS pass explicitly. true = enters automatic triage/dispatch without further human review; absent → false. Operator POST /api/triage and issue_triage ignore it."),
362
362
  ...boardField,
363
363
  }, async (args) => jsonResult(await issueCreate(client, args, config.board)));
364
364
  // ---------------- issue_edit ----------------
365
- server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues/:id/edit. ALLOWED keys: title, summary, description, ac, checklists, effort_level, parent_id, priority, list_id, triage_enabled, type. ANY OTHER KEY (lifecycle timestamps, triage state, dependencies, retro, requires_human, blocked/dispatch gates) returns 400 with offending_keys[] and a pointer to the dedicated semantic handler — use issue_transition / issue_triage / issue_comment / issue_dependency / issue_requires_human / issue_retro instead. TYPE (DX-2484): change the card\'s `type` via the `type` key — the route recomputes `dispatchable_derived` immediately when it changes. Changing type TO `Story`, `Bug`, or `Chore` makes the card eligible for autonomous pickup (subject to every other dispatch gate); changing it TO `Task` or a container (`Epic`/`Feature`) REMOVES that eligibility — a planning record or container is never dispatched, in ANY status. This is the way to promote a planning item into real work. PRIORITY (DX-1532): set card priority via the `priority` key — a tier WORD ("lowest"/"low"/"medium"/"high"/"very_high"/"critical", resolved to the tier midpoint) OR a raw number in [0,6). This is the ONLY way to change priority: the numeric `issues.priority` column is what the Trello priority label AND the dashboard badge read — editing a "Priority: <x>" line in the DESCRIPTION changes nothing downstream (a silent false-positive). To honor a "set priority" request, write `priority` here, do NOT edit description prose. CHECKLISTS (DX-1290, extended DX-2653): a card carries 0..N named checklists, each item ONE status `incomplete|failing|passing|cancelled|deferred` (terminal = passing|cancelled|deferred). `deferred` is 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`; a criterion marked with the `📡` post-deploy-evidence prefix can never be `passing` at all (refused 409 at completion, resolve to `deferred` instead). `ac` is the 2-state CONVENIENCE onto the default "Acceptance Criteria" checklist (checked:true ↔ passing, false ↔ incomplete BY DEFAULT — pass `status` alongside `checked` to reach `cancelled`/`failing`/`deferred` instead) — each incoming item is DIFFED against that checklist\'s current live items (matched by the optional `check_item_id`, else by exact title) so an unchanged item keeps its id; only changed items are updated in place, new titles are inserted, and items missing from the array are removed. `checklists` is the GENERIC wholesale write path: it REPLACES every named checklist on the card with full status control (each `{name, items:[{label, detail?, status}]}`) — use it to author named checklists like "Feature Tests". Send EITHER `ac` OR `checklists`, NOT both (400). list_id (DX-1192 / DX-1200) PINS the card to a specific board list. **Pass EITHER a board_lists id OR the list\'s display NAME (case-insensitive, e.g. a queue name like "⚙️ Fulfillment Queue") — the server resolves a name to its id.** Its type MUST match the card\'s CURRENT derived-status list-type (so to route a ToDo card into a `ready`-type queue, ready it first; mismatch / unknown name or id → 400); pass null to clear the pin (back to default-for-type).', {
365
+ 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. 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.', {
366
366
  id: z.string().min(1),
367
367
  title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
368
368
  summary: z
@@ -375,7 +375,7 @@ server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues
375
375
  type: z
376
376
  .enum(ISSUE_TYPES)
377
377
  .optional()
378
- .describe("DX-2484 — change the card's type. Changing TO `Story`, `Bug`, or `Chore` makes the card eligible for autonomous pickup (dispatchable); changing TO `Task` or a container (`Epic`/`Feature`) removes that eligibility — a planning record or container is never dispatched, in ANY status. The route recomputes `dispatchable_derived` immediately."),
378
+ .describe("Story/Bug/Chore = dispatchable; Task/Epic/Feature = never dispatched, in any status."),
379
379
  ac: z
380
380
  .array(z.object({
381
381
  title: z.string(),
@@ -383,7 +383,7 @@ server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues
383
383
  status: z
384
384
  .enum(CHECKLIST_ITEM_STATUSES)
385
385
  .optional()
386
- .describe('DX-2653 — optional full-status override. Omitted → derived from `checked` (`true`→passing, `false`→incomplete), same as before. Present → used verbatim, reaching `cancelled`/`failing`/`deferred` from this everyday convenience path instead of only via `checklists` or `issue_checklist`. `deferred` REQUIRES a non-empty `detail` naming the outstanding real-world/post-deploy check — a marker-prefixed ("📡") item can never be `passing` at all (refused 409 at completion).'),
386
+ .describe("Optional full status; omitted → from `checked`. `deferred` REQUIRES a non-empty `detail`; a 📡 item can never be `passing`."),
387
387
  detail: z
388
388
  .string()
389
389
  .optional()
@@ -391,10 +391,10 @@ server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues
391
391
  check_item_id: z
392
392
  .union([z.string(), z.number()])
393
393
  .optional()
394
- .describe("OPTIONAL — the item's real checklist-item id (the same value issue_get returns as ac[].check_item_id). Each incoming item is correlated to the checklist's current live rows by this id when supplied, else by an exact title match. An item that matches and is byte-identical (title + checked/status/detail) to its stored row is left untouched and KEEPS its id; only items that actually changed are updated in place, a title with no match is inserted fresh, and a live row missing from this array is removed. Supply check_item_id only to disambiguate two AC items that share an identical title — every other call can omit it and rely on title matching."),
394
+ .describe("Optional id from issue_get's ac[].check_item_id. Only needed to tell apart two items with identical titles; otherwise items match by title."),
395
395
  }))
396
396
  .optional()
397
- .describe("The 2-state convenience onto the default Acceptance Criteria checklist (checked:true ↔ passing, false ↔ incomplete BY DEFAULT — pass `status` to reach `cancelled`/`failing`/`deferred` instead). Diffed against the checklist's current live items — unchanged items keep their id, changed ones are updated in place, new titles are inserted, and existing items missing from this array are removed. See check_item_id below for disambiguating duplicate titles, and status/detail below for the full status vocabulary."),
397
+ .describe("The default Acceptance Criteria checklist: checked true → passing, false → incomplete, or pass `status`. Diffed against live items — unchanged items keep their id, new titles are inserted, missing ones removed."),
398
398
  checklists: z
399
399
  .array(z.object({
400
400
  name: z.string().min(1),
@@ -410,16 +410,16 @@ server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues
410
410
  priority: z
411
411
  .union([z.enum(PRIORITY_TIER_WORDS), z.number()])
412
412
  .optional()
413
- .describe('Card priority (DX-1532). A tier WORD ("lowest"/"low"/"medium"/"high"/"very_high"/"critical") resolved to the tier midpoint, OR a raw number in [0,6). Writes the numeric `issues.priority` column the Trello label + dashboard badge read — set priority HERE, never via description prose (which no system reads for priority).'),
413
+ .describe('A tier word ("lowest"…"critical", resolved to the tier midpoint) or a number in [0,6). The only way to set priority.'),
414
414
  list_id: z.string().min(1).nullable().optional(),
415
415
  triage_enabled: z
416
416
  .boolean()
417
417
  .optional()
418
- .describe("DX-1895 (explicit-only — reverting DX-1928): per-card opt-in for the DX-1886 auto-triage dispatcher. Default false — the create path stamps false unless the creator explicitly set it, so this edit field is the only post-create way to opt a card in or out. false = the automatic dispatcher trigger never selects this card, even when every other eligibility condition holds. This flag gates ONLY the automatic dispatcher; operator-directed POST /api/triage and direct issue_triage calls remain flag-independent."),
418
+ .describe("Per-card opt-in to the automatic triage dispatcher (default false = never auto-selected). Operator POST /api/triage and issue_triage ignore it."),
419
419
  ...boardField,
420
420
  }, async (args) => jsonResult(await issueEdit(client, args)));
421
421
  // ---------------- issue_transition ----------------
422
- server.tool("issue_transition", "Stamp a lifecycle transition via POST /api/issues/:id/transition. **THIS IS THE ONLY WAY TO MOVE A CARD'S LIFECYCLE STATE** — DX-835 separated card lifecycle (this tool) from dispatch finalization (`mcp__danxbot__danxbot_complete`). The worker no longer infers card moves from `danxbot_complete.status`; agents that want the card to move MUST call this tool BEFORE calling `danxbot_complete`. Actions: ready (Review→ToDo), pickup (ToDo→In Progress — server checks every dispatch gate: ready_at, blocked_at, requires_human_reason, depends_on partners terminal, conflict_on partners idle; refuses 409 with failed_gate naming the cause; pass manual:true for OPERATOR-SESSION self-pickup — stamps dispatch_kind 'manual', bypasses every card-flow gate except terminal/deleted/already-dispatched, and the worker NEVER auto-transitions the card: no orphan-heal rollback, no Epic auto-rollup — use this whenever the work happens in YOUR current session rather than a worker dispatch, DX-946), rollback_pickup, **complete** (stamps completed_at — moves card to Done; this is YOUR explicit decision, not a side effect of danxbot_complete; REFUSES 409 on Epic if any phase child non-terminal — see non_terminal_phases[]; ALSO refuses 409 with failed_gate 'quality_gate_post' + failed_post_gates[] while any required POST quality gate row != pass — DX-1177), cancel (stamps cancelled_at — terminal), **block** (requires non-empty reason — stamps blocked_at + blocked_reason + clears dispatch; USE THIS when the CARD itself cannot proceed without human intervention; distinct from env-fault dispatch failures which use `danxbot_complete({status:'failed'})`), unblock, archive (parks to Backlog, clears ready_at), reopen (terminal→active, clears completed_at/cancelled_at/archived_at). Terminal cards refuse every action except reopen. Ladder timestamps preserved — forward stamps never clear earlier ones (CLAUDE.md Core Principle 2). **DX-2282 — every path into In Progress now requires an identified claimer.** For `manual:true` pickup called from a DISPATCHED-AGENT session (this MCP tool, bearer = your dispatch token): you MUST pass `assigned_agent` set to YOUR resolved agent/profile name — omitting it, or passing the shared dispatch-token identity itself, is refused 409 `assigned_agent (required, distinguishing)`. A genuine human dashboard session may omit it (auto-resolved from the real logged-in user). Every pickup flavor (work/gate/manual) is refused 409 `assigned_agent (required)` if it would otherwise leave the card with no owner at all. **A manual pickup can now also be refused 409 `failed_gate: \"dispatch_id\"` — \"pickup refused — card claimed by another actor between read and write\" — when it loses a genuine race against a concurrent manual pickup of the same idle card.** Before this, two overlapping manual pickups of the same card could both return 200: the second write silently overwrote the first winner's claim with no error and no distinguishing status code. The claiming write is now atomic (fenced on `dispatch_id IS NULL`), so the loser gets this 409 instead of a false success — on this response, do NOT retry blindly; re-check the card's current `assigned_agent`/`dispatch_id` first, since another actor already has it. **A successful `block` returns a `solutions_reminder` beside the envelope** — `{solution_count, instruction}` — because blocking stops the card for a human: before you stop, EVERY viable solution must be listed on the card with issue_solution (the operator answers by picking one). A count of zero means you have listed none.", {
422
+ server.tool("issue_transition", "Move a card's lifecycle via POST /api/issues/:id/transition — the ONLY way to; `danxbot_complete` never moves a card, so call this BEFORE it. Actions: ready (Review→ToDo); pickup (ToDo→In Progress; checks every dispatch gate — ready, blocked, requires_human, depends_on terminal, conflict_on idle — and refuses 409 with failed_gate naming the cause; `manual:true` is an operator-session self-pickup that bypasses card-flow gates and is never auto-rolled-back: use it when the work happens in YOUR session); rollback_pickup; complete (your explicit decision; refuses 409 on an Epic with non-terminal children, non_terminal_phases[], or while a required POST quality gate is not pass, failed_gate 'quality_gate_post' + failed_post_gates[]); cancel (terminal); block (non-empty reason; the CARD needs a human — env faults use `danxbot_complete({status:'failed'})` instead); unblock; archive (to Backlog, clears ready_at); reopen (terminal→active). Terminal cards refuse everything but reopen; forward stamps never clear earlier ones. Every path into In Progress needs an identified claimer: a dispatched agent's manual pickup MUST pass `assigned_agent` = your agent/profile name (409 otherwise). A manual pickup that loses a race is refused 409 `failed_gate: \"dispatch_id\"` — re-read the card's assigned_agent/dispatch_id, never retry blindly. A successful block returns `solutions_reminder: {solution_count, instruction}`: before stopping, list EVERY viable solution with issue_solution; zero means you listed none.", {
423
423
  id: z.string().min(1),
424
424
  action: z.enum(TRANSITION_ACTIONS),
425
425
  reason: z.string().optional(),
@@ -430,7 +430,7 @@ server.tool("issue_transition", "Stamp a lifecycle transition via POST /api/issu
430
430
  .string()
431
431
  .min(1)
432
432
  .optional()
433
- .describe("DX-2282 — REQUIRED for a manual:true pickup called by a dispatched agent (this MCP tool): your resolved agent/profile name, identifying YOU as the claimer. Never supply the generic shared dispatch-token identity — that is refused. Optional for a human dashboard session (auto-resolved from the real logged-in user). Ignored for every other action."),
433
+ .describe("Required for a dispatched agent's manual:true pickup: your agent/profile name, never the shared dispatch-token identity. Optional for a human session; ignored by other actions."),
434
434
  ...boardField,
435
435
  }, async (args) => jsonResult(await issueTransition(client, args)));
436
436
  // ---------------- issue_triage ----------------
@@ -513,7 +513,7 @@ server.tool("issue_requires_human", "Set or clear the requires_human dispatch ga
513
513
  ...boardField,
514
514
  }, async (args) => jsonResult(await issueRequiresHuman(client, args)));
515
515
  // ---------------- issue_quality_gate ----------------
516
- server.tool("issue_quality_gate", "Toggle a single card's per-card quality-gate `required` flag via POST /api/issues/:id/quality-gates/:gate {required} — the SAME write the dashboard drawer's Quality Gates tab performs (DX-1181). This is the ONLY post-create way to mark a gate required/not-required: `issue_create` carries `gate_decisions` at birth, and `issue_edit` REJECTS gate keys (400 offending_keys) — without this tool a card created without a gate can never have it turned on by an agent. `gate` is a registry name: `plan-dependency` | `plan-architecture` | `plan-tdd` | `code-test-quality` | `code-architecture` | `code-quality` (the PRE/plan- gates run before the work dispatch; the POST/code- gates block issue_transition complete). Unknown gate → 400 (never a silent no-op); a card with no seeded row for a registered gate → 500 (canonical corruption). NOTE board requirement is TRI-STATE per gate (`board_quality_gate_settings.default_state`, the Agents-tab surface), NOT a binary on/off: `required` = gate always runs (this flag irrelevant); `optional` = gate runs WHEN this per-card flag is true (per-card opt-in — `optional` is ENABLED, NOT off); `disabled` = never runs (this flag inert). So flipping `required:true` here LAUNCHES the gate when the board state is `required` OR `optional`; it is inert ONLY when the board state is `disabled`. Do not read `optional` as off. (Source of truth: `isGateEffectivelyRequired` in `src/issues/quality-gates/read.ts`.) DX-1760: optionally pass `effort_level` — a per-`(card, gate)` reviewer-rung override for a `plan-*` gate, written alongside `required`; omit to leave it untouched, pass `null` to clear a prior override. **The write itself ALWAYS succeeds and is echoed back** — the response is `{issue, applied: true, effective, reason}`, not just `{issue}`: `effective` is what `isGateEffectivelyRequired` resolves to for this (card, gate) right AFTER the write (accounts for the board tri-state above), and `reason` is `null` when `effective` matches the `required` value you just sent, or a plain-English explanation when the board's `required`/`disabled` state overrode it — READ `effective`/`reason`, not just the 200, to know whether this call actually changed whether the gate runs. Board-scoped; pass `board` (`<repo>:<slug>`) to target another board.", {
516
+ 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.", {
517
517
  id: z.string().min(1),
518
518
  gate: z.enum([
519
519
  "plan-dependency",
@@ -632,7 +632,7 @@ server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug
632
632
  // also takes no plan id, but for a different reason: it MAKES a plan rather
633
633
  // than acting on one, so there is no existing plan for an id to name yet.
634
634
  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)));
635
- server.tool("plan_get", "Read one plan WHOLE via GET /api/plans (DX-2683) — its member cards (with the boards they cover), its goals, rules and caveats, its architecture document, the sessions working on it, and your own session state. One call, not five. 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. Returns `{plan, cards, boards, records: {goal: [], rule: [], caveat: []}, architecture: {content, contentHash, updatedAt, updatedBy}, sessions, session, sessionListenerAttached}` — `sessionListenerAttached: false` while connected means your event listener is not running; call `plan_connect` again and arm the Monitor it returns. ALWAYS `plan_get` immediately before `plan_set_architecture` and pass the returned `architecture.contentHash` back as `base_hash`.", {
635
+ server.tool("plan_get", "Read one plan WHOLE via GET /api/plans (DX-2683) — its member cards (with the boards they cover), its goals, rules and caveats, its architecture document, the sessions working on it, and your own session state. One call, not five. 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. Returns `{plan, cards, boards, records: {goal, rule, caveat} (each `[{id, ref, body, context, contentHash}]` — `context` is markdown detail or null), architecture: {content, contentHash, updatedAt, updatedBy}, sessions, session, sessionListenerAttached}` — `sessionListenerAttached: false` while connected means your event listener is not running; call `plan_connect` again and arm the Monitor it returns. ALWAYS `plan_get` immediately before `plan_set_architecture` and pass the returned `architecture.contentHash` back as `base_hash`.", {
636
636
  plan_id: z
637
637
  .number()
638
638
  .int()
@@ -646,27 +646,27 @@ server.tool("plan_create", "Create a new, empty plan via POST /api/plans (DX-253
646
646
  server.tool("plan_connect", "Connect THIS session to a plan via POST /api/plan-sessions/me/plan (DX-2683) — the same binding the operator's Connect action writes, through the same server-side path. A session is connected to AT MOST ONE plan (enforced by the schema, not by convention); connecting while already on another plan MOVES you, and the response says which plan you left: `{ok, status, body: {session, movedFrom: {id, name} | null}}`. `movedFrom: null` means you were on no plan, or already on this one. It can only ever bind your OWN session — `me` is resolved from the session id this server forwards, never from anything you pass. After this, every plan WRITE tool acts on this plan, and no plan id is accepted anywhere. THE REPLY ALSO CARRIES `listener: {command, persistent: true, instruction}` — arm it IMMEDIATELY with the Monitor tool (`command` as given, `persistent: true`): from then on every comment, answer, requires_human change and block/unblock on this plan's cards arrives as a notification line like `[DX-8 \"Title\" repo:board] newms87 answered: chose \"Pause E2E\" — note: \"…\"`. Never poll for these. The command carries a narrow stream ticket, not a credential; calling plan_connect again (same plan is fine) issues a new one and ends the old listener, which is how you re-arm after a session restart or after the Monitor reports it gave up. If the ticket cannot be issued the call fails with `listener_not_armed` even though the connect itself happened.", {
647
647
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
648
648
  }, async (args) => jsonResult(await planConnect(client, args, { baseUrl: config.baseUrl, packageSpec: PACKAGE_SPEC })));
649
- server.tool("plan_add_record", "Add a GOAL, RULE or CAVEAT to the plan this session is connected to, via POST /api/plans/mine/records (DX-2683). A goal is what the plan is FOR (the outcome the work is measured against — not a task). A rule is what must HOLD while it is worked. A caveat is what is known to be AWKWARD — the fact that will surprise the next person. Each gets a permanent short reference within the plan (`G-1`, `R-4`, `CAV-12`) allocated by the server, which is how a person cites it in a card or a commit. TAKES NO PLAN ID: the plan is resolved from your connected session, so you cannot write a plan you are not connected to. Not connected → `{error: \"session_not_connected\"}`; call `plan_connect` first. Returns the new record plus that kind's full list.", {
650
- kind: z
651
- .enum(["goal", "rule", "caveat"])
652
- .describe("Which standing record this is. Determines the reference prefix."),
653
- body: z.string().min(1).describe("The record's text. Plain text, not markdown."),
649
+ server.tool("plan_add_record", "Add a goal, rule or caveat to your connected plan (POST /api/plans/mine/records). A GOAL is an outcome the work is measured against. A RULE is a constraint that must hold while it is worked. A CAVEAT is a lasting trade-off or limitation of the ARCHITECTURE — never progress, status or a session note (those are comments on the card). `body` is ONE plain statement of at most 250 characters; the evidence, history and detail go in `context` (markdown). An overlong body is refused with a 400 naming its length. The server allocates a permanent reference (`G-1`, `R-4`, `CAV-12`). Takes no plan id; `session_not_connected` → `plan_connect` first. Returns the record plus that kind's list.", {
650
+ kind: z.enum(["goal", "rule", "caveat"]).describe("goal = outcome, rule = constraint, caveat = architecture trade-off."),
651
+ body: z.string().min(1).describe("One plain statement, at most 250 characters. Details go in `context`."),
652
+ context: z.string().optional().describe("Markdown detail behind the statement: evidence, history, examples."),
654
653
  }, async (args) => jsonResult(await planAddRecord(client, args)));
655
- server.tool("plan_get_record", "Read ONE goal/rule/caveat of the plan this session is connected to, via GET /api/plans/mine/records/:rid (DX-2681 follow-up). Useful for an ordinary targeted read (skip pulling the whole plan via `plan_get` just to see one record) and for conflict recovery after a 409 `stale_plan_record` from `plan_update_record`/`plan_delete_record` — though that refusal already carries `currentBody`, so a second read is rarely needed for that specific case. TAKES NO PLAN ID: the plan is resolved from your connected session, same as `plan_add_record`. Not connected → `{error: \"session_not_connected\"}`. Unknown or another plan's record id → 404. Returns `{record: {id, planId, kind, refNum, ref, body, contentHash, createdAt, updatedAt}}`.", {
654
+ server.tool("plan_get_record", "Read one goal/rule/caveat of your connected plan (GET /api/plans/mine/records/:rid) without pulling the whole plan. Takes no plan id; an unknown or another plan's record id → 404. Returns `{record: {id, planId, kind, refNum, ref, body, context, contentHash, createdAt, updatedAt}}` — `context` is the markdown detail, or null.", {
656
655
  record_id: z.number().int().positive().describe("A record id, from `plan_add_record`, `plan_get` or `plan_get_record` itself."),
657
656
  }, async (args) => jsonResult(await planGetRecord(client, args)));
658
- server.tool("plan_update_record", 'Edit a goal/rule/caveat of the plan this session is connected to, via PATCH /api/plans/mine/records/:rid (DX-2681 follow-up). The reference (`G-1`, `R-4`, `CAV-12`) NEVER moves — only the text changes. `content_hash` MUST be the record\'s `contentHash` from the immediately-prior `plan_get`/`plan_get_record`/`plan_add_record`; the server compares it against the row\'s current hash and, on a mismatch, refuses the write ENTIRELY (nothing is changed) and fails loud with `{ok: false, body: {error: "stale_plan_record", currentHash, currentBody}}` rather than overwriting whoever wrote in between. `currentBody` rides that SAME refusal — unlike the architecture document\'s `stale_plan_architecture`, which carries only `currentHash` — so you can merge and retry in ONE round trip, no second read needed. On a refusal: merge your edit into `currentBody`, and retry with `content_hash: currentHash` — never retry blindly against the old hash. TAKES NO PLAN ID: the plan is resolved from your connected session. Not connected → `{error: "session_not_connected"}`. Returns the updated record plus that kind\'s full list.', {
657
+ server.tool("plan_update_record", 'Edit a goal/rule/caveat of your connected plan (PATCH /api/plans/mine/records/:rid). The reference never moves. `body` stays ONE plain statement of at most 250 characters (400 otherwise); detail belongs in markdown `context`. `content_hash` must be the record\'s `contentHash` from your last read, and covers body AND context. On a mismatch nothing is written and you get `{error: "stale_plan_record", currentHash, currentBody, currentContext}`: merge into those and retry with `content_hash: currentHash`, never blindly. Takes no plan id. Returns the record plus that kind\'s list.', {
659
658
  record_id: z.number().int().positive().describe("The record id to edit."),
660
- content_hash: z
659
+ content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
660
+ body: z.string().min(1).describe("The new statement, at most 250 characters. Plain text."),
661
+ context: z
661
662
  .string()
662
- .describe("The record's `contentHash` from the immediately-prior read. Required — an absent hash is not read as a wildcard."),
663
- body: z.string().min(1).describe("The record's new text, replacing what is stored. Plain text, not markdown."),
663
+ .nullable()
664
+ .optional()
665
+ .describe("New markdown detail. Omit to keep the stored context; null clears it."),
664
666
  }, async (args) => jsonResult(await planUpdateRecord(client, args)));
665
- server.tool("plan_delete_record", 'Soft-delete a goal/rule/caveat of the plan this session is connected to, via DELETE /api/plans/mine/records/:rid (DX-2681 follow-up). Its reference (`G-1`, `R-4`, `CAV-12`) is retired PERMANENTLY and never reused by a later record of the same kind. `content_hash` MUST be the record\'s `contentHash` from the immediately-prior `plan_get`/`plan_get_record`/`plan_add_record`; a stale hash refuses the delete ENTIRELY (nothing is removed) with the same `{ok: false, body: {error: "stale_plan_record", currentHash, currentBody}}` shape `plan_update_record` uses — merge and retry with `content_hash: currentHash` rather than retrying blindly. TAKES NO PLAN ID: the plan is resolved from your connected session. Not connected → `{error: "session_not_connected"}`. Unknown, foreign, or already-deleted record id → 404. Returns that kind\'s remaining list.', {
667
+ server.tool("plan_delete_record", 'Soft-delete a goal/rule/caveat of your connected plan (DELETE /api/plans/mine/records/:rid). Its reference is retired permanently, never reused. `content_hash` must be the record\'s `contentHash` from your last read; a stale hash deletes nothing and returns `{error: "stale_plan_record", currentHash, currentBody, currentContext}`. Takes no plan id. Unknown or already-deleted id → 404. Returns that kind\'s remaining list.', {
666
668
  record_id: z.number().int().positive().describe("The record id to delete."),
667
- content_hash: z
668
- .string()
669
- .describe("The record's `contentHash` from the immediately-prior read. Required — an absent hash is not read as a wildcard."),
669
+ content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
670
670
  }, async (args) => jsonResult(await planDeleteRecord(client, args)));
671
671
  server.tool("plan_add_card", "Add an existing card to the plan this session is connected to, via POST /api/plans/mine/cards (DX-2683). The card may live on ANY board — that is what a plan is for. Idempotent: re-adding a card already on the plan is a no-op, not an error, and a card may sit in several plans at once. This adds MEMBERSHIP only; it never edits the card. TAKES NO PLAN ID: the plan is resolved from your connected session. Not connected → `{error: \"session_not_connected\"}`. Unknown card → 404. Returns the plan's full member list.", {
672
672
  card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.53",
3
+ "version": "0.1.54",
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",