@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/README.md
CHANGED
|
@@ -15,42 +15,20 @@ Each tool is a thin envelope over one HTTP route — Zod-validated at the MCP bo
|
|
|
15
15
|
|
|
16
16
|
BOARD-ONLY (DX-1171): board is the first-level concept; repo is DERIVED from board server-side, never passed. At boot the package composes the dispatch's qualified board id `<repo>:<slug>` from `DANX_REPO_NAME` + `DANXBOT_BOARD_NAME` and appends it as `?board=<id>` (and `body.board` on create) to every `/api/issues/*` request. Each tool exposes an optional `board` arg — a FULL qualified id `<repo>:<slug>` (e.g. `platform:the-supply-operations-hub`) — that overrides it per-call for cross-board reads/writes (unknown board → 404). The dashboard owns board→repo resolution; this package never sends `repo`.
|
|
17
17
|
|
|
18
|
-
## Tool surface
|
|
18
|
+
## Tool surface (DX-3465 — one generic tool, not one per operation)
|
|
19
19
|
|
|
20
|
-
All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `mcp.template.json`.
|
|
20
|
+
All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `mcp.template.json`. There is no per-operation tool any more (no `issue_get`, `issue_create`, `plan_add_note`, …) — three tools cover the whole dashboard API:
|
|
21
21
|
|
|
22
|
-
| Tool |
|
|
23
|
-
|---|---|---|
|
|
24
|
-
| `issue_list` | `GET /api/issues` | filters: `type`, `parent_id` (null → root-only), `dispatchable_derived`, `assigned_agent`, `include_closed`, `limit`, `offset`. `fields` (DX-3427) is the SAME recursive field tree `issue_get` takes, against the same issue resource — list-row fields (`ac_total`, `comments_count`, `waiting_on`, `quality_gates`, `next_pre_gate`, …) sit alongside every detail field. See `resource_fields` |
|
|
25
|
-
| `issue_get` | `GET /api/issues/:id` or `GET /api/issues/batch` | Pass `id` for one card, or `ids[]` (DX-2727, at most 100) to resolve many across boards in ONE call — global, so `ids` with `board` throws; per-id `not_found` rather than a whole-call 404. Minimal scalars by default; `fields` (DX-3426) opts in via a recursive field TREE — `{"description": true, "problems": {"solutions": true}, "comments": {"limit": 10}}` — not a flat group list; a cursor-paged relation (today only `comments`) takes its page args (`limit`/`before`) inside its own nested object, so paging now works on the batch form too. See `resource_fields` |
|
|
26
|
-
| `resource_fields` | `GET /api/resources/:resource/fields` | DX-3426 — what an `issue_get`-style field tree may name for one resource: `{resource, description, always, hashes, fields, relations}`. Install-global, no `board`. `resource: "issue"` is the root; a relation's own `resource` in the response is what to call this again with, one level deeper |
|
|
27
|
-
| `issue_create` | `POST /api/issues` | Epic REQUIRES non-empty `phase_children[]` (atomic insert). `title` = short domain-naming label; `summary` = 1–3 plain-language sentences, always shown; `description` = the collapsed "Context" body. Root and every phase child take their own `summary` |
|
|
28
|
-
| `issue_edit` | `PATCH /api/issues/:id/edit` | Prose + structured keys (`title`, `summary` (null clears), `description`, `ac`, `checklists`, `effort_level`, `parent_id`, `priority`, `list_id`); semantic keys refused with 400 + pointer to dedicated handler. `priority` (DX-1532) takes a tier word (`low`/`high`/…) or a number in `[0,6)` — the ONLY way to set the numeric column the Trello label + dashboard badge read; never set priority via description prose |
|
|
29
|
-
| `issue_transition` | `POST /api/issues/:id/transition` | Actions: ready, pickup, rollback_pickup, complete, cancel, block, unblock, archive, reopen. `block` is a dispatch hold only — it never marks the card as needing a human; use `issue_problem` add for that |
|
|
30
|
-
| `issue_problem` | `GET/POST/PATCH/DELETE /api/issues/:id/problems[/:pid]` + `POST .../answer`, `change-answer`, `unanswer` | Actions list / add / edit / remove / answer / change_answer / unanswer (DX-3471). A problem is one statement the operator must resolve (a question or a plan flaw) with its own solutions and answers; `add` takes `statement` + optional `solutions[]` in one transaction and IS what puts the card in front of a human (`open_problem_count > 0`) — a successful add also returns a `reminders: [{key, text}]` array (DX-3365 — the reminder registry's middleware, DB-driven and operator-overridable from the dashboard). Edit + remove are hash-guarded (`base_hash`, 409 `stale_problem`); removing the card's last open problem is always allowed — it just means the card no longer needs a human. `answer` `{solution_id, note?}` \| `{freeform}` and `change_answer` (same + `base_hash` naming the live DECISION) / `unanswer` (`{base_hash}`) record or retract an operator's decision — server-refused 403 for a dispatched agent's machine credential (DX-2830), reachable from an OPERATOR SESSION relaying an in-chat decision, which also durably records the relaying session (`relayed_by_session`) via the same session header every call already carries |
|
|
31
|
-
| `issue_solution` | `POST/PATCH/DELETE /api/issues/:id/problems/:pid/solutions[/:sid]` | Actions add / edit / remove, `problem_id` required (list via `issue_problem list`). Edit + remove are hash-guarded (`base_hash`, 409 `stale_solution` with the current row); at most one live `recommended` per problem; a chosen option cannot be edited. No answer action on THIS tool — see `issue_problem`'s answer / change_answer / unanswer actions |
|
|
32
|
-
| `issue_triage` | `POST /api/issues/:id/triage` | Send `{confidence, reason}` — an integer 0-5 score; the server computes the verdict (approve/cancel/keep/defer) against the board's configured thresholds (DX-2086). `keep`/`defer` now block the card. None of these are a cross-card ordering gate; use `issue_dependency` to sequence cards |
|
|
33
|
-
| `issue_comment` | `POST/PATCH/DELETE /api/issues/:id/comments[/:cid]` | Author server-stamped, soft-delete preserved |
|
|
34
|
-
| `issue_dependency` | `POST/DELETE /api/issues/:id/dependencies[/:did]` | `depends_on` cycle-checked; remove hardcodes `reason: "recorded_in_error"`. The only mechanism the dispatch picker enforces to sequence one card after another — status alone is not a substitute |
|
|
35
|
-
| `issue_retire_branch` | `POST /api/issues/:id/card-branch-retire` | Mark this card's own `card/<id>` branch RETIRED (unsafe to merge) — `{reason}`, `by` server-stamped. NO status/terminal gate (DX-2845): settable the instant an audit/split judges the branch unsafe, whatever the card's status. The dispatched worker reads this at its next bootstrap and forces `origin/main` instead of re-attaching to the retired content |
|
|
36
|
-
| `issue_retro` | `PUT /api/issues/:id/retro` | Requires terminal card; replace semantics. `correctable_danxbot_problem` + `correctable_danxbot_problem_description` (DX-2794) are REQUIRED on every write, like `tests[]` — answer honestly whether THIS dispatch hit a fixable problem in danxbot's own code/configuration (not "the card was hard"); a true+described retro starts exactly one automated repair |
|
|
37
|
-
|
|
38
|
-
## `plan_get` — cheap by default, opt-in for the rest (DX-2727)
|
|
39
|
-
|
|
40
|
-
A bare `plan_get` (no `fields`) returns only the plan's cheap scalars — `plan`, `boards`, `cardCount`, `bucketCounts`, `status`, `session`, `sessionListenerAttached` — plus `available_field_groups` naming what else exists. Pass `fields` to opt into `cards` (paged: `cards_offset`, default 0, and `cards_limit`, 1..1000, default 200, pick the page; the response carries `cards_total` and `cards_offset`, so page with `cards_offset` while `cards_offset + cards.length < cards_total` — either paging arg without `cards` in `fields` is a 400), `records` (every goal/rule/caveat) or `records:goal` / `records:rule` / `records:caveat` (just one kind), `architecture`, and `sessions`. A `plan_get` made only to grab a hash before a one-line edit no longer pays for the architecture document or every member card.
|
|
41
|
-
|
|
42
|
-
## `plan_list` / `plan_get` — computed `status` (DX-2834)
|
|
43
|
-
|
|
44
|
-
Every plan carries a `status`, computed fresh on every read and never stored — no writer ever sets it:
|
|
45
|
-
|
|
46
|
-
| Status | Meaning |
|
|
22
|
+
| Tool | Purpose |
|
|
47
23
|
|---|---|
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
24
|
+
| `danxbot_api` | `{method, path, query?, body?}` — calls any dashboard route the caller's credential may reach (card/plan CRUD, comments, dependencies, problems/solutions, quality gates, retro, triage, transitions, …). Returns `{ok, status, body}`; `body` carries the route's own response, or its structured error verbatim when `ok:false`. |
|
|
25
|
+
| `danxbot_api_list` | No args. Lists the routes this caller's credential may reach, one line each (`{method, path, summary, permission}`) — call this first when you don't know the route for what you need to do. |
|
|
26
|
+
| `danxbot_api_spec` | `{endpoints}` (comma-joined `METHOD /path` list, required, min 1). Returns the exact OpenAPI contract for those routes — call this when you know the route but not its exact request/response shape. |
|
|
27
|
+
| `resource_fields` | `GET /api/resources/:resource/fields` — what an opt-in field TREE may name for one resource: `{resource, description, always, hashes, fields, relations}`. Install-global, no `board`. `resource: "issue"` is the root; a relation's own `resource` in the response is what to call this again with, one level deeper. |
|
|
28
|
+
|
|
29
|
+
A dedicated tool survives only for something about the agent itself, never a card/plan operation: `plan_connect` (binds the session to a plan; the event-bridge hook keys on it) and the per-dispatch worker callbacks (`danxbot_complete`, `danxbot_slack_reply`, `danxbot_slack_post_update`, `load_template_app`/`save_template_app`, `quality_gate_complete`, `link_thread_to_issue`, `attach_file`, the `slack_recovery_*` trio, `danxbot_set_evaluator_summary`) — see `src/lifecycle.ts`. The live list is always `tool-defs.json` in this package, regenerated by `npm run gen-tool-defs` — read it rather than trusting a list copied into prose.
|
|
52
30
|
|
|
53
|
-
|
|
31
|
+
Card and plan reads/writes go through `danxbot_api` against the issue/plan route families documented in `GET /api/openapi` (via `danxbot_api_spec`) — `POST /api/issues`, `GET /api/issues/:id` (or `/batch` for many at once, DX-2727), `PATCH /api/issues/:id/edit`, `POST /api/issues/:id/transition`, the problems/solutions/dependencies/comments/quality-gates/retro sub-resource routes, and the equivalent `/api/plans/*` family (`GET /api/plans/:planId` — cheap scalars by default, `fields` opts into `cards`/`records`/`architecture`/`sessions`; `GET /api/plans` for the list, with an optional `status` filter over the same computed `complete`/`awaiting-session`/`building`/`planning` value `GET /api/plans/:planId` always returns ungated).
|
|
54
32
|
|
|
55
33
|
## `bridge` — a working session's event stream client
|
|
56
34
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, symlinkSync, rmSync, realpathSync, } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { isEntrypointModule } from "./entrypoint.js";
|
|
7
|
+
describe("isEntrypointModule (DX-1647)", () => {
|
|
8
|
+
let dir;
|
|
9
|
+
let real;
|
|
10
|
+
let link;
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
dir = mkdtempSync(join(tmpdir(), "entrypoint-test-"));
|
|
13
|
+
real = join(dir, "index.js");
|
|
14
|
+
writeFileSync(real, "// stub entry\n");
|
|
15
|
+
link = join(dir, "danx-dashboard-mcp"); // mimics node_modules/.bin symlink
|
|
16
|
+
symlinkSync(real, link);
|
|
17
|
+
});
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
// import.meta.url always reports the module REALPATH — model that here.
|
|
22
|
+
const moduleUrl = () => pathToFileURL(realpathSync(real)).href;
|
|
23
|
+
it("true when argv[1] is the real file (direct `node index.js`)", () => {
|
|
24
|
+
expect(isEntrypointModule(moduleUrl(), real)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
it("true when argv[1] is a SYMLINK to the file (npx / global bin) — the fleet regression", () => {
|
|
27
|
+
// The symlink path !== the realpath, but the entrypoint MUST still be
|
|
28
|
+
// detected, or npx imports the module and exits without booting the server.
|
|
29
|
+
expect(link).not.toBe(realpathSync(link));
|
|
30
|
+
expect(isEntrypointModule(moduleUrl(), link)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
it("false when argv[1] is undefined (module imported, not run as bin)", () => {
|
|
33
|
+
expect(isEntrypointModule(moduleUrl(), undefined)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
it("false when argv[1] is an empty string", () => {
|
|
36
|
+
expect(isEntrypointModule(moduleUrl(), "")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
it("false when argv[1] points at a different real file", () => {
|
|
39
|
+
const other = join(dir, "other.js");
|
|
40
|
+
writeFileSync(other, "// other\n");
|
|
41
|
+
expect(isEntrypointModule(moduleUrl(), other)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `danx-dashboard-mcp event-text <event>` — the ONE-SHOT client of
|
|
3
|
+
* `GET /api/reminders/event/<event>` (DX-3421).
|
|
4
|
+
*
|
|
5
|
+
* WHO RUNS IT. The claude-plugins `danxbot` plugin's single event hook
|
|
6
|
+
* (`event-hook.sh`, SessionStart matcher "startup|resume|compact" plus
|
|
7
|
+
* SubagentStart matcher ".*") — replaces the old `mantra` subcommand's ONE
|
|
8
|
+
* consumer, `mantra.sh` (deleted, DX-3421, along with
|
|
9
|
+
* `plan-workflow-autoload.sh` and `compaction-skill-reload.sh`; all three
|
|
10
|
+
* collapse into the one hook + this one subcommand). `<event>` is one of
|
|
11
|
+
* `session_start` | `session_resume` | `after_compaction` |
|
|
12
|
+
* `sub_agent_start` — the SAME `npx -y @thehammer/danx-dashboard-mcp@<pin>`
|
|
13
|
+
* path the plugin's other dashboard calls already use (`plan-state`,
|
|
14
|
+
* `bridge`) — never a separate install, never a deep import of this
|
|
15
|
+
* package's `dist/` internals.
|
|
16
|
+
*
|
|
17
|
+
* THE CREDENTIAL — the SAME resolver `bridge` / `plan-state` use
|
|
18
|
+
* (`resolveBridgeOptions`, `bridge.ts`): the dashboard URL, credential source
|
|
19
|
+
* and credential all come from the connection record this session's OWN
|
|
20
|
+
* danx-dashboard MCP server wrote on its last successful `plan_connect`
|
|
21
|
+
* (`session-connection.ts`), never from this process's own ambient env. This
|
|
22
|
+
* is only ever called once a session is already connected (`event-hook.sh`
|
|
23
|
+
* checks that first via `plan-connection.mjs` and stays completely silent
|
|
24
|
+
* otherwise — DX-3421 comment 7748/7752 verdict: no output at all before a
|
|
25
|
+
* plan is connected), so a connection record always exists by the time this
|
|
26
|
+
* runs.
|
|
27
|
+
*
|
|
28
|
+
* THE CONTRACT — `event-hook.sh` needs to tell success from failure so it
|
|
29
|
+
* can print the operator-facing "could not load" notice (DX-3421 AC: no
|
|
30
|
+
* committed offline fallback exists any more — a fetch failure is ALWAYS
|
|
31
|
+
* reported to the agent, never silently swallowed and never a `mantra.md`
|
|
32
|
+
* reread). So:
|
|
33
|
+
*
|
|
34
|
+
* - success: the event's EFFECTIVE text on stdout, nothing else, exit 0.
|
|
35
|
+
* - failure (missing env, no connection record, network error, timeout,
|
|
36
|
+
* non-2xx, 404, bad shape): NOTHING on stdout, one short reason on
|
|
37
|
+
* stderr, exit 1. `event-hook.sh` is the one that decides what to print
|
|
38
|
+
* for the agent — this module only reports whether the fetch worked.
|
|
39
|
+
*
|
|
40
|
+
* A HARD TIMEOUT (mirrors `plan-state.ts`) — a SessionStart hook must never
|
|
41
|
+
* hang on a wedged dashboard.
|
|
42
|
+
*/
|
|
43
|
+
import { SESSION_ID_HEADER, resolveBridgeOptions } from "./bridge.js";
|
|
44
|
+
export const EVENT_TEXT_SUBCOMMAND = "event-text";
|
|
45
|
+
export const EVENT_TEXT_REQUEST_TIMEOUT_MS = 5_000;
|
|
46
|
+
const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${EVENT_TEXT_SUBCOMMAND} <event>`;
|
|
47
|
+
function eventPath(event) {
|
|
48
|
+
return `/api/reminders/event/${encodeURIComponent(event)}`;
|
|
49
|
+
}
|
|
50
|
+
/** One authenticated GET, with a hard timeout — never throws, never bubbles a non-2xx. */
|
|
51
|
+
export async function fetchEventText(event, options, deps) {
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
|
|
54
|
+
let status;
|
|
55
|
+
let text;
|
|
56
|
+
try {
|
|
57
|
+
try {
|
|
58
|
+
const response = await deps.fetch(`${options.dashboardUrl}${eventPath(event)}`, {
|
|
59
|
+
method: "GET",
|
|
60
|
+
headers: {
|
|
61
|
+
Authorization: `Bearer ${options.token}`,
|
|
62
|
+
Accept: "application/json",
|
|
63
|
+
[SESSION_ID_HEADER]: options.sessionId,
|
|
64
|
+
},
|
|
65
|
+
signal: controller.signal,
|
|
66
|
+
});
|
|
67
|
+
status = response.status;
|
|
68
|
+
text = await response.text();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
if (status === 401 || status === 403)
|
|
78
|
+
return { ok: false, reason: "unauthorized" };
|
|
79
|
+
if (status === 404)
|
|
80
|
+
return { ok: false, reason: "not_found" };
|
|
81
|
+
if (status < 200 || status >= 300)
|
|
82
|
+
return { ok: false, reason: "http_error" };
|
|
83
|
+
let body;
|
|
84
|
+
try {
|
|
85
|
+
body = text === "" ? null : JSON.parse(text);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return { ok: false, reason: "bad_response" };
|
|
89
|
+
}
|
|
90
|
+
const effectiveText = typeof body === "object" &&
|
|
91
|
+
body !== null &&
|
|
92
|
+
typeof body.effective_text === "string"
|
|
93
|
+
? body.effective_text
|
|
94
|
+
: null;
|
|
95
|
+
if (effectiveText === null)
|
|
96
|
+
return { ok: false, reason: "bad_response" };
|
|
97
|
+
return { ok: true, text: effectiveText };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The bin's `event-text` subcommand, wired to the real process. Success
|
|
101
|
+
* prints the effective text on stdout and returns 0; every failure prints
|
|
102
|
+
* nothing on stdout, one reason on stderr, and returns 1 — `event-hook.sh`
|
|
103
|
+
* reads the exit code, never parses stdout to guess.
|
|
104
|
+
*/
|
|
105
|
+
export async function runEventTextCommand(argv, env = process.env,
|
|
106
|
+
/** Test seam: the home the session's connection record is read from. */
|
|
107
|
+
resolveFrom = {}) {
|
|
108
|
+
const [event, ...rest] = argv;
|
|
109
|
+
if (rest.length !== 0 || typeof event !== "string" || event === "") {
|
|
110
|
+
process.stderr.write(`missing or extra arguments\n${USAGE}\n`);
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
const sessionId = env.CLAUDE_CODE_SESSION_ID;
|
|
114
|
+
if (!sessionId) {
|
|
115
|
+
process.stderr.write(`no_session_id: CLAUDE_CODE_SESSION_ID is not set\n${USAGE}\n`);
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
let options;
|
|
119
|
+
try {
|
|
120
|
+
options = resolveBridgeOptions({ sessionId, resumeIds: [] }, env, resolveFrom);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
const start = err;
|
|
124
|
+
process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
const output = await fetchEventText(event, options, {
|
|
128
|
+
fetch: (input, init) => fetch(input, init),
|
|
129
|
+
requestTimeoutMs: EVENT_TEXT_REQUEST_TIMEOUT_MS,
|
|
130
|
+
});
|
|
131
|
+
if (!output.ok) {
|
|
132
|
+
process.stderr.write(`${output.reason}: could not fetch the effective "${event}" text from the dashboard\n`);
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
process.stdout.write(output.text);
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
package/dist/field-tree.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DX-3426 — the shared recursive FIELD-TREE shape.
|
|
3
3
|
*
|
|
4
|
-
* The dashboard's single-card, batch card and plan reads (`
|
|
5
|
-
* `
|
|
4
|
+
* The dashboard's single-card, batch card and plan reads (`danxbot_api`
|
|
5
|
+
* against `GET /api/issues/:id`, `GET /api/issues`, `GET /api/plans/:planId`
|
|
6
|
+
* — DX-3428) take a nested JSON tree instead of a
|
|
6
7
|
* flat field-GROUP list: each key names a field or relation of the resource
|
|
7
8
|
* at that level, and each value is either `true` (the field, or a relation's
|
|
8
9
|
* child with its default fields), a non-negative integer (a cursor-paged
|
|
@@ -24,8 +25,8 @@
|
|
|
24
25
|
* self-reference to a local `$ref` rather than unrolling it infinitely —
|
|
25
26
|
* verified directly against the listed tool schema in
|
|
26
27
|
* `__tests__/tool-defs.test.ts`, not assumed from reading the library's
|
|
27
|
-
* source.
|
|
28
|
-
* same `z.lazy` self-reference pattern already shipping on this server.
|
|
28
|
+
* source. The problem-solutions route's pre-existing `steps` field (DX-3310)
|
|
29
|
+
* is the same `z.lazy` self-reference pattern already shipping on this server.
|
|
29
30
|
*/
|
|
30
31
|
import { z } from "zod";
|
|
31
32
|
/**
|