@thehammer/danx-dashboard-mcp 0.1.57 → 0.1.58

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
@@ -710,7 +710,7 @@ export async function planList(client) {
710
710
  }
711
711
  /**
712
712
  * One plan, entire — its cards, the boards they cover, its goals + rules +
713
- * caveats, its architecture document, and the sessions working on it. ONE
713
+ * caveats, its architecture sections, and the sessions working on it. ONE
714
714
  * call rather than five, which is what keeps this tool surface small enough
715
715
  * to be worth an agent's context.
716
716
  */
@@ -861,25 +861,104 @@ export async function planRename(client, args) {
861
861
  });
862
862
  }
863
863
  /**
864
- * Write the connected plan's architecture document, under the same
865
- * optimistic-concurrency guard the dashboard editor uses: `base_hash` must be
866
- * the `contentHash` the last `plan_get` returned, and a stale base is refused
867
- * with `{error: "stale_plan_architecture", currentHash}` rather than
868
- * overwriting whoever wrote in between.
864
+ * Read ONE section of the plan this session is connected to, via
865
+ * `GET /api/plans/mine/architecture/sections/:sid`. TAKES NO PLAN ID: the
866
+ * plan is resolved from your connected session, same as `plan_add_record`.
867
+ * Useful for a targeted read (skip pulling the whole plan via `plan_get`)
868
+ * and for conflict recovery after a 409 `stale_plan_architecture_section` —
869
+ * though that refusal already carries `currentTitle`/`currentContent`, so a
870
+ * second read is rarely needed for that specific case. Not connected →
871
+ * `{error: "session_not_connected"}`. Unknown/foreign section id → 404.
869
872
  */
