@thehammer/danx-dashboard-mcp 0.1.88 → 0.1.89
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/README.md +2 -2
- package/dist/bridge.js +365 -154
- package/dist/handlers.js +94 -2
- package/dist/index.js +66 -16
- package/dist/json-body.js +23 -0
- package/dist/listen.js +324 -27
- package/dist/one-line.js +3 -2
- package/dist/plan-state.js +194 -0
- package/package.json +1 -1
package/dist/handlers.js
CHANGED
|
@@ -392,7 +392,11 @@ export async function issueComment(client, args) {
|
|
|
392
392
|
return client.request({
|
|
393
393
|
method: "POST",
|
|
394
394
|
path: `/${idEnc}/comments`,
|
|
395
|
-
body: {
|
|
395
|
+
body: {
|
|
396
|
+
text,
|
|
397
|
+
...(args.metadata !== undefined ? { metadata: args.metadata } : {}),
|
|
398
|
+
...(args.problem_id !== undefined ? { problem_id: args.problem_id } : {}),
|
|
399
|
+
},
|
|
396
400
|
board,
|
|
397
401
|
});
|
|
398
402
|
}
|
|
@@ -562,6 +566,12 @@ export async function issueChecklist(client, args) {
|
|
|
562
566
|
* card is put in front of one; the reminder that used to ride the retired
|
|
563
567
|
* `issue_requires_human({set: true})` now rides here instead.
|
|
564
568
|
*
|
|
569
|
+
* DX-2942 — `statement` is capped at 100 characters server-side (a 400 names
|
|
570
|
+
* the actual length otherwise); `context` is the markdown detail behind it,
|
|
571
|
+
* sent only when given on `add`, and on `edit` `undefined` keeps the stored
|
|
572
|
+
* context while `null` clears it — the same omit/null semantics
|
|
573
|
+
* `plan_update_record` already gives a plan record's context.
|
|
574
|
+
*
|
|
565
575
|
* No answer action, for the reason `issueSolution` gives.
|
|
566
576
|
*/
|
|
567
577
|
export async function issueProblem(client, args) {
|
|
@@ -573,6 +583,8 @@ export async function issueProblem(client, args) {
|
|
|
573
583
|
return client.request({ method: "GET", path: `/${idEnc}/problems`, board });
|
|
574
584
|
case "add": {
|
|
575
585
|
const body = { statement: need.string(args.statement, "statement") };
|
|
586
|
+
if (args.context !== undefined)
|
|
587
|
+
body.context = args.context;
|
|
576
588
|
if (args.solutions !== undefined)
|
|
577
589
|
body.solutions = args.solutions;
|
|
578
590
|
const result = await client.request({ method: "POST", path: `/${idEnc}/problems`, body, board });
|
|
@@ -585,7 +597,7 @@ export async function issueProblem(client, args) {
|
|
|
585
597
|
return client.request({
|
|
586
598
|
method: "PATCH",
|
|
587
599
|
path: `/${idEnc}/problems/${problemId}`,
|
|
588
|
-
body: { base_hash: baseHash, statement },
|
|
600
|
+
body: { base_hash: baseHash, statement, ...(args.context === undefined ? {} : { context: args.context }) },
|
|
589
601
|
board,
|
|
590
602
|
});
|
|
591
603
|
}
|
|
@@ -844,6 +856,7 @@ export const PLAN_FIELD_GROUPS = [
|
|
|
844
856
|
"records:caveat",
|
|
845
857
|
"architecture",
|
|
846
858
|
"sessions",
|
|
859
|
+
"notes",
|
|
847
860
|
];
|
|
848
861
|
/**
|
|
849
862
|
* DX-2834 — the plan-status taxonomy, mirroring `PLAN_FIELD_GROUPS` just
|
|
@@ -1147,6 +1160,85 @@ export async function planDeleteRecord(client, args) {
|
|
|
1147
1160
|
body: { content_hash: args.content_hash },
|
|
1148
1161
|
});
|
|
1149
1162
|
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Write a milestone note to a plan, via `POST /api/plans/:plan_id/notes`.
|
|
1165
|
+
* TAKES AN EXPLICIT `plan_id`, unlike `plan_add_record`/`plan_add_architecture_section`
|
|
1166
|
+
* — mirrors `plan_add_card`'s sibling `plan_remove_card`/`plan_rename`: a
|
|
1167
|
+
* dispatched worker has no plan connection when it finishes a card, and a
|
|
1168
|
+
* card can sit on several plans, so the writer names which one the note
|
|
1169
|
+
* belongs to. A note is a MILESTONE, not a log — write one when a card (or a
|
|
1170
|
+
* related group of cards) finishes, an important decision lands, or a
|
|
1171
|
+
* goal/rule/caveat/architecture section changes meaningfully; routine step
|
|
1172
|
+
* progress stays a card comment. Links resolve on read into what they point
|
|
1173
|
+
* at (a card's title, a record's ref + body, a section's title) — an unknown
|
|
1174
|
+
* card id, an unparseable or foreign record ref, or an unknown or foreign
|
|
1175
|
+
* section id is refused 400 naming exactly which one. A card link does NOT
|
|
1176
|
+
* require the card to be a member of this plan; a record/section link MUST
|
|
1177
|
+
* belong to THIS plan. `author` is stamped server-side from your identity,
|
|
1178
|
+
* never sent by you. Unknown plan → 404. Returns the new note plus the
|
|
1179
|
+
* plan's latest notes page.
|
|
1180
|
+
*/
|
|
1181
|
+
export async function planAddNote(client, args) {
|
|
1182
|
+
return client.request({
|
|
1183
|
+
method: "POST",
|
|
1184
|
+
path: `/${args.plan_id}/notes`,
|
|
1185
|
+
basePath: PLANS_BASE_PATH,
|
|
1186
|
+
body: {
|
|
1187
|
+
title: args.title,
|
|
1188
|
+
body: args.body,
|
|
1189
|
+
...(args.card_ids === undefined ? {} : { card_ids: args.card_ids }),
|
|
1190
|
+
...(args.record_refs === undefined ? {} : { record_refs: args.record_refs }),
|
|
1191
|
+
...(args.section_ids === undefined ? {} : { section_ids: args.section_ids }),
|
|
1192
|
+
},
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* Edit a plan note, via `PATCH /api/plans/:plan_id/notes/:note_id`.
|
|
1197
|
+
* `content_hash` MUST be the note's `contentHash` from your last read; on a
|
|
1198
|
+
* mismatch nothing is written and you get `{error: "stale_plan_note",
|
|
1199
|
+
* currentHash, currentTitle, currentBody, currentLinks}` — merge into those
|
|
1200
|
+
* and retry with `content_hash: currentHash`, never blindly. `title`/`body`
|
|
1201
|
+
* are each optional and keep their stored value when omitted. The LINK
|
|
1202
|
+
* fields are all-or-nothing as a GROUP: omit all three to keep the stored
|
|
1203
|
+
* link set untouched; send ANY one of them to REPLACE THE WHOLE SET (never a
|
|
1204
|
+
* per-link add/remove — the set is the unit of change). The hash covers
|
|
1205
|
+
* title, body AND the link set, so a stale read of any of the three is
|
|
1206
|
+
* refused. TAKES AN EXPLICIT `plan_id`, same reason as `plan_add_note`.
|
|
1207
|
+
* Unknown plan or note id → 404. Returns the edited note plus the plan's
|
|
1208
|
+
* latest notes page.
|
|
1209
|
+
*/
|
|
1210
|
+
export async function planUpdateNote(client, args) {
|
|
1211
|
+
return client.request({
|
|
1212
|
+
method: "PATCH",
|
|
1213
|
+
path: `/${args.plan_id}/notes/${args.note_id}`,
|
|
1214
|
+
basePath: PLANS_BASE_PATH,
|
|
1215
|
+
body: {
|
|
1216
|
+
content_hash: args.content_hash,
|
|
1217
|
+
...(args.title === undefined ? {} : { title: args.title }),
|
|
1218
|
+
...(args.body === undefined ? {} : { body: args.body }),
|
|
1219
|
+
...(args.card_ids === undefined ? {} : { card_ids: args.card_ids }),
|
|
1220
|
+
...(args.record_refs === undefined ? {} : { record_refs: args.record_refs }),
|
|
1221
|
+
...(args.section_ids === undefined ? {} : { section_ids: args.section_ids }),
|
|
1222
|
+
},
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Soft-delete a plan note, via `DELETE /api/plans/:plan_id/notes/:note_id`.
|
|
1227
|
+
* `content_hash` MUST be the note's `contentHash` from your last read; a
|
|
1228
|
+
* stale hash deletes nothing and returns the same `{error:
|
|
1229
|
+
* "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}`
|
|
1230
|
+
* shape `plan_update_note` uses. TAKES AN EXPLICIT `plan_id`, same reason as
|
|
1231
|
+
* `plan_add_note`. Unknown plan, unknown note, or an already-deleted note →
|
|
1232
|
+
* 404. Returns the plan's remaining latest notes page.
|
|
1233
|
+
*/
|
|
1234
|
+
export async function planDeleteNote(client, args) {
|
|
1235
|
+
return client.request({
|
|
1236
|
+
method: "DELETE",
|
|
1237
|
+
path: `/${args.plan_id}/notes/${args.note_id}`,
|
|
1238
|
+
basePath: PLANS_BASE_PATH,
|
|
1239
|
+
body: { content_hash: args.content_hash },
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1150
1242
|
// ---------------- failure_category_list / _create / _update (DX-2792) ----------------
|
|
1151
1243
|
/**
|
|
1152
1244
|
* DX-2792 (Failure evaluation 3/4) — wraps `src/dashboard/failure-categories-routes.ts`,
|
package/dist/index.js
CHANGED
|
@@ -94,13 +94,14 @@
|
|
|
94
94
|
*/
|
|
95
95
|
import { isEntrypointModule } from "./entrypoint.js";
|
|
96
96
|
import { BRIDGE_SUBCOMMAND, runBridgeCommand } from "./bridge.js";
|
|
97
|
+
import { PLAN_STATE_SUBCOMMAND, runPlanStateCommand } from "./plan-state.js";
|
|
97
98
|
import { resolveDeclaredCredential } from "./credential.js";
|
|
98
99
|
import { recordSessionConnectionAfterConnect } from "./session-connection.js";
|
|
99
100
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
100
101
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
101
102
|
import { z } from "zod";
|
|
102
103
|
import { DashboardHttpClient } from "./http-client.js";
|
|
103
|
-
import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, planAddArchitectureSection, planAddCard, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, PLAN_STATUSES, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
|
|
104
|
+
import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, planAddArchitectureSection, planAddCard, planAddNote, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteNote, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, PLAN_STATUSES, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateNote, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
|
|
104
105
|
import { PRIORITY_TIER_WORDS } from "./priority.js";
|
|
105
106
|
function readEnvOrDie(name) {
|
|
106
107
|
const v = process.env[name];
|
|
@@ -503,12 +504,13 @@ server.tool("issue_triage", "Record a triage confidence score via POST /api/issu
|
|
|
503
504
|
...boardField,
|
|
504
505
|
}, async (args) => jsonResult(await issueTriage(client, args)));
|
|
505
506
|
// ---------------- issue_comment ----------------
|
|
506
|
-
server.tool("issue_comment", "Comment CRUD via /api/issues/:id/comments[/:cid]. action=add → POST {text, metadata?} (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).", {
|
|
507
|
+
server.tool("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.", {
|
|
507
508
|
id: z.string().min(1),
|
|
508
509
|
action: z.enum(["add", "edit", "delete"]),
|
|
509
510
|
comment_id: z.number().int().positive().optional(),
|
|
510
511
|
text: z.string().min(1).optional(),
|
|
511
512
|
metadata: z.record(z.unknown()).optional(),
|
|
513
|
+
problem_id: z.number().int().positive().optional().describe("action=add only — thread this comment as a follow-up under a LIVE problem of this same card, without answering it"),
|
|
512
514
|
...boardField,
|
|
513
515
|
}, async (args) => jsonResult(await issueComment(client, args)));
|
|
514
516
|
// ---------------- issue_checklist ----------------
|
|
@@ -546,12 +548,17 @@ const SOLUTION_FIELDS = {
|
|
|
546
548
|
con: z.string().optional(),
|
|
547
549
|
recommended: z.boolean().optional(),
|
|
548
550
|
};
|
|
549
|
-
server.tool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pid]: one statement the operator must resolve (a question, or a flaw in the plan), each with its own solutions/answers — one problem per question. OPEN = not yet answered; the card needs a human 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, content_hash, open, solutions[], decisions[]}; add {statement, solutions?} → problem_id + solution_ids in one transaction (zero solutions is valid: the operator answers free-form) plus `problems_reminder: {open_problem_count, instruction}` naming each open problem's solution count; edit :pid {base_hash, statement}; remove :pid {base_hash} — always allowed, even as the card's last open problem (DX-2830: removing it just means the card no longer needs a human). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. No answer action — the operator answers in the dashboard.", {
|
|
551
|
+
server.tool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pid]: one statement the operator must resolve (a question, or a flaw in the plan), each with its own solutions/answers — one problem per question. OPEN = not yet answered; the card needs a human 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, content_hash, open, solutions[], decisions[]}; add {statement, context?, solutions?} → problem_id + solution_ids in one transaction (zero solutions is valid: the operator answers free-form) plus `problems_reminder: {open_problem_count, instruction}` naming each open problem's solution count; edit :pid {base_hash, statement, context?}; remove :pid {base_hash} — always allowed, even as the card's last open problem (DX-2830: removing it just means the card no longer needs a human). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. No answer action — the operator answers in the dashboard. DX-2942 — `statement` is capped at 100 characters (a 400 names the actual length otherwise): write it as ONE plain question, 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).", {
|
|
550
552
|
id: z.string().min(1),
|
|
551
553
|
action: z.enum(["list", "add", "edit", "remove"]),
|
|
552
554
|
problem_id: z.number().int().positive().optional().describe("edit/remove"),
|
|
553
555
|
base_hash: z.string().min(1).optional().describe("content_hash last read; edit/remove"),
|
|
554
|
-
statement: z.string().min(1).optional().describe("add/edit"),
|
|
556
|
+
statement: z.string().min(1).optional().describe("add/edit; ONE plain question, at most 100 characters"),
|
|
557
|
+
context: z
|
|
558
|
+
.string()
|
|
559
|
+
.nullable()
|
|
560
|
+
.optional()
|
|
561
|
+
.describe("add/edit; markdown detail behind statement — file:line refs, code excerpts, root-cause writeups. add: sent only when given (null/omit means none). edit: omit to keep the stored context, null to clear it."),
|
|
555
562
|
solutions: z
|
|
556
563
|
.array(z.object({ title: z.string().min(1), ...SOLUTION_FIELDS }))
|
|
557
564
|
.optional()
|
|
@@ -652,7 +659,7 @@ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retr
|
|
|
652
659
|
// route's MAX_DECODED_BYTES (src/issues/write/attachments.ts). This package is
|
|
653
660
|
// a separate published artifact and cannot import that constant, so the number
|
|
654
661
|
// is restated here as prose — keep the two in sync if the backend ceiling moves.
|
|
655
|
-
server.tool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/issues/:id/attachments. Pass `id` (the card) and `file_path` (an ABSOLUTE path to a file on the dispatch's shared filesystem — e.g. a screenshot, exported CSV, or diagram you wrote). This MCP server reads the bytes, infers the MIME type from the extension, and uploads through the dashboard, which: stores the bytes in S3, inserts ONE danxbot-origin issue_attachments row, and auto-mirrors the file to the card's linked Trello card AND its Slack card-view thread (DX-1122 outbound projection) — no extra step needed. Board-scoped; see `board`. Fail-loud: a relative/empty path is rejected at the MCP boundary, and a missing/unreadable file throws BEFORE any upload (no partial S3 object, no row). 25 MB decoded ceiling (413). Returns the
|
|
662
|
+
server.tool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/issues/:id/attachments. Pass `id` (the card) and `file_path` (an ABSOLUTE path to a file on the dispatch's shared filesystem — e.g. a screenshot, exported CSV, or diagram you wrote). This MCP server reads the bytes, infers the MIME type from the extension, and uploads through the dashboard, which: stores the bytes in S3, inserts ONE danxbot-origin issue_attachments row, and auto-mirrors the file to the card's linked Trello card AND its Slack card-view thread (DX-1122 outbound projection) — no extra step needed. Board-scoped; see `board`. Fail-loud: a relative/empty path is rejected at the MCP boundary, and a missing/unreadable file throws BEFORE any upload (no partial S3 object, no row). 25 MB decoded ceiling (413). Returns `{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 `` 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`.", {
|
|
656
663
|
id: z.string().min(1),
|
|
657
664
|
file_path: z
|
|
658
665
|
.string()
|
|
@@ -792,6 +799,32 @@ server.tool("plan_delete_record", 'Soft-delete a goal/rule/caveat of your connec
|
|
|
792
799
|
record_id: z.number().int().positive().describe("The record id to delete."),
|
|
793
800
|
content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
|
|
794
801
|
}, async (args) => jsonResult(await planDeleteRecord(client, args)));
|
|
802
|
+
server.tool("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. Returns the new note plus the plan's latest notes page.", {
|
|
803
|
+
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
804
|
+
title: z.string().min(1).describe("At most 60 characters."),
|
|
805
|
+
body: z.string().min(1).describe("The wrap-up, at most 250 characters."),
|
|
806
|
+
card_ids: z.array(z.string().min(1)).optional().describe("Card ids this note concerns, e.g. `[\"DX-2894\"]`."),
|
|
807
|
+
record_refs: z
|
|
808
|
+
.array(z.string().min(1))
|
|
809
|
+
.optional()
|
|
810
|
+
.describe("Goal/rule/caveat references this note announces, e.g. `[\"G-1\", \"R-3\", \"CAV-2\"]`."),
|
|
811
|
+
section_ids: z.array(z.number().int().positive()).optional().describe("Architecture section ids this note announces."),
|
|
812
|
+
}, async (args) => jsonResult(await planAddNote(client, args)));
|
|
813
|
+
server.tool("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. Returns the edited note plus the plan\'s latest notes page.', {
|
|
814
|
+
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
815
|
+
note_id: z.number().int().positive().describe("The note id to edit."),
|
|
816
|
+
content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
|
|
817
|
+
title: z.string().min(1).optional().describe("New title, at most 60 characters. Omit to keep the stored title."),
|
|
818
|
+
body: z.string().min(1).optional().describe("New wrap-up, at most 250 characters. Omit to keep the stored body."),
|
|
819
|
+
card_ids: z.array(z.string().min(1)).optional().describe("REPLACES the whole link set when sent (with record_refs/section_ids)."),
|
|
820
|
+
record_refs: z.array(z.string().min(1)).optional().describe("REPLACES the whole link set when sent (with card_ids/section_ids)."),
|
|
821
|
+
section_ids: z.array(z.number().int().positive()).optional().describe("REPLACES the whole link set when sent (with card_ids/record_refs)."),
|
|
822
|
+
}, async (args) => jsonResult(await planUpdateNote(client, args)));
|
|
823
|
+
server.tool("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. Returns the plan\'s remaining latest notes page.', {
|
|
824
|
+
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
825
|
+
note_id: z.number().int().positive().describe("The note id to delete."),
|
|
826
|
+
content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
|
|
827
|
+
}, async (args) => jsonResult(await planDeleteNote(client, args)));
|
|
795
828
|
server.tool("plan_add_card", "Add an existing card to the plan this session is connected to, via POST /api/plans/mine/cards (DX-2683). The card may live on ANY board — that is what a plan is for. Idempotent: re-adding a card already on the plan is a no-op, not an error, and a card may sit in several plans at once. This adds MEMBERSHIP only; it never edits the card. TAKES NO PLAN ID: the plan is resolved from your connected session. Not connected → `{error: \"session_not_connected\"}`. Unknown card → 404. Returns the plan's full member list.", {
|
|
796
829
|
card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
|
|
797
830
|
}, async (args) => jsonResult(await planAddCard(client, args)));
|
|
@@ -881,16 +914,25 @@ async function main() {
|
|
|
881
914
|
// symlink-aware (DX-1647) so it holds under the symlinked `npx` bin the worker
|
|
882
915
|
// spawns, not just a direct `node dist/index.js`.
|
|
883
916
|
//
|
|
884
|
-
// `bridge`
|
|
885
|
-
// plugin's plan event bridge runs. It never
|
|
886
|
-
// input is `CLAUDE_CODE_SESSION_ID` (read from
|
|
887
|
-
// `--resume-ids` — the dashboard URL, credential source,
|
|
888
|
-
// come from the session-connection record this server's
|
|
889
|
-
// wrote (`session-connection.ts`), never from the bridge's
|
|
890
|
-
// (DX-2862: a bridge trusting its own ambient
|
|
891
|
-
// exact bug this repo fixed).
|
|
892
|
-
//
|
|
893
|
-
//
|
|
917
|
+
// `bridge` and `plan-state` are the two subcommands. `bridge` is the session
|
|
918
|
+
// event stream client the danxbot plugin's plan event bridge runs. It never
|
|
919
|
+
// boots the MCP server; its only input is `CLAUDE_CODE_SESSION_ID` (read from
|
|
920
|
+
// its environment) plus `--resume-ids` — the dashboard URL, credential source,
|
|
921
|
+
// and credential itself come from the session-connection record this server's
|
|
922
|
+
// own `plan_connect` wrote (`session-connection.ts`), never from the bridge's
|
|
923
|
+
// own environment (DX-2862: a bridge trusting its own ambient
|
|
924
|
+
// `DANXBOT_DISPATCH_TOKEN` is the exact bug this repo fixed).
|
|
925
|
+
//
|
|
926
|
+
// `plan-state` (DX-2921) is the ONE-SHOT client of
|
|
927
|
+
// `GET /api/plan-sessions/me/turn-state`, run by the DX-2888 plugin's
|
|
928
|
+
// turn-start / turn-end hooks — see `plan-state.ts`'s own doc comment for the
|
|
929
|
+
// full contract. It ALWAYS exits 0 (success or failure alike: the hook must
|
|
930
|
+
// stay silent), so even an unexpected rejection here is caught and reported
|
|
931
|
+
// the same `{ok:false, reason}` way rather than propagating a non-zero exit
|
|
932
|
+
// or an uncaught stack trace into the hook.
|
|
933
|
+
//
|
|
934
|
+
// Any OTHER argument is refused rather than ignored, so a typo cannot
|
|
935
|
+
// silently start an MCP server on stdio where a subcommand was meant to run.
|
|
894
936
|
if (isEntrypointModule(import.meta.url, process.argv[1])) {
|
|
895
937
|
const [subcommand, ...rest] = process.argv.slice(2);
|
|
896
938
|
if (subcommand === BRIDGE_SUBCOMMAND) {
|
|
@@ -899,8 +941,16 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
|
|
|
899
941
|
process.exit(1);
|
|
900
942
|
});
|
|
901
943
|
}
|
|
944
|
+
else if (subcommand === PLAN_STATE_SUBCOMMAND) {
|
|
945
|
+
runPlanStateCommand(rest).then((code) => process.exit(code), (err) => {
|
|
946
|
+
console.error(`[danx-dashboard-mcp] plan-state fatal: ${err.message}`);
|
|
947
|
+
process.stdout.write(`${JSON.stringify({ ok: false, reason: "fatal" })}\n`);
|
|
948
|
+
process.exit(0);
|
|
949
|
+
});
|
|
950
|
+
}
|
|
902
951
|
else if (subcommand !== undefined) {
|
|
903
|
-
console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only
|
|
952
|
+
console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only ones are ` +
|
|
953
|
+
`"${BRIDGE_SUBCOMMAND}" and "${PLAN_STATE_SUBCOMMAND}")`);
|
|
904
954
|
process.exit(2);
|
|
905
955
|
}
|
|
906
956
|
else {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-2920 — the ONE place that reads a JSON body's top-level `.error` string.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. `bridge.ts` (classifying a mint refusal) and `listen.ts`
|
|
5
|
+
* (classifying a stream admission refusal) both need to answer "what error
|
|
6
|
+
* code did this JSON body carry", and before this card each answered it a
|
|
7
|
+
* different way: `bridge.ts` parsed the body directly, `listen.ts` never
|
|
8
|
+
* looked past a flattened, human-readable detail string at all — a caller
|
|
9
|
+
* that wanted to react to a SPECIFIC refusal (the admission-time 409 the
|
|
10
|
+
* mint route also uses) had to regex-scrape that flattened string back apart,
|
|
11
|
+
* a second, fragile source of truth for the same fact. This module is generic
|
|
12
|
+
* — it knows nothing about "session_not_connected" or "issuer_cannot_read_plan_boards",
|
|
13
|
+
* only that a JSON object may carry a top-level string field named `error` —
|
|
14
|
+
* so it can live in BOTH the domain-aware `bridge.ts` and the deliberately
|
|
15
|
+
* generic `listen.ts` (whose own docblock states it carries no domain
|
|
16
|
+
* knowledge of the dashboard's specific error shapes) without either one
|
|
17
|
+
* importing the other.
|
|
18
|
+
*/
|
|
19
|
+
export function errorCodeOf(body) {
|
|
20
|
+
return typeof body === "object" && body !== null && typeof body.error === "string"
|
|
21
|
+
? body.error
|
|
22
|
+
: null;
|
|
23
|
+
}
|