@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/handlers.js
CHANGED
|
@@ -21,886 +21,169 @@
|
|
|
21
21
|
* decide its next move; swallowing any of it would force the agent to
|
|
22
22
|
* guess.
|
|
23
23
|
*/
|
|
24
|
-
import { readFile } from "node:fs/promises";
|
|
25
24
|
import { basename, extname, isAbsolute } from "node:path";
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
export async function issueList(client, args) {
|
|
36
|
-
const query = {};
|
|
37
|
-
if (args.filter !== undefined && Object.keys(args.filter).length > 0) {
|
|
38
|
-
query.filter = JSON.stringify(args.filter);
|
|
39
|
-
}
|
|
40
|
-
// DX-3427 — a JSON-encoded field tree, mirroring `issue_get` below.
|
|
41
|
-
if (args.fields !== undefined && Object.keys(args.fields).length > 0) {
|
|
42
|
-
query.fields = JSON.stringify(args.fields);
|
|
43
|
-
}
|
|
44
|
-
if (args.sort !== undefined && args.sort.length > 0) {
|
|
45
|
-
query.sort = JSON.stringify(args.sort);
|
|
46
|
-
}
|
|
47
|
-
if (args.limit !== undefined)
|
|
48
|
-
query.limit = args.limit;
|
|
49
|
-
if (args.offset !== undefined)
|
|
50
|
-
query.offset = args.offset;
|
|
51
|
-
// DX-1171 — board-only: forward the qualified board id, no repo.
|
|
52
|
-
return client.request({ method: "GET", path: "", query, board: args.board });
|
|
53
|
-
}
|
|
54
|
-
// ---------------- issue_get ----------------
|
|
55
|
-
/**
|
|
56
|
-
* The most distinct ids one batch `issue_get` may name — mirrors the server's
|
|
57
|
-
* `src/issues/list-page.ts#ISSUE_BATCH_GET_MAX` (the batch route 400s above it);
|
|
58
|
-
* `__tests__/handlers.test.ts` asserts the two agree.
|
|
59
|
-
*/
|
|
60
|
-
export const ISSUE_BATCH_GET_MAX = 100;
|
|
61
|
-
/**
|
|
62
|
-
* The largest page any paged list read accepts (`issue_list`'s `limit`,
|
|
63
|
-
* `plan_get`'s `cards_limit`) — mirrors the server's
|
|
64
|
-
* `src/issues/list-page.ts#MAX_LIMIT` (it 400s above it);
|
|
65
|
-
* `__tests__/handlers.test.ts` asserts the two agree.
|
|
66
|
-
*/
|
|
67
|
-
export const LIST_PAGE_MAX_LIMIT = 1000;
|
|
25
|
+
import { readFile } from "node:fs/promises";
|
|
26
|
+
// ---------------- plan_connect (DX-2683) ----------------
|
|
27
|
+
// DX-3465 (PLN-11 R-25) — every other per-operation handler that used to
|
|
28
|
+
// live in this file (issue_*, plan_* except plan_connect, brief_*,
|
|
29
|
+
// repo_knowledge_*, failure_category_*, dispatch_transcript_search,
|
|
30
|
+
// quality_gate_instruction) is deleted: `danxbotApi`/`danxbotApiSpec` below
|
|
31
|
+
// are the generic replacement. `plan_connect` stays a dedicated tool because
|
|
32
|
+
// it is session-bound (see `index.ts`'s own comment on this).
|
|
33
|
+
const PLAN_SESSIONS_BASE_PATH = "/api/plan-sessions";
|
|
68
34
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* the
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
35
|
+
* Connect THIS session to a plan — the same write the operator's Connect
|
|
36
|
+
* action performs, reaching the same server-side code path. `me` in the URL
|
|
37
|
+
* is resolved from the forwarded session id, so this can only ever bind the
|
|
38
|
+
* caller's own session.
|
|
39
|
+
*
|
|
40
|
+
* A session already on another plan is MOVED, and the response says which
|
|
41
|
+
* plan it left (`movedFrom`).
|
|
42
|
+
*
|
|
43
|
+
* IT DOES NOTHING ABOUT HEARING THE PLAN'S EVENTS, deliberately. Delivery belongs
|
|
44
|
+
* to the danxbot Claude Code plugin's plan event bridge, a background process
|
|
45
|
+
* that holds the session's ONE listener ticket and starts when this tool
|
|
46
|
+
* succeeds. The dashboard keeps one ticket per session, so a ticket minted here
|
|
47
|
+
* would end the bridge's stream.
|
|
48
|
+
*
|
|
49
|
+
* `title` (DX-2816) rides here, not on a header, because the AGENT — not this
|
|
50
|
+
* server — is the one thing able to read Claude's own session title (via a
|
|
51
|
+
* Desktop-side `get_session` tool no MCP server subprocess can call). Sent
|
|
52
|
+
* only when the caller supplied one; the server leaves an unset title alone.
|
|
77
53
|
*/
|
|
78
|
-
export async function
|
|
79
|
-
if (args.id !== undefined && args.ids !== undefined) {
|
|
80
|
-
throw new Error("issue_get: pass exactly one of id or ids, not both");
|
|
81
|
-
}
|
|
82
|
-
if (args.ids !== undefined && args.board !== undefined) {
|
|
83
|
-
throw new Error("issue_get: board does not apply to the ids batch form — it resolves every id globally; drop board");
|
|
84
|
-
}
|
|
85
|
-
const query = {};
|
|
86
|
-
// DX-3426 — the tree travels as ONE JSON-encoded query param (mirrors how
|
|
87
|
-
// `issue_list`'s `filter`/`sort` envelopes already ride the wire above),
|
|
88
|
-
// rather than the old CSV-of-group-names. Sent on both the single and
|
|
89
|
-
// batch forms — comment paging (or any other relation argument) now lives
|
|
90
|
-
// INSIDE the tree, so there is nothing left for the old ids+comments_*
|
|
91
|
-
// refusal to guard against.
|
|
92
|
-
if (args.fields !== undefined && Object.keys(args.fields).length > 0) {
|
|
93
|
-
query.fields = JSON.stringify(args.fields);
|
|
94
|
-
}
|
|
95
|
-
if (args.ids !== undefined) {
|
|
96
|
-
if (args.ids.length === 0) {
|
|
97
|
-
throw new Error("issue_get: ids must be a non-empty array");
|
|
98
|
-
}
|
|
99
|
-
query.ids = args.ids.join(",");
|
|
100
|
-
// DX-2727 — the batch route resolves globally, exactly like the
|
|
101
|
-
// single-id path (see its own doc comment): no `board` is forwarded.
|
|
102
|
-
return client.request({ method: "GET", path: "/batch", query });
|
|
103
|
-
}
|
|
104
|
-
if (args.id === undefined) {
|
|
105
|
-
throw new Error("issue_get: pass exactly one of id or ids");
|
|
106
|
-
}
|
|
107
|
-
return client.request({
|
|
108
|
-
method: "GET",
|
|
109
|
-
path: `/${encodeURIComponent(args.id)}`,
|
|
110
|
-
query,
|
|
111
|
-
board: args.board,
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
export async function issueCreate(client, args, defaultBoard) {
|
|
115
|
-
// Resolve the target board ONCE: per-call `args.board` override (a
|
|
116
|
-
// qualified `<repo>:<slug>` id) wins, else the dispatch's env-derived
|
|
117
|
-
// board. `board` lives in BOTH the query string (via http-client) AND
|
|
118
|
-
// the body (the create route reads it from the JSON envelope, as
|
|
119
|
-
// `repo` did pre-DX-1171). Sending only one fails-loud — the
|
|
120
|
-
// duplication is intentional. The dashboard owns board→repo and 404s
|
|
121
|
-
// an unknown board.
|
|
122
|
-
const board = args.board ?? defaultBoard;
|
|
123
|
-
const body = {
|
|
124
|
-
board,
|
|
125
|
-
type: args.type,
|
|
126
|
-
title: args.title,
|
|
127
|
-
description: args.description,
|
|
128
|
-
// DX-3006 — forwarded UNCONDITIONALLY, alongside the other required
|
|
129
|
-
// fields, never behind an `if (… !== undefined)` like the optional ones
|
|
130
|
-
// below. Zod has already refused a call that omitted it, so there is
|
|
131
|
-
// nothing to coalesce here: whatever the caller decided, including an
|
|
132
|
-
// explicit null, is what the route receives.
|
|
133
|
-
plan: args.plan,
|
|
134
|
-
};
|
|
135
|
-
if (args.summary !== undefined)
|
|
136
|
-
body.summary = args.summary;
|
|
137
|
-
if (args.parent_id !== undefined)
|
|
138
|
-
body.parent_id = args.parent_id;
|
|
139
|
-
if (args.ac !== undefined)
|
|
140
|
-
body.ac = args.ac;
|
|
141
|
-
if (args.effort_level !== undefined)
|
|
142
|
-
body.effort_level = args.effort_level;
|
|
143
|
-
// DX-3238 — same tier-word → numeric midpoint resolution `issueEdit` does;
|
|
144
|
-
// the route only ever accepts a number (`parsePriority`, `create.ts:545`).
|
|
145
|
-
if (args.priority !== undefined)
|
|
146
|
-
body.priority = resolvePriority(args.priority);
|
|
147
|
-
if (args.list_id !== undefined)
|
|
148
|
-
body.list_id = args.list_id;
|
|
149
|
-
if (args.quality_gates !== undefined)
|
|
150
|
-
body.quality_gates = args.quality_gates;
|
|
151
|
-
// DX-1895 (explicit-only) — forward the caller's explicit auto-triage
|
|
152
|
-
// decision; the server stamps false when absent (never auto-triaged).
|
|
153
|
-
// Per-child flags ride along inside phase_children entries verbatim.
|
|
154
|
-
if (args.triage_enabled !== undefined)
|
|
155
|
-
body.triage_enabled = args.triage_enabled;
|
|
156
|
-
if (args.phase_children !== undefined)
|
|
157
|
-
body.phase_children = args.phase_children;
|
|
158
|
-
return client.request({ method: "POST", path: "", body, board });
|
|
159
|
-
}
|
|
160
|
-
export async function issueEdit(client, args) {
|
|
161
|
-
// `board` is a transport concern (query), NOT a patch field — pull it
|
|
162
|
-
// out of `rest` so it never leaks into the `/edit` body (the server
|
|
163
|
-
// 400s on non-allowlisted keys). `priority` is pulled out separately so it
|
|
164
|
-
// can be resolved (tier word → numeric) before it reaches the body.
|
|
165
|
-
const { id, board, priority, ...rest } = args;
|
|
166
|
-
// Strip undefined so we don't send `{title: undefined}` — JSON.stringify
|
|
167
|
-
// drops them anyway but explicit is clearer.
|
|
168
|
-
const body = {};
|
|
169
|
-
for (const [k, v] of Object.entries(rest)) {
|
|
170
|
-
if (v !== undefined)
|
|
171
|
-
body[k] = v;
|
|
172
|
-
}
|
|
173
|
-
// DX-1532 — resolve a tier WORD to its numeric midpoint (inverse of
|
|
174
|
-
// priorityTier); a raw number passes through for the route to range-validate.
|
|
175
|
-
// Omitted (undefined) → never sent, so a prose-only edit is unchanged.
|
|
176
|
-
if (priority !== undefined) {
|
|
177
|
-
body.priority = resolvePriority(priority);
|
|
178
|
-
}
|
|
179
|
-
return client.request({
|
|
180
|
-
method: "PATCH",
|
|
181
|
-
path: `/${encodeURIComponent(id)}/edit`,
|
|
182
|
-
body,
|
|
183
|
-
board,
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
export async function issueTransition(client, args) {
|
|
187
|
-
const { id, board, ...body } = args;
|
|
188
|
-
// DX-2735 / DX-2830: no reminder on block. A block is a dispatch HOLD only —
|
|
189
|
-
// it never puts the card in front of a human — so there is no question to
|
|
190
|
-
// list options for. A card that needs a human uses issue_problem add, which
|
|
191
|
-
// carries its own reminder.
|
|
192
|
-
return client.request({
|
|
193
|
-
method: "POST",
|
|
194
|
-
path: `/${encodeURIComponent(id)}/transition`,
|
|
195
|
-
body,
|
|
196
|
-
board,
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
export async function issueTriage(client, args) {
|
|
200
|
-
const { id, board, ...body } = args;
|
|
54
|
+
export async function planConnect(client, args) {
|
|
201
55
|
return client.request({
|
|
202
56
|
method: "POST",
|
|
203
|
-
path:
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
/**
|
|
209
|
-
* DX-2735 — the ONE boundary check every action-dispatched handler uses for its
|
|
210
|
-
* required args. The MCP schema already validates, but a handler can be called
|
|
211
|
-
* without it (tests, the composition E2E, a future caller), so each required arg
|
|
212
|
-
* is checked by TYPE, not merely for presence: `null`, a number where text
|
|
213
|
-
* belongs, or a string where a row id belongs all throw here, before any request
|
|
214
|
-
* is built, as `<tool> <mode> requires <arg> (<what it must be>)`.
|
|
215
|
-
*
|
|
216
|
-
* Returns the checkers rather than checking anything itself. `mode` is the
|
|
217
|
-
* condition the args are required under — `action=<action>` for the
|
|
218
|
-
* action-dispatched tools — so every tool refuses in the same wording.
|
|
219
|
-
*/
|
|
220
|
-
function argCheckers(tool, mode) {
|
|
221
|
-
const fail = (name, expected) => {
|
|
222
|
-
throw new Error(`${tool} ${mode} requires ${name} (${expected})`);
|
|
223
|
-
};
|
|
224
|
-
return {
|
|
225
|
-
/** Text, a content hash, a name, an enum value. */
|
|
226
|
-
string(value, name) {
|
|
227
|
-
if (typeof value !== "string")
|
|
228
|
-
fail(name, "a string");
|
|
229
|
-
return value;
|
|
230
|
-
},
|
|
231
|
-
/** A database row id. */
|
|
232
|
-
id(value, name) {
|
|
233
|
-
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0)
|
|
234
|
-
fail(name, "a positive integer id");
|
|
235
|
-
return value;
|
|
57
|
+
path: "/me/plan",
|
|
58
|
+
basePath: PLAN_SESSIONS_BASE_PATH,
|
|
59
|
+
body: {
|
|
60
|
+
plan_id: args.plan_id,
|
|
61
|
+
...(args.title === undefined ? {} : { title: args.title }),
|
|
236
62
|
},
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
/**
|
|
240
|
-
* Every action-dispatched tool's args carry these three regardless of
|
|
241
|
-
* `action` — never action-specific, so every per-action allow-list passed to
|
|
242
|
-
* `refuseInapplicableFields` is unioned with this rather than repeating it.
|
|
243
|
-
*/
|
|
244
|
-
const STRUCTURAL_ACTION_FIELDS = ["id", "action", "board"];
|
|
245
|
-
/**
|
|
246
|
-
* DX-3310 (code-review nit) — a field the caller sent that means nothing for
|
|
247
|
-
* the chosen `action` used to be silently ignored: `issue_solution`'s `add`
|
|
248
|
-
* built its `content` object from `title`/`body`/`pro`/`con`/`recommended`
|
|
249
|
-
* regardless of action, then simply never read it on `remove`/`add_step`/etc,
|
|
250
|
-
* and `issue_problem`'s `remove` never looked at `statement`/`context`/
|
|
251
|
-
* `solutions` at all. Either way the caller got a 200 with the field quietly
|
|
252
|
-
* discarded — no different from a typo. Refused loud instead, mirroring
|
|
253
|
-
* `argCheckers`' own wording: every ACTUALLY-SUPPLIED (`!== undefined`) key
|
|
254
|
-
* outside the action's own allow-list (plus `STRUCTURAL_ACTION_FIELDS`,
|
|
255
|
-
* always allowed) is named in one error, so a caller fixes every offending
|
|
256
|
-
* field in one round trip rather than one at a time.
|
|
257
|
-
*/
|
|
258
|
-
// A9 (code-review fix) — takes `object` rather than `Record<string, unknown>`
|
|
259
|
-
// so every caller (each with its own specific `*Args` interface, no index
|
|
260
|
-
// signature) passes `args` directly, with no `as unknown as Record<...>`
|
|
261
|
-
// bridge cast at the call site. `Object.entries` reads the key/value pairs
|
|
262
|
-
// without needing an index signature on the input type either.
|
|
263
|
-
function refuseInapplicableFields(tool, mode, args, allowed) {
|
|
264
|
-
const offending = Object.entries(args)
|
|
265
|
-
.filter(([key, value]) => value !== undefined && !STRUCTURAL_ACTION_FIELDS.includes(key) && !allowed.has(key))
|
|
266
|
-
.map(([key]) => key)
|
|
267
|
-
.sort();
|
|
268
|
-
if (offending.length > 0) {
|
|
269
|
-
throw new Error(`${tool} ${mode} refuses fields that do not apply to this action: ${offending.join(", ")}`);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
export async function issueComment(client, args) {
|
|
273
|
-
const idEnc = encodeURIComponent(args.id);
|
|
274
|
-
const board = args.board;
|
|
275
|
-
const need = argCheckers("issue_comment", `action=${args.action}`);
|
|
276
|
-
if (args.action === "add") {
|
|
277
|
-
const text = need.string(args.text, "text");
|
|
278
|
-
return client.request({
|
|
279
|
-
method: "POST",
|
|
280
|
-
path: `/${idEnc}/comments`,
|
|
281
|
-
body: {
|
|
282
|
-
text,
|
|
283
|
-
...(args.metadata !== undefined ? { metadata: args.metadata } : {}),
|
|
284
|
-
...(args.problem_id !== undefined ? { problem_id: args.problem_id } : {}),
|
|
285
|
-
},
|
|
286
|
-
board,
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
if (args.action === "edit") {
|
|
290
|
-
const commentId = need.id(args.comment_id, "comment_id");
|
|
291
|
-
const text = need.string(args.text, "text");
|
|
292
|
-
return client.request({
|
|
293
|
-
method: "PATCH",
|
|
294
|
-
path: `/${idEnc}/comments/${commentId}`,
|
|
295
|
-
body: { text },
|
|
296
|
-
board,
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
// delete
|
|
300
|
-
const commentId = need.id(args.comment_id, "comment_id");
|
|
301
|
-
return client.request({
|
|
302
|
-
method: "DELETE",
|
|
303
|
-
path: `/${idEnc}/comments/${commentId}`,
|
|
304
|
-
board,
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
export async function issueDependency(client, args) {
|
|
308
|
-
const idEnc = encodeURIComponent(args.id);
|
|
309
|
-
const board = args.board;
|
|
310
|
-
const need = argCheckers("issue_dependency", `action=${args.action}`);
|
|
311
|
-
if (args.action === "add") {
|
|
312
|
-
const kind = need.string(args.kind, "kind");
|
|
313
|
-
const targetId = need.string(args.target_id, "target_id");
|
|
314
|
-
return client.request({
|
|
315
|
-
method: "POST",
|
|
316
|
-
path: `/${idEnc}/dependencies`,
|
|
317
|
-
body: {
|
|
318
|
-
kind,
|
|
319
|
-
target_id: targetId,
|
|
320
|
-
reason: args.reason ?? "",
|
|
321
|
-
},
|
|
322
|
-
board,
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
// remove
|
|
326
|
-
const dependencyId = need.id(args.dependency_id, "dependency_id");
|
|
327
|
-
return client.request({
|
|
328
|
-
method: "DELETE",
|
|
329
|
-
path: `/${idEnc}/dependencies/${dependencyId}`,
|
|
330
|
-
// Server demands literal "recorded_in_error" — any other value is
|
|
331
|
-
// a 400. The MCP boundary hardcodes it to remove a footgun: an
|
|
332
|
-
// agent that types the wrong reason gets a refusal here at MCP
|
|
333
|
-
// arg-validation, not a confusing 400 envelope.
|
|
334
|
-
body: { reason: "recorded_in_error" },
|
|
335
|
-
board,
|
|
336
63
|
});
|
|
337
64
|
}
|
|
65
|
+
// ---------------- resource_fields (DX-3426) ----------------
|
|
66
|
+
const RESOURCES_BASE_PATH = "/api/resources";
|
|
338
67
|
/**
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
* client-supplied. Idempotent: retiring an already-retired branch just
|
|
346
|
-
* re-stamps reason/actor/timestamp.
|
|
68
|
+
* `GET /api/resources/:resource/fields` — what a field TREE (the `fields`
|
|
69
|
+
* arg on `GET /api/issues/:id`, and any later route built on the same shape)
|
|
70
|
+
* may name for ONE resource: `{resource, description, always, hashes,
|
|
71
|
+
* fields, relations}`. Install-global (no `board` — the failure-category
|
|
72
|
+
* list route is board-less the same way: nothing server-side would read one).
|
|
73
|
+
* Unknown resource → 404 `unknown_resource`.
|
|
347
74
|
*/
|
|
348
|
-
export async function
|
|
75
|
+
export async function resourceFields(client, args) {
|
|
349
76
|
return client.request({
|
|
350
|
-
method: "
|
|
351
|
-
path: `/${encodeURIComponent(args.
|
|
352
|
-
|
|
353
|
-
board: args.board,
|
|
77
|
+
method: "GET",
|
|
78
|
+
path: `/${encodeURIComponent(args.resource)}/fields`,
|
|
79
|
+
basePath: RESOURCES_BASE_PATH,
|
|
354
80
|
});
|
|
355
81
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
*
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
return client.request({
|
|
406
|
-
method: "POST",
|
|
407
|
-
path: `/${idEnc}/checklists/${checklistId}/items`,
|
|
408
|
-
body,
|
|
409
|
-
board,
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
case "update_item": {
|
|
413
|
-
const checklistId = need.id(args.checklist_id, "checklist_id");
|
|
414
|
-
const itemId = need.id(args.item_id, "item_id");
|
|
415
|
-
const body = {};
|
|
416
|
-
if (args.label !== undefined)
|
|
417
|
-
body.label = args.label;
|
|
418
|
-
if (args.detail !== undefined)
|
|
419
|
-
body.detail = args.detail;
|
|
420
|
-
if (args.status !== undefined)
|
|
421
|
-
body.status = args.status;
|
|
422
|
-
return client.request({
|
|
423
|
-
method: "PATCH",
|
|
424
|
-
path: `/${idEnc}/checklists/${checklistId}/items/${itemId}`,
|
|
425
|
-
body,
|
|
426
|
-
board,
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
case "remove_item": {
|
|
430
|
-
const checklistId = need.id(args.checklist_id, "checklist_id");
|
|
431
|
-
const itemId = need.id(args.item_id, "item_id");
|
|
432
|
-
return client.request({
|
|
433
|
-
method: "DELETE",
|
|
434
|
-
path: `/${idEnc}/checklists/${checklistId}/items/${itemId}`,
|
|
435
|
-
board,
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
/**
|
|
441
|
-
* A card's PROBLEMS via `/api/issues/:id/problems[/:pid]` (DX-2735),
|
|
442
|
-
* action-dispatched like `issue_checklist`. A problem is one statement the
|
|
443
|
-
* operator must resolve — a question or a flaw in the plan — owning its own
|
|
444
|
-
* solutions and its own decisions. A missing required arg throws at this
|
|
445
|
-
* boundary (no round-trip); the server's refusals (`stale_problem` with the
|
|
446
|
-
* current row) pass through verbatim.
|
|
447
|
-
*
|
|
448
|
-
* `add` carries `solutions[]` in the SAME request so a problem and its options
|
|
449
|
-
* land in one server transaction — never a problem briefly visible to the
|
|
450
|
-
* operator with none of the options it was created with. DX-2830 — a card
|
|
451
|
-
* needs a human exactly when it has an open problem, so `add` IS the moment a
|
|
452
|
-
* card is put in front of one; the reminder that used to ride the retired
|
|
453
|
-
* `issue_requires_human({set: true})` now rides here instead.
|
|
454
|
-
*
|
|
455
|
-
* DX-2942 — `statement` is capped at 200 characters server-side (a 400 names
|
|
456
|
-
* the actual length otherwise); `context` is the markdown detail behind it,
|
|
457
|
-
* sent only when given on `add`, and on `edit` `undefined` keeps the stored
|
|
458
|
-
* context while `null` clears it — the same omit/null semantics
|
|
459
|
-
* `plan_update_record` already gives a plan record's context.
|
|
460
|
-
*
|
|
461
|
-
* DX-3309 — `add` also takes `type`: `question` (the operator decides) or
|
|
462
|
-
* `action` (only a person can carry it out). Sent only when given — the
|
|
463
|
-
* server itself defaults an omitted value to `question`, so a caller that
|
|
464
|
-
* predates this field keeps creating exactly what it always created. It is
|
|
465
|
-
* set once, at creation, and never editable afterward. The FULL teaching
|
|
466
|
-
* text (the could-I-do-this-myself test, and what `statement`/`context`/
|
|
467
|
-
* `solutions[]` mean under each type) lives on the tool's own `type` field
|
|
468
|
-
* `.describe()` in `index.ts` — that description, not this comment, is what
|
|
469
|
-
* a calling agent actually reads.
|
|
470
|
-
*
|
|
471
|
-
* DX-3471 — `answer` {solution_id, note?} | {freeform} → POST :pid/answer;
|
|
472
|
-
* `change_answer` {base_hash, solution_id, note?} | {base_hash, freeform} →
|
|
473
|
-
* POST :pid/change-answer (retracts the live decision and records a new one
|
|
474
|
-
* in ONE server transaction); `unanswer` {base_hash} → POST :pid/unanswer
|
|
475
|
-
* (retracts with no new answer, reopening the problem). `base_hash` on these
|
|
476
|
-
* two names the live DECISION (`decisionContentHash`), NOT the problem —
|
|
477
|
-
* read it off a problem's `decisions[].content_hash` (from `list`), never
|
|
478
|
-
* off the problem's own `content_hash`. A stale one → 409 `stale_decision`
|
|
479
|
-
* with `currentHash` + `currentProblem`: merge, then retry.
|
|
480
|
-
*
|
|
481
|
-
* ALL THREE ARE OPERATOR-SESSION ONLY. The server's route policy admits only
|
|
482
|
-
* a `human` principal on these three routes (`route-policies/index.ts`) — a
|
|
483
|
-
* dispatched agent's machine credential is refused before it ever reaches a
|
|
484
|
-
* handler, for the DX-2830 reason: an agent that could answer its own
|
|
485
|
-
* question could release the very human gate it set to stop and wait for.
|
|
486
|
-
* An operator-session agent (running under the OPERATOR's own dashboard
|
|
487
|
-
* token, human-authenticated) CAN call these — this is what turns "the
|
|
488
|
-
* operator decided in chat" into a durable, correctly-attributed decision
|
|
489
|
-
* instead of a comment or a raw curl call. The decision records the human
|
|
490
|
-
* decider (`decided_by`) and, when this call carries the session id
|
|
491
|
-
* `@thehammer/danx-dashboard-mcp` already stamps on every request
|
|
492
|
-
* (`x-danx-session-id`, DX-2683 — no separate parameter needed here),
|
|
493
|
-
* which working session relayed it (`relayed_by_session` on the resulting
|
|
494
|
-
* decision, DX-3471) — so a decision entered from the dashboard's own UI
|
|
495
|
-
* (no header) stays distinguishable from one an agent relayed in chat.
|
|
496
|
-
*/
|
|
497
|
-
/**
|
|
498
|
-
* DX-3310 (nit) — per-action allow-list for `refuseInapplicableFields`.
|
|
499
|
-
* `id`/`action`/`board` are structural and never listed (every action takes
|
|
500
|
-
* them). `type` is listed under `edit` even though it can never actually be
|
|
501
|
-
* SENT there (see the dedicated throw below) — that keeps the generic
|
|
502
|
-
* refusal from firing first with a less specific message than the one that
|
|
503
|
-
* already names the real reason.
|
|
504
|
-
*/
|
|
505
|
-
const PROBLEM_ACTION_FIELDS = {
|
|
506
|
-
list: [],
|
|
507
|
-
add: ["statement", "context", "type", "summary", "solutions"],
|
|
508
|
-
edit: ["problem_id", "base_hash", "statement", "context", "summary", "type"],
|
|
509
|
-
remove: ["problem_id", "base_hash"],
|
|
510
|
-
answer: ["problem_id", "solution_id", "note", "freeform"],
|
|
511
|
-
change_answer: ["problem_id", "base_hash", "solution_id", "note", "freeform"],
|
|
512
|
-
unanswer: ["problem_id", "base_hash"],
|
|
513
|
-
};
|
|
514
|
-
export async function issueProblem(client, args) {
|
|
515
|
-
const idEnc = encodeURIComponent(args.id);
|
|
516
|
-
const board = args.board;
|
|
517
|
-
const need = argCheckers("issue_problem", `action=${args.action}`);
|
|
518
|
-
refuseInapplicableFields("issue_problem", `action=${args.action}`, args, new Set(PROBLEM_ACTION_FIELDS[args.action]));
|
|
519
|
-
switch (args.action) {
|
|
520
|
-
case "list":
|
|
521
|
-
return client.request({ method: "GET", path: `/${idEnc}/problems`, board });
|
|
522
|
-
case "add": {
|
|
523
|
-
const body = { statement: need.string(args.statement, "statement") };
|
|
524
|
-
if (args.context !== undefined)
|
|
525
|
-
body.context = args.context;
|
|
526
|
-
// DX-3309 — sent only when given; the server itself defaults an omitted
|
|
527
|
-
// value to "question" (today's behavior, unchanged for any caller that
|
|
528
|
-
// doesn't yet know about this field).
|
|
529
|
-
if (args.type !== undefined)
|
|
530
|
-
body.type = args.type;
|
|
531
|
-
// DX-3309 (follow-up) — sent only when given; the server itself refuses
|
|
532
|
-
// an `action` add with no summary (400 naming the field), and defaults
|
|
533
|
-
// an omitted `question` summary to absent (`null`).
|
|
534
|
-
if (args.summary !== undefined)
|
|
535
|
-
body.summary = args.summary;
|
|
536
|
-
if (args.solutions !== undefined)
|
|
537
|
-
body.solutions = args.solutions;
|
|
538
|
-
// DX-3365 — the server's own response now carries the reminder (a
|
|
539
|
-
// `reminders: [{key, text}]` field appended by the registry
|
|
540
|
-
// middleware, `src/issues/reminders/middleware.ts`); this used to be an
|
|
541
|
-
// EXTRA client-side GET (`withProblemsReminder`, deleted) computing the
|
|
542
|
-
// identical text a request round-trip later. Passed through verbatim —
|
|
543
|
-
// no dual path.
|
|
544
|
-
return client.request({ method: "POST", path: `/${idEnc}/problems`, body, board });
|
|
545
|
-
}
|
|
546
|
-
case "edit": {
|
|
547
|
-
const problemId = need.id(args.problem_id, "problem_id");
|
|
548
|
-
const baseHash = need.string(args.base_hash, "base_hash");
|
|
549
|
-
const statement = need.string(args.statement, "statement");
|
|
550
|
-
// DX-3309 (test-review follow-up) — `type` is set once, at creation,
|
|
551
|
-
// and never editable afterward; the HTTP edit route already refuses it
|
|
552
|
-
// as an unknown key. This call used to silently DROP `type` on edit
|
|
553
|
-
// (it was never read from `args` here at all), so an agent
|
|
554
|
-
// "changing" question -> action got a 200 back with the type
|
|
555
|
-
// unchanged, no error, no signal anything was ignored. Fail loud
|
|
556
|
-
// instead: the fix for a wrong type is remove + re-add, never edit.
|
|
557
|
-
if (args.type !== undefined) {
|
|
558
|
-
throw new Error("issue_problem edit: type cannot be changed after creation — remove this problem and add a new one with the correct type");
|
|
559
|
-
}
|
|
560
|
-
return client.request({
|
|
561
|
-
method: "PATCH",
|
|
562
|
-
path: `/${idEnc}/problems/${problemId}`,
|
|
563
|
-
body: {
|
|
564
|
-
base_hash: baseHash,
|
|
565
|
-
statement,
|
|
566
|
-
...(args.context === undefined ? {} : { context: args.context }),
|
|
567
|
-
...(args.summary === undefined ? {} : { summary: args.summary }),
|
|
568
|
-
},
|
|
569
|
-
board,
|
|
570
|
-
});
|
|
571
|
-
}
|
|
572
|
-
case "remove": {
|
|
573
|
-
const problemId = need.id(args.problem_id, "problem_id");
|
|
574
|
-
const baseHash = need.string(args.base_hash, "base_hash");
|
|
575
|
-
return client.request({
|
|
576
|
-
method: "DELETE",
|
|
577
|
-
path: `/${idEnc}/problems/${problemId}`,
|
|
578
|
-
body: { base_hash: baseHash },
|
|
579
|
-
board,
|
|
580
|
-
});
|
|
581
|
-
}
|
|
582
|
-
// DX-3471 — answer/change_answer share the exact-one-of solution_id/freeform
|
|
583
|
-
// shape the server's own `validateAnswer` enforces (`write/problem-answer.ts`);
|
|
584
|
-
// checked here too so a caller gets the same clear boundary error every
|
|
585
|
-
// OTHER missing/conflicting-arg case in this tool already gets, rather than
|
|
586
|
-
// a round trip to the server for a 400 the client could have caught.
|
|
587
|
-
case "answer": {
|
|
588
|
-
const problemId = need.id(args.problem_id, "problem_id");
|
|
589
|
-
return client.request({
|
|
590
|
-
method: "POST",
|
|
591
|
-
path: `/${idEnc}/problems/${problemId}/answer`,
|
|
592
|
-
body: answerChoiceBody(args, "answer"),
|
|
593
|
-
board,
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
case "change_answer": {
|
|
597
|
-
const problemId = need.id(args.problem_id, "problem_id");
|
|
598
|
-
const baseHash = need.string(args.base_hash, "base_hash");
|
|
599
|
-
return client.request({
|
|
600
|
-
method: "POST",
|
|
601
|
-
path: `/${idEnc}/problems/${problemId}/change-answer`,
|
|
602
|
-
body: { base_hash: baseHash, ...answerChoiceBody(args, "change_answer") },
|
|
603
|
-
board,
|
|
604
|
-
});
|
|
605
|
-
}
|
|
606
|
-
case "unanswer": {
|
|
607
|
-
const problemId = need.id(args.problem_id, "problem_id");
|
|
608
|
-
const baseHash = need.string(args.base_hash, "base_hash");
|
|
609
|
-
return client.request({
|
|
610
|
-
method: "POST",
|
|
611
|
-
path: `/${idEnc}/problems/${problemId}/unanswer`,
|
|
612
|
-
body: { base_hash: baseHash },
|
|
613
|
-
board,
|
|
614
|
-
});
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
/**
|
|
619
|
-
* DX-3471 — the either/or answer shape `answer`/`change_answer` both send:
|
|
620
|
-
* `{solution_id, note?}` XOR `{freeform}`, mirroring the server's own
|
|
621
|
-
* `validateAnswer` (`write/problem-answer.ts`) so a caller gets the SAME
|
|
622
|
-
* refusal at this client boundary that the server would otherwise 400 on,
|
|
623
|
-
* with no round trip.
|
|
624
|
-
*/
|
|
625
|
-
function answerChoiceBody(args, mode) {
|
|
626
|
-
const hasSolution = args.solution_id !== undefined;
|
|
627
|
-
const hasFreeform = args.freeform !== undefined;
|
|
628
|
-
if (hasSolution === hasFreeform) {
|
|
629
|
-
throw new Error(`issue_problem ${mode} requires either solution_id or freeform (never both, never neither)`);
|
|
630
|
-
}
|
|
631
|
-
if (hasFreeform) {
|
|
632
|
-
if (args.note !== undefined) {
|
|
633
|
-
throw new Error(`issue_problem ${mode}: note qualifies a chosen solution — it is not valid with freeform`);
|
|
634
|
-
}
|
|
635
|
-
return { freeform: args.freeform };
|
|
636
|
-
}
|
|
637
|
-
const body = { solution_id: args.solution_id };
|
|
638
|
-
if (args.note !== undefined)
|
|
639
|
-
body.note = args.note;
|
|
640
|
-
return body;
|
|
641
|
-
}
|
|
642
|
-
/**
|
|
643
|
-
* One problem's candidate solutions, AND (DX-3310) one solution's individual
|
|
644
|
-
* procedure steps, via `/api/issues/:id/problems/:pid/solutions[/:sid][/steps[/:stepId]]`
|
|
645
|
-
* — action-dispatched like `issue_checklist`. A missing required arg for the
|
|
646
|
-
* chosen action throws at this boundary (no round-trip); the server's
|
|
647
|
-
* refusal envelopes (`stale_solution`/`stale_step` with the current row, a
|
|
648
|
-
* second recommendation on the same problem, editing a chosen option, a
|
|
649
|
-
* solution/step id from the wrong scope → 404, nesting past depth 3 → 400)
|
|
650
|
-
* pass through verbatim.
|
|
651
|
-
*
|
|
652
|
-
* There is deliberately NO answer action on THIS tool — answering releases the
|
|
653
|
-
* human gate on a card, and this tool has no notion of "who is calling".
|
|
654
|
-
* `issue_problem`'s `answer` / `change_answer` / `unanswer` actions (DX-3471)
|
|
655
|
-
* are where that lives instead: server-side human-principal-only (a
|
|
656
|
-
* dispatched agent's machine credential is refused 403), so an operator
|
|
657
|
-
* session can record a decision but a dispatched worker cannot release its
|
|
658
|
-
* own gate.
|
|
659
|
-
*/
|
|
660
|
-
const SOLUTION_ACTION_FIELDS = {
|
|
661
|
-
add: ["title", "body", "pro", "con", "recommended", "steps"],
|
|
662
|
-
edit: ["solution_id", "base_hash", "title", "body", "pro", "con", "recommended", "steps", "steps_base_hash"],
|
|
663
|
-
remove: ["solution_id", "base_hash"],
|
|
664
|
-
add_step: ["solution_id", "title", "description", "parent_step_id", "position"],
|
|
665
|
-
edit_step: ["solution_id", "step_id", "base_hash", "title", "description"],
|
|
666
|
-
remove_step: ["solution_id", "step_id", "base_hash"],
|
|
667
|
-
};
|
|
668
|
-
export async function issueSolution(client, args) {
|
|
669
|
-
const need = argCheckers("issue_solution", `action=${args.action}`);
|
|
670
|
-
// DX-2735: checked at runtime too, not only by the schema — a caller that skips
|
|
671
|
-
// the MCP boundary must never build `/problems/undefined/solutions`.
|
|
672
|
-
const problemId = need.id(args.problem_id, "problem_id");
|
|
673
|
-
// DX-3310 (nit) — `problem_id` is unioned in here rather than listed in
|
|
674
|
-
// every `SOLUTION_ACTION_FIELDS` entry: unlike `issue_problem` (where it
|
|
675
|
-
// only applies to edit/remove), `issue_solution` requires it for every
|
|
676
|
-
// action (checked above), so it is structural FOR THIS TOOL specifically.
|
|
677
|
-
refuseInapplicableFields("issue_solution", `action=${args.action}`, args, new Set(["problem_id", ...SOLUTION_ACTION_FIELDS[args.action]]));
|
|
678
|
-
const base = `/${encodeURIComponent(args.id)}/problems/${problemId}/solutions`;
|
|
679
|
-
const board = args.board;
|
|
680
|
-
const content = {};
|
|
681
|
-
for (const key of ["title", "body", "pro", "con", "recommended"]) {
|
|
682
|
-
if (args[key] !== undefined)
|
|
683
|
-
content[key] = args[key];
|
|
684
|
-
}
|
|
685
|
-
switch (args.action) {
|
|
686
|
-
case "add": {
|
|
687
|
-
// DX-2735: the POST carries the CHECKED title, not a re-read of args.title.
|
|
688
|
-
const title = need.string(args.title, "title");
|
|
689
|
-
// DX-3310 — omitted `steps` on add means "no steps", not "leave
|
|
690
|
-
// untouched" (there is nothing yet to leave untouched on a brand new
|
|
691
|
-
// solution) — sent through only when the caller actually supplied it,
|
|
692
|
-
// so an existing add-only caller that never heard of `steps` keeps
|
|
693
|
-
// creating exactly what it always created.
|
|
694
|
-
if (args.steps !== undefined)
|
|
695
|
-
content.steps = args.steps;
|
|
696
|
-
return client.request({ method: "POST", path: base, body: { ...content, title }, board });
|
|
697
|
-
}
|
|
698
|
-
case "edit": {
|
|
699
|
-
const solutionId = need.id(args.solution_id, "solution_id");
|
|
700
|
-
const baseHash = need.string(args.base_hash, "base_hash");
|
|
701
|
-
// DX-3310 — omitted keeps the stored steps untouched; an explicit
|
|
702
|
-
// (including empty) array replaces them via the server's diff.
|
|
703
|
-
if (args.steps !== undefined) {
|
|
704
|
-
content.steps = args.steps;
|
|
705
|
-
// B2 (code-review fix) — REQUIRED alongside `steps`, checked here
|
|
706
|
-
// (not only by the server) so a caller gets the teaching error at
|
|
707
|
-
// this boundary rather than a round trip for something this client
|
|
708
|
-
// already knows is missing.
|
|
709
|
-
content.steps_base_hash = need.string(args.steps_base_hash, "steps_base_hash");
|
|
710
|
-
}
|
|
711
|
-
return client.request({
|
|
712
|
-
method: "PATCH",
|
|
713
|
-
path: `${base}/${solutionId}`,
|
|
714
|
-
body: { base_hash: baseHash, ...content },
|
|
715
|
-
board,
|
|
716
|
-
});
|
|
717
|
-
}
|
|
718
|
-
case "remove": {
|
|
719
|
-
const solutionId = need.id(args.solution_id, "solution_id");
|
|
720
|
-
const baseHash = need.string(args.base_hash, "base_hash");
|
|
721
|
-
return client.request({
|
|
722
|
-
method: "DELETE",
|
|
723
|
-
path: `${base}/${solutionId}`,
|
|
724
|
-
body: { base_hash: baseHash },
|
|
725
|
-
board,
|
|
726
|
-
});
|
|
727
|
-
}
|
|
728
|
-
// DX-3310 (AC 35250) — granular single-step CUD, so changing one step
|
|
729
|
-
// never requires resending the whole `steps` tree.
|
|
730
|
-
case "add_step": {
|
|
731
|
-
const solutionId = need.id(args.solution_id, "solution_id");
|
|
732
|
-
const title = need.string(args.title, "title");
|
|
733
|
-
const body = { title };
|
|
734
|
-
if (args.description !== undefined)
|
|
735
|
-
body.description = args.description;
|
|
736
|
-
if (args.parent_step_id !== undefined)
|
|
737
|
-
body.parent_step_id = args.parent_step_id;
|
|
738
|
-
if (args.position !== undefined)
|
|
739
|
-
body.position = args.position;
|
|
740
|
-
return client.request({ method: "POST", path: `${base}/${solutionId}/steps`, body, board });
|
|
741
|
-
}
|
|
742
|
-
case "edit_step": {
|
|
743
|
-
const solutionId = need.id(args.solution_id, "solution_id");
|
|
744
|
-
const stepId = need.id(args.step_id, "step_id");
|
|
745
|
-
const baseHash = need.string(args.base_hash, "base_hash");
|
|
746
|
-
const body = { base_hash: baseHash };
|
|
747
|
-
if (args.title !== undefined)
|
|
748
|
-
body.title = args.title;
|
|
749
|
-
if (args.description !== undefined)
|
|
750
|
-
body.description = args.description;
|
|
751
|
-
return client.request({ method: "PATCH", path: `${base}/${solutionId}/steps/${stepId}`, body, board });
|
|
752
|
-
}
|
|
753
|
-
case "remove_step": {
|
|
754
|
-
const solutionId = need.id(args.solution_id, "solution_id");
|
|
755
|
-
const stepId = need.id(args.step_id, "step_id");
|
|
756
|
-
const baseHash = need.string(args.base_hash, "base_hash");
|
|
757
|
-
return client.request({
|
|
758
|
-
method: "DELETE",
|
|
759
|
-
path: `${base}/${solutionId}/steps/${stepId}`,
|
|
760
|
-
body: { base_hash: baseHash },
|
|
761
|
-
board,
|
|
762
|
-
});
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
/**
|
|
767
|
-
* Put ONE quality gate on a card, or take it off, via
|
|
768
|
-
* POST /api/issues/:id/quality-gates/:gate {action} — the same write the
|
|
769
|
-
* dashboard drawer's Quality Gates tab performs (DX-1181). This is the ONLY
|
|
770
|
-
* post-create path to change a card's gates: `issue_create` names them at
|
|
771
|
-
* birth via `quality_gates`, and `issue_edit` rejects gate keys.
|
|
772
|
-
*
|
|
773
|
-
* `gate` is a registry name (`plan-dependency` | `plan-architecture` |
|
|
774
|
-
* `plan-tdd` | `code-test-quality` | `code-architecture` | `code-quality`).
|
|
775
|
-
* An unknown gate is a 400 from the server (never a silent no-op), as is a
|
|
776
|
-
* card whose type is never gated (`Epic` / `Feature` / `Task`).
|
|
777
|
-
*
|
|
778
|
-
* DX-3015 — there is no board tri-state to reason about any more. A gate is on
|
|
779
|
-
* the card or it is not, and every gate on a card is required, so `add` always
|
|
780
|
-
* means "this gate now runs on this card" and `remove` always means it does
|
|
781
|
-
* not. What the board still owns is the DEFAULT SET a NEW card is born with;
|
|
782
|
-
* it has no say over a card that already exists.
|
|
783
|
-
*
|
|
784
|
-
* Because of that, the response is plain `{issue}`. The old body carried
|
|
785
|
-
* `{applied, effective, reason}` because the per-card flag was only one of
|
|
786
|
-
* three inputs and the board could silently make the write a no-op — with the
|
|
787
|
-
* tri-state gone, the write IS the outcome and those fields would be constants.
|
|
788
|
-
*
|
|
789
|
-
* `note` records why the gate is on the card; `effort_level` (DX-1760) sets
|
|
790
|
-
* `card_quality_gates.effort_level` (`null` clears a prior override). Both are
|
|
791
|
-
* `add`-only — the server 400s either one alongside `remove`, since a removed
|
|
792
|
-
* gate has no row to carry them.
|
|
793
|
-
*
|
|
794
|
-
* Re-adding a gate the card already carries updates `note`/`effort_level` and
|
|
795
|
-
* leaves any recorded verdict untouched, so `add` is never a way to quietly
|
|
796
|
-
* clear a `fail`. `remove` DOES discard the row and its verdict.
|
|
797
|
-
*/
|
|
798
|
-
export async function issueQualityGate(client, args) {
|
|
799
|
-
const body = { action: args.action };
|
|
800
|
-
if (args.note !== undefined)
|
|
801
|
-
body.note = args.note;
|
|
802
|
-
if (args.effort_level !== undefined)
|
|
803
|
-
body.effort_level = args.effort_level;
|
|
804
|
-
return client.request({
|
|
805
|
-
method: "POST",
|
|
806
|
-
path: `/${encodeURIComponent(args.id)}/quality-gates/${encodeURIComponent(args.gate)}`,
|
|
807
|
-
body,
|
|
82
|
+
// ---------------- danxbot_api (DX-3465, PLN-11 R-25) ----------------
|
|
83
|
+
export const DANXBOT_API_METHODS = ["GET", "POST", "PATCH", "PUT", "DELETE"];
|
|
84
|
+
export function isDanxbotApiMethod(value) {
|
|
85
|
+
return typeof value === "string" && DANXBOT_API_METHODS.includes(value);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* `danxbot_api({method, path, query, body})` — the ONE generic tool that
|
|
89
|
+
* reaches every dashboard API route this credential is allowed to call
|
|
90
|
+
* (R-25, DX-3465). Replaces the ~45 per-operation tools this package used to
|
|
91
|
+
* ship one-for-one against each route: a new route needs a permission and a
|
|
92
|
+
* spec entry (`danxbot_api_spec`), never a new tool.
|
|
93
|
+
*
|
|
94
|
+
* The credential lives inside `client` (resolved once at boot — the
|
|
95
|
+
* operator's declared token file for a session, the per-dispatch
|
|
96
|
+
* `DANXBOT_DISPATCH_TOKEN` for a worker) and never appears in this
|
|
97
|
+
* function's arguments or return value — nothing in `DanxbotApiArgs` can
|
|
98
|
+
* carry one. The session id (`x-danx-session-id`) rides every call the SAME
|
|
99
|
+
* way it already does for every other tool on this server
|
|
100
|
+
* (`http-client.ts#request` stamps it from `config.session`); this tool adds
|
|
101
|
+
* no special-casing for that.
|
|
102
|
+
*
|
|
103
|
+
* `path` MUST start with `/api/` — this is a guardrail against a caller
|
|
104
|
+
* accidentally reaching for something outside the dashboard's own API
|
|
105
|
+
* surface (there is nothing else this credential could reach through
|
|
106
|
+
* `client`'s configured `baseUrl` that would make sense to call this way),
|
|
107
|
+
* not a route allowlist: the dashboard's own route policy (`authorize()`,
|
|
108
|
+
* `src/dashboard/route-policy.ts`) is the actual authorization boundary —
|
|
109
|
+
* an unknown or unauthorized route 404s/401s/403s exactly as it would to any
|
|
110
|
+
* other caller, and that refusal body (which names the fix — CP1) is
|
|
111
|
+
* returned verbatim, unmodified, like every other route's response.
|
|
112
|
+
*
|
|
113
|
+
* The response is the target route's JSON body, VERBATIM — including its
|
|
114
|
+
* own `reminders[]` field when the route embeds one (every reminder-
|
|
115
|
+
* registry row is keyed on `{method, route}` now, DX-3465 item 5, so a
|
|
116
|
+
* route that used to attach a reminder to a deleted per-operation tool's
|
|
117
|
+
* response still attaches it here, with zero special MCP-side plumbing).
|
|
118
|
+
*/
|
|
119
|
+
export async function danxbotApi(client, args) {
|
|
120
|
+
return client.request({
|
|
121
|
+
method: args.method,
|
|
122
|
+
// `basePath: ""` — `path` here is the FULL absolute path already (see
|
|
123
|
+
// the tool's own docs), unlike every other handler in this file, which
|
|
124
|
+
// passes a path RELATIVE to a fixed `basePath` (`/api/issues`,
|
|
125
|
+
// `/api/plans`, ...). This tool has no fixed base; the caller names the
|
|
126
|
+
// whole route.
|
|
127
|
+
basePath: "",
|
|
128
|
+
path: args.path,
|
|
129
|
+
query: args.query,
|
|
130
|
+
body: args.body,
|
|
808
131
|
board: args.board,
|
|
809
132
|
});
|
|
810
133
|
}
|
|
134
|
+
// ---------------- danxbot_api_list (DX-3465 A5, PLN-11 R-25) ----------------
|
|
135
|
+
const ROUTES_BASE_PATH = "/api/routes";
|
|
811
136
|
/**
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
* DX-2222 — why this tool has to exist. A card cannot reach Done while any
|
|
824
|
-
* required POST gate row is not `pass` (`issue_transition complete` refuses
|
|
825
|
-
* 409 with `failed_gate: "quality_gate_post"`). Until now the ONLY way to
|
|
826
|
-
* write a verdict was the worker route `src/worker/quality-gate-route.ts`,
|
|
827
|
-
* authed by the per-dispatch `DANX_AGENT_TOKEN` that exists only INSIDE a
|
|
828
|
-
* spawned dispatch. An operator/host session (`issue_transition pickup
|
|
829
|
-
* {manual:true}`, DX-946) has no such token, so it could pick a card up and
|
|
830
|
-
* do the work but never close it — every manually-claimed card stranded In
|
|
831
|
-
* Progress, and each stranded card then projects `conflict_on` / `waiting_on`
|
|
832
|
-
* gates onto the ToDo queue, stalling dispatch for cards nobody is working.
|
|
833
|
-
*
|
|
834
|
-
* This route is the RIGHT one to expose for that: its handler
|
|
835
|
-
* (`src/issues/write/quality-gate-verdict.ts`) is a pure, dispatch-less row
|
|
836
|
-
* write authed by the ordinary per-user dashboard bearer — explicitly "NOT
|
|
837
|
-
* the dispatch token — there is no dispatch behind a manual stamp". It does
|
|
838
|
-
* NOT fire the worker route's per-dispatch side effects (`gate_release` on
|
|
839
|
-
* pass, `block` on fail), so a manual verdict can never release or block a
|
|
840
|
-
* dispatch that does not exist.
|
|
841
|
-
*
|
|
842
|
-
* Fail-loud (CP1), all server-side: unknown `gate` → 400; `status` outside
|
|
843
|
-
* pass/fail/pending → 400; a pass/fail whose `message` is under 20 characters
|
|
844
|
-
* → 400, because the note IS the accountability record for a human override.
|
|
845
|
-
* `pending` reverts a prior verdict and clears the message.
|
|
137
|
+
* `danxbot_api_list()` — the routes YOUR credential may currently call,
|
|
138
|
+
* `[{method, path, summary, permission}]`, one line each. Backed by DX-3528's
|
|
139
|
+
* `GET /api/routes`, which filters the served route table through the SAME
|
|
140
|
+
* `isRouteAuthorizedForPrincipal` check `authorize()` enforces on every
|
|
141
|
+
* request — a route listed here is always callable, and a route this
|
|
142
|
+
* credential cannot call never appears. This is how you find the route for
|
|
143
|
+
* something you need to do: call this first when you don't already know the
|
|
144
|
+
* path, then `danxbot_api_spec({endpoints})` for the exact contract of the
|
|
145
|
+
* one(s) you picked, then `danxbot_api` to call it. No arguments — the
|
|
146
|
+
* server-side route table has no filter to pass.
|
|
846
147
|
*/
|
|
847
|
-
export async function
|
|
848
|
-
const body = { status: args.status };
|
|
849
|
-
if (args.message !== undefined)
|
|
850
|
-
body.message = args.message;
|
|
148
|
+
export async function danxbotApiList(client) {
|
|
851
149
|
return client.request({
|
|
852
|
-
method: "
|
|
853
|
-
path:
|
|
854
|
-
|
|
855
|
-
board: args.board,
|
|
150
|
+
method: "GET",
|
|
151
|
+
path: "",
|
|
152
|
+
basePath: ROUTES_BASE_PATH,
|
|
856
153
|
});
|
|
857
154
|
}
|
|
858
|
-
// ----------------
|
|
859
|
-
const
|
|
155
|
+
// ---------------- danxbot_api_spec (DX-3465, PLN-11 R-25) ----------------
|
|
156
|
+
const OPENAPI_BASE_PATH = "/api/openapi";
|
|
860
157
|
/**
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
* `.claude/skills/<segment>/SKILL.md`. No second source: the dashboard route
|
|
867
|
-
* behind this call reuses the exact same catalog-read + materialize-transform
|
|
868
|
-
* chain a real gate dispatch's clean-room assembly uses.
|
|
158
|
+
* `danxbot_api_spec({endpoints})` — the exact OpenAPI 3.1 contract (DX-3527)
|
|
159
|
+
* for the named routes: request/response shapes, required permission/
|
|
160
|
+
* consumer action, and every documented error envelope. Call this before a
|
|
161
|
+
* `danxbot_api` call whose shape you are not already sure of — "a 4xx body
|
|
162
|
+
* names the fix; check the spec" (the MCP server's own shared instructions).
|
|
869
163
|
*
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
* `gate` is a registry name (same enum `issue_quality_gate` accepts). Unknown
|
|
876
|
-
* gate -> 400; a registered gate with no resolvable catalog skill artifact
|
|
877
|
-
* for this repo (a boot-seed/catalog-drift defect) -> 500.
|
|
164
|
+
* Backed by DX-3528's permission-aware `GET /api/openapi` — the SAME
|
|
165
|
+
* `isRouteAuthorizedForPrincipal` check `danxbot_api_list` uses, so an
|
|
166
|
+
* endpoint this credential may not call refuses loud (400
|
|
167
|
+
* `invalid_endpoints`, naming it under `unknown` or `disallowed`) rather
|
|
168
|
+
* than silently documenting a route you cannot actually reach.
|
|
878
169
|
*/
|
|
879
|
-
export async function
|
|
170
|
+
export async function danxbotApiSpec(client, args) {
|
|
880
171
|
return client.request({
|
|
881
172
|
method: "GET",
|
|
882
|
-
path:
|
|
883
|
-
basePath:
|
|
884
|
-
|
|
885
|
-
});
|
|
886
|
-
}
|
|
887
|
-
export async function issueRetro(client, args) {
|
|
888
|
-
const { id, board, ...body } = args;
|
|
889
|
-
return client.request({
|
|
890
|
-
method: "PUT",
|
|
891
|
-
path: `/${encodeURIComponent(id)}/retro`,
|
|
892
|
-
body,
|
|
893
|
-
board,
|
|
173
|
+
path: "",
|
|
174
|
+
basePath: OPENAPI_BASE_PATH,
|
|
175
|
+
query: { endpoints: args.endpoints.join(",") },
|
|
894
176
|
});
|
|
895
177
|
}
|
|
896
178
|
/**
|
|
897
179
|
* Extension → MIME map for the agent-attached file types that matter for
|
|
898
|
-
* downstream rendering (image previews in the dashboard drawer, Trello
|
|
899
|
-
* and Slack thread all key on the persisted `mime`). This package is
|
|
900
|
-
* standalone published artifact and cannot import the
|
|
901
|
-
* the small map lives here. An unknown
|
|
902
|
-
* octet-stream — a sensible default for
|
|
903
|
-
* silenced error (the bytes still upload +
|
|
180
|
+
* downstream rendering (image previews in the dashboard drawer, Trello
|
|
181
|
+
* card, and Slack thread all key on the persisted `mime`). This package is
|
|
182
|
+
* a standalone published artifact and cannot import the dashboard
|
|
183
|
+
* backend's own MIME table, so the small map lives here. An unknown
|
|
184
|
+
* extension resolves to the generic octet-stream — a sensible default for
|
|
185
|
+
* "we don't know the type", NOT a silenced error (the bytes still upload +
|
|
186
|
+
* the row is still written).
|
|
904
187
|
*/
|
|
905
188
|
const MIME_BY_EXT = {
|
|
906
189
|
".png": "image/png",
|
|
@@ -921,700 +204,49 @@ const MIME_BY_EXT = {
|
|
|
921
204
|
".tgz": "application/gzip",
|
|
922
205
|
};
|
|
923
206
|
export function inferAttachmentMime(filename) {
|
|
924
|
-
return
|
|
925
|
-
}
|
|
926
|
-
/**
|
|
927
|
-
* Attach a local file to
|
|
928
|
-
* shared filesystem (this MCP server runs co-located with the agent
|
|
929
|
-
* forwards to the dashboard's `POST /api/issues/:id/attachments`
|
|
930
|
-
* SAME ingest the dashboard UI upload uses
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
* Fail-loud (`base:fail-loudly`):
|
|
936
|
-
* and the file is read BEFORE any HTTP call, so a bad/missing/
|
|
937
|
-
* throws here and NEVER reaches S3, the DB row, or the
|
|
938
|
-
*
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
207
|
+
return MIME_BY_EXT[extname(filename).toLowerCase()] ?? "application/octet-stream";
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Attach a local file to a card. Reads the bytes from the dispatch's
|
|
211
|
+
* shared filesystem (this MCP server runs co-located with the agent that
|
|
212
|
+
* calls it), then forwards to the dashboard's `POST /api/issues/:id/attachments`
|
|
213
|
+
* route — the SAME ingest the dashboard UI's own upload uses: stores the
|
|
214
|
+
* bytes in S3, inserts one attachment row, and (via the dashboard's
|
|
215
|
+
* existing outbound projection) auto-mirrors the file to the card's
|
|
216
|
+
* linked Trello card and Slack thread. Board-scoped; see `board`.
|
|
217
|
+
*
|
|
218
|
+
* Fail-loud (`base:fail-loudly`): `path` is validated (non-empty +
|
|
219
|
+
* absolute) and the file is read BEFORE any HTTP call, so a bad/missing/
|
|
220
|
+
* unreadable path throws here and NEVER reaches S3, the DB row, or the
|
|
221
|
+
* projection. THE FILE'S BYTES NEVER APPEAR IN THIS FUNCTION'S ARGUMENTS
|
|
222
|
+
* OR IN THE AGENT'S OWN TRANSCRIPT — they are read directly off disk into
|
|
223
|
+
* this process's memory and sent straight to the dashboard, the same
|
|
224
|
+
* privacy property `danxbot_api`'s credential handling has for tokens. An
|
|
225
|
+
* S3 / server fault on the route surfaces as a 5xx, which the HTTP client
|
|
226
|
+
* throws.
|
|
227
|
+
*/
|
|
228
|
+
export async function attachFile(client, args, deps = {}) {
|
|
229
|
+
if (typeof args.path !== "string" || args.path.trim() === "") {
|
|
230
|
+
throw new Error("attach_file: path must be a non-empty string");
|
|
231
|
+
}
|
|
232
|
+
if (!isAbsolute(args.path)) {
|
|
233
|
+
throw new Error(`attach_file: path must be an absolute path (got "${args.path}")`);
|
|
946
234
|
}
|
|
947
235
|
const read = deps.readFile ?? readFile;
|
|
948
236
|
// A missing / unreadable file rejects here — no HTTP call is made, so no
|
|
949
237
|
// S3 upload, no row insert, and no projection are ever triggered.
|
|
950
|
-
const bytes = await read(args.
|
|
951
|
-
const filename = basename(args.
|
|
238
|
+
const bytes = await read(args.path);
|
|
239
|
+
const filename = args.filename?.trim() ? args.filename.trim() : basename(args.path);
|
|
240
|
+
const contentType = args.content_type?.trim() ? args.content_type.trim() : inferAttachmentMime(filename);
|
|
952
241
|
return client.request({
|
|
953
242
|
method: "POST",
|
|
954
|
-
|
|
243
|
+
basePath: "/api/issues",
|
|
244
|
+
path: `/${encodeURIComponent(args.card_id)}/attachments`,
|
|
955
245
|
body: {
|
|
956
246
|
filename,
|
|
957
|
-
contentType
|
|
247
|
+
contentType,
|
|
958
248
|
base64: bytes.toString("base64"),
|
|
959
249
|
},
|
|
960
250
|
board: args.board,
|
|
961
251
|
});
|
|
962
252
|
}
|
|
963
|
-
/* ── Plans: what a connected session may read and add to (DX-2683) ────────── */
|
|
964
|
-
const PLANS_BASE_PATH = "/api/plans";
|
|
965
|
-
const PLAN_SESSIONS_BASE_PATH = "/api/plan-sessions";
|
|
966
|
-
/**
|
|
967
|
-
* DX-3428 — every kind the plan resource's `events` relation accepts as a
|
|
968
|
-
* `kinds` filter (`{"events": {"kinds": [...]}}`). The ONE copy in this
|
|
969
|
-
* package (the `resource_fields`-describable `events` relation validates the
|
|
970
|
-
* same set server-side), duplicated from the server's
|
|
971
|
-
* `src/issues/db/plan-events.ts#PLAN_EVENT_KINDS` because the published
|
|
972
|
-
* package cannot import server source at runtime — kept here purely for a
|
|
973
|
-
* CONSUMER'S typed convenience (`PlanEventKind`); the MCP tool schema itself
|
|
974
|
-
* no longer enumerates it (DX-3428 moved `plan_get` onto the generic,
|
|
975
|
-
* resource-agnostic `fieldTreeSchema` every field-tree tool shares — see
|
|
976
|
-
* `field-tree.ts`). `__tests__/handlers.test.ts` asserts the two are equal,
|
|
977
|
-
* so drift fails a test rather than a live call.
|
|
978
|
-
*/
|
|
979
|
-
export const PLAN_EVENT_KINDS = [
|
|
980
|
-
"comment_added",
|
|
981
|
-
"solution_answered",
|
|
982
|
-
"problem_added",
|
|
983
|
-
"problem_unanswered",
|
|
984
|
-
"problem_answer_changed",
|
|
985
|
-
"blocked",
|
|
986
|
-
"unblocked",
|
|
987
|
-
"transitioned",
|
|
988
|
-
"edited",
|
|
989
|
-
"dependency_added",
|
|
990
|
-
"dependency_removed",
|
|
991
|
-
"card_created",
|
|
992
|
-
"plan_created",
|
|
993
|
-
"plan_renamed",
|
|
994
|
-
"card_added_to_plan",
|
|
995
|
-
"card_removed_from_plan",
|
|
996
|
-
"record_added",
|
|
997
|
-
"record_updated",
|
|
998
|
-
"record_removed",
|
|
999
|
-
"note_added",
|
|
1000
|
-
"note_updated",
|
|
1001
|
-
"note_removed",
|
|
1002
|
-
"architecture_section_added",
|
|
1003
|
-
"architecture_section_updated",
|
|
1004
|
-
"architecture_section_removed",
|
|
1005
|
-
"architecture_sections_reordered",
|
|
1006
|
-
"session_connected",
|
|
1007
|
-
"session_switched_away",
|
|
1008
|
-
"idle_nudge_sent",
|
|
1009
|
-
// DX-3519 — plan sign-off.
|
|
1010
|
-
"plan_signed_off",
|
|
1011
|
-
"plan_sign_off_cleared",
|
|
1012
|
-
"plan_auto_sign_off_changed",
|
|
1013
|
-
];
|
|
1014
|
-
/**
|
|
1015
|
-
* DX-3428 — every origin the `events` relation's `origin` filter accepts.
|
|
1016
|
-
* Mirrors the server's `src/issues/db/plan-events.ts#PlanEventOrigin`
|
|
1017
|
-
* (itself `ActivityOrigin`) — same drift protection as `PLAN_EVENT_KINDS`
|
|
1018
|
-
* above, and likewise kept only for a consumer's typed convenience (not read
|
|
1019
|
-
* by the MCP tool schema any more).
|
|
1020
|
-
*/
|
|
1021
|
-
export const PLAN_EVENT_ORIGINS = ["operator", "agent", "machine"];
|
|
1022
|
-
/**
|
|
1023
|
-
* DX-2834 / DX-3519 — the plan-status taxonomy, mirroring `PLAN_FIELD_GROUPS`
|
|
1024
|
-
* just above: the ONE copy in this package (the `plan_list` zod enum in
|
|
1025
|
-
* `index.ts` reads this const), duplicated from the server's
|
|
1026
|
-
* `src/issues/db/plans.ts#PLAN_STATUS_IDS` because the published package
|
|
1027
|
-
* cannot import server source at runtime. `__tests__/handlers.test.ts`
|
|
1028
|
-
* asserts the two are equal, so drift fails a test rather than a live call.
|
|
1029
|
-
*/
|
|
1030
|
-
export const PLAN_STATUSES = ["awaiting-session", "planning", "building", "awaiting-sign-off", "complete"];
|
|
1031
|
-
/** Every plan, plus which one THIS session is connected to. */
|
|
1032
|
-
export async function planList(client, args = {}) {
|
|
1033
|
-
return client.request({
|
|
1034
|
-
method: "GET",
|
|
1035
|
-
path: "",
|
|
1036
|
-
basePath: PLANS_BASE_PATH,
|
|
1037
|
-
query: { status: args.status },
|
|
1038
|
-
});
|
|
1039
|
-
}
|
|
1040
|
-
/**
|
|
1041
|
-
* One plan — its cheap scalars by default, or opt into its cards, its goals
|
|
1042
|
-
* + rules + caveats, its architecture sections, its sessions and its event
|
|
1043
|
-
* ledger via a field TREE (DX-3428, replacing the retired flat field-GROUP
|
|
1044
|
-
* list). One call rather than several, which is what keeps this tool
|
|
1045
|
-
* surface small enough to be worth an agent's context even once every part
|
|
1046
|
-
* of a plan is opt-in rather than always-on.
|
|
1047
|
-
*/
|
|
1048
|
-
export async function planGet(client, args = {}) {
|
|
1049
|
-
const query = {};
|
|
1050
|
-
// DX-3428 — the tree travels as ONE JSON-encoded query param, exactly like
|
|
1051
|
-
// `issue_get`/`issue_list`'s own `fields` (see `issueGet` above).
|
|
1052
|
-
if (args.fields !== undefined && Object.keys(args.fields).length > 0) {
|
|
1053
|
-
query.fields = JSON.stringify(args.fields);
|
|
1054
|
-
}
|
|
1055
|
-
return client.request({
|
|
1056
|
-
method: "GET",
|
|
1057
|
-
path: args.plan_id === undefined ? "/mine" : `/${args.plan_id}/full`,
|
|
1058
|
-
basePath: PLANS_BASE_PATH,
|
|
1059
|
-
query,
|
|
1060
|
-
});
|
|
1061
|
-
}
|
|
1062
|
-
/**
|
|
1063
|
-
* Connect THIS session to a plan — the same write the operator's Connect
|
|
1064
|
-
* action performs, reaching the same server-side code path. `me` in the URL
|
|
1065
|
-
* is resolved from the forwarded session id, so this can only ever bind the
|
|
1066
|
-
* caller's own session.
|
|
1067
|
-
*
|
|
1068
|
-
* A session already on another plan is MOVED, and the response says which
|
|
1069
|
-
* plan it left (`movedFrom`).
|
|
1070
|
-
*
|
|
1071
|
-
* IT DOES NOTHING ABOUT HEARING THE PLAN'S EVENTS, deliberately. Delivery belongs
|
|
1072
|
-
* to the danxbot Claude Code plugin's plan event bridge, a background process
|
|
1073
|
-
* that holds the session's ONE listener ticket and starts when this tool
|
|
1074
|
-
* succeeds. The dashboard keeps one ticket per session, so a ticket minted here
|
|
1075
|
-
* would end the bridge's stream.
|
|
1076
|
-
*
|
|
1077
|
-
* `title` (DX-2816) rides here, not on a header, because the AGENT — not this
|
|
1078
|
-
* server — is the one thing able to read Claude's own session title (via a
|
|
1079
|
-
* Desktop-side `get_session` tool no MCP server subprocess can call). Sent
|
|
1080
|
-
* only when the caller supplied one; the server leaves an unset title alone.
|
|
1081
|
-
*/
|
|
1082
|
-
export async function planConnect(client, args) {
|
|
1083
|
-
return client.request({
|
|
1084
|
-
method: "POST",
|
|
1085
|
-
path: "/me/plan",
|
|
1086
|
-
basePath: PLAN_SESSIONS_BASE_PATH,
|
|
1087
|
-
body: {
|
|
1088
|
-
plan_id: args.plan_id,
|
|
1089
|
-
...(args.title === undefined ? {} : { title: args.title }),
|
|
1090
|
-
},
|
|
1091
|
-
});
|
|
1092
|
-
}
|
|
1093
|
-
/** Add a goal, rule or caveat to the connected plan. `context` is sent only when given. */
|
|
1094
|
-
export async function planAddRecord(client, args) {
|
|
1095
|
-
return client.request({
|
|
1096
|
-
method: "POST",
|
|
1097
|
-
path: "/mine/records",
|
|
1098
|
-
basePath: PLANS_BASE_PATH,
|
|
1099
|
-
body: {
|
|
1100
|
-
kind: args.kind,
|
|
1101
|
-
body: args.body,
|
|
1102
|
-
...(args.context === undefined ? {} : { context: args.context }),
|
|
1103
|
-
},
|
|
1104
|
-
});
|
|
1105
|
-
}
|
|
1106
|
-
/** Add an existing card to the connected plan. */
|
|
1107
|
-
export async function planAddCard(client, args) {
|
|
1108
|
-
return client.request({
|
|
1109
|
-
method: "POST",
|
|
1110
|
-
path: "/mine/cards",
|
|
1111
|
-
basePath: PLANS_BASE_PATH,
|
|
1112
|
-
body: { card_id: args.card_id },
|
|
1113
|
-
});
|
|
1114
|
-
}
|
|
1115
|
-
/**
|
|
1116
|
-
* Remove a card from a plan, via `DELETE /api/plans/:plan_id/cards/:card_id`
|
|
1117
|
-
* — the existing route `plan_add_card`'s sibling. TAKES AN EXPLICIT
|
|
1118
|
-
* `plan_id`, unlike `plan_add_card`: this is a membership toggle on a named
|
|
1119
|
-
* plan (like `issue_dependency` add/remove), not a write scoped to the
|
|
1120
|
-
* caller's connected session, so there is no `/mine` form of it. Idempotent
|
|
1121
|
-
* — removing a card that was never a member is a no-op, not an error.
|
|
1122
|
-
* Returns the plan's remaining member list.
|
|
1123
|
-
*/
|
|
1124
|
-
export async function planRemoveCard(client, args) {
|
|
1125
|
-
return client.request({
|
|
1126
|
-
method: "DELETE",
|
|
1127
|
-
path: `/${args.plan_id}/cards/${encodeURIComponent(args.card_id)}`,
|
|
1128
|
-
basePath: PLANS_BASE_PATH,
|
|
1129
|
-
});
|
|
1130
|
-
}
|
|
1131
|
-
/**
|
|
1132
|
-
* Rename a plan, via `PATCH /api/plans/:plan_id` `{name}`. TAKES AN
|
|
1133
|
-
* EXPLICIT `plan_id` for the same reason `plan_remove_card` does — renaming
|
|
1134
|
-
* a plan is not a write scoped to the caller's connected session, it names
|
|
1135
|
-
* the plan directly. The new name shows up immediately in `plan_list`.
|
|
1136
|
-
*/
|
|
1137
|
-
export async function planRename(client, args) {
|
|
1138
|
-
return client.request({
|
|
1139
|
-
method: "PATCH",
|
|
1140
|
-
path: `/${args.plan_id}`,
|
|
1141
|
-
basePath: PLANS_BASE_PATH,
|
|
1142
|
-
body: { name: args.name },
|
|
1143
|
-
});
|
|
1144
|
-
}
|
|
1145
|
-
/**
|
|
1146
|
-
* Read ONE section of the plan this session is connected to, via
|
|
1147
|
-
* `GET /api/plans/mine/architecture/sections/:sid`. TAKES NO PLAN ID: the
|
|
1148
|
-
* plan is resolved from your connected session, same as `plan_add_record`.
|
|
1149
|
-
* Useful for a targeted read (skip pulling the whole plan via `plan_get`)
|
|
1150
|
-
* and for conflict recovery after a 409 `stale_plan_architecture_section` —
|
|
1151
|
-
* though that refusal already carries `currentTitle`/`currentContent`, so a
|
|
1152
|
-
* second read is rarely needed for that specific case. Not connected →
|
|
1153
|
-
* `{error: "session_not_connected"}`. Unknown/foreign section id → 404.
|
|
1154
|
-
*/
|
|
1155
|
-
export async function planGetArchitectureSection(client, args) {
|
|
1156
|
-
return client.request({
|
|
1157
|
-
method: "GET",
|
|
1158
|
-
path: `/mine/architecture/sections/${args.section_id}`,
|
|
1159
|
-
basePath: PLANS_BASE_PATH,
|
|
1160
|
-
});
|
|
1161
|
-
}
|
|
1162
|
-
/**
|
|
1163
|
-
* Append a section to the connected plan's architecture, via
|
|
1164
|
-
* `POST /api/plans/mine/architecture/sections`. TAKES NO PLAN ID, same as
|
|
1165
|
-
* `plan_add_record`. The new section sorts after every existing live
|
|
1166
|
-
* section — see `plan_reorder_architecture_section` to move it.
|
|
1167
|
-
*/
|
|
1168
|
-
export async function planAddArchitectureSection(client, args) {
|
|
1169
|
-
return client.request({
|
|
1170
|
-
method: "POST",
|
|
1171
|
-
path: "/mine/architecture/sections",
|
|
1172
|
-
basePath: PLANS_BASE_PATH,
|
|
1173
|
-
body: { title: args.title, content: args.content },
|
|
1174
|
-
});
|
|
1175
|
-
}
|
|
1176
|
-
/**
|
|
1177
|
-
* Edit a section's title and/or content, via
|
|
1178
|
-
* `PATCH /api/plans/mine/architecture/sections/:sid`. Both `title` and
|
|
1179
|
-
* `content` are OPTIONAL — send only whichever changed. `base_hash` MUST be
|
|
1180
|
-
* the section's `contentHash` from the immediately-prior `plan_get`/
|
|
1181
|
-
* `plan_get_architecture_section`; the server compares it against the row's
|
|
1182
|
-
* current hash and, on a mismatch, refuses the write ENTIRELY and fails loud
|
|
1183
|
-
* with `{ok: false, status: 409, body: {error:
|
|
1184
|
-
* "stale_plan_architecture_section", currentHash, currentTitle,
|
|
1185
|
-
* currentContent}}` rather than overwriting whoever wrote in between —
|
|
1186
|
-
* `currentTitle`/`currentContent` ride the SAME refusal so you can merge and
|
|
1187
|
-
* retry in ONE round trip. TAKES NO PLAN ID: the plan is resolved from your
|
|
1188
|
-
* connected session, same as `plan_add_record`.
|
|
1189
|
-
*/
|
|
1190
|
-
export async function planUpdateArchitectureSection(client, args) {
|
|
1191
|
-
return client.request({
|
|
1192
|
-
method: "PATCH",
|
|
1193
|
-
path: `/mine/architecture/sections/${args.section_id}`,
|
|
1194
|
-
basePath: PLANS_BASE_PATH,
|
|
1195
|
-
body: {
|
|
1196
|
-
base_hash: args.base_hash,
|
|
1197
|
-
...(args.title === undefined ? {} : { title: args.title }),
|
|
1198
|
-
...(args.content === undefined ? {} : { content: args.content }),
|
|
1199
|
-
},
|
|
1200
|
-
});
|
|
1201
|
-
}
|
|
1202
|
-
/**
|
|
1203
|
-
* Soft-delete a section of the connected plan's architecture, via
|
|
1204
|
-
* `DELETE /api/plans/mine/architecture/sections/:sid`. `base_hash` MUST be
|
|
1205
|
-
* the section's `contentHash` from the immediately-prior read; a stale hash
|
|
1206
|
-
* refuses the delete ENTIRELY (nothing is removed) with the same
|
|
1207
|
-
* `{ok: false, status: 409, body: {error: "stale_plan_architecture_section",
|
|
1208
|
-
* currentHash, currentTitle, currentContent}}` shape
|
|
1209
|
-
* `plan_update_architecture_section` uses — on a mismatch, re-fetch and
|
|
1210
|
-
* confirm this is still the section you meant to remove before retrying,
|
|
1211
|
-
* never blindly re-send with the fresh hash. TAKES NO PLAN ID. Unknown/
|
|
1212
|
-
* foreign/already-deleted section id → 404.
|
|
1213
|
-
*/
|
|
1214
|
-
export async function planDeleteArchitectureSection(client, args) {
|
|
1215
|
-
return client.request({
|
|
1216
|
-
method: "DELETE",
|
|
1217
|
-
path: `/mine/architecture/sections/${args.section_id}`,
|
|
1218
|
-
basePath: PLANS_BASE_PATH,
|
|
1219
|
-
body: { base_hash: args.base_hash },
|
|
1220
|
-
});
|
|
1221
|
-
}
|
|
1222
|
-
/**
|
|
1223
|
-
* Reassign the connected plan's section display order, via
|
|
1224
|
-
* `PUT /api/plans/mine/architecture/sections/reorder`. UNGUARDED by content
|
|
1225
|
-
* hash, by design: moving a section never changes its (or any other
|
|
1226
|
-
* section's) `contentHash`. `order` must name exactly the plan's current
|
|
1227
|
-
* live section ids, each once — a partial or foreign list is refused with a
|
|
1228
|
-
* 400 rather than silently reordering a subset or dropping a section from
|
|
1229
|
-
* view. TAKES NO PLAN ID.
|
|
1230
|
-
*/
|
|
1231
|
-
export async function planReorderArchitectureSection(client, args) {
|
|
1232
|
-
return client.request({
|
|
1233
|
-
method: "PUT",
|
|
1234
|
-
path: "/mine/architecture/sections/reorder",
|
|
1235
|
-
basePath: PLANS_BASE_PATH,
|
|
1236
|
-
body: { order: args.order },
|
|
1237
|
-
});
|
|
1238
|
-
}
|
|
1239
|
-
/**
|
|
1240
|
-
* Create a new, empty plan via `POST /api/plans`. GLOBAL — a plan is not
|
|
1241
|
-
* board-scoped, so this takes no board and creates no membership; the caller
|
|
1242
|
-
* still owns zero cards, zero records and zero architecture sections until
|
|
1243
|
-
* it adds them. This does NOT connect any session to the new plan — call
|
|
1244
|
-
* `plan_connect` separately (mirroring how creating a card does not add it to
|
|
1245
|
-
* a plan; these are two deliberately separate steps, same as everywhere else
|
|
1246
|
-
* in this tool surface).
|
|
1247
|
-
*/
|
|
1248
|
-
export async function planCreate(client, args) {
|
|
1249
|
-
return client.request({
|
|
1250
|
-
method: "POST",
|
|
1251
|
-
path: "",
|
|
1252
|
-
basePath: PLANS_BASE_PATH,
|
|
1253
|
-
body: { name: args.name },
|
|
1254
|
-
});
|
|
1255
|
-
}
|
|
1256
|
-
/**
|
|
1257
|
-
* Read ONE goal/rule/caveat of the plan this session is connected to, via
|
|
1258
|
-
* `GET /api/plans/mine/records/:rid`. TAKES NO PLAN ID: the plan is resolved
|
|
1259
|
-
* from your connected session, same as `plan_add_record`. Useful both for an
|
|
1260
|
-
* ordinary targeted read (skip pulling the whole plan via `plan_get` just to
|
|
1261
|
-
* see one record) and for conflict recovery after a 409 `stale_plan_record`
|
|
1262
|
-
* — though that refusal already carries `currentBody`, so a second read is
|
|
1263
|
-
* rarely needed for that specific case. Not connected →
|
|
1264
|
-
* `{error: "session_not_connected"}`. Unknown/foreign record id → 404.
|
|
1265
|
-
*/
|
|
1266
|
-
export async function planGetRecord(client, args) {
|
|
1267
|
-
return client.request({
|
|
1268
|
-
method: "GET",
|
|
1269
|
-
path: `/mine/records/${args.record_id}`,
|
|
1270
|
-
basePath: PLANS_BASE_PATH,
|
|
1271
|
-
});
|
|
1272
|
-
}
|
|
1273
|
-
/**
|
|
1274
|
-
* Edit a goal/rule/caveat of the plan this session is connected to, via
|
|
1275
|
-
* `PATCH /api/plans/mine/records/:rid`. The reference (`G-1`, `R-4`,
|
|
1276
|
-
* `CAV-12`) NEVER moves — only the text changes. `content_hash` MUST be the
|
|
1277
|
-
* record's `contentHash` from the immediately-prior `plan_get`/
|
|
1278
|
-
* `plan_get_record`; the server compares it against the row's current hash
|
|
1279
|
-
* and, on a mismatch, refuses the write ENTIRELY and fails loud with
|
|
1280
|
-
* `{ok: false, status: 409, body: {error: "stale_plan_record", currentHash,
|
|
1281
|
-
* currentBody, currentContext}}` rather than overwriting whoever wrote in
|
|
1282
|
-
* between. DX-2734: the hash covers body AND context, and `context` is sent
|
|
1283
|
-
* only when given (omitted keeps the stored context, `null` clears it).
|
|
1284
|
-
* `currentBody` rides the SAME refusal — the same richer shape
|
|
1285
|
-
* `stale_plan_architecture_section` carries for a section — so you can
|
|
1286
|
-
* merge and retry in ONE round trip without a second `plan_get_record` call.
|
|
1287
|
-
* TAKES NO PLAN ID: the plan is resolved from your connected session, same as
|
|
1288
|
-
* `plan_add_record`. Not connected → `{error: "session_not_connected"}`.
|
|
1289
|
-
*/
|
|
1290
|
-
export async function planUpdateRecord(client, args) {
|
|
1291
|
-
return client.request({
|
|
1292
|
-
method: "PATCH",
|
|
1293
|
-
path: `/mine/records/${args.record_id}`,
|
|
1294
|
-
basePath: PLANS_BASE_PATH,
|
|
1295
|
-
body: {
|
|
1296
|
-
body: args.body,
|
|
1297
|
-
content_hash: args.content_hash,
|
|
1298
|
-
...(args.context === undefined ? {} : { context: args.context }),
|
|
1299
|
-
},
|
|
1300
|
-
});
|
|
1301
|
-
}
|
|
1302
|
-
/**
|
|
1303
|
-
* Soft-delete a goal/rule/caveat of the plan this session is connected to,
|
|
1304
|
-
* via `DELETE /api/plans/mine/records/:rid`. Its reference (`G-1`, `R-4`,
|
|
1305
|
-
* `CAV-12`) is retired PERMANENTLY and never reused by a later record of the
|
|
1306
|
-
* same kind. `content_hash` MUST be the record's `contentHash` from the
|
|
1307
|
-
* immediately-prior `plan_get`/`plan_get_record`; a stale hash refuses the
|
|
1308
|
-
* delete ENTIRELY (nothing is removed) with the same
|
|
1309
|
-
* `{ok: false, status: 409, body: {error: "stale_plan_record", currentHash,
|
|
1310
|
-
* currentBody}}` shape `plan_update_record` uses. TAKES NO PLAN ID: the plan
|
|
1311
|
-
* is resolved from your connected session, same as `plan_add_record`. Not
|
|
1312
|
-
* connected → `{error: "session_not_connected"}`. Unknown/foreign/already-
|
|
1313
|
-
* deleted record id → 404.
|
|
1314
|
-
*/
|
|
1315
|
-
export async function planDeleteRecord(client, args) {
|
|
1316
|
-
return client.request({
|
|
1317
|
-
method: "DELETE",
|
|
1318
|
-
path: `/mine/records/${args.record_id}`,
|
|
1319
|
-
basePath: PLANS_BASE_PATH,
|
|
1320
|
-
body: { content_hash: args.content_hash },
|
|
1321
|
-
});
|
|
1322
|
-
}
|
|
1323
|
-
/**
|
|
1324
|
-
* Write a milestone note to a plan, via `POST /api/plans/:plan_id/notes`.
|
|
1325
|
-
* TAKES AN EXPLICIT `plan_id`, unlike `plan_add_record`/`plan_add_architecture_section`
|
|
1326
|
-
* — mirrors `plan_add_card`'s sibling `plan_remove_card`/`plan_rename`: a
|
|
1327
|
-
* dispatched worker has no plan connection when it finishes a card, and a
|
|
1328
|
-
* card can sit on several plans, so the writer names which one the note
|
|
1329
|
-
* belongs to. A note is a MILESTONE, not a log — write one when a card (or a
|
|
1330
|
-
* related group of cards) finishes, an important decision lands, or a
|
|
1331
|
-
* goal/rule/caveat/architecture section changes meaningfully; routine step
|
|
1332
|
-
* progress stays a card comment. Links resolve on read into what they point
|
|
1333
|
-
* at (a card's title, a record's ref + body, a section's title) — an unknown
|
|
1334
|
-
* card id, an unparseable or foreign record ref, or an unknown or foreign
|
|
1335
|
-
* section id is refused 400 naming exactly which one. A card link does NOT
|
|
1336
|
-
* require the card to be a member of this plan; a record/section link MUST
|
|
1337
|
-
* belong to THIS plan. `author` is stamped server-side from your identity,
|
|
1338
|
-
* never sent by you. Unknown plan → 404. Returns the new note plus the
|
|
1339
|
-
* plan's latest notes page.
|
|
1340
|
-
*/
|
|
1341
|
-
export async function planAddNote(client, args) {
|
|
1342
|
-
return client.request({
|
|
1343
|
-
method: "POST",
|
|
1344
|
-
path: `/${args.plan_id}/notes`,
|
|
1345
|
-
basePath: PLANS_BASE_PATH,
|
|
1346
|
-
body: {
|
|
1347
|
-
title: args.title,
|
|
1348
|
-
body: args.body,
|
|
1349
|
-
...(args.card_ids === undefined ? {} : { card_ids: args.card_ids }),
|
|
1350
|
-
...(args.record_refs === undefined ? {} : { record_refs: args.record_refs }),
|
|
1351
|
-
...(args.section_ids === undefined ? {} : { section_ids: args.section_ids }),
|
|
1352
|
-
},
|
|
1353
|
-
});
|
|
1354
|
-
}
|
|
1355
|
-
/**
|
|
1356
|
-
* Edit a plan note, via `PATCH /api/plans/:plan_id/notes/:note_id`.
|
|
1357
|
-
* `content_hash` MUST be the note's `contentHash` from your last read; on a
|
|
1358
|
-
* mismatch nothing is written and you get `{error: "stale_plan_note",
|
|
1359
|
-
* currentHash, currentTitle, currentBody, currentLinks}` — merge into those
|
|
1360
|
-
* and retry with `content_hash: currentHash`, never blindly. `title`/`body`
|
|
1361
|
-
* are each optional and keep their stored value when omitted. The LINK
|
|
1362
|
-
* fields are all-or-nothing as a GROUP: omit all three to keep the stored
|
|
1363
|
-
* link set untouched; send ANY one of them to REPLACE THE WHOLE SET (never a
|
|
1364
|
-
* per-link add/remove — the set is the unit of change). The hash covers
|
|
1365
|
-
* title, body AND the link set, so a stale read of any of the three is
|
|
1366
|
-
* refused. TAKES AN EXPLICIT `plan_id`, same reason as `plan_add_note`.
|
|
1367
|
-
* Unknown plan or note id → 404. Returns the edited note plus the plan's
|
|
1368
|
-
* latest notes page.
|
|
1369
|
-
*/
|
|
1370
|
-
export async function planUpdateNote(client, args) {
|
|
1371
|
-
return client.request({
|
|
1372
|
-
method: "PATCH",
|
|
1373
|
-
path: `/${args.plan_id}/notes/${args.note_id}`,
|
|
1374
|
-
basePath: PLANS_BASE_PATH,
|
|
1375
|
-
body: {
|
|
1376
|
-
content_hash: args.content_hash,
|
|
1377
|
-
...(args.title === undefined ? {} : { title: args.title }),
|
|
1378
|
-
...(args.body === undefined ? {} : { body: args.body }),
|
|
1379
|
-
...(args.card_ids === undefined ? {} : { card_ids: args.card_ids }),
|
|
1380
|
-
...(args.record_refs === undefined ? {} : { record_refs: args.record_refs }),
|
|
1381
|
-
...(args.section_ids === undefined ? {} : { section_ids: args.section_ids }),
|
|
1382
|
-
},
|
|
1383
|
-
});
|
|
1384
|
-
}
|
|
1385
|
-
/**
|
|
1386
|
-
* Soft-delete a plan note, via `DELETE /api/plans/:plan_id/notes/:note_id`.
|
|
1387
|
-
* `content_hash` MUST be the note's `contentHash` from your last read; a
|
|
1388
|
-
* stale hash deletes nothing and returns the same `{error:
|
|
1389
|
-
* "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}`
|
|
1390
|
-
* shape `plan_update_note` uses. TAKES AN EXPLICIT `plan_id`, same reason as
|
|
1391
|
-
* `plan_add_note`. Unknown plan, unknown note, or an already-deleted note →
|
|
1392
|
-
* 404. Returns the plan's remaining latest notes page.
|
|
1393
|
-
*/
|
|
1394
|
-
export async function planDeleteNote(client, args) {
|
|
1395
|
-
return client.request({
|
|
1396
|
-
method: "DELETE",
|
|
1397
|
-
path: `/${args.plan_id}/notes/${args.note_id}`,
|
|
1398
|
-
basePath: PLANS_BASE_PATH,
|
|
1399
|
-
body: { content_hash: args.content_hash },
|
|
1400
|
-
});
|
|
1401
|
-
}
|
|
1402
|
-
// ---------------- failure_category_list / _create / _update (DX-2792) ----------------
|
|
1403
|
-
/**
|
|
1404
|
-
* DX-2792 (Failure evaluation 3/4) — wraps `src/dashboard/failure-categories-routes.ts`,
|
|
1405
|
-
* the DX-2791 (Failure evaluation 2/4) category registry's REST API. INSTALL-
|
|
1406
|
-
* GLOBAL, not board-scoped: `failure_categories` carries no `board_id`
|
|
1407
|
-
* column (every category applies across the whole install), so — unlike
|
|
1408
|
-
* every `/api/issues/*`-backed tool above — these three never send a
|
|
1409
|
-
* `board` query param and the `board` override field is simply absent from
|
|
1410
|
-
* their schemas (mirrors the `plan_*` family's own board-less rationale in
|
|
1411
|
-
* `index.ts`'s `boardField` comment, for the same underlying reason: nothing
|
|
1412
|
-
* server-side would read it).
|
|
1413
|
-
*/
|
|
1414
|
-
const FAILURE_CATEGORIES_BASE_PATH = "/api/failure-categories";
|
|
1415
|
-
/**
|
|
1416
|
-
* List every failure category with its live matched-occurrence count and
|
|
1417
|
-
* last-seen time, via `GET /api/failure-categories`. Returns
|
|
1418
|
-
* `{categories: [{id, name, description, matchers, ignore, ignoreReason,
|
|
1419
|
-
* expectedRate, matchedCount, lastSeenMs, ...}]}`. A fresh install returns
|
|
1420
|
-
* `{categories: []}`.
|
|
1421
|
-
*/
|
|
1422
|
-
export async function failureCategoryList(client) {
|
|
1423
|
-
return client.request({
|
|
1424
|
-
method: "GET",
|
|
1425
|
-
path: "",
|
|
1426
|
-
basePath: FAILURE_CATEGORIES_BASE_PATH,
|
|
1427
|
-
});
|
|
1428
|
-
}
|
|
1429
|
-
/**
|
|
1430
|
-
* Create a new failure category via `POST /api/failure-categories`. At
|
|
1431
|
-
* least one matcher, each with at least one of `sourceKind`/`tool`/
|
|
1432
|
-
* `regexPattern` set, is required (400 otherwise). `ignore: true` REQUIRES a
|
|
1433
|
-
* non-empty `ignoreReason` (400 otherwise). A matcher set that would overlap
|
|
1434
|
-
* an EXISTING category's matchers is refused 400 naming the conflicting
|
|
1435
|
-
* category — expand that category instead of creating a near-duplicate. On
|
|
1436
|
-
* success, the dashboard re-matches every existing uncategorized occurrence
|
|
1437
|
-
* against the new category before responding, so `matchedCount` in the
|
|
1438
|
-
* response already reflects any newly-covered signatures.
|
|
1439
|
-
*/
|
|
1440
|
-
export async function failureCategoryCreate(client, args) {
|
|
1441
|
-
return client.request({
|
|
1442
|
-
method: "POST",
|
|
1443
|
-
path: "",
|
|
1444
|
-
basePath: FAILURE_CATEGORIES_BASE_PATH,
|
|
1445
|
-
body: {
|
|
1446
|
-
name: args.name,
|
|
1447
|
-
description: args.description ?? "",
|
|
1448
|
-
matchers: args.matchers,
|
|
1449
|
-
ignore: args.ignore ?? false,
|
|
1450
|
-
ignoreReason: args.ignoreReason ?? null,
|
|
1451
|
-
expectedRate: args.expectedRate ?? null,
|
|
1452
|
-
},
|
|
1453
|
-
});
|
|
1454
|
-
}
|
|
1455
|
-
/**
|
|
1456
|
-
* Patch an existing failure category via `PATCH /api/failure-categories/:id`
|
|
1457
|
-
* — the tool for BOTH "expand an existing category's matchers" (send the
|
|
1458
|
-
* full replacement `matchers` array) and "mark a category ignored" (send
|
|
1459
|
-
* `ignore: true` + a non-empty `ignoreReason`). At least one field is
|
|
1460
|
-
* required (400 otherwise). Same overlap refusal as create (excluding this
|
|
1461
|
-
* category's own prior matchers). On success, re-matches every
|
|
1462
|
-
* uncategorized occurrence against the updated matcher set before
|
|
1463
|
-
* responding.
|
|
1464
|
-
*/
|
|
1465
|
-
export async function failureCategoryUpdate(client, args) {
|
|
1466
|
-
const { id, ...patch } = args;
|
|
1467
|
-
return client.request({
|
|
1468
|
-
method: "PATCH",
|
|
1469
|
-
path: `/${id}`,
|
|
1470
|
-
basePath: FAILURE_CATEGORIES_BASE_PATH,
|
|
1471
|
-
body: patch,
|
|
1472
|
-
});
|
|
1473
|
-
}
|
|
1474
|
-
// ---------------- dispatch_transcript_search (DX-3221) ----------------
|
|
1475
|
-
/**
|
|
1476
|
-
* DX-3221 — search or tail ANOTHER dispatch's stored JSONL transcript
|
|
1477
|
-
* without ever touching the filesystem. Wraps the existing durable
|
|
1478
|
-
* `GET /api/dispatches/:id/logs` route (DX-1682, reading the `dispatch_logs`
|
|
1479
|
-
* sink every worker already streams its raw JSONL lines into per DX-1484 —
|
|
1480
|
-
* no dashboard-side change needed, that data is already captured today for
|
|
1481
|
-
* every worker dispatch).
|
|
1482
|
-
*
|
|
1483
|
-
* Why this exists instead of a filesystem path: the transcript lives in
|
|
1484
|
-
* ANOTHER dispatch's storage, not this one's container, so there is no local
|
|
1485
|
-
* path to point Read/Grep at — an ordinary HTTP call authenticated the same
|
|
1486
|
-
* way every other `mcp__danx-dashboard__*` tool already is, is how a
|
|
1487
|
-
* dispatch reaches it at all.
|
|
1488
|
-
*
|
|
1489
|
-
* Why this ALSO fixes the underlying complaint `Read` can't: `Read`
|
|
1490
|
-
* paginates by LINE, and a persisted JSONL entry can itself be one giant
|
|
1491
|
-
* line (a large MCP tool-result payload, escaped-newline JSON) that exceeds
|
|
1492
|
-
* the 25000-token cap with no way to sub-page inside it (`offset`/`limit`
|
|
1493
|
-
* are line-granular). This handler does the string/regex search ITSELF,
|
|
1494
|
-
* server-side of the model (inside this Node process), and only ever hands
|
|
1495
|
-
* back a bounded excerpt around a match — never the whole line — so a
|
|
1496
|
-
* single oversized line is no longer un-searchable.
|
|
1497
|
-
*/
|
|
1498
|
-
const DISPATCHES_BASE_PATH = "/api/dispatches";
|
|
1499
|
-
/** Tail lines returned when no `pattern` is given. */
|
|
1500
|
-
const DEFAULT_TAIL_LINES = 20;
|
|
1501
|
-
/** Hard ceiling on `tail` — keeps a mistaken huge request bounded. */
|
|
1502
|
-
const MAX_TAIL_LINES = 200;
|
|
1503
|
-
/** Characters of context returned around (or up to, for tail) each line. */
|
|
1504
|
-
const DEFAULT_CONTEXT_CHARS = 1000;
|
|
1505
|
-
/** Hard ceiling on `contextChars` — this is what keeps a single giant line from blowing the model's context the way `Read` does today. */
|
|
1506
|
-
const MAX_CONTEXT_CHARS = 4000;
|
|
1507
|
-
/** Matches returned before search stops scanning further lines. */
|
|
1508
|
-
const DEFAULT_MAX_MATCHES = 10;
|
|
1509
|
-
/** Hard ceiling on `maxMatches`. */
|
|
1510
|
-
const MAX_MATCHES_CEILING = 50;
|
|
1511
|
-
function clampPositiveInt(value, fallback, ceiling) {
|
|
1512
|
-
const n = value ?? fallback;
|
|
1513
|
-
if (!Number.isFinite(n) || n <= 0)
|
|
1514
|
-
return fallback;
|
|
1515
|
-
return Math.min(Math.floor(n), ceiling);
|
|
1516
|
-
}
|
|
1517
|
-
/**
|
|
1518
|
-
* `GET /api/dispatches/:id/logs` (DX-1682) → search or tail, in-process.
|
|
1519
|
-
* Proxies the SAME durable sink `handleGetDispatchLogs` already serves —
|
|
1520
|
-
* this tool adds no new dashboard route, only client-side windowing.
|
|
1521
|
-
*/
|
|
1522
|
-
export async function dispatchTranscriptSearch(client, args) {
|
|
1523
|
-
const hasPattern = args.pattern !== undefined && args.pattern !== "";
|
|
1524
|
-
// Validate the regex BEFORE the network call — a malformed `pattern` is a
|
|
1525
|
-
// caller input error, not a reason to spend an HTTP round-trip first.
|
|
1526
|
-
let re = null;
|
|
1527
|
-
if (hasPattern) {
|
|
1528
|
-
try {
|
|
1529
|
-
re = new RegExp(args.pattern, "gi");
|
|
1530
|
-
}
|
|
1531
|
-
catch (err) {
|
|
1532
|
-
return {
|
|
1533
|
-
ok: false,
|
|
1534
|
-
status: 400,
|
|
1535
|
-
body: {
|
|
1536
|
-
error: `dispatch_transcript_search: invalid \`pattern\` regex: ${err instanceof Error ? err.message : String(err)}`,
|
|
1537
|
-
},
|
|
1538
|
-
};
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1541
|
-
const logsResult = await client.request({
|
|
1542
|
-
method: "GET",
|
|
1543
|
-
path: `/${encodeURIComponent(args.dispatchId)}/logs`,
|
|
1544
|
-
basePath: DISPATCHES_BASE_PATH,
|
|
1545
|
-
});
|
|
1546
|
-
if (!logsResult.ok) {
|
|
1547
|
-
return logsResult;
|
|
1548
|
-
}
|
|
1549
|
-
const body = logsResult.body;
|
|
1550
|
-
const lines = body?.lines ?? [];
|
|
1551
|
-
const contextChars = clampPositiveInt(args.contextChars, DEFAULT_CONTEXT_CHARS, MAX_CONTEXT_CHARS);
|
|
1552
|
-
if (re) {
|
|
1553
|
-
const maxMatches = clampPositiveInt(args.maxMatches, DEFAULT_MAX_MATCHES, MAX_MATCHES_CEILING);
|
|
1554
|
-
const results = [];
|
|
1555
|
-
for (const { ordinal, line } of lines) {
|
|
1556
|
-
re.lastIndex = 0;
|
|
1557
|
-
const match = re.exec(line);
|
|
1558
|
-
if (!match)
|
|
1559
|
-
continue;
|
|
1560
|
-
const half = Math.floor(contextChars / 2);
|
|
1561
|
-
const start = Math.max(0, match.index - half);
|
|
1562
|
-
const end = Math.min(line.length, match.index + match[0].length + half);
|
|
1563
|
-
const truncated = start > 0 || end < line.length;
|
|
1564
|
-
results.push({
|
|
1565
|
-
ordinal,
|
|
1566
|
-
excerpt: `${start > 0 ? "…" : ""}${line.slice(start, end)}${end < line.length ? "…" : ""}`,
|
|
1567
|
-
truncated,
|
|
1568
|
-
});
|
|
1569
|
-
if (results.length >= maxMatches)
|
|
1570
|
-
break;
|
|
1571
|
-
}
|
|
1572
|
-
return {
|
|
1573
|
-
ok: true,
|
|
1574
|
-
status: 200,
|
|
1575
|
-
body: {
|
|
1576
|
-
dispatch_id: args.dispatchId,
|
|
1577
|
-
pattern: args.pattern,
|
|
1578
|
-
total_lines: lines.length,
|
|
1579
|
-
matched_lines: results.length,
|
|
1580
|
-
results,
|
|
1581
|
-
},
|
|
1582
|
-
};
|
|
1583
|
-
}
|
|
1584
|
-
const tail = clampPositiveInt(args.tail, DEFAULT_TAIL_LINES, MAX_TAIL_LINES);
|
|
1585
|
-
const results = lines.slice(-tail).map(({ ordinal, line }) => {
|
|
1586
|
-
const truncated = line.length > contextChars;
|
|
1587
|
-
return {
|
|
1588
|
-
ordinal,
|
|
1589
|
-
excerpt: truncated ? `${line.slice(0, contextChars)}…` : line,
|
|
1590
|
-
truncated,
|
|
1591
|
-
};
|
|
1592
|
-
});
|
|
1593
|
-
return {
|
|
1594
|
-
ok: true,
|
|
1595
|
-
status: 200,
|
|
1596
|
-
body: {
|
|
1597
|
-
dispatch_id: args.dispatchId,
|
|
1598
|
-
total_lines: lines.length,
|
|
1599
|
-
returned_lines: results.length,
|
|
1600
|
-
results,
|
|
1601
|
-
},
|
|
1602
|
-
};
|
|
1603
|
-
}
|
|
1604
|
-
// ---------------- resource_fields (DX-3426) ----------------
|
|
1605
|
-
const RESOURCES_BASE_PATH = "/api/resources";
|
|
1606
|
-
/**
|
|
1607
|
-
* `GET /api/resources/:resource/fields` — what a field TREE (`issue_get`'s
|
|
1608
|
-
* `fields`, and any later tool built on the same shape) may name for ONE
|
|
1609
|
-
* resource: `{resource, description, always, hashes, fields, relations}`.
|
|
1610
|
-
* Install-global (no `board` — mirrors `failure_category_list`'s own
|
|
1611
|
-
* board-less rationale just above: nothing server-side would read one).
|
|
1612
|
-
* Unknown resource → 404 `unknown_resource`.
|
|
1613
|
-
*/
|
|
1614
|
-
export async function resourceFields(client, args) {
|
|
1615
|
-
return client.request({
|
|
1616
|
-
method: "GET",
|
|
1617
|
-
path: `/${encodeURIComponent(args.resource)}/fields`,
|
|
1618
|
-
basePath: RESOURCES_BASE_PATH,
|
|
1619
|
-
});
|
|
1620
|
-
}
|