@thehammer/danx-dashboard-mcp 0.1.140 → 0.1.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/handlers.js +90 -0
- package/dist/index.js +137 -130
- package/package.json +42 -42
package/dist/handlers.js
CHANGED
|
@@ -336,6 +336,39 @@ function argCheckers(tool, mode) {
|
|
|
336
336
|
},
|
|
337
337
|
};
|
|
338
338
|
}
|
|
339
|
+
/**
|
|
340
|
+
* Every action-dispatched tool's args carry these three regardless of
|
|
341
|
+
* `action` — never action-specific, so every per-action allow-list passed to
|
|
342
|
+
* `refuseInapplicableFields` is unioned with this rather than repeating it.
|
|
343
|
+
*/
|
|
344
|
+
const STRUCTURAL_ACTION_FIELDS = ["id", "action", "board"];
|
|
345
|
+
/**
|
|
346
|
+
* DX-3310 (code-review nit) — a field the caller sent that means nothing for
|
|
347
|
+
* the chosen `action` used to be silently ignored: `issue_solution`'s `add`
|
|
348
|
+
* built its `content` object from `title`/`body`/`pro`/`con`/`recommended`
|
|
349
|
+
* regardless of action, then simply never read it on `remove`/`add_step`/etc,
|
|
350
|
+
* and `issue_problem`'s `remove` never looked at `statement`/`context`/
|
|
351
|
+
* `solutions` at all. Either way the caller got a 200 with the field quietly
|
|
352
|
+
* discarded — no different from a typo. Refused loud instead, mirroring
|
|
353
|
+
* `argCheckers`' own wording: every ACTUALLY-SUPPLIED (`!== undefined`) key
|
|
354
|
+
* outside the action's own allow-list (plus `STRUCTURAL_ACTION_FIELDS`,
|
|
355
|
+
* always allowed) is named in one error, so a caller fixes every offending
|
|
356
|
+
* field in one round trip rather than one at a time.
|
|
357
|
+
*/
|
|
358
|
+
// A9 (code-review fix) — takes `object` rather than `Record<string, unknown>`
|
|
359
|
+
// so every caller (each with its own specific `*Args` interface, no index
|
|
360
|
+
// signature) passes `args` directly, with no `as unknown as Record<...>`
|
|
361
|
+
// bridge cast at the call site. `Object.entries` reads the key/value pairs
|
|
362
|
+
// without needing an index signature on the input type either.
|
|
363
|
+
function refuseInapplicableFields(tool, mode, args, allowed) {
|
|
364
|
+
const offending = Object.entries(args)
|
|
365
|
+
.filter(([key, value]) => value !== undefined && !STRUCTURAL_ACTION_FIELDS.includes(key) && !allowed.has(key))
|
|
366
|
+
.map(([key]) => key)
|
|
367
|
+
.sort();
|
|
368
|
+
if (offending.length > 0) {
|
|
369
|
+
throw new Error(`${tool} ${mode} refuses fields that do not apply to this action: ${offending.join(", ")}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
339
372
|
export async function issueComment(client, args) {
|
|
340
373
|
const idEnc = encodeURIComponent(args.id);
|
|
341
374
|
const board = args.board;
|
|
@@ -537,10 +570,25 @@ export async function issueChecklist(client, args) {
|
|
|
537
570
|
*
|
|
538
571
|
* No answer action, for the reason `issueSolution` gives.
|
|
539
572
|
*/
|
|
573
|
+
/**
|
|
574
|
+
* DX-3310 (nit) — per-action allow-list for `refuseInapplicableFields`.
|
|
575
|
+
* `id`/`action`/`board` are structural and never listed (every action takes
|
|
576
|
+
* them). `type` is listed under `edit` even though it can never actually be
|
|
577
|
+
* SENT there (see the dedicated throw below) — that keeps the generic
|
|
578
|
+
* refusal from firing first with a less specific message than the one that
|
|
579
|
+
* already names the real reason.
|
|
580
|
+
*/
|
|
581
|
+
const PROBLEM_ACTION_FIELDS = {
|
|
582
|
+
list: [],
|
|
583
|
+
add: ["statement", "context", "type", "summary", "solutions"],
|
|
584
|
+
edit: ["problem_id", "base_hash", "statement", "context", "summary", "type"],
|
|
585
|
+
remove: ["problem_id", "base_hash"],
|
|
586
|
+
};
|
|
540
587
|
export async function issueProblem(client, args) {
|
|
541
588
|
const idEnc = encodeURIComponent(args.id);
|
|
542
589
|
const board = args.board;
|
|
543
590
|
const need = argCheckers("issue_problem", `action=${args.action}`);
|
|
591
|
+
refuseInapplicableFields("issue_problem", `action=${args.action}`, args, new Set(PROBLEM_ACTION_FIELDS[args.action]));
|
|
544
592
|
switch (args.action) {
|
|
545
593
|
case "list":
|
|
546
594
|
return client.request({ method: "GET", path: `/${idEnc}/problems`, board });
|
|
@@ -621,11 +669,24 @@ export async function issueProblem(client, args) {
|
|
|
621
669
|
* question could release the very stop it set to wait for a human. The operator
|
|
622
670
|
* answers in the dashboard.
|
|
623
671
|
*/
|
|
672
|
+
const SOLUTION_ACTION_FIELDS = {
|
|
673
|
+
add: ["title", "body", "pro", "con", "recommended", "steps"],
|
|
674
|
+
edit: ["solution_id", "base_hash", "title", "body", "pro", "con", "recommended", "steps", "steps_base_hash"],
|
|
675
|
+
remove: ["solution_id", "base_hash"],
|
|
676
|
+
add_step: ["solution_id", "title", "description", "parent_step_id", "position"],
|
|
677
|
+
edit_step: ["solution_id", "step_id", "base_hash", "title", "description"],
|
|
678
|
+
remove_step: ["solution_id", "step_id", "base_hash"],
|
|
679
|
+
};
|
|
624
680
|
export async function issueSolution(client, args) {
|
|
625
681
|
const need = argCheckers("issue_solution", `action=${args.action}`);
|
|
626
682
|
// DX-2735: checked at runtime too, not only by the schema — a caller that skips
|
|
627
683
|
// the MCP boundary must never build `/problems/undefined/solutions`.
|
|
628
684
|
const problemId = need.id(args.problem_id, "problem_id");
|
|
685
|
+
// DX-3310 (nit) — `problem_id` is unioned in here rather than listed in
|
|
686
|
+
// every `SOLUTION_ACTION_FIELDS` entry: unlike `issue_problem` (where it
|
|
687
|
+
// only applies to edit/remove), `issue_solution` requires it for every
|
|
688
|
+
// action (checked above), so it is structural FOR THIS TOOL specifically.
|
|
689
|
+
refuseInapplicableFields("issue_solution", `action=${args.action}`, args, new Set(["problem_id", ...SOLUTION_ACTION_FIELDS[args.action]]));
|
|
629
690
|
const base = `/${encodeURIComponent(args.id)}/problems/${problemId}/solutions`;
|
|
630
691
|
const board = args.board;
|
|
631
692
|
const content = {};
|
|
@@ -806,6 +867,35 @@ export async function issueQualityGateVerdict(client, args) {
|
|
|
806
867
|
board: args.board,
|
|
807
868
|
});
|
|
808
869
|
}
|
|
870
|
+
// ---------------- quality_gate_instruction ----------------
|
|
871
|
+
const QUALITY_GATE_INSTRUCTION_BASE_PATH = "/api/quality-gates";
|
|
872
|
+
/**
|
|
873
|
+
* DX-3341 (PLN-11 R-20) — fetch a named gate's CURRENT instruction via
|
|
874
|
+
* GET /api/quality-gates/:gate/instruction. NOT card-scoped (unlike
|
|
875
|
+
* `issue_quality_gate`/`issue_quality_gate_verdict` above) — this reads the
|
|
876
|
+
* gate's DB-catalog SKILL PROSE for the calling session's board/repo, byte-
|
|
877
|
+
* identical to what a dispatched gate reviewer's clean-room materializes at
|
|
878
|
+
* `.claude/skills/<segment>/SKILL.md`. No second source: the dashboard route
|
|
879
|
+
* behind this call reuses the exact same catalog-read + materialize-transform
|
|
880
|
+
* chain a real gate dispatch's clean-room assembly uses.
|
|
881
|
+
*
|
|
882
|
+
* Use this to run a quality gate from an operator session (R-19/R-20): fetch
|
|
883
|
+
* the gate's instruction with this tool, dispatch a `danxbot:worker-*` tier
|
|
884
|
+
* chosen by effort with that instruction, review, then record the verdict
|
|
885
|
+
* via `issue_quality_gate_verdict` — never a hand-written review brief.
|
|
886
|
+
*
|
|
887
|
+
* `gate` is a registry name (same enum `issue_quality_gate` accepts). Unknown
|
|
888
|
+
* gate -> 400; a registered gate with no resolvable catalog skill artifact
|
|
889
|
+
* for this repo (a boot-seed/catalog-drift defect) -> 500.
|
|
890
|
+
*/
|
|
891
|
+
export async function qualityGateInstruction(client, args) {
|
|
892
|
+
return client.request({
|
|
893
|
+
method: "GET",
|
|
894
|
+
path: `/${encodeURIComponent(args.gate)}/instruction`,
|
|
895
|
+
basePath: QUALITY_GATE_INSTRUCTION_BASE_PATH,
|
|
896
|
+
board: args.board,
|
|
897
|
+
});
|
|
898
|
+
}
|
|
809
899
|
export async function issueRetro(client, args) {
|
|
810
900
|
const { id, board, ...body } = args;
|
|
811
901
|
return client.request({
|
package/dist/index.js
CHANGED
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
* PATCH /api/issues/:id/quality-gates/:gate
|
|
29
29
|
* - issue_retro PUT /api/issues/:id/retro
|
|
30
30
|
* - issue_attach POST /api/issues/:id/attachments (reads a local file)
|
|
31
|
+
* - quality_gate_instruction
|
|
32
|
+
* GET /api/quality-gates/:gate/instruction (DX-3341, not card-scoped)
|
|
31
33
|
* - repo_knowledge_get GET /api/repo-knowledge
|
|
32
34
|
* - repo_knowledge_set PUT /api/repo-knowledge (DX-1128, Story 2)
|
|
33
35
|
* - brief_list GET /api/brief
|
|
@@ -104,7 +106,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
104
106
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
105
107
|
import { z } from "zod";
|
|
106
108
|
import { DashboardHttpClient } from "./http-client.js";
|
|
107
|
-
import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, dispatchTranscriptSearch, planAddArchitectureSection, planAddCard, planAddNote, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteNote, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, PLAN_EVENT_KINDS, PLAN_EVENT_ORIGINS, PLAN_STATUSES, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, PLAN_GET_EVENTS_DEFAULT_LIMIT, PLAN_GET_EVENTS_MAX_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateNote, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
|
|
109
|
+
import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, dispatchTranscriptSearch, planAddArchitectureSection, planAddCard, planAddNote, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteNote, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, PLAN_EVENT_KINDS, PLAN_EVENT_ORIGINS, PLAN_STATUSES, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, PLAN_GET_EVENTS_DEFAULT_LIMIT, PLAN_GET_EVENTS_MAX_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateNote, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, qualityGateInstruction, } from "./handlers.js";
|
|
108
110
|
import { PRIORITY_TIER_WORDS } from "./priority.js";
|
|
109
111
|
function readEnvOrDie(name) {
|
|
110
112
|
const v = process.env[name];
|
|
@@ -340,24 +342,29 @@ const boardField = {
|
|
|
340
342
|
.string()
|
|
341
343
|
.min(1)
|
|
342
344
|
.optional()
|
|
343
|
-
.describe("
|
|
345
|
+
.describe("Board id `<repo>:<slug>`; omit for this dispatch's board. Unknown → 404."),
|
|
344
346
|
};
|
|
345
347
|
// The three prose fields of a card, each with ONE job. Shared by issue_create
|
|
346
348
|
// (root + phase children) and issue_edit so the guidance an agent reads is
|
|
347
349
|
// identical wherever it writes the field.
|
|
348
|
-
const TITLE_DESCRIBE = 'Short, specific label naming the domain
|
|
349
|
-
const SUMMARY_DESCRIBE = "1–3 plain-language sentences, no markdown/jargon
|
|
350
|
-
const DESCRIPTION_DESCRIBE = 'The full body ("Context"): evidence, examples, technical detail; markdown, collapsed by default.
|
|
350
|
+
const TITLE_DESCRIBE = 'Short, specific label naming the domain (e.g. "Guest checkout rejects gift-card carts"). Never generic ("Fix bug", "Follow-up").';
|
|
351
|
+
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.";
|
|
352
|
+
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.';
|
|
351
353
|
// DX-3335 — condensed from the now-deleted danxbot:comment-style skill (PLN-11
|
|
352
354
|
// R-16: fold a skill's rule into the tool description it governs rather than
|
|
353
355
|
// require a separate load). Shared by every prose-writing field below so an
|
|
354
356
|
// agent sees the same rules wherever it writes card markdown.
|
|
357
|
+
// DX-2623 — this constant (and every `.describe()` reference to it) must stay
|
|
358
|
+
// reachable: an earlier dedup pass (05af82086) deleted it outright and left
|
|
359
|
+
// the "never escape markdown" / "GFM not ASCII tables" rules unreachable from
|
|
360
|
+
// any tool in this surface until it was restored. The text below (from
|
|
361
|
+
// DX-3335) already carries both clauses — do not dedup them away again.
|
|
355
362
|
const MARKDOWN_STYLE_DESCRIBE = "Renders as markdown here. Use `##`/`###` headers, fenced code blocks with a language tag, inline code for paths/symbols/flags, `-`/`1.` lists, and GFM tables (`|---|`) for 2D data — never ASCII tables. Never escape markdown characters (`\\*`, `\\_`) to \"play it safe\"; they render wrong. Don't repeat the same content as both prose and a list. Structure the content itself per `base:convey` (concept-first headline, diff, caveats, verify) — this rule only governs the markdown syntax.";
|
|
356
363
|
// ---------------- issue_list ----------------
|
|
357
364
|
strictTool("issue_list",
|
|
358
365
|
// DX-2735: trimmed to pay for the problem tools inside the work-profile
|
|
359
366
|
// injected-surface budget — same facts, no repeated prose.
|
|
360
|
-
"List cards
|
|
367
|
+
"List cards. Board-scoped; see `board`. `filter`: type, parent_id, dispatchable_derived, status_derived[], self_dispatchable_derived, assigned_agent, include_closed, include_deleted, q (free text over id+title+description). THE DEFAULT ROW IS MINIMAL — scalars only (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash), no joins; opt into heavy data with `fields` groups: description (+ summary), problems (open_problem_count — a card needs a human exactly when this is > 0), ac, comments, retro, dependencies, triage, assignment, quality_gates, children, effort. `sort`: [{column, order}] over id|priority|repo_name|title|type|status_derived|triage_ice_total|created_at|updated_at (default order: see `sort`'s own field description). `limit`/`offset` page (uncapped by default). `include_closed` defaults to FALSE: a bare call silently excludes every Done/Cancelled card (leaf AND container alike). The response always carries `total` (the full count matching every filter except limit/offset — compare against `issues.length` to tell an exhausted list from a truncated one) and, whenever `include_closed` was not explicitly `true`, `closed_excluded` (how many additional terminal cards the default withheld — re-call with `include_closed: true` to see them). issue_get reads one card in full.", {
|
|
361
368
|
filter: z
|
|
362
369
|
.object({
|
|
363
370
|
q: z.string().optional(),
|
|
@@ -391,7 +398,7 @@ strictTool("issue_list",
|
|
|
391
398
|
strictTool("issue_get",
|
|
392
399
|
// DX-2735: trimmed to pay for the problem tools inside the work-profile
|
|
393
400
|
// injected-surface budget — same facts, no repeated prose.
|
|
394
|
-
"Fetch one card (
|
|
401
|
+
"Fetch one card (`id`) or many (`ids`, at most " + ISSUE_BATCH_GET_MAX + " — split larger sets); pass exactly one. Ids are globally unique, so both resolve from any board; the batch form is global and throws with `board`. THE DEFAULT ROW IS MINIMAL — scalars only (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash: the concurrency token issue_edit needs for title/description/checklists). `fields` opts into groups, per card in a batch too: description (body + summary), problems (live problems in order, each {id, statement, content_hash, open} with its solutions[] and decisions[] — a card needs a human exactly when open_problem_count > 0), ac (acceptance criteria + checklists), comments (paged via `comments_limit`/`comments_offset` — see their own field descriptions, single-id form only), retro, dependencies (waiting_on/conflict_on/blocked), triage (history + ICE), assignment (dispatch, assigned_agent, lifecycle timestamps), quality_gates (one row per gate {gate, required, status pending|pass|fail, completed_at, message}; a required PRE gate not `pass` pre-empts the work dispatch, and complete refuses while a required POST gate is not `pass`), children (ids + rollups), mirrors (external sync state), code_review_items, plans (every plan this card is on, `{id, ref, name}[]` via `plan_cards` — `ref` is the plan's `PLN-<id>`). Within a `comments` page, comments are ordered CHRONOLOGICALLY (oldest first) — only the requested WINDOW is anchored at the newest end, not the array itself; naming `comments_limit`/`comments_offset` alongside `ids` (the batch form) is refused. Single form: unknown id → 404. Batch form: `{issues: [...], not_found: [...ids]}` — an unknown or deleted id never fails the call.", {
|
|
395
402
|
id: z.string().min(1).optional(),
|
|
396
403
|
ids: z.array(z.string().min(1)).min(1).max(ISSUE_BATCH_GET_MAX).optional(),
|
|
397
404
|
fields: z
|
|
@@ -405,7 +412,7 @@ strictTool("issue_get",
|
|
|
405
412
|
.optional()
|
|
406
413
|
.describe("Single-id form only. How many comments the page holds (server default 20), the window anchored at the " +
|
|
407
414
|
"NEWEST comment. An out-of-range value (server max 200) is refused by the server with its own 400, " +
|
|
408
|
-
"never silently clamped here. Requires `fields
|
|
415
|
+
"never silently clamped here. Requires `fields:[\"comments\"]`."),
|
|
409
416
|
comments_offset: z
|
|
410
417
|
.number()
|
|
411
418
|
.int()
|
|
@@ -417,8 +424,8 @@ strictTool("issue_get",
|
|
|
417
424
|
...boardField,
|
|
418
425
|
}, async (args) => jsonResult(await issueGet(client, args)));
|
|
419
426
|
// ---------------- issue_create ----------------
|
|
420
|
-
strictTool("issue_create", '`plan` is REQUIRED on every create
|
|
421
|
-
'Create a card
|
|
427
|
+
strictTool("issue_create", '`plan` is REQUIRED on every create — no default, no inference; see its own field description for the two values and the refusal. This replaces the plan_add_card follow-up at creation time; plan_add_card remains for putting an EXISTING card on a plan. ' +
|
|
428
|
+
'Create a card. Board-scoped; see `board`. type=Epic REQUIRES non-empty phase_children[] (epic and phases inserted in one transaction; children get the epic as parent); other types refuse phase_children[] (400). Status starts at Review. `list_id` places the card straight into a column — a board_lists id or the list\'s display NAME (case-insensitive, emoji-tolerant): a `ready`-type queue lands it in ToDo, a `completed` list in Done, with no follow-up transition. Not valid on Epic; unknown name/id → 400. `quality_gates` adds gates beyond the board\'s type defaults (see its own field description); add one later with `issue_quality_gate`. ALWAYS pass `triage_enabled` explicitly on the root card and every phase child (see its own field description).', {
|
|
422
429
|
type: z.enum(ISSUE_TYPES),
|
|
423
430
|
title: z.string().min(1).describe(TITLE_DESCRIBE),
|
|
424
431
|
summary: z.string().min(1).optional().describe(SUMMARY_DESCRIBE),
|
|
@@ -430,7 +437,7 @@ strictTool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass "
|
|
|
430
437
|
plan: z
|
|
431
438
|
.literal("mine")
|
|
432
439
|
.nullable()
|
|
433
|
-
.describe('"mine" = attach to the plan THIS session is connected to; null = deliberately no plan. REQUIRED — decide per card. A plan id is not accepted: a card is only ever created onto your own connected plan
|
|
440
|
+
.describe('"mine" = attach to the plan THIS session is connected to; null = deliberately no plan. REQUIRED — decide per card, never omit. "mine" while unconnected is refused 409 session_not_connected (creates NO card). A plan id is not accepted: a card is only ever created onto your own connected plan.'),
|
|
434
441
|
parent_id: z.string().nullable().optional(),
|
|
435
442
|
ac: z.array(z.object({ title: z.string().min(1) }).strict()).optional(),
|
|
436
443
|
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
@@ -472,7 +479,7 @@ strictTool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass "
|
|
|
472
479
|
triage_enabled: z
|
|
473
480
|
.boolean()
|
|
474
481
|
.optional()
|
|
475
|
-
.describe("ALWAYS pass explicitly. true = enters automatic triage/dispatch without further human review; absent → false.
|
|
482
|
+
.describe("ALWAYS pass explicitly. true = enters automatic triage/dispatch without further human review; absent → false. Ignored by issue_triage and by an operator-initiated triage write."),
|
|
476
483
|
// DX-3238 — was absent from this schema entirely (not merely optional),
|
|
477
484
|
// so a caller's value was silently stripped before the request was built
|
|
478
485
|
// even though the route already honoured it (ALLOWED_CREATE_KEYS,
|
|
@@ -481,7 +488,7 @@ strictTool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass "
|
|
|
481
488
|
priority: z
|
|
482
489
|
.union([z.enum(PRIORITY_TIER_WORDS), z.number()])
|
|
483
490
|
.optional()
|
|
484
|
-
.describe('
|
|
491
|
+
.describe('Tier word ("lowest"…"critical", prefer this) or number in [0,6); higher = more urgent. Omit for the route\'s own default.'),
|
|
485
492
|
// DX-3238 — `assigned_agent` is DELIBERATELY left undeclared here, unlike
|
|
486
493
|
// `priority` above. The route accepts it (`ALLOWED_CREATE_KEYS`,
|
|
487
494
|
// create.ts:39-56) but it only matters for the create→`list_id`-lands-
|
|
@@ -497,7 +504,7 @@ strictTool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass "
|
|
|
497
504
|
...boardField,
|
|
498
505
|
}, async (args) => jsonResult(await issueCreate(client, args, config.board)));
|
|
499
506
|
// ---------------- issue_edit ----------------
|
|
500
|
-
strictTool("issue_edit", 'Patch a card
|
|
507
|
+
strictTool("issue_edit", 'Patch a card. ALLOWED keys: title, summary, description, ac, checklists, effort_level, parent_id, priority, list_id, triage_enabled, type, content_hash. Any other key (lifecycle, triage, dependencies, retro, blocked) is refused 400 with offending_keys[] naming the right tool: issue_transition / issue_triage / issue_comment / issue_dependency / issue_problem / issue_retro. `type` (see its own field description) is how a planning item becomes work. `priority` (see its own field description) is the ONLY way to set priority; a "Priority:" line in the description does nothing. CHECKLISTS: each item has one status `incomplete|failing|passing|cancelled|deferred`; `deferred` (work done, a real-world/post-deploy check outstanding) REQUIRES `detail`, and a `📡`-prefixed item can never be `passing`. `ac` edits the default "Acceptance Criteria" checklist (items matched by check_item_id, else exact title); `checklists` REPLACES every named checklist with full status control (`{name, items:[{label, detail?, status}]}`). Send `ac` OR `checklists`, not both (400). `list_id` pins the card to a list by id or display NAME; its type must match the card\'s current derived status (400 otherwise — e.g. ready the card first before pinning it to a `ready`-type list); null clears the pin. `content_hash` (see its own field description) is the card\'s optimistic-concurrency token.', {
|
|
501
508
|
id: z.string().min(1),
|
|
502
509
|
title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
|
|
503
510
|
summary: z
|
|
@@ -518,11 +525,11 @@ strictTool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED k
|
|
|
518
525
|
status: z
|
|
519
526
|
.enum(CHECKLIST_ITEM_STATUSES)
|
|
520
527
|
.optional()
|
|
521
|
-
.describe("Optional full status; omitted → from `checked`.
|
|
528
|
+
.describe("Optional full status; omitted → from `checked`. See tool description for the deferred/📡 rules."),
|
|
522
529
|
detail: z
|
|
523
530
|
.string()
|
|
524
531
|
.optional()
|
|
525
|
-
.describe("
|
|
532
|
+
.describe("paired with `status`; required (non-empty) when `status` is `deferred`."),
|
|
526
533
|
check_item_id: z
|
|
527
534
|
.union([z.string(), z.number()])
|
|
528
535
|
.optional()
|
|
@@ -545,23 +552,23 @@ strictTool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED k
|
|
|
545
552
|
priority: z
|
|
546
553
|
.union([z.enum(PRIORITY_TIER_WORDS), z.number()])
|
|
547
554
|
.optional()
|
|
548
|
-
.describe('
|
|
555
|
+
.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.'),
|
|
549
556
|
list_id: z.string().min(1).nullable().optional(),
|
|
550
557
|
triage_enabled: z
|
|
551
558
|
.boolean()
|
|
552
559
|
.optional()
|
|
553
|
-
.describe("Per-card opt-in to the automatic triage dispatcher (default false = never auto-selected).
|
|
560
|
+
.describe("Per-card opt-in to the automatic triage dispatcher (default false = never auto-selected). Ignored by issue_triage and by an operator-initiated triage write."),
|
|
554
561
|
content_hash: z
|
|
555
562
|
.string()
|
|
556
563
|
.min(1)
|
|
557
564
|
.optional()
|
|
558
|
-
.describe("The card's content_hash last read via issue_get/issue_list. REQUIRED whenever this edit touches title/description/checklists (NOT ac)
|
|
565
|
+
.describe("The card's content_hash last read via issue_get/issue_list's content_hash scalar (present even minimal). REQUIRED whenever this edit touches title/description/checklists (NOT ac, which keeps its own check_item_id/title diffing): missing → 400, stale → 409 stale_issue_content carrying currentHash + currentTitle + currentDescription — re-issue_get and retry with the fresh hash, never blindly."),
|
|
559
566
|
...boardField,
|
|
560
567
|
}, async (args) => jsonResult(await issueEdit(client, args)));
|
|
561
568
|
// ---------------- issue_transition ----------------
|
|
562
569
|
strictTool("issue_transition",
|
|
563
570
|
// DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
|
|
564
|
-
"Move a card's lifecycle
|
|
571
|
+
"Move a card's lifecycle — the ONLY way; `danxbot_complete` never moves a card, so call this first. Actions: ready (Review→ToDo); pickup (ToDo→In Progress; checks every dispatch gate — ready, blocked, open_problem_count (a card needs a human exactly when this is > 0), depends_on terminal, conflict_on idle — and refuses 409 with failed_gate naming the cause; `manual:true` is a self-pickup for work in YOUR session: it bypasses card-flow gates EXCEPT open_problem_count, which never lets a card start, and is never auto-rolled-back — see `assigned_agent`'s own field description for the dispatched-agent requirement); rollback_pickup (see `keep_assignment`'s own field description); complete (your explicit decision; 409 on an Epic with non-terminal children (non_terminal_phases[]) or while a required POST quality gate is not pass (failed_gate 'quality_gate_post' + failed_post_gates[])); cancel (terminal); block (non-empty reason; only holds dispatch, never asks a human — for that use issue_problem add; env faults use `danxbot_complete({status:'failed'})`); unblock; archive (to Backlog, clears ready_at); reopen (terminal→active). Terminal cards refuse all but reopen; forward stamps never clear earlier ones. A pickup that loses a race is refused 409 `failed_gate: \"dispatch_id\"` — re-read assigned_agent/dispatch_id, never retry blindly.", {
|
|
565
572
|
id: z.string().min(1),
|
|
566
573
|
action: z.enum(TRANSITION_ACTIONS),
|
|
567
574
|
reason: z.string().optional(),
|
|
@@ -580,14 +587,14 @@ strictTool("issue_transition",
|
|
|
580
587
|
...boardField,
|
|
581
588
|
}, async (args) => jsonResult(await issueTransition(client, args)));
|
|
582
589
|
// ---------------- issue_triage ----------------
|
|
583
|
-
strictTool("issue_triage", "Record a triage confidence score
|
|
590
|
+
strictTool("issue_triage", "Record a triage confidence score. Caller sends a single `confidence` integer 0-5 plus a required non-empty `reason` — the server computes the verdict by comparing `confidence` against the board's configured thresholds (all band edges inclusive on the low side): confidence <= cancelThreshold -> cancel (stamps cancelled_at, terminal); cancelThreshold < confidence <= archiveThreshold -> defer (stamps archived_at AND ready_at:null); archiveThreshold < confidence <= reviewThreshold -> keep (no column stamp); confidence > reviewThreshold -> approve (stamps ready_at). REFUSES 409 on terminal cards. a keep/defer verdict does NOT block the card: it opens a problem asking the reason with three real choices (approve and ready / defer / cancel, one recommended) — opening it IS what puts the card in front of a human, the same escalation shape every other machine writer uses (the dashboard's Needs You tab reads only open_problem_count). Answering that problem applies the chosen outcome through the normal issue_transition actions automatically. It is not a cross-card ordering gate; use issue_dependency (kind: depends_on) to sequence one card after another.", {
|
|
584
591
|
id: z.string().min(1),
|
|
585
592
|
confidence: z.number().int().min(0).max(5),
|
|
586
593
|
reason: z.string().min(1),
|
|
587
594
|
...boardField,
|
|
588
595
|
}, async (args) => jsonResult(await issueTriage(client, args)));
|
|
589
596
|
// ---------------- issue_comment ----------------
|
|
590
|
-
strictTool("issue_comment", "Comment CRUD
|
|
597
|
+
strictTool("issue_comment", "Comment CRUD. action=add → {text, metadata?, problem_id?} (server stamps author from bearer + auto-incrementing ordinal); action=edit → PATCH /:cid {text}; action=delete → DELETE /:cid (soft-delete, audit trail preserved — comments are NEVER hard-deleted). Client-supplied author is IGNORED (server-stamped to prevent impersonation). `metadata` (action=add only) is an OPTIONAL opaque JSON object a calling app attaches to the comment — e.g. a generated `{sql, explanation}` packet its own UI renders specially. danxbot stores + returns it verbatim and enforces NO shape on its contents; omit for a plain markdown-only comment (unaffected either way). `problem_id` (action=add only) OPTIONALLY threads the comment as a follow-up question under one of this SAME card's problems (from `issue_problem` list/add) WITHOUT answering it — commenting never changes open_problem_count, blocked, or records a decision; use issue_problem's answer route for that. An unknown id, another card's id, or a removed problem's id all 404 naming the problem id — an ANSWERED (but not removed) problem still accepts a follow-up comment, since the thread continues after a decision.", {
|
|
591
598
|
id: z.string().min(1),
|
|
592
599
|
action: z.enum(["add", "edit", "delete"]),
|
|
593
600
|
comment_id: z.number().int().positive().optional(),
|
|
@@ -604,7 +611,7 @@ const CHECKLIST_ITEM_INPUT = z
|
|
|
604
611
|
status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
|
|
605
612
|
})
|
|
606
613
|
.strict();
|
|
607
|
-
strictTool("issue_checklist", "Targeted checklist CUD
|
|
614
|
+
strictTool("issue_checklist", "Targeted checklist CUD — mutates ONE checklist/item without `issue_edit({checklists})`'s wholesale replace, which drops any checklist you omit and churns every item id (orphaning its Trello mirror). Actions: add_list (name, items?) creates a checklist; update_list renames it; remove_list soft-deletes it; add_item appends an item (status defaults incomplete); update_item changes only the fields passed (≥1 required, keeps id + Trello link); remove_item soft-deletes one item. Status: incomplete|failing|passing|cancelled|deferred (terminal = passing|cancelled|deferred; deferred means the work is done but a real-world/post-deploy check remains — requires a non-empty `detail`). checklist_id required for every action except add_list; item_id for update_item/remove_item. Unknown card/checklist/item → 404, invalid status → 400. Additive — `issue_edit({checklists})` still works for bulk authoring.", {
|
|
608
615
|
id: z.string().min(1),
|
|
609
616
|
action: z.enum([
|
|
610
617
|
"add_list",
|
|
@@ -640,22 +647,20 @@ const STEP_INPUT = z.lazy(() => z
|
|
|
640
647
|
.int()
|
|
641
648
|
.positive()
|
|
642
649
|
.optional()
|
|
643
|
-
.describe("present ->
|
|
644
|
-
"
|
|
645
|
-
"
|
|
646
|
-
"
|
|
647
|
-
"
|
|
648
|
-
"progress, a later single-step edit)."),
|
|
650
|
+
.describe("present -> matches an existing step by this id (400 if not a live child of this exact parent scope, " +
|
|
651
|
+
"never claimed by the fallback below); absent -> matches the first unclaimed same-titled sibling " +
|
|
652
|
+
"under the same parent, else creates new (title-based, never positional — keeps later id-less " +
|
|
653
|
+
"siblings' ids intact on a middle insert). Send ids anyway when identity matters (checked progress, " +
|
|
654
|
+
"a later edit) or siblings share a title."),
|
|
649
655
|
title: z
|
|
650
656
|
.string()
|
|
651
657
|
.min(1)
|
|
652
|
-
.describe("one imperative line
|
|
653
|
-
"in the title itself."),
|
|
658
|
+
.describe("one imperative line — labels (\"1.\", \"2a\") are DERIVED on read, never put one in the title."),
|
|
654
659
|
description: z.string().nullable().optional().describe("markdown detail, or null/omitted for none"),
|
|
655
660
|
steps: z.array(STEP_INPUT).optional().describe("this step's own children, nested — refused past 3 levels total"),
|
|
656
661
|
})
|
|
657
662
|
.strict());
|
|
658
|
-
strictTool("issue_problem", "A card's PROBLEMS
|
|
663
|
+
strictTool("issue_problem", "A card's PROBLEMS: one statement the operator must resolve (a question, or a flaw in the plan) OR an action only a person can carry out, each with its own solutions/answers — one problem per question. OPEN = not yet answered; the card needs a human exactly while open_problem_count > 0 — adding a problem IS putting the card in front of a human, there is no separate flag. list → live problems in order, each {id, statement, context, type, summary, content_hash, open, solutions[], decisions[]}; add {statement, context?, type?, summary?, solutions?} → problem_id + solution_ids in one transaction (zero solutions is valid: the operator answers free-form) plus a `reminders: [{key, text}]` array (DX-3365 — every MCP-response reminder rides this ONE field now, DB-registry-driven and operator-overridable from the dashboard) naming each still-open problem's solution count; edit :pid {base_hash, statement, context?, summary?}; remove :pid {base_hash} — always allowed, even as the card's last open problem (removing it just means the card no longer needs a human). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. No answer action — the operator answers in the dashboard. `statement` is capped at 200 characters (400 names the actual length otherwise): one plain sentence, with any investigation detail in `context` (markdown, no cap) instead. Read `type`'s and `summary`'s own descriptions below before your first `add` — together they teach which type this is and the three-field split (`statement`/`summary`/`context`) an action needs.", {
|
|
659
664
|
id: z.string().min(1),
|
|
660
665
|
action: z.enum(["list", "add", "edit", "remove"]),
|
|
661
666
|
problem_id: z.number().int().positive().optional().describe("edit/remove"),
|
|
@@ -664,9 +669,7 @@ strictTool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pi
|
|
|
664
669
|
.string()
|
|
665
670
|
.min(1)
|
|
666
671
|
.optional()
|
|
667
|
-
.describe("add/edit; ONE plain sentence, at most 200 characters
|
|
668
|
-
"WHAT IS TO BE DONE — the deed itself, never phrased as a question (\"Rotate the staging DB credential\", " +
|
|
669
|
-
"not \"Should we rotate it?\")."),
|
|
672
|
+
.describe("add/edit; ONE plain sentence, at most 200 characters — see `type` below for question vs action phrasing."),
|
|
670
673
|
context: z
|
|
671
674
|
.string()
|
|
672
675
|
.nullable()
|
|
@@ -678,67 +681,59 @@ strictTool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pi
|
|
|
678
681
|
type: z
|
|
679
682
|
.enum(["question", "action"])
|
|
680
683
|
.optional()
|
|
681
|
-
.describe("add only.
|
|
682
|
-
"
|
|
683
|
-
"
|
|
684
|
-
"
|
|
685
|
-
"
|
|
686
|
-
"
|
|
687
|
-
"
|
|
688
|
-
"
|
|
689
|
-
"
|
|
690
|
-
"
|
|
691
|
-
"hand in the console\" vs \"run the provisioning script\" vs \"ask the vendor\"), and EACH ONE MUST CARRY " +
|
|
692
|
-
"AT LEAST ONE STEP (refused 400 otherwise) — an action with no procedure is not yet actionable. Omitted " +
|
|
693
|
-
"defaults to \"question\" — decide deliberately every time."),
|
|
684
|
+
.describe("add only. Test: could you do this yourself if you tried harder, and is the only thing missing a decision? " +
|
|
685
|
+
"Yes → \"question\": statement is the question (one plain sentence); summary optional (why it matters); " +
|
|
686
|
+
"context is the evidence; solutions[] are candidate ANSWERS with their own pro/con, done once one is " +
|
|
687
|
+
"picked. No — the blocker is access, credentials, hardware, a human's authority, or a system you " +
|
|
688
|
+
"genuinely cannot reach — \"action\": statement is WHAT IS TO BE DONE, never phrased as a question " +
|
|
689
|
+
"(\"Rotate the staging DB credential\", not \"Should we rotate it?\"); summary is REQUIRED, stating why " +
|
|
690
|
+
"it's needed and why you can't do it yourself (an add with no summary refuses 400); context is detail " +
|
|
691
|
+
"for whoever carries it out; solutions[] are the possible ROUTES (e.g. \"rotate by hand\" vs \"run the " +
|
|
692
|
+
"provisioning script\"), each MUST carry ≥1 step (400 otherwise — no procedure means not yet actionable). " +
|
|
693
|
+
"Omitted defaults to \"question\" — decide deliberately every time."),
|
|
694
694
|
summary: z
|
|
695
695
|
.string()
|
|
696
696
|
.nullable()
|
|
697
697
|
.optional()
|
|
698
|
-
.describe("add/edit. Plain text, 1-3 sentences — never markdown
|
|
699
|
-
"
|
|
700
|
-
"
|
|
701
|
-
"
|
|
702
|
-
"when given (omit/null on a question means none). edit: omit to keep the stored summary, null to clear it " +
|
|
703
|
-
"(refused if the problem is a live action)."),
|
|
698
|
+
.describe("add/edit. Plain text, 1-3 sentences — never markdown (that's `context`'s job). Required+non-empty for an " +
|
|
699
|
+
"action problem (why needed + why you can't do it yourself — see `type`; refused 400 if missing on add or " +
|
|
700
|
+
"cleared on a live action edit); optional for a question. add: sent only when given (omit/null on a " +
|
|
701
|
+
"question means none). edit: omit keeps the stored summary, null clears it."),
|
|
704
702
|
solutions: z
|
|
705
703
|
.array(z.object({ title: z.string().min(1), ...SOLUTION_FIELDS, steps: z.array(STEP_INPUT).optional() }).strict())
|
|
706
704
|
.optional()
|
|
707
|
-
.describe("add only; fields as issue_solution add, INCLUDING steps
|
|
705
|
+
.describe("add only; fields as issue_solution add, INCLUDING steps — an ACTION problem's inline " +
|
|
708
706
|
"solutions must each carry at least one step (refused 400 otherwise); a QUESTION's may carry any number, " +
|
|
709
707
|
"including none."),
|
|
710
708
|
...boardField,
|
|
711
709
|
}, async (args) => jsonResult(await issueProblem(client, args)));
|
|
712
710
|
// ---------------- issue_solution ----------------
|
|
713
|
-
strictTool("issue_solution", "One problem's options
|
|
714
|
-
"procedure steps
|
|
711
|
+
strictTool("issue_solution", "One problem's options, AND one solution's individual " +
|
|
712
|
+
"procedure steps; `problem_id` is REQUIRED (from issue_problem list/add; " +
|
|
715
713
|
"another problem's solution id → 404). add {title, body?, pro?, con?, recommended?, steps?}: title names the " +
|
|
716
|
-
"option
|
|
717
|
-
"
|
|
718
|
-
"
|
|
719
|
-
"(
|
|
720
|
-
"
|
|
721
|
-
"
|
|
722
|
-
"
|
|
723
|
-
"
|
|
724
|
-
"
|
|
725
|
-
"
|
|
726
|
-
"`
|
|
727
|
-
"
|
|
728
|
-
"
|
|
729
|
-
"
|
|
730
|
-
"
|
|
731
|
-
"
|
|
732
|
-
"
|
|
733
|
-
"
|
|
734
|
-
"
|
|
735
|
-
"
|
|
736
|
-
"live children
|
|
737
|
-
"
|
|
738
|
-
"
|
|
739
|
-
"base_hash} — soft-deletes it AND its own live children; refused (409) if this solution belongs to an ACTION " +
|
|
740
|
-
"problem and removing it would leave zero live steps. A stale base_hash on a step → 409 `stale_step` with " +
|
|
741
|
-
"currentHash + currentStep (carrying its real derived `label`).", {
|
|
714
|
+
"option, body/pro/con its markdown detail and case for/against; `steps` authors the WHOLE procedure in one " +
|
|
715
|
+
"call — {title, description?, steps?}[], nestable to 3 levels (4th refused 400, naming the step + limit). " +
|
|
716
|
+
"ACTION problem: `steps` must be non-empty (400 otherwise); QUESTION: may be omitted/empty. Labels " +
|
|
717
|
+
"(\"1\", \"2a\", \"2a.i\") are DERIVED on read from position — never put one in a title. edit :sid {base_hash, " +
|
|
718
|
+
"...changed fields, steps?, steps_base_hash?}: `steps` omitted leaves the procedure untouched; an explicit " +
|
|
719
|
+
"array (incl. [], refused if it leaves an action's solution with zero steps) DIFFS against it — send an " +
|
|
720
|
+
"existing step's `id` to keep it (even while retitling/reordering), omit `id` for a new step; any existing " +
|
|
721
|
+
"step left out is removed. An id-less node matches the first unclaimed same-titled sibling under the same " +
|
|
722
|
+
"parent (never positional), so an id-less MIDDLE insert keeps later id-less siblings' ids — send ids anyway " +
|
|
723
|
+
"when identity matters. `steps_base_hash` REQUIRED whenever `steps` is sent — the solution's current " +
|
|
724
|
+
"`steps_hash`; stale → 409 `stale_steps` with the current tree. Never resend the whole tree to fix one word " +
|
|
725
|
+
"— use add_step/edit_step/remove_step below. remove :sid {base_hash}: stale → 409 `stale_solution` with " +
|
|
726
|
+
"currentHash + currentSolution, merge and retry. At most ONE live recommended per problem — a second → 409 " +
|
|
727
|
+
"naming `recommended_solution_id`. A CHOSEN option's words and WHOLE PROCEDURE are frozen (409 — add a new " +
|
|
728
|
+
"one instead); every steps route below refuses once a decision has chosen this solution.\n\n" +
|
|
729
|
+
"Granular single-step actions, no tree resend: add_step {solution_id, title, description?, parent_step_id?, " +
|
|
730
|
+
"position?} — title is one imperative line (labels derived, never typed); parent_step_id omitted/null = " +
|
|
731
|
+
"top-level; position 1-indexed among current live children, omitted = append, beyond sibling count → 400 " +
|
|
732
|
+
"(never clamped); nesting past depth 3 → 400. edit_step {solution_id, step_id, base_hash, title?, " +
|
|
733
|
+
"description?} — never moves a step. remove_step {solution_id, step_id, base_hash} — soft-deletes it and " +
|
|
734
|
+
"its live children; refused 409 if this solution belongs to an ACTION problem and removing it leaves zero " +
|
|
735
|
+
"live steps. Stale base_hash on a step → 409 `stale_step` with currentHash + currentStep (real derived " +
|
|
736
|
+
"`label`).", {
|
|
742
737
|
id: z.string().min(1),
|
|
743
738
|
action: z.enum(["add", "edit", "remove", "add_step", "edit_step", "remove_step"]),
|
|
744
739
|
problem_id: z.number().int().positive(),
|
|
@@ -770,7 +765,7 @@ strictTool("issue_solution", "One problem's options via /api/issues/:id/problems
|
|
|
770
765
|
...boardField,
|
|
771
766
|
}, async (args) => jsonResult(await issueSolution(client, args)));
|
|
772
767
|
// ---------------- issue_dependency ----------------
|
|
773
|
-
strictTool("issue_dependency", 'Dependency CRUD
|
|
768
|
+
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.', {
|
|
774
769
|
id: z.string().min(1),
|
|
775
770
|
action: z.enum(["add", "remove"]),
|
|
776
771
|
kind: z.enum(["depends_on", "conflict_on"]).optional(),
|
|
@@ -780,13 +775,13 @@ strictTool("issue_dependency", 'Dependency CRUD via /api/issues/:id/dependencies
|
|
|
780
775
|
...boardField,
|
|
781
776
|
}, async (args) => jsonResult(await issueDependency(client, args)));
|
|
782
777
|
// ---------------- issue_retire_branch ----------------
|
|
783
|
-
strictTool("issue_retire_branch", "Mark a card's own `card/<id>` branch RETIRED (unsafe to merge)
|
|
778
|
+
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.", {
|
|
784
779
|
id: z.string().min(1),
|
|
785
780
|
reason: z.string().min(1),
|
|
786
781
|
...boardField,
|
|
787
782
|
}, async (args) => jsonResult(await issueRetireBranch(client, args)));
|
|
788
783
|
// ---------------- issue_quality_gate ----------------
|
|
789
|
-
strictTool("issue_quality_gate", "Put one quality gate ON a card, or take it OFF
|
|
784
|
+
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`.", {
|
|
790
785
|
id: z.string().min(1),
|
|
791
786
|
gate: z.enum([
|
|
792
787
|
"plan-dependency",
|
|
@@ -807,7 +802,7 @@ strictTool("issue_quality_gate", "Put one quality gate ON a card, or take it OFF
|
|
|
807
802
|
...boardField,
|
|
808
803
|
}, async (args) => jsonResult(await issueQualityGate(client, args)));
|
|
809
804
|
// ---------------- issue_quality_gate_verdict ----------------
|
|
810
|
-
strictTool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate VERDICT
|
|
805
|
+
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`.", {
|
|
811
806
|
id: z.string().min(1),
|
|
812
807
|
gate: z.enum([
|
|
813
808
|
"plan-dependency",
|
|
@@ -821,14 +816,26 @@ strictTool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate
|
|
|
821
816
|
message: z.string().optional(),
|
|
822
817
|
...boardField,
|
|
823
818
|
}, async (args) => jsonResult(await issueQualityGateVerdict(client, args)));
|
|
819
|
+
// ---------------- quality_gate_instruction ----------------
|
|
820
|
+
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`.", {
|
|
821
|
+
gate: z.enum([
|
|
822
|
+
"plan-dependency",
|
|
823
|
+
"plan-architecture",
|
|
824
|
+
"plan-tdd",
|
|
825
|
+
"code-test-quality",
|
|
826
|
+
"code-architecture",
|
|
827
|
+
"code-quality",
|
|
828
|
+
]),
|
|
829
|
+
...boardField,
|
|
830
|
+
}, async (args) => jsonResult(await qualityGateInstruction(client, args)));
|
|
824
831
|
// ---------------- issue_retro ----------------
|
|
825
|
-
strictTool("issue_retro", "Replace the retro block
|
|
832
|
+
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).", {
|
|
826
833
|
id: z.string().min(1),
|
|
827
|
-
good: z.string().describe("Free-form markdown (prose or
|
|
828
|
-
bad: z.string().describe("Free-form markdown (prose or
|
|
834
|
+
good: z.string().describe("Free-form markdown (prose or list, they render identically). Style: see issue_comment's `text` param."),
|
|
835
|
+
bad: z.string().describe("Free-form markdown (prose or list, they render identically). Style: see issue_comment's `text` param."),
|
|
829
836
|
correctable_danxbot_problem: z
|
|
830
837
|
.boolean()
|
|
831
|
-
.describe("Required. Did this dispatch hit a problem in danxbot's OWN code/configuration (not \"this card was hard\") that danxbot could change so it stops happening? Answer explicitly every time
|
|
838
|
+
.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."),
|
|
832
839
|
correctable_danxbot_problem_description: z
|
|
833
840
|
.string()
|
|
834
841
|
.describe("Required. Non-empty (naming the concrete problem) when correctable_danxbot_problem is true; must be empty when it is false."),
|
|
@@ -858,7 +865,7 @@ strictTool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retro
|
|
|
858
865
|
// route's MAX_DECODED_BYTES (src/issues/write/attachments.ts). This package is
|
|
859
866
|
// a separate published artifact and cannot import that constant, so the number
|
|
860
867
|
// is restated here as prose — keep the two in sync if the backend ceiling moves.
|
|
861
|
-
strictTool("issue_attach", "Attach a LOCAL file to an issue card
|
|
868
|
+
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.", {
|
|
862
869
|
id: z.string().min(1),
|
|
863
870
|
file_path: z
|
|
864
871
|
.string()
|
|
@@ -867,11 +874,11 @@ strictTool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/i
|
|
|
867
874
|
...boardField,
|
|
868
875
|
}, async (args) => jsonResult(await issueAttach(client, args)));
|
|
869
876
|
// ---------------- repo_knowledge_get ----------------
|
|
870
|
-
strictTool("repo_knowledge_get", "Fetch the board's working-knowledge markdown doc
|
|
877
|
+
strictTool("repo_knowledge_get", "Fetch the board's working-knowledge markdown doc. Board-scoped; see `board`. Returns `{ok, status, body: {content, contentHash, updatedAt, updatedBy, boardId}}` — an unset doc reads as the empty view (content/contentHash `\"\"`), not a 404. Before `repo_knowledge_set`, always get immediately first and pass `contentHash` back as `base_hash` — the concurrency guard rejects a stale write.", {
|
|
871
878
|
...boardField,
|
|
872
879
|
}, async (args) => jsonResult(await repoKnowledgeGet(client, args)));
|
|
873
880
|
// ---------------- repo_knowledge_set ----------------
|
|
874
|
-
strictTool("repo_knowledge_set", 'Write the board\'s working-knowledge markdown doc
|
|
881
|
+
strictTool("repo_knowledge_set", 'Write the board\'s working-knowledge markdown doc. Board-scoped; see `board`. `base_hash` must be the `contentHash` from the immediately-prior `repo_knowledge_get` ("" for the true first write). On mismatch, fails loud with `{error: "stale_repo_knowledge", currentHash}` rather than overwriting — re-get, re-merge, retry with the new hash. On success persists to the DB, publishes `repo-knowledge:updated` over SSE, and returns the new view.', {
|
|
875
882
|
content: z.string(),
|
|
876
883
|
base_hash: z
|
|
877
884
|
.string()
|
|
@@ -880,11 +887,11 @@ strictTool("repo_knowledge_set", 'Write the board\'s working-knowledge markdown
|
|
|
880
887
|
...boardField,
|
|
881
888
|
}, async (args) => jsonResult(await repoKnowledgeSet(client, args)));
|
|
882
889
|
// ---------------- brief_list ----------------
|
|
883
|
-
strictTool("brief_list", "List the board's named Brief pages
|
|
890
|
+
strictTool("brief_list", "List the board's named Brief pages. Board-scoped; see `board`. Returns `{boardId, pages: [{slug, title, contentHash, sortOrder, updatedAt, updatedBy}]}` — metadata only, no content (use `brief_get_page`). Unlike `repo_knowledge_get`/`_set` (one board-level doc), Brief pages are MANY named pages per board (Goals/Architecture/Rules/Caveats tabs), keyed by (board, slug). The reserved `index` slug always exists.", {
|
|
884
891
|
...boardField,
|
|
885
892
|
}, async (args) => jsonResult(await briefList(client, args)));
|
|
886
893
|
// ---------------- brief_get_page ----------------
|
|
887
|
-
strictTool("brief_get_page", 'Fetch one Brief page by slug
|
|
894
|
+
strictTool("brief_get_page", 'Fetch one Brief page by slug. Board-scoped; see `board`. Returns `{boardId, slug, title, content, contentHash, sortOrder, updatedAt, updatedBy}` — a missing page reads as the empty view (content/contentHash `""`), not a 404. Before `brief_set_page`, always get immediately first and pass `contentHash` back as `base_hash` — the concurrency guard rejects a stale write.', {
|
|
888
895
|
slug: z
|
|
889
896
|
.string()
|
|
890
897
|
.min(1)
|
|
@@ -892,7 +899,7 @@ strictTool("brief_get_page", 'Fetch one Brief page by slug via GET /api/brief/pa
|
|
|
892
899
|
...boardField,
|
|
893
900
|
}, async (args) => jsonResult(await briefGetPage(client, args)));
|
|
894
901
|
// ---------------- brief_set_page ----------------
|
|
895
|
-
strictTool("brief_set_page", 'Write one Brief page
|
|
902
|
+
strictTool("brief_set_page", 'Write one Brief page. Board-scoped; see `board`. Body: `{content, title?, sortOrder?, base_hash?}` — same optimistic-concurrency shape as `repo_knowledge_set`, targeting one named page. `base_hash` must be the `contentHash` from the immediately-prior `brief_get_page` (`""` for a true first write). On mismatch fails loud with `{error: "stale_brief_page", currentHash}` — re-get, re-merge, retry, never overwrite blindly. On success persists, publishes `brief:updated` over SSE, and returns the new view. No delete tool on this surface; the reserved `index` slug is never deletable — removing a non-index page is dashboard-UI-only.', {
|
|
896
903
|
slug: z
|
|
897
904
|
.string()
|
|
898
905
|
.min(1)
|
|
@@ -916,13 +923,13 @@ strictTool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug=
|
|
|
916
923
|
// plan id — and it can only ever bind the caller's own session. `plan_create`
|
|
917
924
|
// also takes no plan id, but for a different reason: it MAKES a plan rather
|
|
918
925
|
// than acting on one, so there is no existing plan for an id to name yet.
|
|
919
|
-
strictTool("plan_list", "List every plan
|
|
926
|
+
strictTool("plan_list", "List every plan, and learn which plan THIS session is connected to. Plans are GLOBAL, not board-scoped — a plan is a named, dated set of cards an operator assembled by hand, from any repo. Returns `{plans: [{id, ref, name, createdAt, cardCount, boards, status}], session, sessionListenerAttached}` — `ref` is the plan's short reference (`PLN-<id>`), cite it rather than the bare id. `status` is computed fresh on every read, never stored: `awaiting-session` (no live session — a session row isn't released just because a session ended), `planning` (no cards, or only Review/Backlog/terminal ones with ≥1 not Done/Cancelled), `building` (a live session AND ≥1 card ToDo/In Progress or stuck-but-active — Blocked/Needs Help), `complete` (≥1 card, all Done/Cancelled — wins even with no session). Pass `status` to filter. `session` is your own registration (`{sessionId, title, planId, planName, firstSeenAt, lastActiveAt}`) or null outside a Claude Code session; `planId: null` means connected to no plan — `plan_get` to browse, `plan_connect` to bind. `sessionListenerAttached` says whether your event stream is attached (the plugin's plan bridge); false for a few seconds right after connect is normal, false after that means events aren't reaching you — tell the operator. Distinct from the board Brief (`brief_list`).", {
|
|
920
927
|
status: z
|
|
921
928
|
.enum(PLAN_STATUSES)
|
|
922
929
|
.optional()
|
|
923
930
|
.describe("Filter to one computed status: awaiting-session, planning, building, complete. Omit for every plan."),
|
|
924
931
|
}, async (args) => jsonResult(await planList(client, args)));
|
|
925
|
-
strictTool("plan_get", "Read a plan
|
|
932
|
+
strictTool("plan_get", "Read a plan. Pass `plan_id` to read ANY plan; OMIT to read the plan this session is connected to — omitting while connected to none fails loud `{error: \"session_not_connected\"}`, connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{plan, boards, cardCount, bucketCounts, status, session, sessionListenerAttached, available_field_groups}` — no cards, records, or architecture body. `plan` carries `{id, ref, name, createdAt}`; `ref` is the plan's short reference (`PLN-<id>`) — cite that, not the bare id. `status` is computed fresh on every read, never stored — see `plan_list` for the four values and what each means. Pass `fields` to opt into: `cards` (member cards, paged via `cards_offset`/`cards_limit` — see their own field descriptions — in stable card-reference order: board prefix then card number, pages never repeat/skip unless membership changes between reads; response carries `cards_total`), `records` (every goal+rule+caveat, keyed by kind) or `records:goal` / `records:rule` / `records:caveat` (one kind, cheaper), `architecture` (`{sections: [{id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}]}`), `sessions` (every session connected to the plan), `events` (the plan's durable event ledger — every human action and bridge message, paged/filtered via `events_limit`/`events_before`/`events_kinds`/`events_origin`/`events_writer` — see their own field descriptions; response carries `events: {items: [{id, at, kind, writer, origin, originSessionId, targetSessionId, cardId, cardTitle, boardId, detail}], next_cursor}`, `next_cursor` null on the last page; an event on a card whose board you cannot read is left out, plan-level events are always visible). `session`/`sessionListenerAttached` and `available_field_groups` ride every response regardless. `sessionListenerAttached` is `false` for a few seconds right after `plan_connect` while the plugin's event bridge starts; still `false` after that while connected means the plan's card events are not reaching you — tell the operator. ALWAYS `plan_get`/`plan_get_architecture_section` immediately before `plan_update_architecture_section`/`plan_delete_architecture_section` and pass the returned `contentHash` back as `base_hash`.", {
|
|
926
933
|
plan_id: z
|
|
927
934
|
.number()
|
|
928
935
|
.int()
|
|
@@ -938,46 +945,46 @@ strictTool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id`
|
|
|
938
945
|
.int()
|
|
939
946
|
.nonnegative()
|
|
940
947
|
.optional()
|
|
941
|
-
.describe("Where the `cards` page starts (default 0). Requires `fields
|
|
948
|
+
.describe("Where the `cards` page starts (default 0). Requires `fields:[\"cards\"]`. Page with cards_offset while cards_offset + cards.length < cards_total."),
|
|
942
949
|
cards_limit: z
|
|
943
950
|
.number()
|
|
944
951
|
.int()
|
|
945
952
|
.positive()
|
|
946
953
|
.max(LIST_PAGE_MAX_LIMIT)
|
|
947
954
|
.optional()
|
|
948
|
-
.describe("How many cards one page holds, 1.." + LIST_PAGE_MAX_LIMIT + " (default " + PLAN_GET_CARDS_DEFAULT_LIMIT + "). Requires `fields
|
|
955
|
+
.describe("How many cards one page holds, 1.." + LIST_PAGE_MAX_LIMIT + " (default " + PLAN_GET_CARDS_DEFAULT_LIMIT + "). Requires `fields:[\"cards\"]`."),
|
|
949
956
|
events_limit: z
|
|
950
957
|
.number()
|
|
951
958
|
.int()
|
|
952
959
|
.positive()
|
|
953
960
|
.max(PLAN_GET_EVENTS_MAX_LIMIT)
|
|
954
961
|
.optional()
|
|
955
|
-
.describe("
|
|
962
|
+
.describe("how many ledger events one page holds, 1.." + PLAN_GET_EVENTS_MAX_LIMIT + " (default " + PLAN_GET_EVENTS_DEFAULT_LIMIT + "). Requires `fields` to include `events`."),
|
|
956
963
|
events_before: z
|
|
957
964
|
.string()
|
|
958
965
|
.min(1)
|
|
959
966
|
.optional()
|
|
960
|
-
.describe("
|
|
967
|
+
.describe("an opaque cursor from a previous page's `next_cursor`. Omit for the newest page. Requires `fields` to include `events`."),
|
|
961
968
|
events_kinds: z
|
|
962
969
|
.array(z.enum(PLAN_EVENT_KINDS))
|
|
963
970
|
.optional()
|
|
964
|
-
.describe("
|
|
971
|
+
.describe("only these event kinds. Omit for every kind. Requires `fields` to include `events`."),
|
|
965
972
|
events_origin: z
|
|
966
973
|
.enum(PLAN_EVENT_ORIGINS)
|
|
967
974
|
.optional()
|
|
968
|
-
.describe("
|
|
975
|
+
.describe("only events with this origin. Omit for every origin. Requires `fields` to include `events`."),
|
|
969
976
|
events_writer: z
|
|
970
977
|
.string()
|
|
971
978
|
.min(1)
|
|
972
979
|
.optional()
|
|
973
|
-
.describe("
|
|
980
|
+
.describe("only events with this exact writer name. Omit for every writer. Requires `fields` to include `events`."),
|
|
974
981
|
}, async (args) => jsonResult(await planGet(client, args)));
|
|
975
|
-
strictTool("plan_create", "Create a new, empty plan
|
|
982
|
+
strictTool("plan_create", "Create a new, empty plan. Global — not board-scoped. Adds no cards, records, or architecture sections, and does not connect any session (call `plan_connect` separately). Returns `{plan: {id, ref, name, createdAt}}` — `ref` is the short reference (`PLN-<id>`). Use `plan.id` with `plan_connect` to start working on it, or `plan_get({plan_id})` to browse.", {
|
|
976
983
|
name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
|
|
977
984
|
}, async (args) => jsonResult(await planCreate(client, args)));
|
|
978
985
|
strictTool("plan_connect",
|
|
979
986
|
// DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
|
|
980
|
-
"Connect THIS session to a plan
|
|
987
|
+
"Connect THIS session to a plan. ONE CALL IS ENOUGH TO START: the reply carries `{session, movedFrom, browserInstruction, briefing, listenerHealth}` — `browserInstruction` is a server-built action to take NOW: it names the plan's URL and tells you to open it (in-app browser if you have one, else default), keep that tab open for the whole session without navigating it away, and use a different tab for your own browsing — the operator's tab for following and talking to you. Returned on every connect, including a re-connect, so it doubles as the post-context-loss reminder. `briefing` is every goal/rule/caveat (ref+body), every architecture section (id+title), the plan's own ref/name/status, a first page of open cards (id/type/status/title/openProblemCount/assignedAgent) with a `morePagesHint` when more exist, and the closed-card count — usually replacing the `plan_get({fields:[...]})` + card batch-read a fresh session used to need. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and `movedFrom: {id, name} | null` says which plan it left (null = no plan, or already this one). Binds only your OWN session, resolved from the session id this server forwards; afterwards every plan WRITE tool acts on this plan and takes no plan id. `listenerHealth` reports the event bridge's state — `null` (no session), or `{attached, state: \"unattached\"|\"credential_mismatch\"|\"board_scope_narrowed\"|\"healthy\", nextStep}` naming a concrete fix per unhealthy state (same shape `plan_list`/`plan_get` report). Once healthy, every comment, answer, problem added and block/unblock on this plan's cards reaches the session unpolled, relayed by the plugin's event bridge as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<statement>\": chose \"Pause E2E\"`. Pass `title` (call `get_session({session_id:\"self\"})` first and forward its `title` verbatim) so the dashboard shows the same name Claude does — this server cannot read it itself.", {
|
|
981
988
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
982
989
|
title: z
|
|
983
990
|
.string()
|
|
@@ -1001,15 +1008,15 @@ async (args) => {
|
|
|
1001
1008
|
}),
|
|
1002
1009
|
});
|
|
1003
1010
|
});
|
|
1004
|
-
strictTool("plan_add_record", "Add a goal, rule or caveat to your connected plan (
|
|
1011
|
+
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>\"]})`.", {
|
|
1005
1012
|
kind: z.enum(["goal", "rule", "caveat"]).describe("goal = outcome, rule = constraint, caveat = architecture trade-off."),
|
|
1006
1013
|
body: z.string().min(1).describe("One plain statement, at most 250 characters. Details go in `context`."),
|
|
1007
1014
|
context: z.string().optional().describe("Markdown detail behind the statement: evidence, history, examples."),
|
|
1008
1015
|
}, async (args) => jsonResult(await planAddRecord(client, args)));
|
|
1009
|
-
strictTool("plan_get_record", "Read one goal/rule/caveat
|
|
1016
|
+
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.", {
|
|
1010
1017
|
record_id: z.number().int().positive().describe("A record id, from `plan_add_record`, `plan_get` or `plan_get_record` itself."),
|
|
1011
1018
|
}, async (args) => jsonResult(await planGetRecord(client, args)));
|
|
1012
|
-
strictTool("plan_update_record", 'Edit a goal/rule/caveat
|
|
1019
|
+
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`).', {
|
|
1013
1020
|
record_id: z.number().int().positive().describe("The record id to edit."),
|
|
1014
1021
|
content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
|
|
1015
1022
|
body: z.string().min(1).describe("The new statement, at most 250 characters. Plain text."),
|
|
@@ -1019,11 +1026,11 @@ strictTool("plan_update_record", 'Edit a goal/rule/caveat of your connected plan
|
|
|
1019
1026
|
.optional()
|
|
1020
1027
|
.describe("New markdown detail. Omit to keep the stored context; null clears it."),
|
|
1021
1028
|
}, async (args) => jsonResult(await planUpdateRecord(client, args)));
|
|
1022
|
-
strictTool("plan_delete_record", 'Soft-delete a goal/rule/caveat
|
|
1029
|
+
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`).', {
|
|
1023
1030
|
record_id: z.number().int().positive().describe("The record id to delete."),
|
|
1024
1031
|
content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
|
|
1025
1032
|
}, async (args) => jsonResult(await planDeleteRecord(client, args)));
|
|
1026
|
-
strictTool("plan_add_note", "Write a milestone note to a plan's timeline
|
|
1033
|
+
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\"]})`.", {
|
|
1027
1034
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
1028
1035
|
title: z.string().min(1).describe("At most 60 characters."),
|
|
1029
1036
|
body: z.string().min(1).describe("The wrap-up, at most 250 characters."),
|
|
@@ -1034,50 +1041,50 @@ strictTool("plan_add_note", "Write a milestone note to a plan's timeline, via PO
|
|
|
1034
1041
|
.describe("Goal/rule/caveat references this note announces, e.g. `[\"G-1\", \"R-3\", \"CAV-2\"]`."),
|
|
1035
1042
|
section_ids: z.array(z.number().int().positive()).optional().describe("Architecture section ids this note announces."),
|
|
1036
1043
|
}, async (args) => jsonResult(await planAddNote(client, args)));
|
|
1037
|
-
strictTool("plan_update_note", 'Edit a plan note
|
|
1044
|
+
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`.', {
|
|
1038
1045
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
1039
1046
|
note_id: z.number().int().positive().describe("The note id to edit."),
|
|
1040
1047
|
content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
|
|
1041
1048
|
title: z.string().min(1).optional().describe("New title, at most 60 characters. Omit to keep the stored title."),
|
|
1042
1049
|
body: z.string().min(1).optional().describe("New wrap-up, at most 250 characters. Omit to keep the stored body."),
|
|
1043
|
-
card_ids: z.array(z.string().min(1)).optional().describe("
|
|
1044
|
-
record_refs: z.array(z.string().min(1)).optional().describe("
|
|
1045
|
-
section_ids: z.array(z.number().int().positive()).optional().describe("
|
|
1050
|
+
card_ids: z.array(z.string().min(1)).optional().describe("Part of the link-set group — see tool description."),
|
|
1051
|
+
record_refs: z.array(z.string().min(1)).optional().describe("Part of the link-set group — see tool description."),
|
|
1052
|
+
section_ids: z.array(z.number().int().positive()).optional().describe("Part of the link-set group — see tool description."),
|
|
1046
1053
|
}, async (args) => jsonResult(await planUpdateNote(client, args)));
|
|
1047
|
-
strictTool("plan_delete_note", 'Soft-delete a plan note
|
|
1054
|
+
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}`.', {
|
|
1048
1055
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
1049
1056
|
note_id: z.number().int().positive().describe("The note id to delete."),
|
|
1050
1057
|
content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
|
|
1051
1058
|
}, async (args) => jsonResult(await planDeleteNote(client, args)));
|
|
1052
|
-
strictTool("plan_add_card", "Add an existing card to the plan this session is connected to
|
|
1059
|
+
strictTool("plan_add_card", "Add an existing card, from ANY board, to the plan this session is connected to. Idempotent (re-adding is a no-op; a card may sit in several plans). Adds MEMBERSHIP only, never edits the card. Takes no plan id — resolved from your connected session. Not connected → `{error: \"session_not_connected\"}`. Unknown card → 404. Returns `{card_id, member: true, cards_count}` (the plan's total member-card count, not the full list — a plan can hold hundreds; read it with `plan_get({fields:[\"cards\"]})`, paged).", {
|
|
1053
1060
|
card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
|
|
1054
1061
|
}, async (args) => jsonResult(await planAddCard(client, args)));
|
|
1055
|
-
strictTool("plan_remove_card", "Remove a card from a plan
|
|
1062
|
+
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`).", {
|
|
1056
1063
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
1057
1064
|
card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
|
|
1058
1065
|
}, async (args) => jsonResult(await planRemoveCard(client, args)));
|
|
1059
|
-
strictTool("plan_rename", "Rename a plan
|
|
1066
|
+
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}`.", {
|
|
1060
1067
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
1061
1068
|
name: z.string().min(1).describe("The plan's new name."),
|
|
1062
1069
|
}, async (args) => jsonResult(await planRename(client, args)));
|
|
1063
|
-
strictTool("plan_get_architecture_section", "Read
|
|
1070
|
+
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}}`.", {
|
|
1064
1071
|
section_id: z.number().int().positive().describe("A section id, from `plan_get` or `plan_add_architecture_section`."),
|
|
1065
1072
|
}, async (args) => jsonResult(await planGetArchitectureSection(client, args)));
|
|
1066
|
-
strictTool("plan_add_architecture_section", "Append a section to your connected plan's architecture
|
|
1073
|
+
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.", {
|
|
1067
1074
|
title: z.string().min(1).describe("The section's heading, shown in the navigation index."),
|
|
1068
1075
|
content: z.string().describe("The section's markdown. May be empty — a section awaiting its first draft is a real state."),
|
|
1069
1076
|
}, async (args) => jsonResult(await planAddArchitectureSection(client, args)));
|
|
1070
|
-
strictTool("plan_update_architecture_section", 'Edit a section\'s title and/or content
|
|
1077
|
+
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.', {
|
|
1071
1078
|
section_id: z.number().int().positive().describe("The section id to edit."),
|
|
1072
1079
|
base_hash: z.string().describe("The section's `contentHash` from your last read. Required."),
|
|
1073
1080
|
title: z.string().min(1).optional().describe("New heading. Omit to keep the stored title."),
|
|
1074
1081
|
content: z.string().optional().describe("New markdown. Omit to keep the stored content."),
|
|
1075
1082
|
}, async (args) => jsonResult(await planUpdateArchitectureSection(client, args)));
|
|
1076
|
-
strictTool("plan_delete_architecture_section", 'Soft-delete a section of your connected plan\'s architecture
|
|
1083
|
+
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.', {
|
|
1077
1084
|
section_id: z.number().int().positive().describe("The section id to delete."),
|
|
1078
1085
|
base_hash: z.string().describe("The section's `contentHash` from your last read. Required."),
|
|
1079
1086
|
}, async (args) => jsonResult(await planDeleteArchitectureSection(client, args)));
|
|
1080
|
-
strictTool("plan_reorder_architecture_section", "Reassign your connected plan's section display order
|
|
1087
|
+
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.", {
|
|
1081
1088
|
order: z
|
|
1082
1089
|
.array(z.number().int().positive())
|
|
1083
1090
|
.min(1)
|
|
@@ -1108,8 +1115,8 @@ const expectedRateField = z
|
|
|
1108
1115
|
.nullable()
|
|
1109
1116
|
.optional()
|
|
1110
1117
|
.describe("Both fields together, or omit/null entirely — never a half-specified rate.");
|
|
1111
|
-
strictTool("failure_category_list", "List every failure category
|
|
1112
|
-
strictTool("failure_category_create", 'Create a new failure category
|
|
1118
|
+
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)));
|
|
1119
|
+
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.', {
|
|
1113
1120
|
name: z.string().min(1).describe("The category's name."),
|
|
1114
1121
|
description: z.string().optional().describe("Optional free-text description. Defaults to empty."),
|
|
1115
1122
|
matchers: z.array(matcherField).min(1).describe("At least one matcher; a category matches an occurrence when ANY ONE matches (OR across matchers)."),
|
|
@@ -1117,7 +1124,7 @@ strictTool("failure_category_create", 'Create a new failure category via POST /a
|
|
|
1117
1124
|
ignoreReason: z.string().nullable().optional().describe("Required (non-empty) when ignore is true."),
|
|
1118
1125
|
expectedRate: expectedRateField,
|
|
1119
1126
|
}, async (args) => jsonResult(await failureCategoryCreate(client, args)));
|
|
1120
|
-
strictTool("failure_category_update", "Patch an existing failure category
|
|
1127
|
+
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.", {
|
|
1121
1128
|
id: z.number().int().positive().describe("The category id to patch — from failure_category_list."),
|
|
1122
1129
|
name: z.string().min(1).optional(),
|
|
1123
1130
|
description: z.string().optional(),
|
|
@@ -1127,7 +1134,7 @@ strictTool("failure_category_update", "Patch an existing failure category via PA
|
|
|
1127
1134
|
expectedRate: expectedRateField,
|
|
1128
1135
|
}, async (args) => jsonResult(await failureCategoryUpdate(client, args)));
|
|
1129
1136
|
// ---------------- dispatch_transcript_search (DX-3221) ----------------
|
|
1130
|
-
strictTool("dispatch_transcript_search", "Search or tail
|
|
1137
|
+
strictTool("dispatch_transcript_search", "Search or tail another dispatch's stored JSONL transcript. Needs no filesystem access (an authenticated HTTP call — never touches worktree-guard or the worktree boundary) and fixes what Read cannot: a single JSONL entry can exceed Read's token cap with no way to sub-page inside it, so this tool searches itself and returns only a bounded excerpt around a match. With `pattern`: up to `maxMatches` matching lines (case-insensitive regex), each windowed to `contextChars` around the first match. Without `pattern`: the most recent `tail` lines instead, each capped at `contextChars` (`truncated: true` marks a capped excerpt either way, never a thrown error). `dispatchId` is a real `dispatches.id` — e.g. from a failure-repair card's own body.", {
|
|
1131
1138
|
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."),
|
|
1132
1139
|
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."),
|
|
1133
1140
|
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."),
|
package/package.json
CHANGED
|
@@ -1,42 +1,42 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@thehammer/danx-dashboard-mcp",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Stdio MCP server wrapping danxbot's dashboard /api/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"type": "module",
|
|
7
|
-
"main": "dist/index.js",
|
|
8
|
-
"bin": {
|
|
9
|
-
"danx-dashboard-mcp": "dist/index.js"
|
|
10
|
-
},
|
|
11
|
-
"files": [
|
|
12
|
-
"dist",
|
|
13
|
-
"README.md"
|
|
14
|
-
],
|
|
15
|
-
"repository": {
|
|
16
|
-
"type": "git",
|
|
17
|
-
"url": "https://github.com/newms87/danxbot.git",
|
|
18
|
-
"directory": "packages/danx-dashboard-mcp"
|
|
19
|
-
},
|
|
20
|
-
"engines": {
|
|
21
|
-
"node": ">=20"
|
|
22
|
-
},
|
|
23
|
-
"scripts": {
|
|
24
|
-
"build": "tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
|
|
25
|
-
"verify:boot": "bash scripts/verify-boot.sh",
|
|
26
|
-
"start": "node dist/index.js",
|
|
27
|
-
"dev": "tsx src/index.ts",
|
|
28
|
-
"gen-tool-defs": "tsx scripts/gen-tool-defs.ts",
|
|
29
|
-
"test": "vitest run",
|
|
30
|
-
"test:watch": "vitest"
|
|
31
|
-
},
|
|
32
|
-
"dependencies": {
|
|
33
|
-
"@modelcontextprotocol/sdk": "1.29.0",
|
|
34
|
-
"zod": "^3.25.76"
|
|
35
|
-
},
|
|
36
|
-
"devDependencies": {
|
|
37
|
-
"@types/node": "^22.0.0",
|
|
38
|
-
"tsx": "^4.0.0",
|
|
39
|
-
"typescript": "^5.7.0",
|
|
40
|
-
"vitest": "^4.1.5"
|
|
41
|
-
}
|
|
42
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@thehammer/danx-dashboard-mcp",
|
|
3
|
+
"version": "0.1.143",
|
|
4
|
+
"description": "Stdio MCP server wrapping danxbot's dashboard /api/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"danx-dashboard-mcp": "dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/newms87/danxbot.git",
|
|
18
|
+
"directory": "packages/danx-dashboard-mcp"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
|
|
25
|
+
"verify:boot": "bash scripts/verify-boot.sh",
|
|
26
|
+
"start": "node dist/index.js",
|
|
27
|
+
"dev": "tsx src/index.ts",
|
|
28
|
+
"gen-tool-defs": "tsx scripts/gen-tool-defs.ts",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"test:watch": "vitest"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
34
|
+
"zod": "^3.25.76"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^22.0.0",
|
|
38
|
+
"tsx": "^4.0.0",
|
|
39
|
+
"typescript": "^5.7.0",
|
|
40
|
+
"vitest": "^4.1.5"
|
|
41
|
+
}
|
|
42
|
+
}
|