@thehammer/danx-dashboard-mcp 0.1.162 → 0.1.164
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 +10 -32
- package/dist/entrypoint.test.js +43 -0
- package/dist/event-text.js +137 -0
- package/dist/field-tree.js +5 -4
- package/dist/handlers.js +166 -1534
- package/dist/index.js +175 -819
- package/dist/lifecycle.js +447 -0
- package/dist/priority.js +4 -4
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -11,49 +11,23 @@
|
|
|
11
11
|
* Tool surface (all exposed as `mcp__danx-dashboard__<name>` once wired
|
|
12
12
|
* through the workspace `mcp.template.json`):
|
|
13
13
|
*
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* - danxbot_api the generic surface for every non-agent-specific dashboard
|
|
15
|
+
* operation (DX-3465 / PLN-11 R-25) — list/get/create/update/delete
|
|
16
|
+
* against the uniform resource routes that used to each have their
|
|
17
|
+
* own per-operation tool (issue_*, plan_* (except plan_connect),
|
|
18
|
+
* brief_*, repo_knowledge_*, failure_category_*,
|
|
19
|
+
* dispatch_transcript_search, quality_gate_instruction — all deleted
|
|
20
|
+
* here). See `SERVER_INSTRUCTIONS` below for the calling convention.
|
|
21
|
+
* - danxbot_api_list lists the routes your credential may call, one line each (DX-3465
|
|
22
|
+
* A5, backed by DX-3528's permission-aware discovery). Call this
|
|
23
|
+
* first to find the route for something you need to do.
|
|
24
|
+
* - danxbot_api_spec discovers the exact request/response shape of any route
|
|
25
|
+
* `danxbot_api` can call, before calling it.
|
|
18
26
|
* - resource_fields GET /api/resources/:resource/fields (DX-3426 — what `fields` may
|
|
19
27
|
* name for a resource; install-global, no `board`)
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* - issue_triage POST /api/issues/:id/triage
|
|
24
|
-
* - issue_comment POST/PATCH/DELETE /api/issues/:id/comments[/:cid]
|
|
25
|
-
* - issue_checklist POST/PATCH/DELETE /api/issues/:id/checklists[/:cid[/items[/:iid]]]
|
|
26
|
-
* - issue_problem GET/POST/PATCH/DELETE /api/issues/:id/problems[/:pid] (DX-2735)
|
|
27
|
-
* - issue_solution POST/PATCH/DELETE /api/issues/:id/problems/:pid/solutions[/:sid][/steps[/:stepId]] (DX-2735, steps DX-3310)
|
|
28
|
-
* - issue_dependency POST/DELETE /api/issues/:id/dependencies[/:did]
|
|
29
|
-
* - issue_retire_branch POST /api/issues/:id/card-branch-retire (DX-2845)
|
|
30
|
-
* - issue_quality_gate POST /api/issues/:id/quality-gates/:gate
|
|
31
|
-
* - issue_quality_gate_verdict
|
|
32
|
-
* PATCH /api/issues/:id/quality-gates/:gate
|
|
33
|
-
* - issue_retro PUT /api/issues/:id/retro
|
|
34
|
-
* - issue_attach POST /api/issues/:id/attachments (reads a local file)
|
|
35
|
-
* - quality_gate_instruction
|
|
36
|
-
* GET /api/quality-gates/:gate/instruction (DX-3341, not card-scoped)
|
|
37
|
-
* - plan_list GET /api/plans (DX-2683)
|
|
38
|
-
* - plan_get GET /api/plans/:id/full | /api/plans/mine (DX-2727: bare = scalars only, fields= opts in)
|
|
39
|
-
* - plan_create POST /api/plans
|
|
40
|
-
* - plan_connect POST /api/plan-sessions/me/plan
|
|
41
|
-
* - plan_add_record POST /api/plans/mine/records
|
|
42
|
-
* - plan_get_record GET /api/plans/mine/records/:rid (DX-2681 follow-up)
|
|
43
|
-
* - plan_update_record PATCH /api/plans/mine/records/:rid (DX-2681 follow-up)
|
|
44
|
-
* - plan_delete_record DELETE /api/plans/mine/records/:rid (DX-2681 follow-up)
|
|
45
|
-
* - plan_add_card POST /api/plans/mine/cards
|
|
46
|
-
* - plan_remove_card DELETE /api/plans/:plan_id/cards/:card_id (DX-2740)
|
|
47
|
-
* - plan_rename PATCH /api/plans/:plan_id (DX-2740)
|
|
48
|
-
* - plan_get_architecture_section GET /api/plans/mine/architecture/sections/:sid (DX-2726)
|
|
49
|
-
* - plan_add_architecture_section POST /api/plans/mine/architecture/sections (DX-2726)
|
|
50
|
-
* - plan_update_architecture_section PATCH /api/plans/mine/architecture/sections/:sid (DX-2726)
|
|
51
|
-
* - plan_delete_architecture_section DELETE /api/plans/mine/architecture/sections/:sid (DX-2726)
|
|
52
|
-
* - plan_reorder_architecture_section PUT /api/plans/mine/architecture/sections/reorder (DX-2726)
|
|
53
|
-
* - failure_category_list GET /api/failure-categories (DX-2791/DX-2792, board-less)
|
|
54
|
-
* - failure_category_create POST /api/failure-categories (DX-2791/DX-2792, board-less)
|
|
55
|
-
* - failure_category_update PATCH /api/failure-categories/:id (DX-2791/DX-2792, board-less)
|
|
56
|
-
* - dispatch_transcript_search GET /api/dispatches/:id/logs (DX-3221 — search/tail, no new route)
|
|
28
|
+
* - plan_connect POST /api/plan-sessions/me/plan — SESSION-BOUND (kept as a
|
|
29
|
+
* dedicated tool; every other plan_* operation goes through
|
|
30
|
+
* danxbot_api now)
|
|
57
31
|
*
|
|
58
32
|
* DX-2683 — THE PLAN TOOLS ARE SESSION-BOUND, and asymmetrically so. Reads
|
|
59
33
|
* may name any plan; WRITES take no plan id at all and act on the plan this
|
|
@@ -98,16 +72,15 @@ import { isEntrypointModule } from "./entrypoint.js";
|
|
|
98
72
|
import { BRIDGE_SUBCOMMAND, runBridgeCommand } from "./bridge.js";
|
|
99
73
|
import { PLAN_STATE_SUBCOMMAND, runPlanStateCommand } from "./plan-state.js";
|
|
100
74
|
import { BACKGROUND_WORK_SUBCOMMAND, runBackgroundWorkCommand } from "./background-work.js";
|
|
101
|
-
import {
|
|
75
|
+
import { EVENT_TEXT_SUBCOMMAND, runEventTextCommand } from "./event-text.js";
|
|
102
76
|
import { resolveDeclaredCredential } from "./credential.js";
|
|
103
77
|
import { recordSessionConnectionAfterConnect } from "./session-connection.js";
|
|
104
78
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
105
79
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
106
80
|
import { z } from "zod";
|
|
107
81
|
import { DashboardHttpClient } from "./http-client.js";
|
|
108
|
-
import {
|
|
109
|
-
import {
|
|
110
|
-
import { fieldTreeSchema } from "./field-tree.js";
|
|
82
|
+
import { planConnect, resourceFields, danxbotApi, danxbotApiList, danxbotApiSpec, attachFile, DANXBOT_API_METHODS, } from "./handlers.js";
|
|
83
|
+
import { registerLifecycleTools, applyLifecycleVisibility, readLifecycleUrlsFromEnv, } from "./lifecycle.js";
|
|
111
84
|
function readEnvOrDie(name) {
|
|
112
85
|
const v = process.env[name];
|
|
113
86
|
if (typeof v !== "string" || v === "") {
|
|
@@ -168,6 +141,14 @@ function boot() {
|
|
|
168
141
|
fingerprint: credential.fingerprint,
|
|
169
142
|
};
|
|
170
143
|
client = new DashboardHttpClient(config);
|
|
144
|
+
// DX-3465 (PLN-11 R-25) — the lifecycle tool set's per-tool visibility is
|
|
145
|
+
// per-dispatch, not static: a tool whose callback URL wasn't injected for
|
|
146
|
+
// THIS dispatch must not even be advertised (mirrors the old standalone
|
|
147
|
+
// server's `buildActiveTools`). Applied here (inside `boot()`, before
|
|
148
|
+
// `server.connect()`) so the DX-1606 tool-defs generator — which imports
|
|
149
|
+
// this module WITHOUT calling `boot()` — still sees every lifecycle tool
|
|
150
|
+
// registered and enabled for token-cost accounting.
|
|
151
|
+
applyLifecycleVisibility(lifecycleTools, readLifecycleUrlsFromEnv());
|
|
171
152
|
}
|
|
172
153
|
/**
|
|
173
154
|
* DX-2862 — the credential, through the ONE resolver the `bridge` subcommand
|
|
@@ -219,10 +200,66 @@ function readSessionConfig() {
|
|
|
219
200
|
return undefined;
|
|
220
201
|
return { id };
|
|
221
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* DX-3465 (PLN-11 R-25) — the ONE home for prose that used to be repeated
|
|
205
|
+
* (or would otherwise need repeating) on every per-operation tool this
|
|
206
|
+
* server exposes, now that `danxbot_api` is the generic surface for
|
|
207
|
+
* everything that is not agent-specific (R-25). Read this before your first
|
|
208
|
+
* `danxbot_api` call.
|
|
209
|
+
*
|
|
210
|
+
* Per-tool descriptions stay minimal — this block is where the shared rules
|
|
211
|
+
* live, once.
|
|
212
|
+
*/
|
|
213
|
+
const SERVER_INSTRUCTIONS = `\
|
|
214
|
+
danx-dashboard-mcp — reach the dashboard API through danxbot_api; find a route with danxbot_api_list; discover
|
|
215
|
+
its shape through danxbot_api_spec.
|
|
216
|
+
|
|
217
|
+
UNIFORM RESOURCE SHAPE. Most dashboard resources (cards, plans, records, comments, problems, ...) follow the
|
|
218
|
+
same four verbs:
|
|
219
|
+
- list GET <collection> — filter/sort/limit/offset; the default row is minimal scalars,
|
|
220
|
+
opt into more with \`fields\`.
|
|
221
|
+
- get GET <collection>/:id — one resource; \`fields\` opts into relations/heavy data the same way.
|
|
222
|
+
- create POST <collection> — the resource's own required fields; a 400 names any missing/invalid one.
|
|
223
|
+
- update PATCH/PUT <collection>/:id — send only the fields you are changing; most resources require the
|
|
224
|
+
concurrency token below when the edit touches contested text fields.
|
|
225
|
+
- delete DELETE <collection>/:id — usually a soft-delete; the resource's own docs say so.
|
|
226
|
+
Don't know the route for what you need to do? Call \`danxbot_api_list()\` — it lists exactly the routes your
|
|
227
|
+
credential may call, one line each. Then call \`danxbot_api_spec({endpoints: ["METHOD /path/template"]})\` for the
|
|
228
|
+
EXACT current shape of the one(s) you picked, before calling them through \`danxbot_api\` — request/response
|
|
229
|
+
schema, required permission, and every documented error envelope. Never guess a field name or a route path.
|
|
230
|
+
|
|
231
|
+
ALWAYS PASS \`fields\`. The default response for a list/get route is minimal — scalars only, no relations, no
|
|
232
|
+
heavy text. Every resource that supports it takes a \`fields\` query param (a field tree, or a flat group list on
|
|
233
|
+
an older route) that opts into exactly what you need. Passing nothing and then being surprised a field is
|
|
234
|
+
missing is a caller bug, not a server one — check the spec for the resource's own field vocabulary.
|
|
235
|
+
|
|
236
|
+
THE STALE-HASH RULE. A write that touches contested text (a card's title/description/checklists, a plan
|
|
237
|
+
record, a problem, ...) is optimistic-concurrency guarded by a \`content_hash\` / \`base_hash\` you must have read
|
|
238
|
+
FIRST (from the same resource's own \`get\`/\`list\` response) and send back unchanged. A stale hash 409s with the
|
|
239
|
+
CURRENT hash and CURRENT content in the body — re-read, merge, retry with the fresh hash. Never retry blindly
|
|
240
|
+
with the same stale value, and never omit the hash hoping the write "just works" — omission is refused exactly
|
|
241
|
+
like a stale value on any route that requires one.
|
|
242
|
+
|
|
243
|
+
A 4XX BODY NAMES THE FIX. Every refusal is structured, never a bare string: \`{error, ...extra}\`, where the
|
|
244
|
+
extra fields tell you exactly what to do next — \`offending_keys\` (an unknown/invalid field, by name),
|
|
245
|
+
\`failed_gate\` (which lifecycle/dispatch gate blocked a transition), \`currentHash\`/\`currentContent\` (a stale-
|
|
246
|
+
hash 409, ready to merge from), \`unresolved_items\` (which checklist items still block completion). Read the
|
|
247
|
+
body before retrying — the shape is the same one \`danxbot_api_spec\` documents per route, so a persistent 4xx
|
|
248
|
+
you don't understand means re-checking the spec, not repeating the same call.
|
|
249
|
+
|
|
250
|
+
WRITING STANDARD — for any tool that writes prose onto a card, plan record, or problem (title, summary,
|
|
251
|
+
description, context, a problem's statement, a solution's body, ...). The reader is new to the subject and has
|
|
252
|
+
not read, and will not read, the code or docs: what you write IS their documentation. Define any term a
|
|
253
|
+
newcomer would not know where it first appears. Open with plain background — what the thing is, why it exists,
|
|
254
|
+
what is wrong or what the choice changes — before evidence or technical detail. Cite ids/SHAs/paths precisely
|
|
255
|
+
rather than describing them vaguely ("the migration file" → the actual filename). Be brief: the goal is less to
|
|
256
|
+
read, not more, not a transcript of your reasoning. A summary/title alone must let the reader decide without
|
|
257
|
+
opening the description.
|
|
258
|
+
`;
|
|
222
259
|
export const server = new McpServer({
|
|
223
260
|
name: "danx-dashboard-mcp",
|
|
224
261
|
version: "0.1.0",
|
|
225
|
-
});
|
|
262
|
+
}, { instructions: SERVER_INSTRUCTIONS });
|
|
226
263
|
function jsonResult(value) {
|
|
227
264
|
return {
|
|
228
265
|
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
@@ -234,8 +271,8 @@ function jsonResult(value) {
|
|
|
234
271
|
* shape, cb)` builds its input schema as a bare `z.object(shape)` internally
|
|
235
272
|
* (via the SDK's own `objectFromShape`), and zod's default behaviour for an
|
|
236
273
|
* object schema is to STRIP any key not in `shape` — so a caller's typo (or a
|
|
237
|
-
* field the route accepts but this tool never declared, e.g.
|
|
238
|
-
* `priority` before this card) vanished before the HTTP request was even
|
|
274
|
+
* field the route accepts but this tool never declared, e.g. the issue-create
|
|
275
|
+
* route's `priority` before this card) vanished before the HTTP request was even
|
|
239
276
|
* built, and the server's own fail-loud checks (e.g. `parseFilterObject`'s
|
|
240
277
|
* "Unknown filter key" 400) never got a chance to run.
|
|
241
278
|
*
|
|
@@ -252,55 +289,13 @@ function jsonResult(value) {
|
|
|
252
289
|
function strictTool(name, description, shape, cb) {
|
|
253
290
|
return server.registerTool(name, { description, inputSchema: z.object(shape).strict() }, cb);
|
|
254
291
|
}
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
"low",
|
|
263
|
-
"medium",
|
|
264
|
-
"high",
|
|
265
|
-
"very_high",
|
|
266
|
-
"max",
|
|
267
|
-
];
|
|
268
|
-
// DX-2479 — `Task` is a planning record: a leaf card the dispatcher can never
|
|
269
|
-
// pick up. Convert it to a Story/Bug/Chore via `issue_edit({type:"Story"})`
|
|
270
|
-
// (DX-2484 wired `type` onto that tool's schema — see the issue_edit
|
|
271
|
-
// registration below). Accepted everywhere a type is, so a planning card can
|
|
272
|
-
// still be CREATED through this MCP.
|
|
273
|
-
const ISSUE_TYPES = ["Epic", "Bug", "Feature", "Story", "Chore", "Task"];
|
|
274
|
-
const NON_EPIC_TYPES = ["Bug", "Feature", "Story", "Chore", "Task"];
|
|
275
|
-
const SORT_ORDERS = ["asc", "desc"];
|
|
276
|
-
const sortField = z
|
|
277
|
-
.array(z.object({
|
|
278
|
-
column: z.string().min(1),
|
|
279
|
-
order: z.enum(SORT_ORDERS),
|
|
280
|
-
}).strict())
|
|
281
|
-
.optional()
|
|
282
|
-
.describe("Multi-column sort — ordered list of {column, order}. Absent → the server's default order (priority desc (higher priority=most urgent first), repo_name asc, with a numeric-id tiebreaker always appended).");
|
|
283
|
-
// DX-1290 — the uniform checklist-item status, extended DX-2653 with
|
|
284
|
-
// `deferred` (a named real-world/post-deploy check still outstanding).
|
|
285
|
-
// Terminal = passing|cancelled|deferred.
|
|
286
|
-
const CHECKLIST_ITEM_STATUSES = [
|
|
287
|
-
"incomplete",
|
|
288
|
-
"failing",
|
|
289
|
-
"passing",
|
|
290
|
-
"cancelled",
|
|
291
|
-
"deferred",
|
|
292
|
-
];
|
|
293
|
-
const TRANSITION_ACTIONS = [
|
|
294
|
-
"ready",
|
|
295
|
-
"pickup",
|
|
296
|
-
"rollback_pickup",
|
|
297
|
-
"complete",
|
|
298
|
-
"cancel",
|
|
299
|
-
"block",
|
|
300
|
-
"unblock",
|
|
301
|
-
"archive",
|
|
302
|
-
"reopen",
|
|
303
|
-
];
|
|
292
|
+
// ---------------- lifecycle tool set (DX-3465 / PLN-11 R-25) ----------------
|
|
293
|
+
// Merged from `src/mcp/danxbot-server.ts` — see `lifecycle.ts` for the full
|
|
294
|
+
// per-tool doc comments and the DX-3394 problem 1682 cleanup this merge
|
|
295
|
+
// folded in. `danxbot_complete` registers unconditionally-visible; the rest
|
|
296
|
+
// register here and are `.enable()`/`.disable()`d per-dispatch inside
|
|
297
|
+
// `boot()` above, based on which callback URL env vars are present.
|
|
298
|
+
const lifecycleTools = registerLifecycleTools(strictTool);
|
|
304
299
|
// Optional per-call board override shared by every tool. Omitted → the
|
|
305
300
|
// dispatch's env-derived board (`<repo>:<slug>`) is used; provided → that
|
|
306
301
|
// board is targeted instead (the dashboard owns board→repo and 404s an
|
|
@@ -316,599 +311,92 @@ const boardField = {
|
|
|
316
311
|
.optional()
|
|
317
312
|
.describe("Board id `<repo>:<slug>`; omit for this dispatch's board. Unknown → 404."),
|
|
318
313
|
};
|
|
319
|
-
// The three prose fields of a card, each with ONE job. Shared by issue_create
|
|
320
|
-
// (root + phase children) and issue_edit so the guidance an agent reads is
|
|
321
|
-
// identical wherever it writes the field.
|
|
322
|
-
const TITLE_DESCRIBE = 'Short, specific label naming the domain (e.g. "Guest checkout rejects gift-card carts"). Never generic ("Fix bug", "Follow-up").';
|
|
323
|
-
const SUMMARY_DESCRIBE = "1–3 plain-language sentences, no markdown/jargon: what the card is and why it matters. Always shown, never collapsed — not a second title, not a teaser.";
|
|
324
|
-
const DESCRIPTION_DESCRIBE = 'The full body ("Context"): evidence, examples, technical detail; markdown, collapsed by default. Operator questions go in issue_problem. Style: see issue_comment\'s `text` param.';
|
|
325
|
-
// DX-3335 — condensed from the now-deleted danxbot:comment-style skill (PLN-11
|
|
326
|
-
// R-16: fold a skill's rule into the tool description it governs rather than
|
|
327
|
-
// require a separate load). Shared by every prose-writing field below so an
|
|
328
|
-
// agent sees the same rules wherever it writes card markdown.
|
|
329
|
-
// DX-2623 — this constant (and every `.describe()` reference to it) must stay
|
|
330
|
-
// reachable: an earlier dedup pass (05af82086) deleted it outright and left
|
|
331
|
-
// the "never escape markdown" / "GFM not ASCII tables" rules unreachable from
|
|
332
|
-
// any tool in this surface until it was restored. The text below (from
|
|
333
|
-
// DX-3335) already carries both clauses — do not dedup them away again.
|
|
334
|
-
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.";
|
|
335
|
-
// ---------------- issue_list ----------------
|
|
336
|
-
strictTool("issue_list",
|
|
337
|
-
// DX-2735: trimmed to pay for the problem tools inside the work-profile
|
|
338
|
-
// injected-surface budget — same facts, no repeated prose.
|
|
339
|
-
"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; `fields` opts into more — the SAME recursive field-tree JSON `issue_get` takes, against the SAME issue resource (list-row fields like `ac_total`/`comments_count`/`waiting_on`/`quality_gates`/`next_pre_gate` sit alongside every detail field `issue_get` can name). Call `resource_fields({resource:\"issue\"})` for the full, current list of what `fields` may name — never guess a name; unknown → 400 `unknown_field`. `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.", {
|
|
340
|
-
filter: z
|
|
341
|
-
.object({
|
|
342
|
-
q: z.string().optional(),
|
|
343
|
-
type: z.enum(ISSUE_TYPES).optional(),
|
|
344
|
-
parent_id: z.string().nullable().optional(),
|
|
345
|
-
dispatchable_derived: z.boolean().optional(),
|
|
346
|
-
status_derived: z.array(z.string()).optional(),
|
|
347
|
-
self_dispatchable_derived: z.boolean().optional(),
|
|
348
|
-
assigned_agent: z.string().optional(),
|
|
349
|
-
include_closed: z.boolean().optional(),
|
|
350
|
-
include_deleted: z.boolean().optional(),
|
|
351
|
-
})
|
|
352
|
-
// DX-3238 — the card's own repro: a mistyped key here (`dispatchable`
|
|
353
|
-
// for `dispatchable_derived`) was silently stripped by this NESTED
|
|
354
|
-
// object's own bare `z.object`, so the query ran unfiltered — the
|
|
355
|
-
// outer `strictTool` wrapper (below) only guards the TOP-LEVEL shape,
|
|
356
|
-
// never a nested one, so every nested z.object() in this file needs
|
|
357
|
-
// its own `.strict()`.
|
|
358
|
-
.strict()
|
|
359
|
-
.optional(),
|
|
360
|
-
// DX-3427 — a field TREE, exactly like `issue_get`'s `fields` below
|
|
361
|
-
// (against the same shared issue resource) — replaces the retired
|
|
362
|
-
// CSV field-GROUP list.
|
|
363
|
-
fields: fieldTreeSchema
|
|
364
|
-
.optional()
|
|
365
|
-
.describe("A field tree (see tool description); absent/empty = minimal scalars. `resource_fields({resource:\"issue\"})` names every valid key."),
|
|
366
|
-
sort: sortField,
|
|
367
|
-
limit: z.number().int().positive().max(LIST_PAGE_MAX_LIMIT).optional(),
|
|
368
|
-
offset: z.number().int().nonnegative().optional(),
|
|
369
|
-
...boardField,
|
|
370
|
-
}, async (args) => jsonResult(await issueList(client, args)));
|
|
371
|
-
// ---------------- issue_get ----------------
|
|
372
|
-
strictTool("issue_get",
|
|
373
|
-
// DX-3426: field GROUPS replaced by a recursive field TREE (see
|
|
374
|
-
// `./field-tree.ts`) — comment paging now lives INSIDE the tree (a
|
|
375
|
-
// cursor-paged relation, today only `comments`, takes `limit`/`before` in
|
|
376
|
-
// its own nested object) instead of top-level `comments_limit`/
|
|
377
|
-
// `comments_offset`, which is also why the old "ids + comments_* refused"
|
|
378
|
-
// rule is gone: paging is per-relation now, so it works on the batch form
|
|
379
|
-
// too. Kept compact — call `resource_fields({resource:"issue"})` for the
|
|
380
|
-
// full, current list of what `fields` may name, rather than enumerating it
|
|
381
|
-
// here (it would drift the moment a field/relation is added or removed).
|
|
382
|
-
"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 THE ALWAYS-ON FIELDS ONLY — identity, lifecycle stamps, derived status/list/blocked, assigned_agent, and content_hash (the concurrency token issue_edit needs for title/description/checklists); every child hash is likewise always returned. `fields` opts into more, per card in a batch too: a JSON object tree — each key names a field or relation, each value is `true` (that field, or a relation's child with its default fields), a nested object (a relation's child with named fields), or on the cursor-paged `comments` relation the nested object may also carry `limit`/`before` (comment id) to page — e.g. `{\"description\": true, \"problems\": {\"solutions\": {\"steps\": true}}, \"comments\": {\"limit\": 10}}`; a paged response also carries `comments_page: {limit, total, next_cursor}` (`next_cursor` → pass back as `before` for the next older page, null = none). Call `resource_fields({resource:\"issue\"})` for exactly what may be requested (every field/relation name, each relation's own child resource, and its paging shape if any) — never guess a name. Unknown field/relation → 400 `unknown_field`; bad shape → 400 `invalid_fields`; bad `limit`/`before` → 400 `invalid_field_argument`; a field you lack permission for → 403 `forbidden_field`. Single form: unknown id → 404. Batch form: `{issues: [...], not_found: [...ids]}` — an unknown or deleted id never fails the call.", {
|
|
383
|
-
id: z.string().min(1).optional(),
|
|
384
|
-
ids: z.array(z.string().min(1)).min(1).max(ISSUE_BATCH_GET_MAX).optional(),
|
|
385
|
-
fields: fieldTreeSchema
|
|
386
|
-
.optional()
|
|
387
|
-
.describe("A field tree (see tool description); absent/empty = minimal scalars. `resource_fields({resource:\"issue\"})` names every valid key."),
|
|
388
|
-
...boardField,
|
|
389
|
-
}, async (args) => jsonResult(await issueGet(client, args)));
|
|
390
314
|
// ---------------- resource_fields ----------------
|
|
391
|
-
strictTool("resource_fields", "DX-3426: what a field TREE (`
|
|
315
|
+
strictTool("resource_fields", "DX-3426: what a field TREE (the `fields` arg on `GET /api/issues/:id` via `danxbot_api`, and any later tool that takes one) may name for one resource — `{resource, description, always: string[], hashes: string[], fields: [{name, description}], relations: [{name, description, resource, paging?: {default_limit, max_limit, page_field}}]}`. `always`/`hashes` ride every response regardless of `fields` (never request them). A relation's `resource` names what to call this same tool with next, to go one level deeper. Install-global, not board-scoped. `resource: \"issue\"` is the root; unknown resource → 404 `unknown_resource`.", {
|
|
392
316
|
resource: z.string().min(1).describe('Resource name, e.g. "issue", or a relation\'s own `resource` from a prior call.'),
|
|
393
317
|
}, async (args) => jsonResult(await resourceFields(client, args)));
|
|
394
|
-
// ----------------
|
|
395
|
-
strictTool("
|
|
396
|
-
'
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
// `.optional()` here silently restores the hole this field closes.
|
|
405
|
-
plan: z
|
|
406
|
-
.literal("mine")
|
|
407
|
-
.nullable()
|
|
408
|
-
.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.'),
|
|
409
|
-
parent_id: z.string().nullable().optional(),
|
|
410
|
-
ac: z.array(z.object({ title: z.string().min(1) }).strict()).optional(),
|
|
411
|
-
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
412
|
-
list_id: z.string().min(1).nullable().optional(),
|
|
413
|
-
quality_gates: z
|
|
414
|
-
.array(z.object({
|
|
415
|
-
gate: z.string().min(1),
|
|
416
|
-
note: z.string().optional(),
|
|
417
|
-
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
418
|
-
}).strict())
|
|
419
|
-
.optional()
|
|
420
|
-
.describe("The gates this card carries BEYOND the board's default set for its type — one {gate, note?} each, `note` = why it applies here. Every gate on a card is required; a gate you do not name is not on the card at all (not displayed, not counted, never run), and omitting this field entirely is normal. Optional `effort_level` overrides a `plan-*` gate's reviewer rung."),
|
|
421
|
-
phase_children: z
|
|
422
|
-
.array(z.object({
|
|
423
|
-
type: z.enum(NON_EPIC_TYPES),
|
|
424
|
-
title: z.string().min(1).describe(TITLE_DESCRIBE),
|
|
425
|
-
summary: z
|
|
426
|
-
.string()
|
|
427
|
-
.min(1)
|
|
428
|
-
.optional()
|
|
429
|
-
.describe(`${SUMMARY_DESCRIBE} This child's OWN summary — never inherited from the root card.`),
|
|
430
|
-
description: z.string().describe(DESCRIPTION_DESCRIBE),
|
|
431
|
-
ac: z.array(z.object({ title: z.string().min(1) }).strict()).optional(),
|
|
432
|
-
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
433
|
-
quality_gates: z
|
|
434
|
-
.array(z.object({
|
|
435
|
-
gate: z.string().min(1),
|
|
436
|
-
note: z.string().optional(),
|
|
437
|
-
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
438
|
-
}).strict())
|
|
439
|
-
.optional()
|
|
440
|
-
.describe("Same as the root quality_gates, resolved against THIS child's type."),
|
|
441
|
-
triage_enabled: z
|
|
442
|
-
.boolean()
|
|
443
|
-
.optional()
|
|
444
|
-
.describe("ALWAYS pass per child; absent → false (never auto-triaged). Not inherited from the root."),
|
|
445
|
-
}).strict())
|
|
446
|
-
.optional(),
|
|
447
|
-
triage_enabled: z
|
|
448
|
-
.boolean()
|
|
449
|
-
.optional()
|
|
450
|
-
.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."),
|
|
451
|
-
// DX-3238 — was absent from this schema entirely (not merely optional),
|
|
452
|
-
// so a caller's value was silently stripped before the request was built
|
|
453
|
-
// even though the route already honoured it (ALLOWED_CREATE_KEYS,
|
|
454
|
-
// create.ts:39-56; parsePriority, create.ts:545). Same shape as
|
|
455
|
-
// issue_edit's `priority` above.
|
|
456
|
-
priority: z
|
|
457
|
-
.union([z.enum(PRIORITY_TIER_WORDS), z.number()])
|
|
458
|
-
.optional()
|
|
459
|
-
.describe('Tier word ("lowest"…"critical", prefer this) or number in [0,6); higher = more urgent. Omit for the route\'s own default.'),
|
|
460
|
-
// DX-3238 — `assigned_agent` is DELIBERATELY left undeclared here, unlike
|
|
461
|
-
// `priority` above. The route accepts it (`ALLOWED_CREATE_KEYS`,
|
|
462
|
-
// create.ts:39-56) but it only matters for the create→`list_id`-lands-
|
|
463
|
-
// in-progress placement path (create.ts:918-955), a dispatched-worker
|
|
464
|
-
// internal-bookkeeping concern this MCP tool never exposes (no MCP
|
|
465
|
-
// caller can pick an in-progress-mapped `list_id`) — so there is no
|
|
466
|
-
// legitimate agent-facing use for it. Before this card that gap was
|
|
467
|
-
// silent (the key vanished with no signal); now, with every schema
|
|
468
|
-
// here strict, a caller that tries it gets a clear "Invalid arguments"
|
|
469
|
-
// refusal naming `assigned_agent` instead — that refusal IS the fix for
|
|
470
|
-
// this field, not a schema addition. Revisit only if a future card
|
|
471
|
-
// wires the in-progress-placement path up to this tool on purpose.
|
|
472
|
-
...boardField,
|
|
473
|
-
}, async (args) => jsonResult(await issueCreate(client, args, config.board)));
|
|
474
|
-
// ---------------- issue_edit ----------------
|
|
475
|
-
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.', {
|
|
476
|
-
id: z.string().min(1),
|
|
477
|
-
title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
|
|
478
|
-
summary: z
|
|
479
|
-
.string()
|
|
480
|
-
.min(1)
|
|
481
|
-
.nullable()
|
|
482
|
-
.optional()
|
|
483
|
-
.describe(`${SUMMARY_DESCRIBE} Pass null to clear it.`),
|
|
484
|
-
description: z.string().optional().describe(DESCRIPTION_DESCRIBE),
|
|
485
|
-
type: z
|
|
486
|
-
.enum(ISSUE_TYPES)
|
|
487
|
-
.optional()
|
|
488
|
-
.describe("Story/Bug/Chore = dispatchable; Task/Epic/Feature = never dispatched, in any status."),
|
|
489
|
-
ac: z
|
|
490
|
-
.array(z.object({
|
|
491
|
-
title: z.string(),
|
|
492
|
-
checked: z.boolean().optional(),
|
|
493
|
-
status: z
|
|
494
|
-
.enum(CHECKLIST_ITEM_STATUSES)
|
|
495
|
-
.optional()
|
|
496
|
-
.describe("Optional full status; omitted → from `checked`. See tool description for the deferred/📡 rules."),
|
|
497
|
-
detail: z
|
|
498
|
-
.string()
|
|
499
|
-
.optional()
|
|
500
|
-
.describe("paired with `status`; required (non-empty) when `status` is `deferred`."),
|
|
501
|
-
check_item_id: z
|
|
502
|
-
.union([z.string(), z.number()])
|
|
503
|
-
.optional()
|
|
504
|
-
.describe("Optional id from issue_get's ac[].check_item_id. Only needed to tell apart two items with identical titles; otherwise items match by title."),
|
|
505
|
-
}).strict())
|
|
506
|
-
.optional()
|
|
507
|
-
.describe("The default Acceptance Criteria checklist: checked true → passing, false → incomplete, or pass `status`. Diffed against live items — unchanged items keep their id, new titles are inserted, missing ones removed."),
|
|
508
|
-
checklists: z
|
|
509
|
-
.array(z.object({
|
|
510
|
-
name: z.string().min(1),
|
|
511
|
-
items: z.array(z.object({
|
|
512
|
-
label: z.string().min(1),
|
|
513
|
-
detail: z.string().optional(),
|
|
514
|
-
status: z.enum(CHECKLIST_ITEM_STATUSES),
|
|
515
|
-
}).strict()),
|
|
516
|
-
}).strict())
|
|
517
|
-
.optional(),
|
|
518
|
-
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
519
|
-
parent_id: z.string().nullable().optional(),
|
|
520
|
-
priority: z
|
|
521
|
-
.union([z.enum(PRIORITY_TIER_WORDS), z.number()])
|
|
522
|
-
.optional()
|
|
523
|
-
.describe('Tier word ("lowest"…"critical", prefer this) or number in [0,6); higher = more urgent — see tool description for why this is the only way to set it.'),
|
|
524
|
-
list_id: z.string().min(1).nullable().optional(),
|
|
525
|
-
triage_enabled: z
|
|
526
|
-
.boolean()
|
|
527
|
-
.optional()
|
|
528
|
-
.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."),
|
|
529
|
-
content_hash: z
|
|
318
|
+
// ---------------- danxbot_api (DX-3465, PLN-11 R-25) ----------------
|
|
319
|
+
strictTool("danxbot_api", "Call any dashboard API route your credential may reach. `method` + `path` (a FULL path, e.g. " +
|
|
320
|
+
'"/api/issues/DX-1/transition" — substitute real ids yourself, never a route template with `:id` in it) + ' +
|
|
321
|
+
"optional `query`/`body`. The response is the route's JSON body verbatim, `reminders[]` included when the " +
|
|
322
|
+
"route attaches one. `board` overrides this dispatch's configured board for a board-scoped route only " +
|
|
323
|
+
"(ignored by a route that isn't board-scoped). See this server's own instructions (shown once, at connect) " +
|
|
324
|
+
"for the shared resource shape, the stale-hash rule, and how to read a refusal — don't know the route? call " +
|
|
325
|
+
"`danxbot_api_list` first; unsure of its exact shape? call `danxbot_api_spec` first.", {
|
|
326
|
+
method: z.enum(DANXBOT_API_METHODS),
|
|
327
|
+
path: z
|
|
530
328
|
.string()
|
|
531
329
|
.min(1)
|
|
532
|
-
.
|
|
533
|
-
.describe(
|
|
330
|
+
.startsWith("/api/")
|
|
331
|
+
.describe('Full path starting with "/api/" — never a route template.'),
|
|
332
|
+
query: z.record(z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(),
|
|
333
|
+
body: z.unknown().optional(),
|
|
534
334
|
...boardField,
|
|
535
|
-
}, async (args) => jsonResult(await
|
|
536
|
-
// ----------------
|
|
537
|
-
strictTool("
|
|
538
|
-
|
|
539
|
-
"
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
//
|
|
565
|
-
strictTool("
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
.
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
|
|
580
|
-
})
|
|
581
|
-
.strict();
|
|
582
|
-
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.", {
|
|
583
|
-
id: z.string().min(1),
|
|
584
|
-
action: z.enum([
|
|
585
|
-
"add_list",
|
|
586
|
-
"update_list",
|
|
587
|
-
"remove_list",
|
|
588
|
-
"add_item",
|
|
589
|
-
"update_item",
|
|
590
|
-
"remove_item",
|
|
591
|
-
]),
|
|
592
|
-
checklist_id: z.number().int().positive().optional(),
|
|
593
|
-
item_id: z.number().int().positive().optional(),
|
|
594
|
-
name: z.string().min(1).optional(),
|
|
595
|
-
items: z.array(CHECKLIST_ITEM_INPUT).optional(),
|
|
596
|
-
label: z.string().min(1).optional(),
|
|
597
|
-
detail: z.string().optional(),
|
|
598
|
-
status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
|
|
599
|
-
...boardField,
|
|
600
|
-
}, async (args) => jsonResult(await issueChecklist(client, args)));
|
|
601
|
-
// ---------------- issue_problem ----------------
|
|
602
|
-
// DX-2735: the option fields, shared by `issue_problem` add's inline solutions[]
|
|
603
|
-
// and `issue_solution`. Their meaning is stated ONCE, in issue_solution's
|
|
604
|
-
// description — per-field describes repeated it on every call's context.
|
|
605
|
-
const SOLUTION_FIELDS = {
|
|
606
|
-
body: z.string().optional(),
|
|
607
|
-
pro: z.string().optional(),
|
|
608
|
-
con: z.string().optional(),
|
|
609
|
-
recommended: z.boolean().optional(),
|
|
610
|
-
};
|
|
611
|
-
const STEP_INPUT = z.lazy(() => z
|
|
612
|
-
.object({
|
|
613
|
-
id: z
|
|
614
|
-
.number()
|
|
615
|
-
.int()
|
|
616
|
-
.positive()
|
|
617
|
-
.optional()
|
|
618
|
-
.describe("present -> matches an existing step by this id (400 if not a live child of this exact parent scope, " +
|
|
619
|
-
"never claimed by the fallback below); absent -> matches the first unclaimed same-titled sibling " +
|
|
620
|
-
"under the same parent, else creates new (title-based, never positional — keeps later id-less " +
|
|
621
|
-
"siblings' ids intact on a middle insert). Send ids anyway when identity matters (checked progress, " +
|
|
622
|
-
"a later edit) or siblings share a title."),
|
|
623
|
-
title: z
|
|
624
|
-
.string()
|
|
625
|
-
.min(1)
|
|
626
|
-
.describe("one imperative line — labels (\"1.\", \"2a\") are DERIVED on read, never put one in the title."),
|
|
627
|
-
description: z.string().nullable().optional().describe("markdown detail, or null/omitted for none"),
|
|
628
|
-
steps: z.array(STEP_INPUT).optional().describe("this step's own children, nested — refused past 3 levels total"),
|
|
629
|
-
})
|
|
630
|
-
.strict());
|
|
631
|
-
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[]} (each decision carries `relayed_by_session`, DX-3471 — the working session that recorded it, or null for one entered directly in the dashboard); 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. " +
|
|
632
|
-
"DX-3471 — answer :pid {solution_id, note?} | {freeform} records the operator's decision on a LIVE problem (exactly one of solution_id/freeform; note only qualifies a chosen solution); change_answer :pid {base_hash, solution_id, note?} | {base_hash, freeform} retracts the current decision and records a new one in ONE server transaction; unanswer :pid {base_hash} retracts with no new answer, reopening the problem. `base_hash` on these two names the live DECISION (from that problem's `decisions[].content_hash` in `list`), NOT the problem's own hash — a stale one → 409 `stale_decision` with currentHash + currentDecision: merge, then retry. " +
|
|
633
|
-
"**These three are OPERATOR-SESSION ONLY** — the server refuses a dispatched agent's machine credential 403 (DX-2830: an agent that could answer its own question could release the very human gate it set to stop and wait for); call them only when relaying a decision the OPERATOR actually made in this chat, never a decision you are making yourself. Session attribution is automatic (the same `x-danx-session-id` every call already carries, DX-2683) — no session parameter to pass here. " +
|
|
634
|
-
"`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.", {
|
|
635
|
-
id: z.string().min(1),
|
|
636
|
-
action: z.enum(["list", "add", "edit", "remove", "answer", "change_answer", "unanswer"]),
|
|
637
|
-
problem_id: z.number().int().positive().optional().describe("edit/remove/answer/change_answer/unanswer"),
|
|
638
|
-
base_hash: z
|
|
639
|
-
.string()
|
|
640
|
-
.min(1)
|
|
641
|
-
.optional()
|
|
642
|
-
.describe("edit/remove: content_hash last read for the PROBLEM. change_answer/unanswer: content_hash last read for " +
|
|
643
|
-
"the live DECISION instead (that problem's `decisions[].content_hash` from `list`) — the two are never " +
|
|
644
|
-
"interchangeable."),
|
|
645
|
-
solution_id: z
|
|
646
|
-
.number()
|
|
647
|
-
.int()
|
|
648
|
-
.positive()
|
|
649
|
-
.optional()
|
|
650
|
-
.describe("answer/change_answer only. The chosen option's id, from this problem's own solutions[]. Exactly one of " +
|
|
651
|
-
"solution_id / freeform — never both, never neither (refused before any request)."),
|
|
652
|
-
note: z
|
|
653
|
-
.string()
|
|
654
|
-
.min(1)
|
|
655
|
-
.optional()
|
|
656
|
-
.describe("answer/change_answer only, solution_id answers only: an optional qualification (\"this, but…\"). Refused " +
|
|
657
|
-
"alongside freeform, which is already the operator's own words."),
|
|
658
|
-
freeform: z
|
|
659
|
-
.string()
|
|
660
|
-
.min(1)
|
|
661
|
-
.optional()
|
|
662
|
-
.describe("answer/change_answer only. The operator's own words, when no listed solution fits. Exactly one of " +
|
|
663
|
-
"solution_id / freeform — never both, never neither."),
|
|
664
|
-
statement: z
|
|
665
|
-
.string()
|
|
666
|
-
.min(1)
|
|
667
|
-
.optional()
|
|
668
|
-
.describe("add/edit; ONE plain sentence, at most 200 characters — see `type` below for question vs action phrasing."),
|
|
669
|
-
context: z
|
|
670
|
-
.string()
|
|
671
|
-
.nullable()
|
|
672
|
-
.optional()
|
|
673
|
-
.describe("add/edit; markdown detail behind statement — file:line refs, code excerpts, root-cause writeups, or (for " +
|
|
674
|
-
"an action) whatever detail the person carrying it out needs. NOT where an action's why-can't-the-agent " +
|
|
675
|
-
"sentence goes — that is `summary`. add: sent only when given (null/omit means none). edit: omit to keep " +
|
|
676
|
-
"the stored context, null to clear it."),
|
|
677
|
-
type: z
|
|
678
|
-
.enum(["question", "action"])
|
|
679
|
-
.optional()
|
|
680
|
-
.describe("add only. Test: could you do this yourself if you tried harder, and is the only thing missing a decision? " +
|
|
681
|
-
"Yes → \"question\": statement is the question (one plain sentence); summary optional (why it matters); " +
|
|
682
|
-
"context is the evidence; solutions[] are candidate ANSWERS with their own pro/con, done once one is " +
|
|
683
|
-
"picked. No — the blocker is access, credentials, hardware, a human's authority, or a system you " +
|
|
684
|
-
"genuinely cannot reach — \"action\": statement is WHAT IS TO BE DONE, never phrased as a question " +
|
|
685
|
-
"(\"Rotate the staging DB credential\", not \"Should we rotate it?\"); summary is REQUIRED, stating why " +
|
|
686
|
-
"it's needed and why you can't do it yourself (an add with no summary refuses 400); context is detail " +
|
|
687
|
-
"for whoever carries it out; solutions[] are the possible ROUTES (e.g. \"rotate by hand\" vs \"run the " +
|
|
688
|
-
"provisioning script\"), each MUST carry ≥1 step (400 otherwise — no procedure means not yet actionable). " +
|
|
689
|
-
"Omitted defaults to \"question\" — decide deliberately every time."),
|
|
690
|
-
summary: z
|
|
691
|
-
.string()
|
|
692
|
-
.nullable()
|
|
693
|
-
.optional()
|
|
694
|
-
.describe("add/edit. Plain text, 1-3 sentences — never markdown (that's `context`'s job). Required+non-empty for an " +
|
|
695
|
-
"action problem (why needed + why you can't do it yourself — see `type`; refused 400 if missing on add or " +
|
|
696
|
-
"cleared on a live action edit); optional for a question. add: sent only when given (omit/null on a " +
|
|
697
|
-
"question means none). edit: omit keeps the stored summary, null clears it."),
|
|
698
|
-
solutions: z
|
|
699
|
-
.array(z.object({ title: z.string().min(1), ...SOLUTION_FIELDS, steps: z.array(STEP_INPUT).optional() }).strict())
|
|
700
|
-
.optional()
|
|
701
|
-
.describe("add only; fields as issue_solution add, INCLUDING steps — an ACTION problem's inline " +
|
|
702
|
-
"solutions must each carry at least one step (refused 400 otherwise); a QUESTION's may carry any number, " +
|
|
703
|
-
"including none."),
|
|
704
|
-
...boardField,
|
|
705
|
-
}, async (args) => jsonResult(await issueProblem(client, args)));
|
|
706
|
-
// ---------------- issue_solution ----------------
|
|
707
|
-
strictTool("issue_solution", "One problem's options, AND one solution's individual " +
|
|
708
|
-
"procedure steps; `problem_id` is REQUIRED (from issue_problem list/add; " +
|
|
709
|
-
"another problem's solution id → 404). add {title, body?, pro?, con?, recommended?, steps?}: title names the " +
|
|
710
|
-
"option, body/pro/con its markdown detail and case for/against; `steps` authors the WHOLE procedure in one " +
|
|
711
|
-
"call — {title, description?, steps?}[], nestable to 3 levels (4th refused 400, naming the step + limit). " +
|
|
712
|
-
"ACTION problem: `steps` must be non-empty (400 otherwise); QUESTION: may be omitted/empty. Labels " +
|
|
713
|
-
"(\"1\", \"2a\", \"2a.i\") are DERIVED on read from position — never put one in a title. edit :sid {base_hash, " +
|
|
714
|
-
"...changed fields, steps?, steps_base_hash?}: `steps` omitted leaves the procedure untouched; an explicit " +
|
|
715
|
-
"array (incl. [], refused if it leaves an action's solution with zero steps) DIFFS against it — send an " +
|
|
716
|
-
"existing step's `id` to keep it (even while retitling/reordering), omit `id` for a new step; any existing " +
|
|
717
|
-
"step left out is removed. An id-less node matches the first unclaimed same-titled sibling under the same " +
|
|
718
|
-
"parent (never positional), so an id-less MIDDLE insert keeps later id-less siblings' ids — send ids anyway " +
|
|
719
|
-
"when identity matters. `steps_base_hash` REQUIRED whenever `steps` is sent — the solution's current " +
|
|
720
|
-
"`steps_hash`; stale → 409 `stale_steps` with the current tree. Never resend the whole tree to fix one word " +
|
|
721
|
-
"— use add_step/edit_step/remove_step below. remove :sid {base_hash}: stale → 409 `stale_solution` with " +
|
|
722
|
-
"currentHash + currentSolution, merge and retry. At most ONE live recommended per problem — a second → 409 " +
|
|
723
|
-
"naming `recommended_solution_id`. A CHOSEN option's words and WHOLE PROCEDURE are frozen (409 — add a new " +
|
|
724
|
-
"one instead); every steps route below refuses once a decision has chosen this solution.\n\n" +
|
|
725
|
-
"Granular single-step actions, no tree resend: add_step {solution_id, title, description?, parent_step_id?, " +
|
|
726
|
-
"position?} — title is one imperative line (labels derived, never typed); parent_step_id omitted/null = " +
|
|
727
|
-
"top-level; position 1-indexed among current live children, omitted = append, beyond sibling count → 400 " +
|
|
728
|
-
"(never clamped); nesting past depth 3 → 400. edit_step {solution_id, step_id, base_hash, title?, " +
|
|
729
|
-
"description?} — never moves a step. remove_step {solution_id, step_id, base_hash} — soft-deletes it and " +
|
|
730
|
-
"its live children; refused 409 if this solution belongs to an ACTION problem and removing it leaves zero " +
|
|
731
|
-
"live steps. Stale base_hash on a step → 409 `stale_step` with currentHash + currentStep (real derived " +
|
|
732
|
-
"`label`).", {
|
|
733
|
-
id: z.string().min(1),
|
|
734
|
-
action: z.enum(["add", "edit", "remove", "add_step", "edit_step", "remove_step"]),
|
|
735
|
-
problem_id: z.number().int().positive(),
|
|
736
|
-
solution_id: z.number().int().positive().optional().describe("edit/remove/add_step/edit_step/remove_step"),
|
|
737
|
-
base_hash: z.string().min(1).optional().describe("content_hash last read; edit/remove/edit_step/remove_step"),
|
|
738
|
-
title: z.string().min(1).optional().describe("add; also add_step/edit_step"),
|
|
739
|
-
...SOLUTION_FIELDS,
|
|
740
|
-
steps: z.array(STEP_INPUT).optional().describe("add/edit only — see this tool's own description"),
|
|
741
|
-
steps_base_hash: z
|
|
742
|
-
.string()
|
|
743
|
-
.min(1)
|
|
744
|
-
.optional()
|
|
745
|
-
.describe("edit only, REQUIRED whenever steps is sent — the solution's current steps_hash; a stale value is refused 409 stale_steps"),
|
|
746
|
-
description: z.string().nullable().optional().describe("add_step/edit_step; markdown detail, or null/omitted for none"),
|
|
747
|
-
step_id: z.number().int().positive().optional().describe("edit_step/remove_step"),
|
|
748
|
-
parent_step_id: z
|
|
749
|
-
.number()
|
|
750
|
-
.int()
|
|
751
|
-
.positive()
|
|
752
|
-
.nullable()
|
|
753
|
-
.optional()
|
|
754
|
-
.describe("add_step only; omitted/null = top-level step"),
|
|
755
|
-
position: z
|
|
756
|
-
.number()
|
|
757
|
-
.int()
|
|
758
|
-
.positive()
|
|
759
|
-
.optional()
|
|
760
|
-
.describe("add_step only; 1-indexed among the parent's current live children, omitted = append"),
|
|
761
|
-
...boardField,
|
|
762
|
-
}, async (args) => jsonResult(await issueSolution(client, args)));
|
|
763
|
-
// ---------------- issue_dependency ----------------
|
|
764
|
-
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.', {
|
|
765
|
-
id: z.string().min(1),
|
|
766
|
-
action: z.enum(["add", "remove"]),
|
|
767
|
-
kind: z.enum(["depends_on", "conflict_on"]).optional(),
|
|
768
|
-
target_id: z.string().min(1).optional(),
|
|
769
|
-
reason: z.string().optional(),
|
|
770
|
-
dependency_id: z.number().int().positive().optional(),
|
|
771
|
-
...boardField,
|
|
772
|
-
}, async (args) => jsonResult(await issueDependency(client, args)));
|
|
773
|
-
// ---------------- issue_retire_branch ----------------
|
|
774
|
-
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.", {
|
|
775
|
-
id: z.string().min(1),
|
|
776
|
-
reason: z.string().min(1),
|
|
777
|
-
...boardField,
|
|
778
|
-
}, async (args) => jsonResult(await issueRetireBranch(client, args)));
|
|
779
|
-
// ---------------- issue_quality_gate ----------------
|
|
780
|
-
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`.", {
|
|
781
|
-
id: z.string().min(1),
|
|
782
|
-
gate: z.enum([
|
|
783
|
-
"plan-dependency",
|
|
784
|
-
"plan-architecture",
|
|
785
|
-
"plan-tdd",
|
|
786
|
-
"code-test-quality",
|
|
787
|
-
"code-architecture",
|
|
788
|
-
"code-quality",
|
|
789
|
-
]),
|
|
790
|
-
action: z
|
|
791
|
-
.enum(["add", "remove"])
|
|
792
|
-
.describe("add = put this gate on the card; remove = take it off."),
|
|
793
|
-
note: z
|
|
794
|
-
.string()
|
|
795
|
-
.optional()
|
|
796
|
-
.describe("Why this gate applies to this card. `add` only."),
|
|
797
|
-
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
798
|
-
...boardField,
|
|
799
|
-
}, async (args) => jsonResult(await issueQualityGate(client, args)));
|
|
800
|
-
// ---------------- issue_quality_gate_verdict ----------------
|
|
801
|
-
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`.", {
|
|
802
|
-
id: z.string().min(1),
|
|
803
|
-
gate: z.enum([
|
|
804
|
-
"plan-dependency",
|
|
805
|
-
"plan-architecture",
|
|
806
|
-
"plan-tdd",
|
|
807
|
-
"code-test-quality",
|
|
808
|
-
"code-architecture",
|
|
809
|
-
"code-quality",
|
|
810
|
-
]),
|
|
811
|
-
status: z.enum(["pass", "fail", "pending"]),
|
|
812
|
-
message: z.string().optional(),
|
|
813
|
-
...boardField,
|
|
814
|
-
}, async (args) => jsonResult(await issueQualityGateVerdict(client, args)));
|
|
815
|
-
// ---------------- quality_gate_instruction ----------------
|
|
816
|
-
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`.", {
|
|
817
|
-
gate: z.enum([
|
|
818
|
-
"plan-dependency",
|
|
819
|
-
"plan-architecture",
|
|
820
|
-
"plan-tdd",
|
|
821
|
-
"code-test-quality",
|
|
822
|
-
"code-architecture",
|
|
823
|
-
"code-quality",
|
|
824
|
-
]),
|
|
825
|
-
...boardField,
|
|
826
|
-
}, async (args) => jsonResult(await qualityGateInstruction(client, args)));
|
|
827
|
-
// ---------------- issue_retro ----------------
|
|
828
|
-
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).", {
|
|
829
|
-
id: z.string().min(1),
|
|
830
|
-
good: z.string().describe("Free-form markdown (prose or list, they render identically). Style: see issue_comment's `text` param."),
|
|
831
|
-
bad: z.string().describe("Free-form markdown (prose or list, they render identically). Style: see issue_comment's `text` param."),
|
|
832
|
-
correctable_danxbot_problem: z
|
|
833
|
-
.boolean()
|
|
834
|
-
.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."),
|
|
835
|
-
correctable_danxbot_problem_description: z
|
|
836
|
-
.string()
|
|
837
|
-
.describe("Required. Non-empty (naming the concrete problem) when correctable_danxbot_problem is true; must be empty when it is false."),
|
|
838
|
-
action_item_ids: z.array(z.string()),
|
|
839
|
-
commits: z.array(z.object({
|
|
840
|
-
sha: z.string().min(1),
|
|
841
|
-
subject: z.string().optional(),
|
|
842
|
-
}).strict()),
|
|
843
|
-
tests: z.array(z.object({
|
|
844
|
-
name: z.string().min(1),
|
|
845
|
-
kind: z.enum(["group", "e2e"]),
|
|
846
|
-
num_tests: z.number().int().nonnegative(),
|
|
847
|
-
num_assertions: z.number().int().nonnegative().nullable().optional(),
|
|
848
|
-
num_passing_tests: z.number().int().nonnegative(),
|
|
849
|
-
num_passing_assertions: z
|
|
850
|
-
.number()
|
|
851
|
-
.int()
|
|
852
|
-
.nonnegative()
|
|
853
|
-
.nullable()
|
|
854
|
-
.optional(),
|
|
855
|
-
duration_ms: z.number().int().nonnegative(),
|
|
856
|
-
}).strict()),
|
|
857
|
-
...boardField,
|
|
858
|
-
}, async (args) => jsonResult(await issueRetro(client, args)));
|
|
859
|
-
// ---------------- issue_attach ----------------
|
|
860
|
-
// The "25 MB" ceiling quoted in the description below tracks the backend
|
|
861
|
-
// route's MAX_DECODED_BYTES (src/issues/write/attachments.ts). This package is
|
|
862
|
-
// a separate published artifact and cannot import that constant, so the number
|
|
863
|
-
// is restated here as prose — keep the two in sync if the backend ceiling moves.
|
|
864
|
-
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 `` — the dashboard renders it inline as a thumbnail. Use whenever a screenshot judges something faster than prose.", {
|
|
865
|
-
id: z.string().min(1),
|
|
866
|
-
file_path: z
|
|
335
|
+
}, async (args) => jsonResult(await danxbotApi(client, args)));
|
|
336
|
+
// ---------------- danxbot_api_list (DX-3465 A5, PLN-11 R-25) ----------------
|
|
337
|
+
strictTool("danxbot_api_list", "Lists the dashboard API routes your credential may call right now — `[{method, path, summary, permission}]`, " +
|
|
338
|
+
"one line each, in the route TEMPLATE form `danxbot_api_spec` also expects (e.g. " +
|
|
339
|
+
'`"POST /api/issues/:id/transition"`). This is how you find the route for something you need to do: a ' +
|
|
340
|
+
"route listed here is always callable by you; one that isn't never appears (DX-3528 filters through the " +
|
|
341
|
+
"SAME permission check the dashboard enforces on every request, so this list can never drift from what " +
|
|
342
|
+
"you can actually call). No arguments. Call this when you don't already know the route, then " +
|
|
343
|
+
"`danxbot_api_spec({endpoints})` for the exact contract of the one(s) you picked, then `danxbot_api` to " +
|
|
344
|
+
"call it.", {}, async () => jsonResult(await danxbotApiList(client)));
|
|
345
|
+
// ---------------- danxbot_api_spec (DX-3465, PLN-11 R-25) ----------------
|
|
346
|
+
strictTool("danxbot_api_spec", "The exact OpenAPI 3.1 contract (DX-3527) for one or more routes — request/response shape, required " +
|
|
347
|
+
"permission, and every documented error envelope. `endpoints` (REQUIRED, non-empty): an array of " +
|
|
348
|
+
'`"METHOD /path/template"` strings (e.g. `"POST /api/issues/:id/transition"`, the route TEMPLATE form, ' +
|
|
349
|
+
"unlike `danxbot_api`'s own `path` which is a real path with real ids — the same form `danxbot_api_list` " +
|
|
350
|
+
"reports). A requested endpoint this dashboard does not serve, or that your credential may not call, is " +
|
|
351
|
+
"refused loud (never silently dropped or silently included) — see the response for which. Don't know which " +
|
|
352
|
+
"endpoints to name? call `danxbot_api_list` first.", {
|
|
353
|
+
endpoints: z.array(z.string().min(1)).min(1),
|
|
354
|
+
}, async (args) => jsonResult(await danxbotApiSpec(client, args)));
|
|
355
|
+
// ---------------- attach_file (DX-3465 A4, checklist item 36140) ----------------
|
|
356
|
+
//
|
|
357
|
+
// The "25 MB" ceiling quoted below tracks the backend route's
|
|
358
|
+
// MAX_DECODED_BYTES (`src/issues/write/attachments.ts`). This package is a
|
|
359
|
+
// separate published artifact and cannot import that constant, so the
|
|
360
|
+
// number is restated here as prose — keep the two in sync if the backend
|
|
361
|
+
// ceiling moves. Opt-in (R-25's "operations about the agent itself" carve-
|
|
362
|
+
// out plus a role-by-role grant, see `tool-defs.ts`'s `MCP_SERVER_OPT_IN_TOOLS`
|
|
363
|
+
// — a role needs local-file-upload only when it deals with files an agent
|
|
364
|
+
// might hold locally, e.g. the Slack recovery agent's CSV path).
|
|
365
|
+
strictTool("attach_file", "Attach a LOCAL file to a card: `card_id` + `path` (an ABSOLUTE path on the dispatch's shared filesystem). " +
|
|
366
|
+
"Reads the bytes INSIDE this MCP server process and uploads them — the file's bytes never pass through " +
|
|
367
|
+
"your own context/transcript. Infers MIME from the extension unless you pass `content_type`; defaults " +
|
|
368
|
+
"`filename` to the path's basename unless you pass one. Stores in S3, inserts one attachment row, auto-" +
|
|
369
|
+
"mirrors to the card's linked Trello card and Slack thread. Board-scoped; see `board`. Fail-loud: a " +
|
|
370
|
+
"relative/empty path is rejected before any upload; a missing/unreadable file throws before any upload " +
|
|
371
|
+
"(no partial S3 object or row). 25 MB decoded ceiling (413). Returns `{issue, attachment_id, s3_key}` — " +
|
|
372
|
+
"find the attachment in `issue.attachments` by `attachment_id` to read its permanent, non-expiring `url`. " +
|
|
373
|
+
"That url can be embedded in ANY markdown field (a problem's statement/context, a comment, a plan record, " +
|
|
374
|
+
"a solution body) as `` — the dashboard renders it inline as a thumbnail. Use whenever " +
|
|
375
|
+
"a screenshot or a local file judges something faster than prose, or replaces pasting a large CSV/log " +
|
|
376
|
+
"straight into a card field.", {
|
|
377
|
+
card_id: z.string().min(1).describe("The card id to attach the file to."),
|
|
378
|
+
path: z
|
|
867
379
|
.string()
|
|
868
380
|
.min(1)
|
|
869
381
|
.describe("Absolute path to a local file on the dispatch's shared filesystem (must start with `/`)."),
|
|
382
|
+
filename: z.string().min(1).optional().describe("Override for the stored filename; default: path's basename."),
|
|
383
|
+
content_type: z.string().min(1).optional().describe("Override for the stored MIME type; default: an extension guess."),
|
|
870
384
|
...boardField,
|
|
871
|
-
}, async (args) => jsonResult(await
|
|
385
|
+
}, async (args) => jsonResult(await attachFile(client, args)));
|
|
872
386
|
// ---------------- plans (DX-2683) ----------------
|
|
873
|
-
// THE BINDING ASYMMETRY IS IN THE SCHEMAS, NOT IN PROSE. `
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
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, signedOffAt, signedOffBy, autoSignOff}], session, sessionListenerAttached}` — `ref` is the plan's short reference (`PLAN-<id>`), cite it rather than the bare id. `status` is computed fresh on every read, never stored — except the sign-off pair itself: `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), `awaiting-sign-off` (≥1 card, all Done/Cancelled, but nobody has signed the plan off yet — DX-3519), `complete` (≥1 card, all Done/Cancelled, AND signed off — wins even with no session). `signedOffAt`/`signedOffBy` are `null` until a human (`signedOffBy` = their identity) or the `autoSignOff` toggle (`signedOffBy: 'auto'`) signs the plan off; sign-off itself is a human-only write, not exposed through this MCP surface — direct the operator to the dashboard's plan page. 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.", {
|
|
883
|
-
status: z
|
|
884
|
-
.enum(PLAN_STATUSES)
|
|
885
|
-
.optional()
|
|
886
|
-
.describe("Filter to one computed status: awaiting-session, planning, building, awaiting-sign-off, complete. Omit for every plan."),
|
|
887
|
-
}, async (args) => jsonResult(await planList(client, args)));
|
|
888
|
-
strictTool("plan_get",
|
|
889
|
-
// DX-3428 — the retired flat field-GROUP list (`cards`, `records:<kind>`,
|
|
890
|
-
// `cards_offset`/`cards_limit`, `events_*`) is replaced by a JSON field
|
|
891
|
-
// TREE against the plan resource — the SAME `fieldTreeSchema` `issue_get`/
|
|
892
|
-
// `issue_list` already take (`./field-tree.ts`). Every former query param
|
|
893
|
-
// now lives INSIDE the tree as a relation argument.
|
|
894
|
-
"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: `{id, ref, name, created_at, signed_off_at, signed_off_by, auto_sign_off, boards, card_count, bucket_counts, status, session, sessionListenerAttached}` — no cards, records, or architecture body. `ref` is the plan's short reference (`PLAN-<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 a `fields` tree to opt into: `cards` (member cards — the ISSUE resource itself, so any of `issue_get`'s own fields/relations may be nested under it, e.g. `{\"cards\": {\"title\": true, \"status\": true}}`; cursor-paged via `{\"limit\": N, \"before\": \"<cursor>\"}`, response carries a sibling `cards_page: {limit, total, next_cursor}`), `records` (every goal+rule+caveat) or `{\"records\": {\"kind\": \"goal\"}}` / `{\"kind\": [\"goal\",\"rule\"]}` (narrow to one or more kinds, cheaper), `architecture_sections` (`[{id, plan_id, content_hash, title, content, sort_order, created_at, updated_at}]`), `sessions` (every session connected to the plan), `notes` (the latest milestone-timeline page), `events` (the plan's durable event ledger — every human action and bridge message; cursor-paged via `{\"limit\": N, \"before\": \"<cursor>\"}` plus the filter args `{\"kinds\": [...], \"origin\": \"...\", \"writer\": \"...\"}`; response carries `events` rows plus a sibling `events_page: {limit, total, 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). Call `resource_fields({resource:\"plan\"})` for the full, current list of what a tree may name — never guess a name; unknown → 400 `unknown_field`. `session`/`sessionListenerAttached` ride every response regardless (not part of the tree — they describe YOUR session, not the plan). `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 `content_hash`/`contentHash` back as `base_hash`.", {
|
|
895
|
-
plan_id: z
|
|
896
|
-
.number()
|
|
897
|
-
.int()
|
|
898
|
-
.positive()
|
|
899
|
-
.optional()
|
|
900
|
-
.describe("A plan id from `plan_list`. Omit to read the plan this session is connected to."),
|
|
901
|
-
fields: fieldTreeSchema
|
|
902
|
-
.optional()
|
|
903
|
-
.describe("A field tree (see tool description); absent/empty = cheap scalars only. `resource_fields({resource:\"plan\"})` names every valid key."),
|
|
904
|
-
}, async (args) => jsonResult(await planGet(client, args)));
|
|
905
|
-
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 (`PLAN-<id>`). Use `plan.id` with `plan_connect` to start working on it, or `plan_get({plan_id})` to browse.", {
|
|
906
|
-
name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
|
|
907
|
-
}, async (args) => jsonResult(await planCreate(client, args)));
|
|
387
|
+
// THE BINDING ASYMMETRY IS IN THE SCHEMAS, NOT IN PROSE. `GET /api/plans/:planId`
|
|
388
|
+
// takes a plan id in its path; the plan records, plan cards and
|
|
389
|
+
// architecture-section routes under `/api/plans/mine/...` take NO plan id in
|
|
390
|
+
// any form, so a connected session cannot even express "write to that other
|
|
391
|
+
// plan". `plan_connect` takes a plan id because binding a session to a plan
|
|
392
|
+
// is the one operation that is ABOUT a plan id — and it can only ever bind
|
|
393
|
+
// the caller's own session. `POST /api/plans` also takes no plan id, but for
|
|
394
|
+
// a different reason: it MAKES a plan rather than acting on one, so there is
|
|
395
|
+
// no existing plan for an id to name yet.
|
|
908
396
|
strictTool("plan_connect",
|
|
909
397
|
// DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
|
|
910
|
-
"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
|
|
911
|
-
plan_id: z.number().int().positive().describe("The plan id, from `
|
|
398
|
+
"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 read + 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\"|\"healthy\", nextStep}` naming a concrete fix per unhealthy state (same shape the plan list/get routes 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. The response's `reminders[]` (when present) carries the mantra — read and follow it.", {
|
|
399
|
+
plan_id: z.number().int().positive().describe("The plan id, from the plans list (`danxbot_api` `GET /api/plans`)."),
|
|
912
400
|
title: z
|
|
913
401
|
.string()
|
|
914
402
|
.min(1)
|
|
@@ -931,139 +419,6 @@ async (args) => {
|
|
|
931
419
|
}),
|
|
932
420
|
});
|
|
933
421
|
});
|
|
934
|
-
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: \"<kind>\"}}})`.", {
|
|
935
|
-
kind: z.enum(["goal", "rule", "caveat"]).describe("goal = outcome, rule = constraint, caveat = architecture trade-off."),
|
|
936
|
-
body: z.string().min(1).describe("One plain statement, at most 250 characters. Details go in `context`."),
|
|
937
|
-
context: z.string().optional().describe("Markdown detail behind the statement: evidence, history, examples."),
|
|
938
|
-
}, async (args) => jsonResult(await planAddRecord(client, args)));
|
|
939
|
-
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.", {
|
|
940
|
-
record_id: z.number().int().positive().describe("A record id, from `plan_add_record`, `plan_get` or `plan_get_record` itself."),
|
|
941
|
-
}, async (args) => jsonResult(await planGetRecord(client, args)));
|
|
942
|
-
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`).', {
|
|
943
|
-
record_id: z.number().int().positive().describe("The record id to edit."),
|
|
944
|
-
content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
|
|
945
|
-
body: z.string().min(1).describe("The new statement, at most 250 characters. Plain text."),
|
|
946
|
-
context: z
|
|
947
|
-
.string()
|
|
948
|
-
.nullable()
|
|
949
|
-
.optional()
|
|
950
|
-
.describe("New markdown detail. Omit to keep the stored context; null clears it."),
|
|
951
|
-
}, async (args) => jsonResult(await planUpdateRecord(client, args)));
|
|
952
|
-
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`).', {
|
|
953
|
-
record_id: z.number().int().positive().describe("The record id to delete."),
|
|
954
|
-
content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
|
|
955
|
-
}, async (args) => jsonResult(await planDeleteRecord(client, args)));
|
|
956
|
-
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: true}})`.", {
|
|
957
|
-
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
958
|
-
title: z.string().min(1).describe("At most 60 characters."),
|
|
959
|
-
body: z.string().min(1).describe("The wrap-up, at most 250 characters."),
|
|
960
|
-
card_ids: z.array(z.string().min(1)).optional().describe("Card ids this note concerns, e.g. `[\"DX-2894\"]`."),
|
|
961
|
-
record_refs: z
|
|
962
|
-
.array(z.string().min(1))
|
|
963
|
-
.optional()
|
|
964
|
-
.describe("Goal/rule/caveat references this note announces, e.g. `[\"G-1\", \"R-3\", \"CAV-2\"]`."),
|
|
965
|
-
section_ids: z.array(z.number().int().positive()).optional().describe("Architecture section ids this note announces."),
|
|
966
|
-
}, async (args) => jsonResult(await planAddNote(client, args)));
|
|
967
|
-
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`.', {
|
|
968
|
-
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
969
|
-
note_id: z.number().int().positive().describe("The note id to edit."),
|
|
970
|
-
content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
|
|
971
|
-
title: z.string().min(1).optional().describe("New title, at most 60 characters. Omit to keep the stored title."),
|
|
972
|
-
body: z.string().min(1).optional().describe("New wrap-up, at most 250 characters. Omit to keep the stored body."),
|
|
973
|
-
card_ids: z.array(z.string().min(1)).optional().describe("Part of the link-set group — see tool description."),
|
|
974
|
-
record_refs: z.array(z.string().min(1)).optional().describe("Part of the link-set group — see tool description."),
|
|
975
|
-
section_ids: z.array(z.number().int().positive()).optional().describe("Part of the link-set group — see tool description."),
|
|
976
|
-
}, async (args) => jsonResult(await planUpdateNote(client, args)));
|
|
977
|
-
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}`.', {
|
|
978
|
-
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
979
|
-
note_id: z.number().int().positive().describe("The note id to delete."),
|
|
980
|
-
content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
|
|
981
|
-
}, async (args) => jsonResult(await planDeleteNote(client, args)));
|
|
982
|
-
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: true}})`, cursor-paged).", {
|
|
983
|
-
card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
|
|
984
|
-
}, async (args) => jsonResult(await planAddCard(client, args)));
|
|
985
|
-
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`).", {
|
|
986
|
-
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
987
|
-
card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
|
|
988
|
-
}, async (args) => jsonResult(await planRemoveCard(client, args)));
|
|
989
|
-
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}`.", {
|
|
990
|
-
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
991
|
-
name: z.string().min(1).describe("The plan's new name."),
|
|
992
|
-
}, async (args) => jsonResult(await planRename(client, args)));
|
|
993
|
-
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}}`.", {
|
|
994
|
-
section_id: z.number().int().positive().describe("A section id, from `plan_get` or `plan_add_architecture_section`."),
|
|
995
|
-
}, async (args) => jsonResult(await planGetArchitectureSection(client, args)));
|
|
996
|
-
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.", {
|
|
997
|
-
title: z.string().min(1).describe("The section's heading, shown in the navigation index."),
|
|
998
|
-
content: z.string().describe("The section's markdown. May be empty — a section awaiting its first draft is a real state."),
|
|
999
|
-
}, async (args) => jsonResult(await planAddArchitectureSection(client, args)));
|
|
1000
|
-
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.', {
|
|
1001
|
-
section_id: z.number().int().positive().describe("The section id to edit."),
|
|
1002
|
-
base_hash: z.string().describe("The section's `contentHash` from your last read. Required."),
|
|
1003
|
-
title: z.string().min(1).optional().describe("New heading. Omit to keep the stored title."),
|
|
1004
|
-
content: z.string().optional().describe("New markdown. Omit to keep the stored content."),
|
|
1005
|
-
}, async (args) => jsonResult(await planUpdateArchitectureSection(client, args)));
|
|
1006
|
-
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.', {
|
|
1007
|
-
section_id: z.number().int().positive().describe("The section id to delete."),
|
|
1008
|
-
base_hash: z.string().describe("The section's `contentHash` from your last read. Required."),
|
|
1009
|
-
}, async (args) => jsonResult(await planDeleteArchitectureSection(client, args)));
|
|
1010
|
-
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.", {
|
|
1011
|
-
order: z
|
|
1012
|
-
.array(z.number().int().positive())
|
|
1013
|
-
.min(1)
|
|
1014
|
-
.describe("Every live section id of the connected plan, in the desired order — exactly once each."),
|
|
1015
|
-
}, async (args) => jsonResult(await planReorderArchitectureSection(client, args)));
|
|
1016
|
-
// ---------------- failure_category_list / _create / _update (DX-2792) ----------------
|
|
1017
|
-
const matcherField = z
|
|
1018
|
-
.object({
|
|
1019
|
-
sourceKind: z
|
|
1020
|
-
.enum(["tool-error", "hook-refusal", "api-error", "usage-limit", "session-result"])
|
|
1021
|
-
.optional()
|
|
1022
|
-
.describe("Absent matches any source kind."),
|
|
1023
|
-
tool: z.string().min(1).optional().describe("Exact tool name (e.g. \"Bash\"); absent matches any tool, including null."),
|
|
1024
|
-
regexPattern: z
|
|
1025
|
-
.string()
|
|
1026
|
-
.min(1)
|
|
1027
|
-
.optional()
|
|
1028
|
-
.describe("Regex source tested against the normalized excerpt (paths/UUIDs/timestamps/ports already stripped)."),
|
|
1029
|
-
})
|
|
1030
|
-
.strict()
|
|
1031
|
-
.describe("At least one of sourceKind/tool/regexPattern must be set — an empty matcher is refused.");
|
|
1032
|
-
const expectedRateField = z
|
|
1033
|
-
.object({
|
|
1034
|
-
count: z.number().int().min(0).describe("N — how many failures are expected."),
|
|
1035
|
-
overDispatches: z.number().int().positive().describe("X — over how many dispatches."),
|
|
1036
|
-
})
|
|
1037
|
-
.strict()
|
|
1038
|
-
.nullable()
|
|
1039
|
-
.optional()
|
|
1040
|
-
.describe("Both fields together, or omit/null entirely — never a half-specified rate.");
|
|
1041
|
-
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)));
|
|
1042
|
-
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.', {
|
|
1043
|
-
name: z.string().min(1).describe("The category's name."),
|
|
1044
|
-
description: z.string().optional().describe("Optional free-text description. Defaults to empty."),
|
|
1045
|
-
matchers: z.array(matcherField).min(1).describe("At least one matcher; a category matches an occurrence when ANY ONE matches (OR across matchers)."),
|
|
1046
|
-
ignore: z.boolean().optional().describe("Mark this category as expected/non-actionable noise. Requires ignoreReason. Defaults to false."),
|
|
1047
|
-
ignoreReason: z.string().nullable().optional().describe("Required (non-empty) when ignore is true."),
|
|
1048
|
-
expectedRate: expectedRateField,
|
|
1049
|
-
}, async (args) => jsonResult(await failureCategoryCreate(client, args)));
|
|
1050
|
-
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.", {
|
|
1051
|
-
id: z.number().int().positive().describe("The category id to patch — from failure_category_list."),
|
|
1052
|
-
name: z.string().min(1).optional(),
|
|
1053
|
-
description: z.string().optional(),
|
|
1054
|
-
matchers: z.array(matcherField).min(1).optional().describe("REPLACES the full matcher set when sent — never a partial append."),
|
|
1055
|
-
ignore: z.boolean().optional(),
|
|
1056
|
-
ignoreReason: z.string().nullable().optional(),
|
|
1057
|
-
expectedRate: expectedRateField,
|
|
1058
|
-
}, async (args) => jsonResult(await failureCategoryUpdate(client, args)));
|
|
1059
|
-
// ---------------- dispatch_transcript_search (DX-3221) ----------------
|
|
1060
|
-
strictTool("dispatch_transcript_search", "Search or tail another dispatch's stored JSONL transcript. Needs no filesystem access (an authenticated HTTP call, not a local read) 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.", {
|
|
1061
|
-
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."),
|
|
1062
|
-
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."),
|
|
1063
|
-
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."),
|
|
1064
|
-
contextChars: z.number().int().positive().optional().describe("Characters of context per returned line/match. Defaults to 1000, capped at 4000 — this cap is what keeps even a single oversized line searchable instead of erroring the way Read does."),
|
|
1065
|
-
maxMatches: z.number().int().positive().optional().describe("Only used with `pattern`. Stop after this many matching lines. Defaults to 10, capped at 50."),
|
|
1066
|
-
}, async (args) => jsonResult(await dispatchTranscriptSearch(client, args)));
|
|
1067
422
|
// ---------------- main ----------------
|
|
1068
423
|
async function main() {
|
|
1069
424
|
boot();
|
|
@@ -1119,20 +474,21 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
|
|
|
1119
474
|
process.exit(0);
|
|
1120
475
|
});
|
|
1121
476
|
}
|
|
1122
|
-
else if (subcommand ===
|
|
1123
|
-
// DX-3366 — unlike plan-state/
|
|
1124
|
-
// genuine fatal (never expected:
|
|
1125
|
-
// classifies every network/shape
|
|
1126
|
-
// 1), so it exits 1 too rather than
|
|
1127
|
-
// those two hook-only subcommands
|
|
1128
|
-
|
|
1129
|
-
|
|
477
|
+
else if (subcommand === EVENT_TEXT_SUBCOMMAND) {
|
|
478
|
+
// DX-3421 (was DX-3366's `mantra` subcommand) — unlike plan-state/
|
|
479
|
+
// background-work, a rejection here is a genuine fatal (never expected:
|
|
480
|
+
// runEventTextCommand itself already classifies every network/shape
|
|
481
|
+
// failure into its own {ok:false} exit 1), so it exits 1 too rather than
|
|
482
|
+
// the "always 0, stay silent" contract those two hook-only subcommands
|
|
483
|
+
// use.
|
|
484
|
+
runEventTextCommand(rest).then((code) => process.exit(code), (err) => {
|
|
485
|
+
console.error(`[danx-dashboard-mcp] event-text fatal: ${err.message}`);
|
|
1130
486
|
process.exit(1);
|
|
1131
487
|
});
|
|
1132
488
|
}
|
|
1133
489
|
else if (subcommand !== undefined) {
|
|
1134
490
|
console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only ones are ` +
|
|
1135
|
-
`"${BRIDGE_SUBCOMMAND}", "${PLAN_STATE_SUBCOMMAND}", "${BACKGROUND_WORK_SUBCOMMAND}", and "${
|
|
491
|
+
`"${BRIDGE_SUBCOMMAND}", "${PLAN_STATE_SUBCOMMAND}", "${BACKGROUND_WORK_SUBCOMMAND}", and "${EVENT_TEXT_SUBCOMMAND}")`);
|
|
1136
492
|
process.exit(2);
|
|
1137
493
|
}
|
|
1138
494
|
else {
|