@thehammer/danx-dashboard-mcp 0.1.139 → 0.1.142

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
@@ -806,6 +806,35 @@ export async function issueQualityGateVerdict(client, args) {
806
806
  board: args.board,
807
807
  });
808
808
  }
809
+ // ---------------- quality_gate_instruction ----------------
810
+ const QUALITY_GATE_INSTRUCTION_BASE_PATH = "/api/quality-gates";
811
+ /**
812
+ * DX-3341 (PLN-11 R-20) — fetch a named gate's CURRENT instruction via
813
+ * GET /api/quality-gates/:gate/instruction. NOT card-scoped (unlike
814
+ * `issue_quality_gate`/`issue_quality_gate_verdict` above) — this reads the
815
+ * gate's DB-catalog SKILL PROSE for the calling session's board/repo, byte-
816
+ * identical to what a dispatched gate reviewer's clean-room materializes at
817
+ * `.claude/skills/<segment>/SKILL.md`. No second source: the dashboard route
818
+ * behind this call reuses the exact same catalog-read + materialize-transform
819
+ * chain a real gate dispatch's clean-room assembly uses.
820
+ *
821
+ * Use this to run a quality gate from an operator session (R-19/R-20): fetch
822
+ * the gate's instruction with this tool, dispatch a `danxbot:worker-*` tier
823
+ * chosen by effort with that instruction, review, then record the verdict
824
+ * via `issue_quality_gate_verdict` — never a hand-written review brief.
825
+ *
826
+ * `gate` is a registry name (same enum `issue_quality_gate` accepts). Unknown
827
+ * gate -> 400; a registered gate with no resolvable catalog skill artifact
828
+ * for this repo (a boot-seed/catalog-drift defect) -> 500.
829
+ */
830
+ export async function qualityGateInstruction(client, args) {
831
+ return client.request({
832
+ method: "GET",
833
+ path: `/${encodeURIComponent(args.gate)}/instruction`,
834
+ basePath: QUALITY_GATE_INSTRUCTION_BASE_PATH,
835
+ board: args.board,
836
+ });
837
+ }
809
838
  export async function issueRetro(client, args) {
810
839
  const { id, board, ...body } = args;
811
840
  return client.request({
package/dist/index.js CHANGED
@@ -28,6 +28,8 @@
28
28
  * PATCH /api/issues/:id/quality-gates/:gate
29
29
  * - issue_retro PUT /api/issues/:id/retro
30
30
  * - issue_attach POST /api/issues/:id/attachments (reads a local file)
31
+ * - quality_gate_instruction
32
+ * GET /api/quality-gates/:gate/instruction (DX-3341, not card-scoped)
31
33
  * - repo_knowledge_get GET /api/repo-knowledge
32
34
  * - repo_knowledge_set PUT /api/repo-knowledge (DX-1128, Story 2)
33
35
  * - brief_list GET /api/brief
@@ -97,13 +99,14 @@ import { isEntrypointModule } from "./entrypoint.js";
97
99
  import { BRIDGE_SUBCOMMAND, runBridgeCommand } from "./bridge.js";
98
100
  import { PLAN_STATE_SUBCOMMAND, runPlanStateCommand } from "./plan-state.js";
99
101
  import { BACKGROUND_WORK_SUBCOMMAND, runBackgroundWorkCommand } from "./background-work.js";
102
+ import { MANTRA_SUBCOMMAND, runMantraCommand } from "./mantra.js";
100
103
  import { resolveDeclaredCredential } from "./credential.js";
101
104
  import { recordSessionConnectionAfterConnect } from "./session-connection.js";
102
105
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
103
106
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
104
107
  import { z } from "zod";
105
108
  import { DashboardHttpClient } from "./http-client.js";
106
- import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, dispatchTranscriptSearch, planAddArchitectureSection, planAddCard, planAddNote, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteNote, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, PLAN_EVENT_KINDS, PLAN_EVENT_ORIGINS, PLAN_STATUSES, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, PLAN_GET_EVENTS_DEFAULT_LIMIT, PLAN_GET_EVENTS_MAX_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateNote, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
109
+ import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, dispatchTranscriptSearch, planAddArchitectureSection, planAddCard, planAddNote, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteNote, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, PLAN_EVENT_KINDS, PLAN_EVENT_ORIGINS, PLAN_STATUSES, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, PLAN_GET_EVENTS_DEFAULT_LIMIT, PLAN_GET_EVENTS_MAX_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateNote, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, qualityGateInstruction, } from "./handlers.js";
107
110
  import { PRIORITY_TIER_WORDS } from "./priority.js";
108
111
  function readEnvOrDie(name) {
109
112
  const v = process.env[name];
@@ -351,12 +354,17 @@ const DESCRIPTION_DESCRIBE = 'The full body ("Context"): evidence, examples, tec
351
354
  // R-16: fold a skill's rule into the tool description it governs rather than
352
355
  // require a separate load). Shared by every prose-writing field below so an
353
356
  // agent sees the same rules wherever it writes card markdown.
357
+ // DX-2623 — this constant (and every `.describe()` reference to it) must stay
358
+ // reachable: an earlier dedup pass (05af82086) deleted it outright and left
359
+ // the "never escape markdown" / "GFM not ASCII tables" rules unreachable from
360
+ // any tool in this surface until it was restored. The text below (from
361
+ // DX-3335) already carries both clauses — do not dedup them away again.
354
362
  const MARKDOWN_STYLE_DESCRIBE = "Renders as markdown here. Use `##`/`###` headers, fenced code blocks with a language tag, inline code for paths/symbols/flags, `-`/`1.` lists, and GFM tables (`|---|`) for 2D data — never ASCII tables. Never escape markdown characters (`\\*`, `\\_`) to \"play it safe\"; they render wrong. Don't repeat the same content as both prose and a list. Structure the content itself per `base:convey` (concept-first headline, diff, caveats, verify) — this rule only governs the markdown syntax.";
355
363
  // ---------------- issue_list ----------------
356
364
  strictTool("issue_list",
357
365
  // DX-2735: trimmed to pay for the problem tools inside the work-profile
358
366
  // injected-surface budget — same facts, no repeated prose.
359
- "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 — a card needs a human exactly when this is > 0), ac, comments, retro, dependencies, triage, 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 (highest priority=most urgent first), repo_name asc, numeric-id tiebreaker. `limit`/`offset` page (uncapped by default). DX-3113 — `include_closed` defaults to FALSE: a bare call silently excludes every Done/Cancelled card (leaf AND container alike). The response always carries `total` (the full count matching every filter except limit/offset — compare against `issues.length` to tell an exhausted list from a truncated one) and, whenever `include_closed` was not explicitly `true`, `closed_excluded` (how many additional terminal cards the default withheld — re-call with `include_closed: true` to see them). issue_get reads one card in full.", {
367
+ "List cards. 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 — a card needs a human exactly when this is > 0), ac, comments, retro, dependencies, triage, 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 order: see `sort`'s own field description). `limit`/`offset` page (uncapped by default). `include_closed` defaults to FALSE: a bare call silently excludes every Done/Cancelled card (leaf AND container alike). The response always carries `total` (the full count matching every filter except limit/offset — compare against `issues.length` to tell an exhausted list from a truncated one) and, whenever `include_closed` was not explicitly `true`, `closed_excluded` (how many additional terminal cards the default withheld — re-call with `include_closed: true` to see them). issue_get reads one card in full.", {
360
368
  filter: z
361
369
  .object({
362
370
  q: z.string().optional(),
@@ -390,7 +398,7 @@ strictTool("issue_list",
390
398
  strictTool("issue_get",
391
399
  // DX-2735: trimmed to pay for the problem tools inside the work-profile
392
400
  // injected-surface budget — same facts, no repeated prose.
393
- "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.", {
401
+ "Fetch one card (`id`) or many (`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 (paged via `comments_limit`/`comments_offset` — see their own field descriptions, single-id form only), 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>`). Within a `comments` page, comments are ordered CHRONOLOGICALLY (oldest first) — only the requested WINDOW is anchored at the newest end, not the array itself; naming `comments_limit`/`comments_offset` alongside `ids` (the batch form) is refused. Single form: unknown id → 404. Batch form: `{issues: [...], not_found: [...ids]}` — an unknown or deleted id never fails the call.", {
394
402
  id: z.string().min(1).optional(),
395
403
  ids: z.array(z.string().min(1)).min(1).max(ISSUE_BATCH_GET_MAX).optional(),
396
404
  fields: z
@@ -416,8 +424,8 @@ strictTool("issue_get",
416
424
  ...boardField,
417
425
  }, async (args) => jsonResult(await issueGet(client, args)));
418
426
  // ---------------- issue_create ----------------
419
- strictTool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass "mine" to put the card on THIS session\'s connected plan, or null when it deliberately belongs to no plan. There is no default and no inference — a card that names no plan is one nobody following the work can see, which is why the answer has to be given rather than omitted. "mine" while this session is on no plan is refused (409 session_not_connected) and creates NO card; a plan id is not accepted (a card is only ever created onto your own connected plan). This replaces the plan_add_card follow-up at creation time; plan_add_card remains for putting an EXISTING card on a plan. ' +
420
- 'Create a card via POST /api/issues. Board-scoped; see `board`. type=Epic REQUIRES non-empty phase_children[] (epic and phases inserted in one transaction; children get the epic as parent); other types refuse phase_children[] (400). Status starts at Review. `list_id` places the card straight into a column — a board_lists id or the list\'s display NAME (case-insensitive, emoji-tolerant): a `ready`-type queue lands it in ToDo, a `completed` list in Done, with no follow-up transition. Not valid on Epic; unknown name/id → 400. `quality_gates` names the gates this card carries BEYOND the board\'s default set for its type — one `{gate, note?}` each. Omit it for just the board defaults; a gate you do not name simply is not on the card (there is no optional gate and nothing fails closed for going unnamed). Add one later with `issue_quality_gate`. ALWAYS pass `triage_enabled` explicitly on the root card and every phase child: true only when it should enter automatic triage/dispatch without human review; absent → false.', {
427
+ strictTool("issue_create", '`plan` is REQUIRED on every create — no default, no inference; see its own field description for the two values and the refusal. This replaces the plan_add_card follow-up at creation time; plan_add_card remains for putting an EXISTING card on a plan. ' +
428
+ 'Create a card. Board-scoped; see `board`. type=Epic REQUIRES non-empty phase_children[] (epic and phases inserted in one transaction; children get the epic as parent); other types refuse phase_children[] (400). Status starts at Review. `list_id` places the card straight into a column — a board_lists id or the list\'s display NAME (case-insensitive, emoji-tolerant): a `ready`-type queue lands it in ToDo, a `completed` list in Done, with no follow-up transition. Not valid on Epic; unknown name/id → 400. `quality_gates` adds gates beyond the board\'s type defaults (see its own field description); add one later with `issue_quality_gate`. ALWAYS pass `triage_enabled` explicitly on the root card and every phase child (see its own field description).', {
421
429
  type: z.enum(ISSUE_TYPES),
422
430
  title: z.string().min(1).describe(TITLE_DESCRIBE),
423
431
  summary: z.string().min(1).optional().describe(SUMMARY_DESCRIBE),
@@ -429,7 +437,7 @@ strictTool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass "
429
437
  plan: z
430
438
  .literal("mine")
431
439
  .nullable()
432
- .describe('"mine" = attach to the plan THIS session is connected to; null = deliberately no plan. REQUIRED — decide per card. A plan id is not accepted: a card is only ever created onto your own connected plan, so there is no id to name.'),
440
+ .describe('"mine" = attach to the plan THIS session is connected to; null = deliberately no plan. REQUIRED — decide per card, never omit. "mine" while unconnected is refused 409 session_not_connected (creates NO card). A plan id is not accepted: a card is only ever created onto your own connected plan.'),
433
441
  parent_id: z.string().nullable().optional(),
434
442
  ac: z.array(z.object({ title: z.string().min(1) }).strict()).optional(),
435
443
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
@@ -471,7 +479,7 @@ strictTool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass "
471
479
  triage_enabled: z
472
480
  .boolean()
473
481
  .optional()
474
- .describe("ALWAYS pass explicitly. true = enters automatic triage/dispatch without further human review; absent → false. Operator POST /api/triage and issue_triage ignore it."),
482
+ .describe("ALWAYS pass explicitly. true = enters automatic triage/dispatch without further human review; absent → false. Ignored by issue_triage and by an operator-initiated triage write."),
475
483
  // DX-3238 — was absent from this schema entirely (not merely optional),
476
484
  // so a caller's value was silently stripped before the request was built
477
485
  // even though the route already honoured it (ALLOWED_CREATE_KEYS,
@@ -496,7 +504,7 @@ strictTool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass "
496
504
  ...boardField,
497
505
  }, async (args) => jsonResult(await issueCreate(client, args, config.board)));
498
506
  // ---------------- issue_edit ----------------
499
- strictTool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED keys: title, summary, description, ac, checklists, effort_level, parent_id, priority, list_id, triage_enabled, type, content_hash. Any other key (lifecycle, triage, dependencies, retro, blocked) is refused 400 with offending_keys[] naming the right tool: issue_transition / issue_triage / issue_comment / issue_dependency / issue_problem / issue_retro. `type`: Story/Bug/Chore = eligible for autonomous pickup; Task or a container (Epic/Feature) removes that eligibility — how a planning item becomes work. `priority` (tier word: "lowest"–"critical", or number 0–6; higher = more urgent, prefer tier word) is the ONLY way to set priority; a "Priority:" line does nothing. CHECKLISTS: each item has one status `incomplete|failing|passing|cancelled|deferred`; `deferred` (work done, a real-world/post-deploy check outstanding) REQUIRES `detail`, and a `📡`-prefixed item can never be `passing`. `ac` edits the default "Acceptance Criteria" checklist (items matched by check_item_id, else exact title); `checklists` REPLACES every named checklist with full status control (`{name, items:[{label, detail?, status}]}`). Send `ac` OR `checklists`, not both (400). `list_id` pins the card to a list by id or display NAME; its type must match the card\'s current derived status (400 otherwise — e.g. ready the card first before pinning it to a `ready`-type list); null clears the pin. `content_hash` (DX-2741) is the card\'s optimistic-concurrency token — REQUIRED whenever the edit touches `title` / `description` / `checklists` (NOT `ac`, which keeps its own check_item_id/title diffing): missing → 400, stale → 409 `stale_issue_content` carrying `currentHash` + `currentTitle` + `currentDescription`. Read it off `issue_get`/`issue_list`\'s `content_hash` scalar (present even minimal); on 409, re-`issue_get` and retry with the fresh hash — never blindly.', {
507
+ strictTool("issue_edit", 'Patch a card. ALLOWED keys: title, summary, description, ac, checklists, effort_level, parent_id, priority, list_id, triage_enabled, type, content_hash. Any other key (lifecycle, triage, dependencies, retro, blocked) is refused 400 with offending_keys[] naming the right tool: issue_transition / issue_triage / issue_comment / issue_dependency / issue_problem / issue_retro. `type` (see its own field description) is how a planning item becomes work. `priority` (see its own field description) 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` (see its own field description) is the card\'s optimistic-concurrency token.', {
500
508
  id: z.string().min(1),
501
509
  title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
502
510
  summary: z
@@ -521,7 +529,7 @@ strictTool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED k
521
529
  detail: z
522
530
  .string()
523
531
  .optional()
524
- .describe("DX-2653 — paired with `status`; required (non-empty) when `status` is `deferred`."),
532
+ .describe("paired with `status`; required (non-empty) when `status` is `deferred`."),
525
533
  check_item_id: z
526
534
  .union([z.string(), z.number()])
527
535
  .optional()
@@ -549,18 +557,18 @@ strictTool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED k
549
557
  triage_enabled: z
550
558
  .boolean()
551
559
  .optional()
552
- .describe("Per-card opt-in to the automatic triage dispatcher (default false = never auto-selected). Operator POST /api/triage and issue_triage ignore it."),
560
+ .describe("Per-card opt-in to the automatic triage dispatcher (default false = never auto-selected). Ignored by issue_triage and by an operator-initiated triage write."),
553
561
  content_hash: z
554
562
  .string()
555
563
  .min(1)
556
564
  .optional()
557
- .describe("The card's content_hash last read via issue_get/issue_list. REQUIRED whenever this edit touches title/description/checklists (NOT ac); a stale value 409s stale_issue_content."),
565
+ .describe("The card's content_hash last read via issue_get/issue_list's content_hash scalar (present even minimal). REQUIRED whenever this 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 — re-issue_get and retry with the fresh hash, never blindly."),
558
566
  ...boardField,
559
567
  }, async (args) => jsonResult(await issueEdit(client, args)));
560
568
  // ---------------- issue_transition ----------------
561
569
  strictTool("issue_transition",
562
570
  // DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
563
- "Move a card's lifecycle via POST /api/issues/:id/transition — the ONLY way; `danxbot_complete` never moves a card, so call this first. Actions: ready (Review→ToDo); pickup (ToDo→In Progress; checks every dispatch gate — ready, blocked, open_problem_count (a card needs a human exactly when this is > 0), depends_on terminal, conflict_on idle — and refuses 409 with failed_gate naming the cause; `manual:true` is a self-pickup for work in YOUR session: it bypasses card-flow gates EXCEPT open_problem_count, which never lets a card start, and is never auto-rolled-back); rollback_pickup (`keep_assignment:true` releases the card to ready WITHOUT clearing its assignment — use this to hand a manually-held card back to `ready` while you keep holding it, instead of a follow-up assigned-agent call); complete (your explicit decision; 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; only holds dispatch, never asks a human — for that use issue_problem add; env faults use `danxbot_complete({status:'failed'})`); unblock; archive (to Backlog, clears ready_at); reopen (terminal→active). Terminal cards refuse all but reopen; forward stamps never clear earlier ones. A dispatched agent's manual pickup MUST pass `assigned_agent` = your agent/profile name (409 otherwise); one that loses a race is refused 409 `failed_gate: \"dispatch_id\"` — re-read assigned_agent/dispatch_id, never retry blindly.", {
571
+ "Move a card's lifecycle — the ONLY way; `danxbot_complete` never moves a card, so call this first. Actions: ready (Review→ToDo); pickup (ToDo→In Progress; checks every dispatch gate — ready, blocked, open_problem_count (a card needs a human exactly when this is > 0), depends_on terminal, conflict_on idle — and refuses 409 with failed_gate naming the cause; `manual:true` is a self-pickup for work in YOUR session: it bypasses card-flow gates EXCEPT open_problem_count, which never lets a card start, and is never auto-rolled-back — see `assigned_agent`'s own field description for the dispatched-agent requirement); rollback_pickup (see `keep_assignment`'s own field description); complete (your explicit decision; 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; only holds dispatch, never asks a human — for that use issue_problem add; env faults use `danxbot_complete({status:'failed'})`); unblock; archive (to Backlog, clears ready_at); reopen (terminal→active). Terminal cards refuse all but reopen; forward stamps never clear earlier ones. A pickup that loses a race is refused 409 `failed_gate: \"dispatch_id\"` — re-read assigned_agent/dispatch_id, never retry blindly.", {
564
572
  id: z.string().min(1),
565
573
  action: z.enum(TRANSITION_ACTIONS),
566
574
  reason: z.string().optional(),
@@ -579,14 +587,14 @@ strictTool("issue_transition",
579
587
  ...boardField,
580
588
  }, async (args) => jsonResult(await issueTransition(client, args)));
581
589
  // ---------------- issue_triage ----------------
582
- strictTool("issue_triage", "Record a triage confidence score via POST /api/issues/:id/triage (DX-2086). Caller sends a single `confidence` integer 0-5 plus a required non-empty `reason` — the server computes the verdict by comparing `confidence` against the board's configured thresholds (all band edges inclusive on the low side): confidence <= cancelThreshold -> cancel (stamps cancelled_at, terminal); cancelThreshold < confidence <= archiveThreshold -> defer (stamps archived_at AND ready_at:null); archiveThreshold < confidence <= reviewThreshold -> keep (no column stamp); confidence > reviewThreshold -> approve (stamps ready_at). REFUSES 409 on terminal cards. DX-2782 / DX-2830 — a keep/defer verdict does NOT block the card: it opens a problem asking the reason with three real choices (approve and ready / defer / cancel, one recommended) — opening it IS what puts the card in front of a human, the same escalation shape every other machine writer uses (the dashboard's Needs You tab reads only open_problem_count). Answering that problem applies the chosen outcome through the normal issue_transition actions automatically. It is not a cross-card ordering gate; use issue_dependency (kind: depends_on) to sequence one card after another.", {
590
+ strictTool("issue_triage", "Record a triage confidence score. Caller sends a single `confidence` integer 0-5 plus a required non-empty `reason` — the server computes the verdict by comparing `confidence` against the board's configured thresholds (all band edges inclusive on the low side): confidence <= cancelThreshold -> cancel (stamps cancelled_at, terminal); cancelThreshold < confidence <= archiveThreshold -> defer (stamps archived_at AND ready_at:null); archiveThreshold < confidence <= reviewThreshold -> keep (no column stamp); confidence > reviewThreshold -> approve (stamps ready_at). REFUSES 409 on terminal cards. a keep/defer verdict does NOT block the card: it opens a problem asking the reason with three real choices (approve and ready / defer / cancel, one recommended) — opening it IS what puts the card in front of a human, the same escalation shape every other machine writer uses (the dashboard's Needs You tab reads only open_problem_count). Answering that problem applies the chosen outcome through the normal issue_transition actions automatically. It is not a cross-card ordering gate; use issue_dependency (kind: depends_on) to sequence one card after another.", {
583
591
  id: z.string().min(1),
584
592
  confidence: z.number().int().min(0).max(5),
585
593
  reason: z.string().min(1),
586
594
  ...boardField,
587
595
  }, async (args) => jsonResult(await issueTriage(client, args)));
588
596
  // ---------------- issue_comment ----------------
589
- strictTool("issue_comment", "Comment CRUD via /api/issues/:id/comments[/:cid]. action=add → POST {text, metadata?, problem_id?} (server stamps author from bearer + auto-incrementing ordinal); action=edit → PATCH /:cid {text}; action=delete → DELETE /:cid (soft-delete, audit trail preserved — comments are NEVER hard-deleted). Client-supplied author is IGNORED (server-stamped to prevent impersonation). `metadata` (DX-2157, action=add only) is an OPTIONAL opaque JSON object a calling app attaches to the comment — e.g. a generated `{sql, explanation}` packet its own UI renders specially. danxbot stores + returns it verbatim and enforces NO shape on its contents; omit for a plain markdown-only comment (unaffected either way). `problem_id` (DX-2906, action=add only) OPTIONALLY threads the comment as a follow-up question under one of this SAME card's problems (from `issue_problem` list/add) WITHOUT answering it — commenting never changes open_problem_count, blocked, or records a decision; use issue_problem's answer route for that. An unknown id, another card's id, or a removed problem's id all 404 naming the problem id — an ANSWERED (but not removed) problem still accepts a follow-up comment, since the thread continues after a decision.", {
597
+ strictTool("issue_comment", "Comment CRUD. action=add → {text, metadata?, problem_id?} (server stamps author from bearer + auto-incrementing ordinal); action=edit → PATCH /:cid {text}; action=delete → DELETE /:cid (soft-delete, audit trail preserved — comments are NEVER hard-deleted). Client-supplied author is IGNORED (server-stamped to prevent impersonation). `metadata` (action=add only) is an OPTIONAL opaque JSON object a calling app attaches to the comment — e.g. a generated `{sql, explanation}` packet its own UI renders specially. danxbot stores + returns it verbatim and enforces NO shape on its contents; omit for a plain markdown-only comment (unaffected either way). `problem_id` (action=add only) OPTIONALLY threads the comment as a follow-up question under one of this SAME card's problems (from `issue_problem` list/add) WITHOUT answering it — commenting never changes open_problem_count, blocked, or records a decision; use issue_problem's answer route for that. An unknown id, another card's id, or a removed problem's id all 404 naming the problem id — an ANSWERED (but not removed) problem still accepts a follow-up comment, since the thread continues after a decision.", {
590
598
  id: z.string().min(1),
591
599
  action: z.enum(["add", "edit", "delete"]),
592
600
  comment_id: z.number().int().positive().optional(),
@@ -603,7 +611,7 @@ const CHECKLIST_ITEM_INPUT = z
603
611
  status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
604
612
  })
605
613
  .strict();
606
- strictTool("issue_checklist", "Targeted checklist CUD via /api/issues/:id/checklists[/:cid[/items[/:iid]]] (DX-1362). Mutates ONE checklist/item without the wholesale `issue_edit({checklists})` replace, which DROPS any checklist you omit and churns every item id (orphaning its Trello mirror) — prefer this for single-item changes. Action-dispatched: add_list (POST :id/checklists {name, items?}) — create a named checklist, optionally with initial items; update_list (PATCH :id/checklists/:cid {name}) — rename; remove_list (DELETE :id/checklists/:cid) — soft-delete the checklist (audit trail preserved); add_item (POST :id/checklists/:cid/items {label, detail?, status?}) — append an item (status defaults `incomplete`); update_item (PATCH :id/checklists/:cid/items/:iid {label?, detail?, status?}) — change ONLY the fields you pass, in place (keeps id + Trello link; ≥1 field required); remove_item (DELETE :id/checklists/:cid/items/:iid) — soft-delete one item. Status: incomplete|failing|passing|cancelled|deferred (terminal = passing|cancelled|deferred; DX-2653 added `deferred` — the honest disposition for a criterion whose work is done but names a real-world/post-deploy check still outstanding, REQUIRES a non-empty `detail`). checklist_id is required for every action except add_list; item_id for update_item/remove_item. Each returns the {ok,status,body} envelope; unknown card/checklist/item → 404, invalid status → 400. ADDITIVE — `issue_edit({checklists})` still works for bulk authoring.", {
614
+ strictTool("issue_checklist", "Targeted checklist CUD — mutates ONE checklist/item without `issue_edit({checklists})`'s wholesale replace, which drops any checklist you omit and churns every item id (orphaning its Trello mirror). Actions: add_list (name, items?) creates a checklist; update_list renames it; remove_list soft-deletes it; add_item appends an item (status defaults incomplete); update_item changes only the fields passed (≥1 required, keeps id + Trello link); remove_item soft-deletes one item. Status: incomplete|failing|passing|cancelled|deferred (terminal = passing|cancelled|deferred; deferred means the work is done but a real-world/post-deploy check remains — requires a non-empty `detail`). checklist_id required for every action except add_list; item_id for update_item/remove_item. Unknown card/checklist/item → 404, invalid status → 400. Additive — `issue_edit({checklists})` still works for bulk authoring.", {
607
615
  id: z.string().min(1),
608
616
  action: z.enum([
609
617
  "add_list",
@@ -639,12 +647,10 @@ const STEP_INPUT = z.lazy(() => z
639
647
  .int()
640
648
  .positive()
641
649
  .optional()
642
- .describe("present -> this node IS an existing step (matched on this id, 400 if it does not resolve to a live " +
643
- "child of this exact parent scope — a supplied id is NEVER claimed by the positional/title fallback " +
644
- "below); absent -> matched against unclaimed siblings by TITLE (first unclaimed match, in order), else " +
645
- "created new. An id-less MIDDLE insert recreates every later id-less sibling's row unless you send ids " +
646
- "for the ones you want preserved — send ids whenever an existing step's identity matters (checked " +
647
- "progress, a later single-step edit)."),
650
+ .describe("present -> this node IS an existing step (matched on this id; 400 if not a live child of this exact " +
651
+ "parent scope). absent -> matched against unclaimed siblings by TITLE (first match), else created new. " +
652
+ "An id-less MIDDLE insert recreates every later id-less sibling's row — send ids whenever an existing " +
653
+ "step's identity matters (checked progress, a later edit)."),
648
654
  title: z
649
655
  .string()
650
656
  .min(1)
@@ -654,7 +660,7 @@ const STEP_INPUT = z.lazy(() => z
654
660
  steps: z.array(STEP_INPUT).optional().describe("this step's own children, nested — refused past 3 levels total"),
655
661
  })
656
662
  .strict());
657
- 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 a `reminders: [{key, text}]` array (DX-3365 — every MCP-response reminder rides this ONE field now, DB-registry-driven and operator-overridable from the dashboard) naming each still-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.", {
663
+ strictTool("issue_problem", "A card's PROBLEMS: 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 — adding a problem IS putting the card in front of a human, there is no separate flag. 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 a `reminders: [{key, text}]` array (DX-3365 — every MCP-response reminder rides this ONE field now, DB-registry-driven and operator-overridable from the dashboard) naming each still-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 (400 names the actual length otherwise): one plain sentence, with any investigation detail in `context` (markdown, no cap) instead. Read `type`'s and `summary`'s own descriptions below before your first `add` — together they teach which type this is and the three-field split (`statement`/`summary`/`context`) an action needs.", {
658
664
  id: z.string().min(1),
659
665
  action: z.enum(["list", "add", "edit", "remove"]),
660
666
  problem_id: z.number().int().positive().optional().describe("edit/remove"),
@@ -677,19 +683,16 @@ strictTool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pi
677
683
  type: z
678
684
  .enum(["question", "action"])
679
685
  .optional()
680
- .describe("add only. Before you raise this problem, apply the test: could I do this myself if I tried harder, and is the " +
681
- "only thing missing a decision? If yes, this is a \"question\": statement is the question itself (one plain " +
682
- "sentence); summary (optional) is why it matters; context is the evidence behind it; solutions[] are " +
683
- "candidate ANSWERS, each with its own pro/con, and the operator is done the moment they pick one. If the " +
684
- "blocker is access, credentials, hardware, a human's authority, or a system you genuinely cannot reach, " +
685
- "this is an \"action\": statement is WHAT IS TO BE DONE — the deed itself, never phrased as a question " +
686
- "(\"Rotate the staging DB credential\", not \"Should we rotate it?\"); summary is REQUIRED and MUST say " +
687
- "WHY THE ACTION IS NEEDED AND WHY YOU CANNOT DO IT YOURSELF — that sentence is what tells the operator this " +
688
- "is not you being lazy (an add with no summary is refused 400); context is whatever additional detail the " +
689
- "person needs to carry it out; solutions[] are the possible ROUTES a person could take (e.g. \"rotate by " +
690
- "hand in the console\" vs \"run the provisioning script\" vs \"ask the vendor\"), and EACH ONE MUST CARRY " +
691
- "AT LEAST ONE STEP (refused 400 otherwise) — an action with no procedure is not yet actionable. Omitted " +
692
- "defaults to \"question\" — decide deliberately every time."),
686
+ .describe("add only. Test: could you do this yourself if you tried harder, and is the only thing missing a decision? " +
687
+ "Yes → \"question\": statement is the question (one plain sentence); summary optional (why it matters); " +
688
+ "context is the evidence; solutions[] are candidate ANSWERS with their own pro/con, done once one is " +
689
+ "picked. No — the blocker is access, credentials, hardware, a human's authority, or a system you " +
690
+ "genuinely cannot reach — \"action\": statement is WHAT IS TO BE DONE, never phrased as a question " +
691
+ "(\"Rotate the staging DB credential\", not \"Should we rotate it?\"); summary is REQUIRED, stating why " +
692
+ "it's needed and why you can't do it yourself (an add with no summary refuses 400); context is detail " +
693
+ "for whoever carries it out; solutions[] are the possible ROUTES (e.g. \"rotate by hand\" vs \"run the " +
694
+ "provisioning script\"), each MUST carry ≥1 step (400 otherwise — no procedure means not yet actionable). " +
695
+ "Omitted defaults to \"question\" — decide deliberately every time."),
693
696
  summary: z
694
697
  .string()
695
698
  .nullable()
@@ -709,35 +712,30 @@ strictTool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pi
709
712
  ...boardField,
710
713
  }, async (args) => jsonResult(await issueProblem(client, args)));
711
714
  // ---------------- issue_solution ----------------
712
- strictTool("issue_solution", "One problem's options via /api/issues/:id/problems/:pid/solutions[/:sid], AND (DX-3310) one solution's individual " +
713
- "procedure steps via .../solutions/:sid/steps[/:stepId]; `problem_id` is REQUIRED (from issue_problem list/add; " +
715
+ strictTool("issue_solution", "One problem's options, AND one solution's individual " +
716
+ "procedure steps; `problem_id` is REQUIRED (from issue_problem list/add; " +
714
717
  "another problem's solution id → 404). add {title, body?, pro?, con?, recommended?, steps?}: title names the " +
715
- "option/route, body is its markdown detail, pro/con the case for and against, `steps` is its WHOLE procedure " +
716
- "authored in one call — an ordered array of {title, description?, steps?}, nestable to 3 levels (a 4th is refused " +
717
- "400 naming the offending step and the limit). AC 35332 — under an ACTION problem, `steps` must be non-empty " +
718
- "(refused 400 otherwise); under a QUESTION it may be omitted or empty. Labels (\"1\", \"2a\", \"2a.i\") are " +
719
- "ALWAYS DERIVED on read from position — never put one in a title yourself. edit :sid {base_hash, ...only the " +
720
- "changed fields, steps?, steps_base_hash?}: `steps` omitted leaves the stored procedure untouched, an explicit " +
721
- "array (including [], refused if it would leave an action's solution with zero steps) DIFFS against it — send " +
722
- "back an existing step's `id` (read from the card) to keep it (even while retitling/reordering it) and omit " +
723
- "`id` for a genuinely new step; any existing step you don't include gets removed, and an id-less MIDDLE insert " +
724
- "recreates every later id-less sibling's row unless you send ids for the ones whose identity matters. " +
725
- "`steps_base_hash` is REQUIRED whenever `steps` is sent — the solution's current `steps_hash` (read it off the " +
726
- "card first); a stale value → 409 `stale_steps` carrying the current tree, so a step added/removed since your " +
727
- "read is never silently dropped. NEVER resend the whole tree just to fix one word — see add_step/edit_step/" +
728
- "remove_step below. remove :sid {base_hash}. A stale base_hash → 409 `stale_solution` with currentHash + " +
729
- "currentSolution: merge, then retry. At most ONE live recommended per problem: a second → 409 naming " +
730
- "`recommended_solution_id`. A CHOSEN option's words are frozen (409 — add a new one instead), and so is its " +
731
- "WHOLE PROCEDURE — every steps route below also refuses once a decision has chosen this solution.\n\n" +
732
- "Granular single-step actions — change ONE step without resending the tree: add_step {solution_id, title, " +
733
- "description?, parent_step_id?, position?} — title is ONE imperative line, never a label like \"1.\" or \"2a\" " +
734
- "(derived on read); parent_step_id omitted/null = top-level; position is 1-indexed among the parent's current " +
735
- "live children, omitted = append, and a position beyond the current sibling count is refused (400), never " +
736
- "silently clamped to append; nesting past depth 3 → 400. edit_step {solution_id, step_id, base_hash, title?, " +
737
- "description?} — never moves a step (no parent_step_id/position here). remove_step {solution_id, step_id, " +
738
- "base_hash} — soft-deletes it AND its own live children; refused (409) if this solution belongs to an ACTION " +
739
- "problem and removing it would leave zero live steps. A stale base_hash on a step → 409 `stale_step` with " +
740
- "currentHash + currentStep (carrying its real derived `label`).", {
718
+ "option/route, body its markdown detail, pro/con the case for and against; `steps` authors its WHOLE procedure " +
719
+ "in one call — an ordered array of {title, description?, steps?}, nestable to 3 levels (a 4th refused 400 " +
720
+ "naming the step and the limit). AC 35332 — under an ACTION problem `steps` must be non-empty (400 otherwise); " +
721
+ "under a QUESTION it may be empty/omitted. Labels (\"1\", \"2a\", \"2a.i\") are ALWAYS DERIVED on read from " +
722
+ "position — never put one in a title. edit :sid {base_hash, ...changed fields, steps?, steps_base_hash?}: " +
723
+ "`steps` omitted leaves the procedure untouched; an explicit array (including [], refused if it would leave " +
724
+ "an action's solution with zero steps) DIFFS against the stored tree the same way STEP_INPUT's own `id` field " +
725
+ "describes. `steps_base_hash` is REQUIRED whenever `steps` is sent — the solution's current `steps_hash`; " +
726
+ "stale → 409 `stale_steps` with the current tree. Prefer add_step/edit_step/remove_step below over resending " +
727
+ "the whole tree for a one-word fix. remove :sid {base_hash}. A stale base_hash → 409 `stale_solution` with " +
728
+ "currentHash + currentSolution: merge, then retry. At most ONE live recommended per problem (second → 409 " +
729
+ "naming `recommended_solution_id`). A CHOSEN option's words AND whole procedure are frozen — every route below " +
730
+ "also refuses once a decision has chosen this solution (add a new solution instead).\n\n" +
731
+ "Granular single-step edits: add_step {solution_id, title, " +
732
+ "description?, parent_step_id?, position?} — title is ONE imperative line, never a label (derived on read); " +
733
+ "parent_step_id omitted/null = top-level; position 1-indexed among current live children, omitted = append, " +
734
+ "beyond sibling count refused 400; nesting past depth 3 → 400. edit_step {solution_id, step_id, base_hash, " +
735
+ "title?, description?} — never moves a step. remove_step {solution_id, step_id, base_hash} — soft-deletes it " +
736
+ "and its own live children; refused 409 if this solution belongs to an ACTION problem and removing it would " +
737
+ "leave zero live steps. A stale base_hash on a step → 409 `stale_step` with currentHash + currentStep (its " +
738
+ "derived `label`).", {
741
739
  id: z.string().min(1),
742
740
  action: z.enum(["add", "edit", "remove", "add_step", "edit_step", "remove_step"]),
743
741
  problem_id: z.number().int().positive(),
@@ -769,7 +767,7 @@ strictTool("issue_solution", "One problem's options via /api/issues/:id/problems
769
767
  ...boardField,
770
768
  }, async (args) => jsonResult(await issueSolution(client, args)));
771
769
  // ---------------- issue_dependency ----------------
772
- strictTool("issue_dependency", 'Dependency CRUD via /api/issues/:id/dependencies[/:did]. action=add → POST {kind, target_id, reason} where kind ∈ {depends_on, conflict_on}. depends_on adds are CYCLE-CHECKED (BFS from target back to source — 409 if loop). Idempotent: re-adding a live triple returns the existing id. Self-loops refuse 409. action=remove → DELETE /:did. The server REQUIRES the literal reason="recorded_in_error" on removal (encodes "removal means NOT related, never satisfied") — this MCP boundary hardcodes it, so callers do not pass reason on remove. This is the ONLY mechanism the dispatch picker enforces to sequence one card after another — leaving a card at a Review/held status (e.g. an issue_triage "keep" verdict) is NOT a substitute and provides no cross-card ordering protection.', {
770
+ strictTool("issue_dependency", 'Dependency CRUD. add: {kind, target_id, reason}, kind ∈ {depends_on, conflict_on}. depends_on is cycle-checked (409 on a loop back to source); self-loops refuse 409; re-adding a live triple returns the existing id. remove: {dependency_id} — reason is fixed server-side to "recorded_in_error" (removal means NOT related, never satisfied), so never pass it. The ONLY mechanism the dispatch picker uses to sequence one card after another — a held status (e.g. an issue_triage "keep") is not a substitute and gives no cross-card ordering.', {
773
771
  id: z.string().min(1),
774
772
  action: z.enum(["add", "remove"]),
775
773
  kind: z.enum(["depends_on", "conflict_on"]).optional(),
@@ -779,13 +777,13 @@ strictTool("issue_dependency", 'Dependency CRUD via /api/issues/:id/dependencies
779
777
  ...boardField,
780
778
  }, async (args) => jsonResult(await issueDependency(client, args)));
781
779
  // ---------------- issue_retire_branch ----------------
782
- strictTool("issue_retire_branch", "Mark a card's own `card/<id>` branch RETIRED (unsafe to merge) via POST /api/issues/:id/card-branch-retire {reason} (DX-2845). NO status/terminal gate — settable the moment a branch is judged unsafe (an audit rejected it, the card was split into fresh slices, ...), whether the card is ToDo, In Progress, or anything else; this is deliberately NOT the same as captureAndDeleteCardBranch, which only fires once the card itself reaches Done/Cancelled. `by` is server-stamped from the resolved writing identity, never client-supplied. The dispatched worker reads this at its next bootstrap and forces `origin/main` as the checkout start point instead of re-attaching to the retired content — the origin `card/<id>` ref itself is separately backed up then deleted by the worker as a lazy hygiene step. Idempotent: retiring an already-retired branch just re-stamps reason/actor/timestamp.", {
780
+ strictTool("issue_retire_branch", "Mark a card's own `card/<id>` branch RETIRED (unsafe to merge); pass `reason`. No status gate — settable at any lifecycle stage, unlike the Done/Cancelled-only branch cleanup this is distinct from. `by` is server-stamped, never client-supplied. The next dispatch bootstrap forces `origin/main` as its checkout start point instead of re-attaching to the retired content; the origin ref itself is backed up then deleted separately as lazy hygiene. Idempotent — re-retiring just re-stamps reason/actor/timestamp.", {
783
781
  id: z.string().min(1),
784
782
  reason: z.string().min(1),
785
783
  ...boardField,
786
784
  }, async (args) => jsonResult(await issueRetireBranch(client, args)));
787
785
  // ---------------- issue_quality_gate ----------------
788
- strictTool("issue_quality_gate", "Put one quality gate ON a card, or take it OFF, via POST /api/issues/:id/quality-gates/:gate {action} — the only post-create way (issue_create names gates in quality_gates; issue_edit refuses gate keys). A gate is on the card or it does not exist for it; every gate on a card is required, so `add` means it now runs and `remove` means it is gone (not displayed, not counted). Adding a gate at any time is fully supported — that is what this tool is for. PRE `plan-*` gates run before the work dispatch; POST `code-*` gates block complete. Unknown gate → 400; a never-gated card type (Epic/Feature/Task) → 400. `note` (why it applies) and `effort_level` (overrides a `plan-*` gate's reviewer rung; null clears it) are `add`-only — passing either with `remove` → 400. Re-adding a gate the card already has updates note/effort and KEEPS its verdict; `remove` discards the row and any verdict on it. Board-scoped; see `board`.", {
786
+ strictTool("issue_quality_gate", "Put one quality gate ON a card, or take it OFF (`action`) — the only way after create (issue_create names gates in quality_gates; issue_edit refuses gate keys). A gate on the card is required; add means it now runs, remove means it's gone entirely. PRE `plan-*` gates run before the work dispatch; POST `code-*` gates block complete. Unknown gate, or a never-gated type (Epic/Feature/Task), → 400. `note`/`effort_level` are add-only (400 with remove). Re-adding an existing gate updates note/effort and keeps its verdict; remove discards the row and its verdict. Board-scoped; see `board`.", {
789
787
  id: z.string().min(1),
790
788
  gate: z.enum([
791
789
  "plan-dependency",
@@ -806,7 +804,7 @@ strictTool("issue_quality_gate", "Put one quality gate ON a card, or take it OFF
806
804
  ...boardField,
807
805
  }, async (args) => jsonResult(await issueQualityGate(client, args)));
808
806
  // ---------------- issue_quality_gate_verdict ----------------
809
- strictTool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate VERDICT via PATCH /api/issues/:id/quality-gates/:gate {status, message} — the same write the dashboard Gates-tab Pass / Fail / Revert controls perform (DX-1373). SIBLING of `issue_quality_gate`, not a replacement: that one puts a gate ON or OFF the card (does this gate apply at all), THIS one records the VERDICT (did it pass) — POST vs PATCH on the same resource, neither substitutes for the other. **Use this to close out a card you picked up with `issue_transition pickup {manual:true}`** (DX-946 operator-session self-pickup): `issue_transition complete` REFUSES 409 (`failed_gate: \"quality_gate_post\"`, `failed_post_gates[]`) while any required POST gate (`code-quality` / `code-test-quality` / `code-architecture`) is not `pass`, so without a verdict a manually-claimed card can never reach Done — it strands In Progress and its `conflict_on` / `waiting_on` edges then stall OTHER cards' dispatch. `status`: `pass` | `fail` | `pending` (revert a prior verdict, clears the message). `message` is the accountability record for the override, REQUIRED at >= 20 characters for `pass`/`fail` (shorter → 400), ignored for `pending`. Record the REAL reviewer finding, not a rubber stamp — a human-attributed override, stamped with the operator actor, standing in for a reviewer dispatch. A manual verdict is a PURE row write: no side effects — a manual `fail` never blocks the card and a manual `pass` never releases a dispatch. Unknown gate → 400; bad status → 400; unknown card → 404. Board-scoped; see `board`.", {
807
+ strictTool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate VERDICT (`status`, `message`) — the dashboard Gates-tab Pass/Fail/Revert write. Sibling of `issue_quality_gate` (that toggles whether a gate applies; this records whether it passed) — neither substitutes for the other. Needed to close a card claimed via `issue_transition pickup {manual:true}`: `complete` refuses 409 (`failed_gate: \"quality_gate_post\"`, `failed_post_gates[]`) while any required POST gate (`code-quality`/`code-test-quality`/`code-architecture`) isn't `pass`. `status`: pass|fail|pending (pending reverts a prior verdict, clearing the message). `message` required at >=20 characters for pass/fail (else 400), ignored for pending — record the real reviewer finding, not a rubber stamp. Pure row write: no side effects — a manual fail never blocks the card, a manual pass never releases a dispatch. Unknown gate/status → 400; unknown card → 404. Board-scoped; see `board`.", {
810
808
  id: z.string().min(1),
811
809
  gate: z.enum([
812
810
  "plan-dependency",
@@ -820,14 +818,26 @@ strictTool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate
820
818
  message: z.string().optional(),
821
819
  ...boardField,
822
820
  }, async (args) => jsonResult(await issueQualityGateVerdict(client, args)));
821
+ // ---------------- quality_gate_instruction ----------------
822
+ strictTool("quality_gate_instruction", "Fetch a named quality gate's current instruction — the same DB-catalog skill prose a dispatched gate reviewer's clean-room materializes, not card-scoped. Use from an operator session to run a gate manually: fetch the instruction, dispatch a tier-selected `danxbot:worker-*` with it, review, then record the verdict via `issue_quality_gate_verdict` — never a hand-written brief. `gate` is the same enum `issue_quality_gate` accepts. Unknown gate → 400; an unresolvable catalog artifact for this repo → 500. Board-scoped; see `board`.", {
823
+ gate: z.enum([
824
+ "plan-dependency",
825
+ "plan-architecture",
826
+ "plan-tdd",
827
+ "code-test-quality",
828
+ "code-architecture",
829
+ "code-quality",
830
+ ]),
831
+ ...boardField,
832
+ }, async (args) => jsonResult(await qualityGateInstruction(client, args)));
823
833
  // ---------------- issue_retro ----------------
824
- strictTool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retro. Body: {good, bad, correctable_danxbot_problem, correctable_danxbot_problem_description, action_item_ids[], commits[], tests[]}. REFUSES 409 unless the card is terminal (completed_at OR cancelled_at) — retro ships when work concludes. Replace semantics: good/bad upsert; action_item_ids[] + commits[] + tests[] soft-delete prior live rows and insert with fresh ordinals. action_item_ids[] entries MUST match <PREFIX>-N. commits[] entries take {sha, subject?}. tests[] (DX-1646) is REQUIRED (empty array allowed — the \"ran no tests\" case): one row per test GROUP that ran (a whole suite/class — name the group, do NOT list individual unit tests) or per individual e2e test (kind:'e2e', few + expensive so listed explicitly). Each row: {name, kind:'group'|'e2e', num_tests, num_passing_tests, duration_ms} required; num_assertions + num_passing_assertions NULLABLE (vitest has no assertion totals — pass null or omit). `correctable_danxbot_problem` (DX-2794) is REQUIRED on every write, like tests[] — ANSWER IT HONESTLY: did THIS dispatch hit a problem in danxbot's own code or configuration (not merely \"this card was hard\") that danxbot could change so it stops happening? true REQUIRES a non-empty `correctable_danxbot_problem_description` naming the problem; false REQUIRES the description be empty. A true+described retro is read by a deterministic, no-LLM check and starts exactly one automated repair (fixes the problem or files a ready card) — this is the ONLY reliable channel for a danxbot defect found mid-dispatch to actually get fixed, so do not default to false out of haste.", {
834
+ strictTool("issue_retro", "Replace the retro block: {good, bad, correctable_danxbot_problem, correctable_danxbot_problem_description, action_item_ids[], commits[], tests[]}. Refuses 409 unless the card is terminal (completed_at or cancelled_at). good/bad upsert; the three list fields REPLACE (soft-delete prior live rows, fresh ordinals). action_item_ids[] entries match <PREFIX>-N. commits[] entries: {sha, subject?}. tests[] is required (empty array = ran none): one row per test GROUP that ran (name the group, not individual unit tests) or per e2e test (kind:'e2e', listed explicitly since few/expensive); each row {name, kind:'group'|'e2e', num_tests, num_passing_tests, duration_ms} required, num_assertions/num_passing_assertions nullable (omit when the runner has no assertion totals).", {
825
835
  id: z.string().min(1),
826
836
  good: z.string().describe("Free-form markdown (prose or a list — pick whichever reads fastest, they render identically). Style rules: see issue_comment's `text` param."),
827
837
  bad: z.string().describe("Free-form markdown (prose or a list — pick whichever reads fastest, they render identically). Style rules: see issue_comment's `text` param."),
828
838
  correctable_danxbot_problem: z
829
839
  .boolean()
830
- .describe("Required. Did this dispatch hit a problem in danxbot's OWN code/configuration (not \"this card was hard\") that danxbot could change so it stops happening? Answer explicitly every time — never left at a default."),
840
+ .describe("Required. Did this dispatch hit a problem in danxbot's OWN code/configuration (not \"this card was hard\") that danxbot could change so it stops happening? A true+described answer starts exactly one automated repair (fix or a ready card) — the only reliable channel for a danxbot defect found mid-dispatch. Answer explicitly every time, never a default."),
831
841
  correctable_danxbot_problem_description: z
832
842
  .string()
833
843
  .describe("Required. Non-empty (naming the concrete problem) when correctable_danxbot_problem is true; must be empty when it is false."),
@@ -857,7 +867,7 @@ strictTool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retro
857
867
  // route's MAX_DECODED_BYTES (src/issues/write/attachments.ts). This package is
858
868
  // a separate published artifact and cannot import that constant, so the number
859
869
  // is restated here as prose — keep the two in sync if the backend ceiling moves.
860
- strictTool("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 `{issue, attachment_id, s3_key}` — find the new attachment in `issue.attachments` by `attachment_id` to read its public, long-lived `url` (no expiry — never a presigned link). EMBEDDING: that `url` is not only a card-level attachment — paste it into ANY markdown-bearing field (a problem's `statement`/`context` via `issue_problem`, a comment via `issue_comment`, a plan record's `context`, a solution's `body`) as `![description](url)` and the dashboard renders it inline, capped to a compact thumbnail with click-to-enlarge. Use this whenever a screenshot would let a human judge something faster than prose — a UI bug, a before/after, a broken layout. Example: after uploading and reading the attachment's url (say `https://dx-issues.s3.us-east-1.amazonaws.com/abc123.png`), call `issue_comment` with text `Before the fix, the sidebar overlapped the header:\\n\\n![sidebar overlapping header](https://dx-issues.s3.us-east-1.amazonaws.com/abc123.png)`.", {
870
+ strictTool("issue_attach", "Attach a LOCAL file to an issue card: `id` (the card) + `file_path` (an ABSOLUTE path on the dispatch's shared filesystem). Reads the bytes, infers MIME from the extension, uploads via the dashboard: stores in S3, inserts one attachment row, auto-mirrors to the card's linked Trello card and Slack thread. Board-scoped; see `board`. Fail-loud: a relative/empty path is rejected at the boundary; a missing/unreadable file throws before any upload (no partial S3 object or row). 25 MB decoded ceiling (413). Returns `{issue, attachment_id, s3_key}` — find the attachment in `issue.attachments` by `attachment_id` to read its permanent, non-expiring `url`. That url can be embedded in ANY markdown field (a problem's statement/context, a comment, a plan record, a solution body) as `![description](url)` — the dashboard renders it inline as a thumbnail. Use whenever a screenshot judges something faster than prose.", {
861
871
  id: z.string().min(1),
862
872
  file_path: z
863
873
  .string()
@@ -866,11 +876,11 @@ strictTool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/i
866
876
  ...boardField,
867
877
  }, async (args) => jsonResult(await issueAttach(client, args)));
868
878
  // ---------------- repo_knowledge_get ----------------
869
- strictTool("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.", {
879
+ strictTool("repo_knowledge_get", "Fetch the board's working-knowledge markdown doc. 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. Before `repo_knowledge_set`, always get immediately first and pass `contentHash` back as `base_hash` — the concurrency guard rejects a stale write.", {
870
880
  ...boardField,
871
881
  }, async (args) => jsonResult(await repoKnowledgeGet(client, args)));
872
882
  // ---------------- repo_knowledge_set ----------------
873
- strictTool("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.', {
883
+ strictTool("repo_knowledge_set", 'Write the board\'s working-knowledge markdown doc. Board-scoped; see `board`. `base_hash` must be the `contentHash` from the immediately-prior `repo_knowledge_get` ("" for the true first write). On mismatch, fails loud with `{error: "stale_repo_knowledge", currentHash}` rather than overwriting — re-get, re-merge, retry with the new hash. On success persists to the DB, publishes `repo-knowledge:updated` over SSE, and returns the new view.', {
874
884
  content: z.string(),
875
885
  base_hash: z
876
886
  .string()
@@ -879,11 +889,11 @@ strictTool("repo_knowledge_set", 'Write the board\'s working-knowledge markdown
879
889
  ...boardField,
880
890
  }, async (args) => jsonResult(await repoKnowledgeSet(client, args)));
881
891
  // ---------------- brief_list ----------------
882
- strictTool("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.", {
892
+ strictTool("brief_list", "List the board's named Brief pages. Board-scoped; see `board`. Returns `{boardId, pages: [{slug, title, contentHash, sortOrder, updatedAt, updatedBy}]}` — metadata only, no content (use `brief_get_page`). Unlike `repo_knowledge_get`/`_set` (one board-level doc), Brief pages are MANY named pages per board (Goals/Architecture/Rules/Caveats tabs), keyed by (board, slug). The reserved `index` slug always exists.", {
883
893
  ...boardField,
884
894
  }, async (args) => jsonResult(await briefList(client, args)));
885
895
  // ---------------- brief_get_page ----------------
886
- strictTool("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.', {
896
+ strictTool("brief_get_page", 'Fetch one Brief page by slug. Board-scoped; see `board`. Returns `{boardId, slug, title, content, contentHash, sortOrder, updatedAt, updatedBy}` — a missing page reads as the empty view (content/contentHash `""`), not a 404. Before `brief_set_page`, always get immediately first and pass `contentHash` back as `base_hash` — the concurrency guard rejects a stale write.', {
887
897
  slug: z
888
898
  .string()
889
899
  .min(1)
@@ -891,7 +901,7 @@ strictTool("brief_get_page", 'Fetch one Brief page by slug via GET /api/brief/pa
891
901
  ...boardField,
892
902
  }, async (args) => jsonResult(await briefGetPage(client, args)));
893
903
  // ---------------- brief_set_page ----------------
894
- strictTool("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.', {
904
+ strictTool("brief_set_page", 'Write one Brief page. Board-scoped; see `board`. Body: `{content, title?, sortOrder?, base_hash?}` — same optimistic-concurrency shape as `repo_knowledge_set`, targeting one named page. `base_hash` must be the `contentHash` from the immediately-prior `brief_get_page` (`""` for a true first write). On mismatch fails loud with `{error: "stale_brief_page", currentHash}` — re-get, re-merge, retry, never overwrite blindly. On success persists, publishes `brief:updated` over SSE, and returns the new view. No delete tool on this surface; the reserved `index` slug is never deletable — removing a non-index page is dashboard-UI-only.', {
895
905
  slug: z
896
906
  .string()
897
907
  .min(1)
@@ -915,13 +925,13 @@ strictTool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug=
915
925
  // plan id — and it can only ever bind the caller's own session. `plan_create`
916
926
  // also takes no plan id, but for a different reason: it MAKES a plan rather
917
927
  // than acting on one, so there is no existing plan for an id to name yet.
918
- strictTool("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, ref, name, createdAt, cardCount, boards, status}], session, sessionListenerAttached}}` — `ref` is the plan's short reference (`PLN-<id>`), the same thing a card's own id is for a card; cite it rather than a bare id. Each plan's `status` (DX-2834) is COMPUTED fresh on every read, never stored — one of `awaiting-session` (no session is live on it — a `plan_sessions` row is never released when a session merely ends, so this is a real liveness check, not just \"has anyone ever connected\"), `planning` (no cards, or only Review/Backlog/terminal cards with at least one not Done/Cancelled), `building` (a live session AND at least one card ToDo/In Progress or in an active-but-stuck state — Blocked, Needs Help), `complete` (at least one card and every one Done/Cancelled — wins even with no session). Pass `status` to filter to one of them. `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 session's event stream is attached (the danxbot plugin's plan event bridge holds it). It is `false` for a few seconds right after `plan_connect` while the bridge starts; still `false` after that while connected means its card events are NOT reaching you — tell the operator. There is nothing to arm. NOTE this is NOT the board Brief (`brief_list`), which is a different feature entirely.", {
928
+ strictTool("plan_list", "List every plan, 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, from any repo. Returns `{plans: [{id, ref, name, createdAt, cardCount, boards, status}], session, sessionListenerAttached}` — `ref` is the plan's short reference (`PLN-<id>`), cite it rather than the bare id. `status` is computed fresh on every read, never stored: `awaiting-session` (no live session — a session row isn't released just because a session ended), `planning` (no cards, or only Review/Backlog/terminal ones with ≥1 not Done/Cancelled), `building` (a live session AND ≥1 card ToDo/In Progress or stuck-but-active — Blocked/Needs Help), `complete` (≥1 card, all Done/Cancelled — wins even with no session). Pass `status` to filter. `session` is your own registration (`{sessionId, title, planId, planName, firstSeenAt, lastActiveAt}`) or null outside a Claude Code session; `planId: null` means connected to no plan — `plan_get` to browse, `plan_connect` to bind. `sessionListenerAttached` says whether your event stream is attached (the plugin's plan bridge); false for a few seconds right after connect is normal, false after that means events aren't reaching you — tell the operator. Distinct from the board Brief (`brief_list`).", {
919
929
  status: z
920
930
  .enum(PLAN_STATUSES)
921
931
  .optional()
922
932
  .describe("Filter to one computed status: awaiting-session, planning, building, complete. Omit for every plan."),
923
933
  }, async (args) => jsonResult(await planList(client, args)));
924
- strictTool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id` to read ANY plan; OMIT to read the plan this session is connected to — omitting while connected to none fails loud `{error: \"session_not_connected\"}`, connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{plan, boards, cardCount, bucketCounts, status, session, sessionListenerAttached, available_field_groups}` — no cards, records, or architecture body. `plan` carries `{id, ref, name, createdAt}`; `ref` is the plan's short reference (`PLN-<id>`) — cite that, not the bare id. `status` (DX-2834) is computed fresh on every read, never stored — see `plan_list` for the four values and what each means. Pass `fields` to opt into: `cards` (member cards, PAGED: `cards_offset` (default 0) and `cards_limit` (1.." + LIST_PAGE_MAX_LIMIT + ", default " + PLAN_GET_CARDS_DEFAULT_LIMIT + ") pick the page, in stable card-reference order (board prefix, then card number — pages never repeat/skip unless membership changes between reads); response carries `cards_total`/`cards_offset` — page while cards_offset + cards.length < cards_total; either paging arg without `fields: [\"cards\"]` is a 400), `records` (every goal+rule+caveat, keyed by kind) or `records:goal` / `records:rule` / `records:caveat` (one kind, cheaper), `architecture` (`{sections: [{id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}]}`), `sessions` (every session connected to the plan), `events` (DX-2987/DX-3027 — the plan's durable event ledger: every human action and every bridge message recorded on it, cursor-paged newest first via `events_limit` (1.." + PLAN_GET_EVENTS_MAX_LIMIT + ", default " + PLAN_GET_EVENTS_DEFAULT_LIMIT + ") and `events_before` (an opaque cursor — pass a previous page's `next_cursor` to read older; omit for the newest page); filter with `events_kinds` (one or more of " + PLAN_EVENT_KINDS.join(", ") + "), `events_origin` (one of " + PLAN_EVENT_ORIGINS.join(", ") + "), `events_writer` (exact writer name); every `events_*` param without `fields: [\"events\"]` is a 400, same as the `cards_*` params above; response carries `events: {items: [{id, at, kind, writer, origin, originSessionId, targetSessionId, cardId, cardTitle, boardId, detail}], next_cursor}` — `next_cursor` is `null` on the last page; an event on a card whose board you cannot read is left out, plan-level events are always visible). `session`/`sessionListenerAttached` and `available_field_groups` ride every response regardless. `sessionListenerAttached` is `false` for a few seconds right after `plan_connect` while the plugin's event bridge starts; still `false` after that while connected means the plan's card events are not reaching you — tell the operator. ALWAYS `plan_get`/`plan_get_architecture_section` immediately before `plan_update_architecture_section`/`plan_delete_architecture_section` and pass the returned `contentHash` back as `base_hash`.", {
934
+ strictTool("plan_get", "Read a plan. Pass `plan_id` to read ANY plan; OMIT to read the plan this session is connected to — omitting while connected to none fails loud `{error: \"session_not_connected\"}`, connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{plan, boards, cardCount, bucketCounts, status, session, sessionListenerAttached, available_field_groups}` — no cards, records, or architecture body. `plan` carries `{id, ref, name, createdAt}`; `ref` is the plan's short reference (`PLN-<id>`) — cite that, not the bare id. `status` is computed fresh on every read, never stored — see `plan_list` for the four values and what each means. Pass `fields` to opt into: `cards` (member cards, paged via `cards_offset`/`cards_limit` — see their own field descriptions — in stable card-reference order: board prefix then card number, pages never repeat/skip unless membership changes between reads; response carries `cards_total`), `records` (every goal+rule+caveat, keyed by kind) or `records:goal` / `records:rule` / `records:caveat` (one kind, cheaper), `architecture` (`{sections: [{id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}]}`), `sessions` (every session connected to the plan), `events` (the plan's durable event ledger — every human action and bridge message, paged/filtered via `events_limit`/`events_before`/`events_kinds`/`events_origin`/`events_writer` — see their own field descriptions; response carries `events: {items: [{id, at, kind, writer, origin, originSessionId, targetSessionId, cardId, cardTitle, boardId, detail}], next_cursor}`, `next_cursor` null on the last page; an event on a card whose board you cannot read is left out, plan-level events are always visible). `session`/`sessionListenerAttached` and `available_field_groups` ride every response regardless. `sessionListenerAttached` is `false` for a few seconds right after `plan_connect` while the plugin's event bridge starts; still `false` after that while connected means the plan's card events are not reaching you — tell the operator. ALWAYS `plan_get`/`plan_get_architecture_section` immediately before `plan_update_architecture_section`/`plan_delete_architecture_section` and pass the returned `contentHash` back as `base_hash`.", {
925
935
  plan_id: z
926
936
  .number()
927
937
  .int()
@@ -951,32 +961,32 @@ strictTool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id`
951
961
  .positive()
952
962
  .max(PLAN_GET_EVENTS_MAX_LIMIT)
953
963
  .optional()
954
- .describe("DX-3027 — how many ledger events one page holds, 1.." + PLAN_GET_EVENTS_MAX_LIMIT + " (default " + PLAN_GET_EVENTS_DEFAULT_LIMIT + "). Requires `fields` to include `events`."),
964
+ .describe("how many ledger events one page holds, 1.." + PLAN_GET_EVENTS_MAX_LIMIT + " (default " + PLAN_GET_EVENTS_DEFAULT_LIMIT + "). Requires `fields` to include `events`."),
955
965
  events_before: z
956
966
  .string()
957
967
  .min(1)
958
968
  .optional()
959
- .describe("DX-3027 — an opaque cursor from a previous page's `next_cursor`. Omit for the newest page. Requires `fields` to include `events`."),
969
+ .describe("an opaque cursor from a previous page's `next_cursor`. Omit for the newest page. Requires `fields` to include `events`."),
960
970
  events_kinds: z
961
971
  .array(z.enum(PLAN_EVENT_KINDS))
962
972
  .optional()
963
- .describe("DX-3027 — only these event kinds. Omit for every kind. Requires `fields` to include `events`."),
973
+ .describe("only these event kinds. Omit for every kind. Requires `fields` to include `events`."),
964
974
  events_origin: z
965
975
  .enum(PLAN_EVENT_ORIGINS)
966
976
  .optional()
967
- .describe("DX-3027 — only events with this origin. Omit for every origin. Requires `fields` to include `events`."),
977
+ .describe("only events with this origin. Omit for every origin. Requires `fields` to include `events`."),
968
978
  events_writer: z
969
979
  .string()
970
980
  .min(1)
971
981
  .optional()
972
- .describe("DX-3027 — only events with this exact writer name. Omit for every writer. Requires `fields` to include `events`."),
982
+ .describe("only events with this exact writer name. Omit for every writer. Requires `fields` to include `events`."),
973
983
  }, async (args) => jsonResult(await planGet(client, args)));
974
- strictTool("plan_create", "Create a new, empty plan via POST /api/plans (DX-2531). GLOBAL — a plan is not board-scoped, and this call adds no cards, no records, and no architecture sections; it does NOT connect any session to the new plan (call `plan_connect` separately, exactly as adding a card to a plan is its own separate step). Returns `{ok, status, body: {plan: {id, ref, name, createdAt}}}` — `ref` is the plan's short reference (`PLN-<id>`). Use the returned `plan.id` with `plan_connect` to start working on it, or with `plan_get({plan_id})` to browse it.", {
984
+ strictTool("plan_create", "Create a new, empty plan. Global — not board-scoped. Adds no cards, records, or architecture sections, and does not connect any session (call `plan_connect` separately). Returns `{plan: {id, ref, name, createdAt}}` — `ref` is the short reference (`PLN-<id>`). Use `plan.id` with `plan_connect` to start working on it, or `plan_get({plan_id})` to browse.", {
975
985
  name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
976
986
  }, async (args) => jsonResult(await planCreate(client, args)));
977
987
  strictTool("plan_connect",
978
988
  // DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
979
- "Connect THIS session to a plan via POST /api/plan-sessions/me/plan — the same binding the operator's Connect action writes. ONE CALL IS ENOUGH TO START (DX-2859): the reply carries `{session, movedFrom, browserInstruction, briefing, listenerHealth}` — `browserInstruction` (DX-3276) is a server-built action to take NOW, not just informational text: it names the plan's URL and tells you to open it (your in-app browser if you have one, else your default browser), keep that tab open for the whole session, never navigate it away, and use a different tab for your own browsing — it is the operator's tab for following and talking to you through the plan. Returned on EVERY connect, including a re-connect, so it doubles as the reminder after a context loss — act on it again each time. `briefing` is every goal/rule/caveat (ref+body), every architecture section (id+title), the plan's own ref/name/status, a first page of open cards (id/type/status/title/openProblemCount/assignedAgent) with a `morePagesHint` when more exist, and the closed-card count — usually replacing the `plan_get({fields:[...]})` + card batch-read a fresh session used to need. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and `movedFrom: {id, name} | null` says which plan it left (null = no plan, or already this one). It binds only your OWN session, resolved from the session id this server forwards. Afterwards every plan WRITE tool acts on this plan and takes no plan id. `listenerHealth` reports the event bridge's state — `null` (no session), or `{attached, state: \"unattached\"|\"credential_mismatch\"|\"board_scope_narrowed\"|\"healthy\", nextStep}` naming a concrete fix for every unhealthy state (the SAME shape `plan_list`/`plan_get` report). Once healthy, every comment, answer, problem added and block/unblock on this plan's cards reaches the session on its own, relayed by the danxbot plugin's plan event bridge as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<problem statement>\": chose \"Pause E2E\"`. Nothing to arm; never poll for these. DX-2816: pass `title` (call `get_session({session_id:\"self\"})` first and forward its `title` verbatim) so the dashboard shows the same name Claude does — this server has no way to read it itself.", {
989
+ "Connect THIS session to a plan. ONE CALL IS ENOUGH TO START: the reply carries `{session, movedFrom, browserInstruction, briefing, listenerHealth}` — `browserInstruction` is a server-built action to take NOW: it names the plan's URL and tells you to open it (in-app browser if you have one, else default), keep that tab open for the whole session without navigating it away, and use a different tab for your own browsing — the operator's tab for following and talking to you. Returned on every connect, including a re-connect, so it doubles as the post-context-loss reminder. `briefing` is every goal/rule/caveat (ref+body), every architecture section (id+title), the plan's own ref/name/status, a first page of open cards (id/type/status/title/openProblemCount/assignedAgent) with a `morePagesHint` when more exist, and the closed-card count — usually replacing the `plan_get({fields:[...]})` + card batch-read a fresh session used to need. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and `movedFrom: {id, name} | null` says which plan it left (null = no plan, or already this one). Binds only your OWN session, resolved from the session id this server forwards; afterwards every plan WRITE tool acts on this plan and takes no plan id. `listenerHealth` reports the event bridge's state — `null` (no session), or `{attached, state: \"unattached\"|\"credential_mismatch\"|\"board_scope_narrowed\"|\"healthy\", nextStep}` naming a concrete fix per unhealthy state (same shape `plan_list`/`plan_get` report). Once healthy, every comment, answer, problem added and block/unblock on this plan's cards reaches the session unpolled, relayed by the plugin's event bridge as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<statement>\": chose \"Pause E2E\"`. Pass `title` (call `get_session({session_id:\"self\"})` first and forward its `title` verbatim) so the dashboard shows the same name Claude does — this server cannot read it itself.", {
980
990
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
981
991
  title: z
982
992
  .string()
@@ -1000,15 +1010,15 @@ async (args) => {
1000
1010
  }),
1001
1011
  });
1002
1012
  });
1003
- strictTool("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. DX-3072 — returns the created record plus `records_count` (that kind's live count, not the whole list, which can grow unboundedly over a plan's life); read the list itself with `plan_get({fields:[\"records:<kind>\"]})` or the dedicated GET.", {
1013
+ strictTool("plan_add_record", "Add a goal, rule or caveat to your connected plan (see `kind`'s own values below for what each means — never progress/status/session notes, those are card comments). `body` is one plain statement, at most 250 characters (400 if longer); detail goes in markdown `context`. Allocates a permanent reference (`G-1`, `R-4`, `CAV-12`). Takes no plan id; not connected → `plan_connect` first. Returns the created record plus `records_count` (that kind's live count, not the whole list) — read the list with `plan_get({fields:[\"records:<kind>\"]})`.", {
1004
1014
  kind: z.enum(["goal", "rule", "caveat"]).describe("goal = outcome, rule = constraint, caveat = architecture trade-off."),
1005
1015
  body: z.string().min(1).describe("One plain statement, at most 250 characters. Details go in `context`."),
1006
1016
  context: z.string().optional().describe("Markdown detail behind the statement: evidence, history, examples."),
1007
1017
  }, async (args) => jsonResult(await planAddRecord(client, args)));
1008
- strictTool("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.", {
1018
+ strictTool("plan_get_record", "Read one goal/rule/caveat without pulling the whole plan. Takes no plan id; an unknown or foreign record id → 404. Returns `{record: {id, planId, kind, refNum, ref, body, context, contentHash, createdAt, updatedAt}}` — `context` is markdown detail, or null.", {
1009
1019
  record_id: z.number().int().positive().describe("A record id, from `plan_add_record`, `plan_get` or `plan_get_record` itself."),
1010
1020
  }, async (args) => jsonResult(await planGetRecord(client, args)));
1011
- strictTool("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. DX-3072 — returns the edited record plus `records_count` (that kind\'s live count, not the whole list — see `plan_add_record`).', {
1021
+ strictTool("plan_update_record", 'Edit a goal/rule/caveat. The reference never moves. `body` stays one plain statement, at most 250 characters (400 otherwise); detail belongs in markdown `context`. `content_hash` must be the record\'s last-read `contentHash` (covers body AND context); on mismatch nothing is written and you get `{error: "stale_plan_record", currentHash, currentBody, currentContext}` — merge and retry with `content_hash: currentHash`, never blindly. Takes no plan id. Returns the edited record plus `records_count` (see `plan_add_record`).', {
1012
1022
  record_id: z.number().int().positive().describe("The record id to edit."),
1013
1023
  content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
1014
1024
  body: z.string().min(1).describe("The new statement, at most 250 characters. Plain text."),
@@ -1018,11 +1028,11 @@ strictTool("plan_update_record", 'Edit a goal/rule/caveat of your connected plan
1018
1028
  .optional()
1019
1029
  .describe("New markdown detail. Omit to keep the stored context; null clears it."),
1020
1030
  }, async (args) => jsonResult(await planUpdateRecord(client, args)));
1021
- strictTool("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. DX-3072 — returns `{records_count}`, that kind\'s remaining live count, not the whole list (see `plan_add_record`).', {
1031
+ strictTool("plan_delete_record", 'Soft-delete a goal/rule/caveat; its reference is retired permanently, never reused. `content_hash` must be the record\'s last-read `contentHash` — stale deletes nothing, returns `{error: "stale_plan_record", currentHash, currentBody, currentContext}`. Takes no plan id. Unknown or already-deleted id → 404. Returns `{records_count}` (see `plan_add_record`).', {
1022
1032
  record_id: z.number().int().positive().describe("The record id to delete."),
1023
1033
  content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
1024
1034
  }, async (args) => jsonResult(await planDeleteRecord(client, args)));
1025
- strictTool("plan_add_note", "Write a milestone note to a plan's timeline, via POST /api/plans/:plan_id/notes (DX-2915). A note is a MILESTONE, not a log — write one for a card (or related group of cards) finishing, an important decision, or a meaningful goal/rule/caveat/architecture-section change; routine step progress stays a card comment, never a note. Terse tone: `title` at most 60 characters, `body` (the wrap-up) at most 250 (both 400 if too long, naming the limit and actual length). Links resolve on read into what they point at (a card's title, a record's ref+body, a section's title): an unknown card, an unparseable or foreign record ref, or an unknown or foreign section id is refused 400 naming exactly which one. A card link does NOT require the card to be a member of this plan; a record/section link MUST belong to THIS plan. `author` is stamped from your identity server-side — there is no field for it. TAKES AN EXPLICIT `plan_id` (like `plan_remove_card`/`plan_rename`), so a dispatched worker with no plan connection can still write. Unknown plan → 404. DX-3072 — returns the new note plus `notes_count` (the plan's total live note count, not the latest page); read the timeline itself with `plan_get({fields:[\"notes\"]})` or the dedicated GET.", {
1035
+ strictTool("plan_add_note", "Write a milestone note to a plan's timeline — a MILESTONE, not a log: use for a card (or group) finishing, an important decision, or a meaningful goal/rule/caveat/architecture change; routine progress stays a card comment. Terse: `title` at most 60 characters, `body` at most 250 (400 if too long, naming the limit). Links resolve on read into their target (a card's title, a record's ref+body, a section's title); an unknown card, unparseable/foreign record ref, or unknown/foreign section id is refused 400 naming which one. A card link needn't be a plan member; a record/section link must belong to THIS plan. `author` is stamped server-side. Takes an explicit `plan_id` (like `plan_remove_card`/`plan_rename`) so a dispatched worker with no plan connection can still write. Unknown plan → 404. Returns the new note plus `notes_count` (the plan's total live count, not the latest page) — read the timeline with `plan_get({fields:[\"notes\"]})`.", {
1026
1036
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
1027
1037
  title: z.string().min(1).describe("At most 60 characters."),
1028
1038
  body: z.string().min(1).describe("The wrap-up, at most 250 characters."),
@@ -1033,7 +1043,7 @@ strictTool("plan_add_note", "Write a milestone note to a plan's timeline, via PO
1033
1043
  .describe("Goal/rule/caveat references this note announces, e.g. `[\"G-1\", \"R-3\", \"CAV-2\"]`."),
1034
1044
  section_ids: z.array(z.number().int().positive()).optional().describe("Architecture section ids this note announces."),
1035
1045
  }, async (args) => jsonResult(await planAddNote(client, args)));
1036
- strictTool("plan_update_note", 'Edit a plan note, via PATCH /api/plans/:plan_id/notes/:note_id (DX-2915). `content_hash` MUST be the note\'s `contentHash` from your last read; a mismatch writes NOTHING and returns `{error: "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}` — merge into those and retry with `content_hash: currentHash`, never blindly. `title`/`body` are each optional and keep their stored value when omitted. The link fields (`card_ids`/`record_refs`/`section_ids`) are all-or-nothing AS A GROUP: omit all three to leave the stored link set untouched; send ANY one of them to REPLACE THE WHOLE SET — there is no per-link add/remove. The hash covers title, body AND the link set. TAKES AN EXPLICIT `plan_id`, same reason as `plan_add_note`. Unknown plan or note id → 404. DX-3072 — returns the edited note plus `notes_count` (see `plan_add_note`), not the latest page.', {
1046
+ strictTool("plan_update_note", 'Edit a plan note. `content_hash` must be the note\'s last-read `contentHash` (covers title, body AND the link set); a mismatch writes nothing and returns `{error: "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}` — merge and retry, never blindly. `title`/`body` optional, keep stored value when omitted. The link fields (`card_ids`/`record_refs`/`section_ids`) are all-or-nothing as a group: omit all three to leave links untouched, send ANY one to REPLACE THE WHOLE SET — no per-link add/remove. Takes an explicit `plan_id` (see `plan_add_note`). Unknown plan or note id → 404. Returns the edited note plus `notes_count`.', {
1037
1047
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
1038
1048
  note_id: z.number().int().positive().describe("The note id to edit."),
1039
1049
  content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
@@ -1043,40 +1053,40 @@ strictTool("plan_update_note", 'Edit a plan note, via PATCH /api/plans/:plan_id/
1043
1053
  record_refs: z.array(z.string().min(1)).optional().describe("REPLACES the whole link set when sent (with card_ids/section_ids)."),
1044
1054
  section_ids: z.array(z.number().int().positive()).optional().describe("REPLACES the whole link set when sent (with card_ids/record_refs)."),
1045
1055
  }, async (args) => jsonResult(await planUpdateNote(client, args)));
1046
- strictTool("plan_delete_note", 'Soft-delete a plan note, via DELETE /api/plans/:plan_id/notes/:note_id (DX-2915). `content_hash` must be the note\'s `contentHash` from your last read; a stale hash deletes nothing and returns `{error: "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}` — the same shape `plan_update_note` uses. TAKES AN EXPLICIT `plan_id`, same reason as `plan_add_note`. Unknown plan, unknown note, or an already-deleted note → 404. DX-3072 — returns `{notes_count}`, the plan\'s remaining live note count, not the latest page.', {
1056
+ strictTool("plan_delete_note", 'Soft-delete a plan note. `content_hash` must be the note\'s last-read `contentHash` — stale deletes nothing, returns `{error: "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}` (same shape as `plan_update_note`). Takes an explicit `plan_id` (see `plan_add_note`). Unknown plan, note, or already-deleted note → 404. Returns `{notes_count}`.', {
1047
1057
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
1048
1058
  note_id: z.number().int().positive().describe("The note id to delete."),
1049
1059
  content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
1050
1060
  }, async (args) => jsonResult(await planDeleteNote(client, args)));
1051
- strictTool("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. DX-3072 — returns `{card_id, member: true, cards_count}` (the attachment's own confirmation plus the plan's total member-card count), not the full member list — a plan can hold hundreds of cards, and the whole list was a ~500KB reply that a caller could not read. Read the list itself with `plan_get({fields:[\"cards\"]})` (paged) when you actually need it.", {
1061
+ strictTool("plan_add_card", "Add an existing card, from ANY board, to the plan this session is connected to. Idempotent (re-adding is a no-op; a card may sit in several plans). Adds MEMBERSHIP only, never edits the card. Takes no plan id — resolved from your connected session. Not connected → `{error: \"session_not_connected\"}`. Unknown card → 404. Returns `{card_id, member: true, cards_count}` (the plan's total member-card count, not the full list — a plan can hold hundreds; read it with `plan_get({fields:[\"cards\"]})`, paged).", {
1052
1062
  card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
1053
1063
  }, async (args) => jsonResult(await planAddCard(client, args)));
1054
- strictTool("plan_remove_card", "Remove a card from a plan via DELETE /api/plans/:plan_id/cards/:card_id (DX-2740) — the sibling of `plan_add_card`. The card may live on ANY board. Idempotent: removing a card that was never a member is a no-op, not an error — the same idempotent-toggle contract `issue_dependency` add/remove uses. This removes MEMBERSHIP only; it never edits or deletes the card itself, and its membership in every OTHER plan is untouched. Unlike `plan_add_card`, this takes an EXPLICIT `plan_id` rather than acting on your connected session's plan — you may remove a card from any plan you can name. Unknown plan → 404. DX-3072 — returns `{card_id, member: false, cards_count}`, the plan's remaining member-card count, not the full list (see `plan_add_card`).", {
1064
+ strictTool("plan_remove_card", "Remove a card from a plan — sibling of `plan_add_card`, any board. Idempotent (removing a non-member is a no-op). Removes MEMBERSHIP only — never touches the card itself or its membership in other plans. Unlike `plan_add_card`, takes an EXPLICIT `plan_id` — you may remove from any plan you can name. Unknown plan → 404. Returns `{card_id, member: false, cards_count}` (see `plan_add_card`).", {
1055
1065
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
1056
1066
  card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
1057
1067
  }, async (args) => jsonResult(await planRemoveCard(client, args)));
1058
- strictTool("plan_rename", "Rename a plan via PATCH /api/plans/:plan_id (DX-2740) — the ONLY way to change a plan's `name`; nothing else in this tool surface can fix a stale name. Takes an EXPLICIT `plan_id`, not your connected session's plan, so you may rename any plan you can name. `name` must be a non-empty string (400 otherwise). The new name is visible immediately in a follow-up `plan_list` or `plan_get`. Unknown plan → 404. Returns the renamed plan `{id, ref, name, createdAt}`.", {
1068
+ strictTool("plan_rename", "Rename a plan — the only way to change `name`. Takes an EXPLICIT `plan_id`, so you may rename any plan you can name. `name` non-empty (400 otherwise). Unknown plan → 404. Returns the renamed plan `{id, ref, name, createdAt}`.", {
1059
1069
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
1060
1070
  name: z.string().min(1).describe("The plan's new name."),
1061
1071
  }, async (args) => jsonResult(await planRename(client, args)));
1062
- strictTool("plan_get_architecture_section", "Read ONE section of the plan this session is connected to, via GET /api/plans/mine/architecture/sections/:sid (DX-2726). 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 section id → 404. Returns `{section: {id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}}`.", {
1072
+ strictTool("plan_get_architecture_section", "Read one section of the plan this session is connected to. Takes no plan id — resolved from your connected session. Not connected → `{error: \"session_not_connected\"}`. Unknown or foreign section id → 404. Returns `{section: {id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}}`.", {
1063
1073
  section_id: z.number().int().positive().describe("A section id, from `plan_get` or `plan_add_architecture_section`."),
1064
1074
  }, async (args) => jsonResult(await planGetArchitectureSection(client, args)));
1065
- strictTool("plan_add_architecture_section", "Append a section to your connected plan's architecture, via POST /api/plans/mine/architecture/sections (DX-2726). Architecture is SECTIONS, not one document — each section is independently editable and hash-guarded, so fixing one never stales a concurrent edit to another. The new section sorts after every existing live section; use `plan_reorder_architecture_section` to move it. `title` is the heading shown in the auto-generated navigation index; `content` is its markdown. Takes no plan id; `session_not_connected` → `plan_connect` first. Returns the new section plus the plan's full live section list.", {
1075
+ strictTool("plan_add_architecture_section", "Append a section to your connected plan's architecture. Architecture is SECTIONS, not one document — each independently editable and hash-guarded, so editing one never stales a concurrent edit to another. Sorts after every existing live section; use `plan_reorder_architecture_section` to move it. Takes no plan id; not connected → `plan_connect` first. Returns the new section plus the plan's full live section list.", {
1066
1076
  title: z.string().min(1).describe("The section's heading, shown in the navigation index."),
1067
1077
  content: z.string().describe("The section's markdown. May be empty — a section awaiting its first draft is a real state."),
1068
1078
  }, async (args) => jsonResult(await planAddArchitectureSection(client, args)));
1069
- strictTool("plan_update_architecture_section", 'Edit a section\'s title and/or content, via PATCH /api/plans/mine/architecture/sections/:sid (DX-2726). Both `title` and `content` are OPTIONAL — send only whichever changed. `base_hash` MUST be the section\'s `contentHash` from your last read; the server compares it against the current hash and, on a mismatch, fails loud with `{ok: false, body: {error: "stale_plan_architecture_section", currentHash, currentTitle, currentContent}}` rather than overwriting whoever wrote in between — merge into those and retry with `base_hash: currentHash`, never blindly. Takes no plan id. Returns the edited section plus the plan\'s full live section list.', {
1079
+ strictTool("plan_update_architecture_section", 'Edit a section\'s title and/or content — both optional, send only what changed. `base_hash` must be the section\'s last-read `contentHash`; on mismatch fails loud with `{error: "stale_plan_architecture_section", currentHash, currentTitle, currentContent}` rather than overwriting a concurrent write — merge and retry, never blindly. Takes no plan id. Returns the edited section plus the plan\'s full live section list.', {
1070
1080
  section_id: z.number().int().positive().describe("The section id to edit."),
1071
1081
  base_hash: z.string().describe("The section's `contentHash` from your last read. Required."),
1072
1082
  title: z.string().min(1).optional().describe("New heading. Omit to keep the stored title."),
1073
1083
  content: z.string().optional().describe("New markdown. Omit to keep the stored content."),
1074
1084
  }, async (args) => jsonResult(await planUpdateArchitectureSection(client, args)));
1075
- strictTool("plan_delete_architecture_section", 'Soft-delete a section of your connected plan\'s architecture, via DELETE /api/plans/mine/architecture/sections/:sid (DX-2726). `base_hash` must be the section\'s `contentHash` from your last read; a stale hash deletes nothing and returns `{error: "stale_plan_architecture_section", currentHash, currentTitle, currentContent}` — on that refusal, re-fetch and confirm this is still the section you meant to remove before retrying, never blindly re-send with the fresh hash. Takes no plan id. Unknown or already-deleted section id → 404. Returns the plan\'s remaining live section list.', {
1085
+ strictTool("plan_delete_architecture_section", 'Soft-delete a section of your connected plan\'s architecture. `base_hash` must be the section\'s last-read `contentHash` — stale deletes nothing, returns `{error: "stale_plan_architecture_section", currentHash, currentTitle, currentContent}`; re-fetch and confirm before retrying, never blindly. Takes no plan id. Unknown or already-deleted section id → 404. Returns the plan\'s remaining live section list.', {
1076
1086
  section_id: z.number().int().positive().describe("The section id to delete."),
1077
1087
  base_hash: z.string().describe("The section's `contentHash` from your last read. Required."),
1078
1088
  }, async (args) => jsonResult(await planDeleteArchitectureSection(client, args)));
1079
- strictTool("plan_reorder_architecture_section", "Reassign your connected plan's section display order, via PUT /api/plans/mine/architecture/sections/reorder (DX-2726). UNGUARDED by content hash, by design: moving a section never changes its (or any other section's) `contentHash`, so no `base_hash` is needed. `order` must name EXACTLY the plan's current live section ids, each once — a partial or foreign list is refused with a 400 rather than silently reordering a subset or dropping a section from view. Takes no plan id. Returns the plan's full live section list in its new order.", {
1089
+ strictTool("plan_reorder_architecture_section", "Reassign your connected plan's section display order. Unguarded by content hash by design — moving a section never changes any `contentHash`. `order` must name EXACTLY the plan's current live section ids, each once — a partial or foreign list refuses 400 rather than silently reordering a subset. Takes no plan id. Returns the plan's full live section list in its new order.", {
1080
1090
  order: z
1081
1091
  .array(z.number().int().positive())
1082
1092
  .min(1)
@@ -1107,8 +1117,8 @@ const expectedRateField = z
1107
1117
  .nullable()
1108
1118
  .optional()
1109
1119
  .describe("Both fields together, or omit/null entirely — never a half-specified rate.");
1110
- strictTool("failure_category_list", "List every failure category via GET /api/failure-categories (DX-2791/DX-2792). Board-less — a category applies across the whole install, not one board. Returns `{categories: [{id, name, description, matchers, ignore, ignoreReason, expectedRate, matchedCount, lastSeenMs, createdAtMs, createdBy, updatedAtMs, updatedBy}]}`, id ascending. A fresh install returns `{categories: []}`. Read this immediately before `failure_category_create`/`failure_category_update` so your overlap/expand decision is against the CURRENT set, not a stale snapshot from earlier in the dispatch.", {}, async () => jsonResult(await failureCategoryList(client)));
1111
- strictTool("failure_category_create", 'Create a new failure category via POST /api/failure-categories (DX-2791/DX-2792). Board-less. `matchers` (at least one) is an OR-across-matchers set — a category matches an occurrence when ANY ONE matcher\'s fields all hold. `ignore: true` REQUIRES a non-empty `ignoreReason` (400 otherwise) — use this for an expected, non-actionable failure rather than leaving it uncategorized. A matcher set overlapping an EXISTING category is refused 400 naming the conflict — call `failure_category_list` first and EXPAND that category (`failure_category_update`) instead of creating a near-duplicate. On success, the dashboard re-matches every existing uncategorized occurrence against the new category before responding.', {
1120
+ strictTool("failure_category_list", "List every failure category. Board-less — applies across the whole install. Returns `{categories: [{id, name, description, matchers, ignore, ignoreReason, expectedRate, matchedCount, lastSeenMs, createdAtMs, createdBy, updatedAtMs, updatedBy}]}`, id ascending (`[]` on a fresh install). Read this immediately before `failure_category_create`/`_update` so your overlap decision is against the current set, not a stale snapshot.", {}, async () => jsonResult(await failureCategoryList(client)));
1121
+ strictTool("failure_category_create", 'Create a new failure category. Board-less. `matchers` (≥1) is OR-across-matchers — matches an occurrence when ANY ONE matcher\'s fields all hold. `ignore: true` requires non-empty `ignoreReason` (400 otherwise) — use for an expected, non-actionable failure. A matcher set overlapping an EXISTING category refuses 400 naming the conflict — list first and EXPAND that category (`failure_category_update`) instead of a near-duplicate. On success, re-matches every uncategorized occurrence against the new category.', {
1112
1122
  name: z.string().min(1).describe("The category's name."),
1113
1123
  description: z.string().optional().describe("Optional free-text description. Defaults to empty."),
1114
1124
  matchers: z.array(matcherField).min(1).describe("At least one matcher; a category matches an occurrence when ANY ONE matches (OR across matchers)."),
@@ -1116,7 +1126,7 @@ strictTool("failure_category_create", 'Create a new failure category via POST /a
1116
1126
  ignoreReason: z.string().nullable().optional().describe("Required (non-empty) when ignore is true."),
1117
1127
  expectedRate: expectedRateField,
1118
1128
  }, async (args) => jsonResult(await failureCategoryCreate(client, args)));
1119
- strictTool("failure_category_update", "Patch an existing failure category via PATCH /api/failure-categories/:id (DX-2791/DX-2792). Board-less. This is the tool for BOTH actions: expanding an existing category's matchers (send the FULL replacement `matchers` array — it REPLACES, not appends, so include every matcher you want to keep alongside the new one) and marking a category ignored (`ignore: true` + a non-empty `ignoreReason`). At least one field besides `id` is required (400 otherwise). Same overlap refusal as create, excluding this category's own prior matchers. On success, re-matches every uncategorized occurrence against the updated matcher set before responding.", {
1129
+ strictTool("failure_category_update", "Patch an existing failure category. Board-less. Covers both: expanding matchers (send the FULL replacement `matchers` array — it REPLACES, not appends, so include every matcher you want to keep) and marking ignored (`ignore: true` + non-empty `ignoreReason`). At least one field besides `id` required (400 otherwise). Same overlap refusal as create, excluding this category's own prior matchers. On success, re-matches every uncategorized occurrence against the updated set.", {
1120
1130
  id: z.number().int().positive().describe("The category id to patch — from failure_category_list."),
1121
1131
  name: z.string().min(1).optional(),
1122
1132
  description: z.string().optional(),
@@ -1126,7 +1136,7 @@ strictTool("failure_category_update", "Patch an existing failure category via PA
1126
1136
  expectedRate: expectedRateField,
1127
1137
  }, async (args) => jsonResult(await failureCategoryUpdate(client, args)));
1128
1138
  // ---------------- dispatch_transcript_search (DX-3221) ----------------
1129
- strictTool("dispatch_transcript_search", "Search or tail ANOTHER dispatch's stored JSONL session transcript via the existing durable GET /api/dispatches/:id/logs sink (DX-1682/DX-1484) — every worker dispatch's raw transcript lines are already captured there today, so this adds no new server route, only in-process search/windowing. Use this instead of trying to Read a session transcript file directly: it needs no filesystem access to `~/.claude/projects/` (an ordinary authenticated HTTP call, so it never touches worktree-guard or CLAUDE.md Core Principle 5's worktree boundary), and it fixes what Read structurally cannot — Read paginates by LINE, and one persisted JSONL entry can itself be a single line far past Read's 25000-token cap with no way to sub-page inside it; this tool does the string/regex search itself and only ever returns a bounded excerpt AROUND a match, never the whole line. With `pattern`: returns up to `maxMatches` matching lines (case-insensitive regex), each as a windowed excerpt of up to `contextChars` characters centered on the first match. Without `pattern`: returns the most recent `tail` lines instead, each capped at `contextChars` characters (a `truncated: true` flag marks a capped excerpt either way — never a caller-visible error the way an oversized Read would throw). `dispatchId` is a real `dispatches.id` — exactly the ids a failure-repair card's own body already lists under \"dispatches that hit it\".", {
1139
+ strictTool("dispatch_transcript_search", "Search or tail another dispatch's stored JSONL transcript. Needs no filesystem access (an authenticated HTTP call — never touches worktree-guard or the worktree boundary) and fixes what Read cannot: a single JSONL entry can exceed Read's token cap with no way to sub-page inside it, so this tool searches itself and returns only a bounded excerpt around a match. With `pattern`: up to `maxMatches` matching lines (case-insensitive regex), each windowed to `contextChars` around the first match. Without `pattern`: the most recent `tail` lines instead, each capped at `contextChars` (`truncated: true` marks a capped excerpt either way, never a thrown error). `dispatchId` is a real `dispatches.id` — e.g. from a failure-repair card's own body.", {
1130
1140
  dispatchId: z.string().min(1).describe("The dispatch id whose transcript to search — from a failure-repair card's own body, or any other dispatch id you already have."),
1131
1141
  pattern: z.string().min(1).optional().describe("Case-insensitive regex tested against each raw JSONL line. Omit to get a tail read of the most recent lines instead."),
1132
1142
  tail: z.number().int().positive().optional().describe("Only used when `pattern` is omitted. How many of the most recent lines to return. Defaults to 20, capped at 200."),
@@ -1188,9 +1198,20 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
1188
1198
  process.exit(0);
1189
1199
  });
1190
1200
  }
1201
+ else if (subcommand === MANTRA_SUBCOMMAND) {
1202
+ // DX-3366 — unlike plan-state/background-work, a rejection here is a
1203
+ // genuine fatal (never expected: runMantraCommand itself already
1204
+ // classifies every network/shape failure into its own {ok:false} exit
1205
+ // 1), so it exits 1 too rather than the "always 0, stay silent" contract
1206
+ // those two hook-only subcommands use.
1207
+ runMantraCommand(rest).then((code) => process.exit(code), (err) => {
1208
+ console.error(`[danx-dashboard-mcp] mantra fatal: ${err.message}`);
1209
+ process.exit(1);
1210
+ });
1211
+ }
1191
1212
  else if (subcommand !== undefined) {
1192
1213
  console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only ones are ` +
1193
- `"${BRIDGE_SUBCOMMAND}", "${PLAN_STATE_SUBCOMMAND}", and "${BACKGROUND_WORK_SUBCOMMAND}")`);
1214
+ `"${BRIDGE_SUBCOMMAND}", "${PLAN_STATE_SUBCOMMAND}", "${BACKGROUND_WORK_SUBCOMMAND}", and "${MANTRA_SUBCOMMAND}")`);
1194
1215
  process.exit(2);
1195
1216
  }
1196
1217
  else {
package/dist/mantra.js ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `danx-dashboard-mcp mantra` — the ONE-SHOT client of
3
+ * `GET /api/reminders/mantra.session_start` (DX-3366).
4
+ *
5
+ * WHO RUNS IT. The claude-plugins `danxbot` plugin's `mantra.sh` SessionStart
6
+ * hook, via the SAME `npx -y @thehammer/danx-dashboard-mcp@<pin>` path the
7
+ * plugin's other dashboard calls already use (`plan-state`, `bridge`) — never
8
+ * a separate install, never a deep import of this package's `dist/`
9
+ * internals.
10
+ *
11
+ * THE CREDENTIAL — the SAME resolver `bridge` / `plan-state` use
12
+ * (`resolveBridgeOptions`, `bridge.ts`): the dashboard URL, credential source
13
+ * and credential all come from the connection record this session's OWN
14
+ * danx-dashboard MCP server wrote on its last successful `plan_connect`
15
+ * (`session-connection.ts`), never from this process's own ambient env. The
16
+ * mantra is only ever fetched once a session is already connected (`mantra.sh`
17
+ * checks that first via `plan-connection.mjs` and stays with the short nudge
18
+ * otherwise), so a connection record always exists by the time this runs.
19
+ *
20
+ * THE CONTRACT — unlike `plan-state` (which must stay silent for a hook, so it
21
+ * always exits 0), this subcommand's caller (`mantra.sh`) needs to tell
22
+ * success from failure so it can fall back to the git-committed `mantra.md` +
23
+ * a one-line notice (DX-3366 AC: "Dashboard unreachable → the hook prints the
24
+ * committed file and says so in one line; never silently"). So:
25
+ *
26
+ * - success: the reminder's EFFECTIVE text (`override_text ?? default_text`)
27
+ * on stdout, nothing else, exit 0.
28
+ * - failure (missing env, no connection record, network error, timeout,
29
+ * non-2xx, 404, bad shape): NOTHING on stdout, one short reason on
30
+ * stderr, exit 1. `mantra.sh` is the one that decides what to print for
31
+ * a human — this module only reports whether the fetch worked.
32
+ *
33
+ * A HARD TIMEOUT (mirrors `plan-state.ts`) — a SessionStart hook must never
34
+ * hang on a wedged dashboard.
35
+ */
36
+ import { SESSION_ID_HEADER, resolveBridgeOptions } from "./bridge.js";
37
+ export const MANTRA_SUBCOMMAND = "mantra";
38
+ export const MANTRA_REMINDER_KEY = "mantra.session_start";
39
+ export const MANTRA_REQUEST_TIMEOUT_MS = 5_000;
40
+ const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${MANTRA_SUBCOMMAND}`;
41
+ function reminderPath(key) {
42
+ return `/api/reminders/${encodeURIComponent(key)}`;
43
+ }
44
+ /** One authenticated GET, with a hard timeout — never throws, never bubbles a non-2xx. */
45
+ export async function fetchMantraText(options, deps) {
46
+ const controller = new AbortController();
47
+ const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
48
+ let status;
49
+ let text;
50
+ try {
51
+ try {
52
+ const response = await deps.fetch(`${options.dashboardUrl}${reminderPath(MANTRA_REMINDER_KEY)}`, {
53
+ method: "GET",
54
+ headers: {
55
+ Authorization: `Bearer ${options.token}`,
56
+ Accept: "application/json",
57
+ [SESSION_ID_HEADER]: options.sessionId,
58
+ },
59
+ signal: controller.signal,
60
+ });
61
+ status = response.status;
62
+ text = await response.text();
63
+ }
64
+ catch {
65
+ return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
66
+ }
67
+ }
68
+ finally {
69
+ clearTimeout(timer);
70
+ }
71
+ if (status === 401 || status === 403)
72
+ return { ok: false, reason: "unauthorized" };
73
+ if (status === 404)
74
+ return { ok: false, reason: "not_found" };
75
+ if (status < 200 || status >= 300)
76
+ return { ok: false, reason: "http_error" };
77
+ let body;
78
+ try {
79
+ body = text === "" ? null : JSON.parse(text);
80
+ }
81
+ catch {
82
+ return { ok: false, reason: "bad_response" };
83
+ }
84
+ const effectiveText = typeof body === "object" && body !== null && typeof body.effective_text === "string"
85
+ ? body.effective_text
86
+ : null;
87
+ if (effectiveText === null)
88
+ return { ok: false, reason: "bad_response" };
89
+ return { ok: true, text: effectiveText };
90
+ }
91
+ /**
92
+ * The bin's `mantra` subcommand, wired to the real process. Success prints
93
+ * the effective text on stdout and returns 0; every failure prints nothing on
94
+ * stdout, one reason on stderr, and returns 1 — `mantra.sh` reads the exit
95
+ * code, never parses stdout to guess.
96
+ */
97
+ export async function runMantraCommand(argv, env = process.env,
98
+ /** Test seam: the home the session's connection record is read from. */
99
+ resolveFrom = {}) {
100
+ if (argv.length !== 0) {
101
+ process.stderr.write(`unrecognized arguments\n${USAGE}\n`);
102
+ return 1;
103
+ }
104
+ const sessionId = env.CLAUDE_CODE_SESSION_ID;
105
+ if (!sessionId) {
106
+ process.stderr.write(`no_session_id: CLAUDE_CODE_SESSION_ID is not set\n${USAGE}\n`);
107
+ return 1;
108
+ }
109
+ let options;
110
+ try {
111
+ options = resolveBridgeOptions({ sessionId, resumeIds: [] }, env, resolveFrom);
112
+ }
113
+ catch (err) {
114
+ const start = err;
115
+ process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
116
+ return 1;
117
+ }
118
+ const output = await fetchMantraText(options, {
119
+ fetch: (input, init) => fetch(input, init),
120
+ requestTimeoutMs: MANTRA_REQUEST_TIMEOUT_MS,
121
+ });
122
+ if (!output.ok) {
123
+ process.stderr.write(`${output.reason}: could not fetch the effective mantra text from the dashboard\n`);
124
+ return 1;
125
+ }
126
+ process.stdout.write(output.text);
127
+ return 0;
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.139",
3
+ "version": "0.1.142",
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",