870
- export async function planSetArchitecture(client, args) {
873
+ export async function planGetArchitectureSection(client, args) {
874
+ return client.request({
875
+ method: "GET",
876
+ path: `/mine/architecture/sections/${args.section_id}`,
877
+ basePath: PLANS_BASE_PATH,
878
+ });
879
+ }
880
+ /**
881
+ * Append a section to the connected plan's architecture, via
882
+ * `POST /api/plans/mine/architecture/sections`. TAKES NO PLAN ID, same as
883
+ * `plan_add_record`. The new section sorts after every existing live
884
+ * section — see `plan_reorder_architecture_section` to move it.
885
+ */
886
+ export async function planAddArchitectureSection(client, args) {
887
+ return client.request({
888
+ method: "POST",
889
+ path: "/mine/architecture/sections",
890
+ basePath: PLANS_BASE_PATH,
891
+ body: { title: args.title, content: args.content },
892
+ });
893
+ }
894
+ /**
895
+ * Edit a section's title and/or content, via
896
+ * `PATCH /api/plans/mine/architecture/sections/:sid`. Both `title` and
897
+ * `content` are OPTIONAL — send only whichever changed. `base_hash` MUST be
898
+ * the section's `contentHash` from the immediately-prior `plan_get`/
899
+ * `plan_get_architecture_section`; the server compares it against the row's
900
+ * current hash and, on a mismatch, refuses the write ENTIRELY and fails loud
901
+ * with `{ok: false, status: 409, body: {error:
902
+ * "stale_plan_architecture_section", currentHash, currentTitle,
903
+ * currentContent}}` rather than overwriting whoever wrote in between —
904
+ * `currentTitle`/`currentContent` ride the SAME refusal so you can merge and
905
+ * retry in ONE round trip. TAKES NO PLAN ID: the plan is resolved from your
906
+ * connected session, same as `plan_add_record`.
907
+ */
908
+ export async function planUpdateArchitectureSection(client, args) {
909
+ return client.request({
910
+ method: "PATCH",
911
+ path: `/mine/architecture/sections/${args.section_id}`,
912
+ basePath: PLANS_BASE_PATH,
913
+ body: {
914
+ base_hash: args.base_hash,
915
+ ...(args.title === undefined ? {} : { title: args.title }),
916
+ ...(args.content === undefined ? {} : { content: args.content }),
917
+ },
918
+ });
919
+ }
920
+ /**
921
+ * Soft-delete a section of the connected plan's architecture, via
922
+ * `DELETE /api/plans/mine/architecture/sections/:sid`. `base_hash` MUST be
923
+ * the section's `contentHash` from the immediately-prior read; a stale hash
924
+ * refuses the delete ENTIRELY (nothing is removed) with the same
925
+ * `{ok: false, status: 409, body: {error: "stale_plan_architecture_section",
926
+ * currentHash, currentTitle, currentContent}}` shape
927
+ * `plan_update_architecture_section` uses — on a mismatch, re-fetch and
928
+ * confirm this is still the section you meant to remove before retrying,
929
+ * never blindly re-send with the fresh hash. TAKES NO PLAN ID. Unknown/
930
+ * foreign/already-deleted section id → 404.
931
+ */
932
+ export async function planDeleteArchitectureSection(client, args) {
933
+ return client.request({
934
+ method: "DELETE",
935
+ path: `/mine/architecture/sections/${args.section_id}`,
936
+ basePath: PLANS_BASE_PATH,
937
+ body: { base_hash: args.base_hash },
938
+ });
939
+ }
940
+ /**
941
+ * Reassign the connected plan's section display order, via
942
+ * `PUT /api/plans/mine/architecture/sections/reorder`. UNGUARDED by content
943
+ * hash, by design: moving a section never changes its (or any other
944
+ * section's) `contentHash`. `order` must name exactly the plan's current
945
+ * live section ids, each once — a partial or foreign list is refused with a
946
+ * 400 rather than silently reordering a subset or dropping a section from
947
+ * view. TAKES NO PLAN ID.
948
+ */
949
+ export async function planReorderArchitectureSection(client, args) {
871
950
  return client.request({
872
951
  method: "PUT",
873
- path: "/mine/architecture",
952
+ path: "/mine/architecture/sections/reorder",
874
953
  basePath: PLANS_BASE_PATH,
875
- body: { content: args.content, base_hash: args.base_hash },
954
+ body: { order: args.order },
876
955
  });
877
956
  }
878
957
  /**
879
958
  * Create a new, empty plan via `POST /api/plans`. GLOBAL — a plan is not
880
959
  * board-scoped, so this takes no board and creates no membership; the caller
881
- * still owns zero cards, zero records and no architecture document until it
882
- * adds them. This does NOT connect any session to the new plan — call
960
+ * still owns zero cards, zero records and zero architecture sections until
961
+ * it adds them. This does NOT connect any session to the new plan — call
883
962
  * `plan_connect` separately (mirroring how creating a card does not add it to
884
963
  * a plan; these are two deliberately separate steps, same as everywhere else
885
964
  * in this tool surface).
@@ -920,8 +999,8 @@ export async function planGetRecord(client, args) {
920
999
  * currentBody, currentContext}}` rather than overwriting whoever wrote in
921
1000
  * between. DX-2734: the hash covers body AND context, and `context` is sent
922
1001
  * only when given (omitted keeps the stored context, `null` clears it).
923
- * `currentBody` rides the SAME refusal — unlike the architecture document's
924
- * `stale_plan_architecture`, which carries only `currentHash` — so you can
1002
+ * `currentBody` rides the SAME refusal — the same richer shape
1003
+ * `stale_plan_architecture_section` carries for a section — so you can
925
1004
  * merge and retry in ONE round trip without a second `plan_get_record` call.
926
1005
  * TAKES NO PLAN ID: the plan is resolved from your connected session, same as
927
1006
  * `plan_add_record`. Not connected → `{error: "session_not_connected"}`.
package/dist/index.js CHANGED
@@ -43,7 +43,11 @@
43
43
  * - plan_add_card POST /api/plans/mine/cards
44
44
  * - plan_remove_card DELETE /api/plans/:plan_id/cards/:card_id (DX-2740)
45
45
  * - plan_rename PATCH /api/plans/:plan_id (DX-2740)
46
- * - plan_set_architecture PUT /api/plans/mine/architecture
46
+ * - plan_get_architecture_section GET /api/plans/mine/architecture/sections/:sid (DX-2726)
47
+ * - plan_add_architecture_section POST /api/plans/mine/architecture/sections (DX-2726)
48
+ * - plan_update_architecture_section PATCH /api/plans/mine/architecture/sections/:sid (DX-2726)
49
+ * - plan_delete_architecture_section DELETE /api/plans/mine/architecture/sections/:sid (DX-2726)
50
+ * - plan_reorder_architecture_section PUT /api/plans/mine/architecture/sections/reorder (DX-2726)
47
51
  *
48
52
  * DX-2683 — THE PLAN TOOLS ARE SESSION-BOUND, and asymmetrically so. Reads
49
53
  * may name any plan; WRITES take no plan id at all and act on the plan this
@@ -84,7 +88,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
84
88
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
85
89
  import { z } from "zod";
86
90
  import { DashboardHttpClient } from "./http-client.js";
87
- import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueQualityGate, issueQualityGateVerdict, issueRequiresHuman, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, planAddCard, planAddRecord, planConnect, planCreate, planDeleteRecord, planGet, planGetRecord, planList, planRemoveCard, planRename, planSetArchitecture, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
91
+ import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueQualityGate, issueQualityGateVerdict, issueRequiresHuman, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, planAddArchitectureSection, planAddCard, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteRecord, planGet, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
88
92
  import { PRIORITY_TIER_WORDS } from "./priority.js";
89
93
  function readEnvOrDie(name) {
90
94
  const v = process.env[name];
@@ -278,7 +282,7 @@ const TITLE_DESCRIBE = 'Short, specific label that names the domain, so a reader
278
282
  const SUMMARY_DESCRIBE = "1–3 plain-language sentences — no markdown, no jargon — for someone who has never seen this codebase: what the card is about and why it matters. Always shown, never collapsed. It must stand on its own: not a second title, not a teaser for the description.";
279
283
  const DESCRIPTION_DESCRIBE = 'The full body ("Context"): evidence, examples, technical detail. Long and markdown is normal; UIs collapse it by default. Candidate answers to the question a card is stopped on do NOT go here — list each one with issue_solution.';
280
284
  // ---------------- issue_list ----------------
281
- server.tool("issue_list", "List issues for the dispatch's board by default via GET /api/issues. Board-scoped; defaults to the dispatch's board. Pass `board` (a qualified id `<repo>:<slug>`) to list another board instead (unknown board → 404). Nested envelope (DX-935 / DX-937 — hard-cut, no flat params): `filter` — the OLD flat filters, now nested (type, parent_id, dispatchable_derived, status_derived[], self_dispatchable_derived, assigned_agent, include_closed, include_deleted, and `q` — free-text over id+title+description, the former standalone `q` param now lives at `filter.q`). `fields` — opt-in named field-GROUPS (description, ac, comments, retro, dependencies, triage, requires_human, assignment, quality_gates, children, effort); THE DEFAULT RESPONSE (no `fields`) IS MINIMAL — only cheap scalar columns (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash), zero joins. Point any heavy read (full description, comments[], retro, ac items, dependency edges, triage history, quality-gate rows, children ids) at the matching `fields` entry rather than assuming it's already on the row. `sort` — ordered [{column, order}] (id|priority|repo_name|title|type|status_derived|triage_ice_total|created_at|updated_at); absent → default order (priority desc, repo_name asc) with an always-appended numeric-id tiebreaker (DX-10 follows DX-9). `limit`/`offset` — optional paging (no cap by default). Use issue_get for a single fully-detailed card.", {
285
+ server.tool("issue_list", "List issues for the dispatch's board by default via GET /api/issues. Board-scoped; defaults to the dispatch's board. Pass `board` (a qualified id `<repo>:<slug>`) to list another board instead (unknown board → 404). Nested envelope (DX-935 / DX-937 — hard-cut, no flat params): `filter` — the OLD flat filters, now nested (type, parent_id, dispatchable_derived, status_derived[], self_dispatchable_derived, assigned_agent, include_closed, include_deleted, and `q` — free-text over id+title+description, the former standalone `q` param now lives at `filter.q`). `fields` — opt-in named field-GROUPS (description, ac, comments, retro, dependencies, triage, requires_human, assignment, quality_gates, children, effort); THE DEFAULT RESPONSE (no `fields`) IS MINIMAL — only cheap scalar columns (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent), zero joins. Point any heavy read (full description, comments[], retro, ac items, dependency edges, triage history, quality-gate rows, children ids) at the matching `fields` entry rather than assuming it's already on the row. `sort` — ordered [{column, order}] (id|priority|repo_name|title|type|status_derived|triage_ice_total|created_at|updated_at); absent → default order (priority desc, repo_name asc) with an always-appended numeric-id tiebreaker (DX-10 follows DX-9). `limit`/`offset` — optional paging (no cap by default). Use issue_get for a single fully-detailed card.", {
282
286
  filter: z
283
287
  .object({
284
288
  q: z.string().optional(),
@@ -303,7 +307,7 @@ server.tool("issue_list", "List issues for the dispatch's board by default via G
303
307
  ...boardField,
304
308
  }, async (args) => jsonResult(await issueList(client, args)));
305
309
  // ---------------- issue_get ----------------
306
- server.tool("issue_get", "Fetch a single issue via GET /api/issues/:id. Board-scoped; defaults to the dispatch's board. Issue ids are globally unique, so this resolves from any dispatch regardless of `board`. DEFAULT RESPONSE IS MINIMAL (DX-935 / DX-937) — only cheap scalar columns (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash — DX-2741, the card's optimistic-concurrency token; pass it back as `content_hash` on an `issue_edit` touching title/description/checklists); no joined collections. Pass `fields` to opt into named field-GROUPS: description (full description body + the plain-language summary), solutions (the card's live candidate solutions, each with its content_hash, + every operator answer in decisions[]), ac (acceptance-criteria + checklists model), comments (comments[]), retro (retro good/bad/action_items/commits), dependencies (waiting_on/conflict_on/blocked gate state), triage (triage history + ICE), requires_human (the requires_human gate + steps), assignment (dispatch/assigned_agent/lifecycle timestamps), quality_gates (DX-1177 — one row per registered gate {gate, required, status pending|pass|fail, completed_at, message}; a required PRE gate not yet `pass` pre-empts the work dispatch, and `issue_transition complete` refuses while a required POST gate row != pass), children (child id list + rollups), mirrors (external mirror sync state), code_review_items (code-review findings). Point any heavy read at the matching `fields` entry rather than assuming it's already on the row. 404 envelope on unknown id.", {
310
+ server.tool("issue_get", "Fetch a single issue via GET /api/issues/:id. Board-scoped; defaults to the dispatch's board. Issue ids are globally unique, so this resolves from any dispatch regardless of `board`. DEFAULT RESPONSE IS MINIMAL (DX-935 / DX-937) — only cheap scalar columns (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent); no joined collections. Pass `fields` to opt into named field-GROUPS: description (full description body + the plain-language summary), solutions (the card's live candidate solutions, each with its content_hash, + every operator answer in decisions[]), ac (acceptance-criteria + checklists model), comments (comments[]), retro (retro good/bad/action_items/commits), dependencies (waiting_on/conflict_on/blocked gate state), triage (triage history + ICE), requires_human (the requires_human gate + steps), assignment (dispatch/assigned_agent/lifecycle timestamps), quality_gates (DX-1177 — one row per registered gate {gate, required, status pending|pass|fail, completed_at, message}; a required PRE gate not yet `pass` pre-empts the work dispatch, and `issue_transition complete` refuses while a required POST gate row != pass), children (child id list + rollups), mirrors (external mirror sync state), code_review_items (code-review findings). Point any heavy read at the matching `fields` entry rather than assuming it's already on the row. 404 envelope on unknown id.", {
307
311
  id: z.string().min(1),
308
312
  fields: z
309
313
  .array(z.enum(GET_FIELD_GROUPS))
@@ -364,7 +368,7 @@ server.tool("issue_create", 'Create a card via POST /api/issues on this dispatch
364
368
  ...boardField,
365
369
  }, async (args) => jsonResult(await issueCreate(client, args, config.board)));
366
370
  // ---------------- issue_edit ----------------
367
- server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED keys: title, summary, description, ac, checklists, effort_level, parent_id, priority, list_id, triage_enabled, type, content_hash. Any other key (lifecycle, triage, dependencies, retro, requires_human, blocked) is refused 400 with offending_keys[] naming the right tool: issue_transition / issue_triage / issue_comment / issue_dependency / issue_requires_human / issue_retro. `type`: Story/Bug/Chore makes a card eligible for autonomous pickup; Task or a container (Epic/Feature) removes that eligibility — how a planning item becomes work. `priority` (a tier word or a number) is the ONLY way to set priority; a "Priority:" line in the description changes nothing. CHECKLISTS: each item has one status `incomplete|failing|passing|cancelled|deferred`; `deferred` (work done, a real-world/post-deploy check outstanding) REQUIRES `detail`, and a `📡`-prefixed item can never be `passing`. `ac` edits the default "Acceptance Criteria" checklist (items matched by check_item_id, else exact title); `checklists` REPLACES every named checklist with full status control (`{name, items:[{label, detail?, status}]}`). Send `ac` OR `checklists`, not both (400). `list_id` pins the card to a list by id or display NAME; its type must match the card\'s current derived status (400 otherwise — ready a card before pinning it to a `ready` queue); null clears the pin. `content_hash` (DX-2741) is the card\'s optimistic-concurrency token — REQUIRED (never optional/defaulted) whenever the edit touches `title` / `description` / `checklists` (NOT `ac`, which keeps its own check_item_id/title diffing): a missing hash on one of those fields 400s, a stale one 409s `stale_issue_content` carrying `currentHash` + `currentTitle` + `currentDescription`. Read it off `issue_get`/`issue_list`\'s `content_hash` scalar field (present even in the minimal response) immediately before editing; on a 409, re-`issue_get` and retry with the fresh hash — never blindly.', {
371
+ server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED keys: title, summary, description, ac, checklists, effort_level, parent_id, priority, list_id, triage_enabled, type. Any other key (lifecycle, triage, dependencies, retro, requires_human, blocked) is refused 400 with offending_keys[] naming the right tool: issue_transition / issue_triage / issue_comment / issue_dependency / issue_requires_human / issue_retro. `type`: Story/Bug/Chore makes a card eligible for autonomous pickup; Task or a container (Epic/Feature) removes that eligibility — how a planning item becomes work. `priority` (a tier word or a number) is the ONLY way to set priority; a "Priority:" line in the description changes nothing. CHECKLISTS: each item has one status `incomplete|failing|passing|cancelled|deferred`; `deferred` (work done, a real-world/post-deploy check outstanding) REQUIRES `detail`, and a `📡`-prefixed item can never be `passing`. `ac` edits the default "Acceptance Criteria" checklist (items matched by check_item_id, else exact title); `checklists` REPLACES every named checklist with full status control (`{name, items:[{label, detail?, status}]}`). Send `ac` OR `checklists`, not both (400). `list_id` pins the card to a list by id or display NAME; its type must match the card\'s current derived status (400 otherwise — ready a card before pinning it to a `ready` queue); null clears the pin.', {
368
372
  id: z.string().min(1),
369
373
  title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
370
374
  summary: z
@@ -418,11 +422,6 @@ server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED
418
422
  .boolean()
419
423
  .optional()
420
424
  .describe("Per-card opt-in to the automatic triage dispatcher (default false = never auto-selected). Operator POST /api/triage and issue_triage ignore it."),
421
- content_hash: z
422
- .string()
423
- .min(1)
424
- .optional()
425
- .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."),
426
425
  ...boardField,
427
426
  }, async (args) => jsonResult(await issueEdit(client, args)));
428
427
  // ---------------- issue_transition ----------------
@@ -631,15 +630,15 @@ server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug
631
630
  // ---------------- plans (DX-2683) ----------------
632
631
  // THE BINDING ASYMMETRY IS IN THE SCHEMAS, NOT IN PROSE. `plan_get` takes an
633
632
  // optional `plan_id`; `plan_add_record`, `plan_get_record`,
634
- // `plan_update_record`, `plan_delete_record`, `plan_add_card` and
635
- // `plan_set_architecture` take NO plan id in any form, so a connected session
636
- // cannot even express "write to that other plan". `plan_connect` takes one
633
+ // `plan_update_record`, `plan_delete_record`, `plan_add_card` and the five
634
+ // `plan_*_architecture_section` tools take NO plan id in any form, so a
635
+ // connected session cannot even express "write to that other plan". `plan_connect` takes one
637
636
  // because binding a session to a plan is the one operation that is ABOUT a
638
637
  // plan id — and it can only ever bind the caller's own session. `plan_create`
639
638
  // also takes no plan id, but for a different reason: it MAKES a plan rather
640
639
  // than acting on one, so there is no existing plan for an id to name yet.
641
640
  server.tool("plan_list", "List every plan via GET /api/plans (DX-2683), and learn which plan THIS session is connected to. Plans are GLOBAL, not board-scoped: a plan is a named, dated set of cards an operator assembled by hand, and its cards may come from any repository. Returns `{ok, status, body: {plans: [{id, name, createdAt, cardCount, boards}], session, sessionListenerAttached}}`. `session` is your own registration — `{sessionId, title, planId, planName, firstSeenAt, lastActiveAt}` — or `null` if this process is not running inside a Claude Code session. A `planId` of null means you are connected to no plan: read any plan with `plan_get`, then `plan_connect` to the one you are working on (or ask the operator to connect you from the Plans list). `sessionListenerAttached` says whether your event listener is running: `false` while connected to a plan means you will NOT hear about its cards — call `plan_connect` again and arm the Monitor it returns. NOTE this is NOT the board Brief (`brief_list`), which is a different feature entirely.", {}, async () => jsonResult(await planList(client)));
642
- server.tool("plan_get", "Read one plan WHOLE via GET /api/plans (DX-2683) — its member cards (with the boards they cover), its goals, rules and caveats, its architecture document, the sessions working on it, and your own session state. One call, not five. Pass `plan_id` to read ANY plan (browsing another plan is useful and changes nothing); OMIT it to read the plan this session is connected to. Omitting it while connected to no plan fails loud with `{error: \"session_not_connected\"}` — connect first. Returns `{plan, cards, boards, records: {goal, rule, caveat} (each `[{id, ref, body, context, contentHash}]` — `context` is markdown detail or null), architecture: {content, contentHash, updatedAt, updatedBy}, sessions, session, sessionListenerAttached}` — `sessionListenerAttached: false` while connected means your event listener is not running; call `plan_connect` again and arm the Monitor it returns. ALWAYS `plan_get` immediately before `plan_set_architecture` and pass the returned `architecture.contentHash` back as `base_hash`.", {
641
+ server.tool("plan_get", "Read one plan WHOLE via GET /api/plans (DX-2683) — its member cards (with the boards they cover), its goals, rules and caveats, its architecture sections, the sessions working on it, and your own session state. One call, not five. Pass `plan_id` to read ANY plan (browsing another plan is useful and changes nothing); OMIT it to read the plan this session is connected to. Omitting it while connected to no plan fails loud with `{error: \"session_not_connected\"}` — connect first. Returns `{plan, cards, boards, records: {goal, rule, caveat} (each `[{id, ref, body, context, contentHash}]` — `context` is markdown detail or null), architecture: {sections: [{id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}]}, sessions, session, sessionListenerAttached}` — `sessionListenerAttached: false` while connected means your event listener is not running; call `plan_connect` again and arm the Monitor it returns. ALWAYS `plan_get`/`plan_get_architecture_section` immediately before `plan_update_architecture_section`/`plan_delete_architecture_section` and pass the returned `contentHash` back as `base_hash`.", {
643
642
  plan_id: z
644
643
  .number()
645
644
  .int()
@@ -647,7 +646,7 @@ server.tool("plan_get", "Read one plan WHOLE via GET /api/plans (DX-2683) — it
647
646
  .optional()
648
647
  .describe("A plan id from `plan_list`. Omit to read the plan this session is connected to."),
649
648
  }, async (args) => jsonResult(await planGet(client, args)));
650
- server.tool("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 document; 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, name, createdAt}}}`. Use the returned `plan.id` with `plan_connect` to start working on it, or with `plan_get({plan_id})` to browse it.", {
649
+ server.tool("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, name, createdAt}}}`. Use the returned `plan.id` with `plan_connect` to start working on it, or with `plan_get({plan_id})` to browse it.", {
651
650
  name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
652
651
  }, async (args) => jsonResult(await planCreate(client, args)));
653
652
  server.tool("plan_connect", "Connect THIS session to a plan via POST /api/plan-sessions/me/plan (DX-2683) — the same binding the operator's Connect action writes, through the same server-side path. A session is connected to AT MOST ONE plan (enforced by the schema, not by convention); connecting while already on another plan MOVES you, and the response says which plan you left: `{ok, status, body: {session, movedFrom: {id, name} | null}}`. `movedFrom: null` means you were on no plan, or already on this one. It can only ever bind your OWN session — `me` is resolved from the session id this server forwards, never from anything you pass. After this, every plan WRITE tool acts on this plan, and no plan id is accepted anywhere. THE REPLY ALSO CARRIES `listener: {command, persistent: true, instruction}` — arm it IMMEDIATELY with the Monitor tool (`command` as given, `persistent: true`): from then on every comment, answer, requires_human change and block/unblock on this plan's cards arrives as a notification line like `[DX-8 \"Title\" repo:board] newms87 answered: chose \"Pause E2E\" — note: \"…\"`. Never poll for these. The command carries a narrow stream ticket, not a credential; calling plan_connect again (same plan is fine) issues a new one and ends the old listener, which is how you re-arm after a session restart or after the Monitor reports it gave up. If the ticket cannot be issued the call fails with `listener_not_armed` even though the connect itself happened.", {
@@ -686,12 +685,29 @@ server.tool("plan_rename", "Rename a plan via PATCH /api/plans/:plan_id (DX-2740
686
685
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
687
686
  name: z.string().min(1).describe("The plan's new name."),
688
687
  }, async (args) => jsonResult(await planRename(client, args)));
689
- server.tool("plan_set_architecture", 'Write the ARCHITECTURE DOCUMENT of the plan this session is connected to, via PUT /api/plans/mine/architecture (DX-2683). One markdown document per plan — how the work is shaped, not a task list. `base_hash` MUST be the `architecture.contentHash` from the immediately-prior `plan_get`; the server compares it against the current hash and, on a mismatch, fails loud with `{ok: false, body: {error: "stale_plan_architecture", currentHash}}` rather than overwriting whoever wrote in between. On that refusal: re-`plan_get`, re-merge your changes into the fresh content, and retry with the new hash — never retry blindly. A never-written document reads as `contentHash: ""`, so a true first write passes `base_hash: ""`. Writing REPLACES the whole document, so send the full merged markdown, not a fragment. TAKES NO PLAN ID: the plan is resolved from your connected session.', {
690
- content: z.string().describe("The complete markdown document, replacing what is stored."),
691
- base_hash: z
692
- .string()
693
- .describe('The `architecture.contentHash` from the immediately-prior `plan_get` ("" for a true first write). Required — an absent hash is not read as "".'),
694
- }, async (args) => jsonResult(await planSetArchitecture(client, args)));
688
+ server.tool("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}}`.", {
689
+ section_id: z.number().int().positive().describe("A section id, from `plan_get` or `plan_add_architecture_section`."),
690
+ }, async (args) => jsonResult(await planGetArchitectureSection(client, args)));
691
+ server.tool("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.", {
692
+ title: z.string().min(1).describe("The section's heading, shown in the navigation index."),
693
+ content: z.string().describe("The section's markdown. May be empty — a section awaiting its first draft is a real state."),
694
+ }, async (args) => jsonResult(await planAddArchitectureSection(client, args)));
695
+ server.tool("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.', {
696
+ section_id: z.number().int().positive().describe("The section id to edit."),
697
+ base_hash: z.string().describe("The section's `contentHash` from your last read. Required."),
698
+ title: z.string().min(1).optional().describe("New heading. Omit to keep the stored title."),
699
+ content: z.string().optional().describe("New markdown. Omit to keep the stored content."),
700
+ }, async (args) => jsonResult(await planUpdateArchitectureSection(client, args)));
701
+ server.tool("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.', {
702
+ section_id: z.number().int().positive().describe("The section id to delete."),
703
+ base_hash: z.string().describe("The section's `contentHash` from your last read. Required."),
704
+ }, async (args) => jsonResult(await planDeleteArchitectureSection(client, args)));
705
+ server.tool("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.", {
706
+ order: z
707
+ .array(z.number().int().positive())
708
+ .min(1)
709
+ .describe("Every live section id of the connected plan, in the desired order — exactly once each."),
710
+ }, async (args) => jsonResult(await planReorderArchitectureSection(client, args)));
695
711
  // ---------------- main ----------------
696
712
  async function main() {
697
713
  boot();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.57",
3
+ "version": "0.1.58",
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",