@thehammer/danx-dashboard-mcp 0.1.263 → 0.1.264

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @thehammer/danx-dashboard-mcp
2
2
 
3
- Stdio MCP server fronting danxbot's dashboard HTTP API. Since DX-3465 it exposes three generic tools (`danxbot_api`, `danxbot_api_list`, `danxbot_api_spec` — see "Tool surface" below) that reach any route the caller's credential may reach, plus a few dedicated tools for what no route call can do (`plan_connect`, `request_permission`, `attach_file`, `danxbot_slack_reply`) — there is no per-operation tool any more, and since DX-4525 no dispatch-callback tool either: `danxbot_api` is every agent's primary channel to the dashboard (ending a dispatch, gate sign-off, code-review request, Slack update, thread link, Slack recovery, evaluator summary and template-app load/save are all `danxbot_api` calls to the routes in `src/dispatch/callback-routes.ts`). The server instructions and the `danxbot_api_spec` description tell an agent to read the spec before its first call to a route it has not called before.
3
+ Stdio MCP server fronting danxbot's dashboard HTTP API. Since DX-3465 it exposes three generic tools (`danxbot_api`, `danxbot_api_list`, `danxbot_api_spec` — see "Tool surface" below) that reach any route the caller's credential may reach, plus a handful of dedicated tools for agent-specific operations (`plan_connect`, `danxbot_complete`, Slack/template-app callbacks, …) — there is no per-operation tool any more.
4
4
 
5
5
  `danxbot_api` is Zod-validated at the MCP boundary, fetches under the hood, and passes the server response back to the agent verbatim. Refusal envelopes (`{error, ...extra}` with `failed_gate`, `non_terminal_phases`, `offending_keys`, etc.) come through as `{ok: false, status, body}` so the agent can pick the right next action without guessing. 5xx and network failures throw.
6
6
 
@@ -21,7 +21,7 @@ BOARD-ONLY (DX-1171): board is the first-level concept; repo is DERIVED from boa
21
21
  An operator session has no stored credential. Signed out, `plan_connect` (optionally with `title`, the session's own Claude title) posts `POST /api/access-requests` and answers at once `{state: "approval_required", approvalUrl, confirmCode, expiresAt, instruction}`: show the user the code and open the URL; they approve in the dashboard. The server polls `POST /api/access-requests/claim` in the background, so the key is stored when the approval lands; the next `plan_connect` waits up to about 45 seconds for it, then connects, or answers `approval_pending`. One tool call never blocks for minutes. `denied` is reported once; a request that lapsed (the dashboard deletes it within about a minute of expiry, so a claim answers 404) starts a fresh one on the next call.
22
22
 
23
23
  - **Storage.** `~/.config/danxbot/session-keys/<sha12(dashboard url)>/<session id>.json`, mode 0600, written write-then-rename, pruned after 7 days. One file per dashboard and per session; the bridge reads the same file through the `session-key` credential source and proves the fingerprint. The key lapses on the dashboard after 24 hours without use (every authenticated call, and the bridge's heartbeat, renew it), and is revocable from the account page.
24
- - **A 401 on the stored key** deletes the file and returns the sign-in halt (in `plan_connect`: a new request in the same call). That is a key that lapsed. A key a PERSON revoked is answered `401 key_revoked` (DX-4418): the key file is deleted and EVERY dashboard tool, `plan_connect` included, returns one stop halt (who revoked it and when; commit work in progress and end; a dispatched agent makes no further call), for the rest of the process. No access request is made. The worker finds the revocation itself and ends the dispatch as revoked (every dashboard route, the dispatch-complete one included, refuses a revoked key). The session's event bridge ends with the stop reason `key_revoked` the same way. **A 404 on create** means the dashboard predates request-and-approve and says so.
24
+ - **A 401 on the stored key** deletes the file and returns the sign-in halt (in `plan_connect`: a new request in the same call). That is a key that lapsed. A key a PERSON revoked is answered `401 key_revoked` (DX-4418): the key file is deleted and EVERY dashboard tool, `plan_connect` included, returns one stop halt (who revoked it and when; commit work in progress and end; a dispatched agent ends with `danxbot_complete`), for the rest of the process. No access request is made. `danxbot_complete` and the other worker lifecycle tools do not use the dashboard key, so a dispatched agent can still end. The session's event bridge ends with the stop reason `key_revoked` the same way. **A 404 on create** means the dashboard predates request-and-approve and says so.
25
25
  - A dispatch's `DANX_DASHBOARD_CREDENTIAL` skips all of this. There is no token-file credential source.
26
26
 
27
27
  ### Asking for one more permission (DX-4434)
@@ -36,7 +36,7 @@ A session is shown only the tools it can call. `src/tool-access.ts` declares, fo
36
36
 
37
37
  - **Bootstrap set**, for every session and the whole of a signed-out one: `plan_connect`, `request_permission` and the `danxbot_api` trio.
38
38
  - **Permission tools** (`attach_file` needs `cards.edit`): shown once the key holds the permission the tool's dashboard route needs.
39
- - **Callback tool** (`danxbot_slack_reply`, the only one, DX-4525): shown when the worker injected its URL (`DANXBOT_SLACK_REPLY_URL`, `<dashboard>/api/slack/reply`, no dispatch id) AND the key holds `slack.threads.post`, so a non-Slack dispatch never sees it. It posts with the dispatch's own credential (`DANX_DASHBOARD_CREDENTIAL`), the same one `danxbot_api` uses.
39
+ - **Callback tools**: shown when the worker injected the tool's per-dispatch URL (`DANXBOT_*_URL`). The six that post to dashboard routes (`danxbot_slack_*`, `link_thread_to_issue`, `slack_recovery_*`) also need the route's permission; the six that post to a worker route (`danxbot_complete`, `quality_gate_complete`, `request_code_review`, `danxbot_set_evaluator_summary`, `save_template_app`, `load_template_app`) authenticate with the dispatch's agent token and need only the URL.
40
40
  - **When it is applied.** At boot, as a session with no key; then, if the session holds a key (a dispatch's env credential, or a stored session key), right away from `GET /api/auth/me`; a dispatch retries that read three times and fails the boot rather than start without its tools. After `plan_connect` signs a session in. And again when a person approves a `request_permission` grant: the dashboard ends the key's open `/api/stream` with `event: end {reason: "permissions_changed"}`, this process holds one stream open on the key-self topic `key:self` (`src/permission-watch.ts`; any key may subscribe with no permission, because a dispatch's agent role holds no read-floor key), and after EVERY (re)connect re-reads the key's permissions (a failed read is retried with bounded backoff) and enables what is newly allowed in ONE `list_changed`. It reconnects with backoff after a drop, and stops when stdin closes. A grant that adds no tool, and a repeated read, switch nothing and send no notification, because a changed tool list costs the session its prompt cache.
41
41
  - A stream the dashboard refuses (any 4xx) is logged and ends the watch; that session's grants then show their tools at its next `plan_connect`. A permission read the dashboard refuses (401/403, other than the Terms halt) fails a dispatch's boot rather than quietly narrowing its tools.
42
42
 
@@ -47,14 +47,14 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
47
47
  | Tool | Purpose |
48
48
  |---|---|
49
49
  | `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`. While this session is plan-connected, the dashboard's own reminders for it (today: `plan_list.listener_unattached`, attached by the server to `GET /api/plans` when the event listener is unattached past the startup grace, DX-3997) are relayed into the body's `reminders[]`; the MCP carries no reminder text or state of its own and re-reads at most every 30s. |
50
- | `danxbot_api_list` | No args. Lists the routes this caller's credential may reach as `{routes: ["METHOD /path"]}` (one bare string per route, exactly what `danxbot_api_spec` takes; a dispatch's own callbacks are in it) — call this first when you don't know the route for what you need to do. |
50
+ | `danxbot_api_list` | No args. Lists the routes this caller's credential may reach as `{routes: ["METHOD /path"], external: [{route, served_by}]}` (one bare string per route, exactly what `danxbot_api_spec` takes) — call this first when you don't know the route for what you need to do. |
51
51
  | `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. |
52
52
  | `attach_file` | `{card_id, path, replaces?, primary?, filename?, content_type?}` — the server reads the LOCAL file at `path` (absolute, `/tmp/a.png` or `C:\x\a.png`) and uploads it, so its bytes never pass through the agent's context. `replaces: <attachment id>` swaps that attachment's file in place (`PUT /api/issues/:id/attachments/:aid`: same id, name and cover flag) instead of adding one; `primary: true` makes it the card's single cover, atomically with the write. Reply: `{issue: <compact row>, attachment_id, url, thumbnail_url, is_primary}`. Shown to a key holding `cards.edit` (DX-4523); a dispatch profile still receives it only when its catalog selection names it (`slack-agent` and `work`, DX-4386, through `extraTools`). A screenshot of browser-visible work: first `primary: true`, then `replaces` at each milestone. |
53
53
  | `request_permission` | `{permissions, reason}` — asks a person to grant this key ONE MORE permission (see "Asking for one more permission" above). Answers at once with the approval URL and confirm code; the grant is picked up in the background. |
54
54
 
55
55
  There is no dedicated `resource_fields` tool (removed DX-3465 — it was a thin wrapper `danxbot_api` already reaches with no agent-specific behavior earning it its own tool). What an opt-in field TREE may name for one resource (`{resource, description, always, hashes, fields, relations}`) is `danxbot_api({method:"GET", path:"/api/resources/<resource>/fields"})` — install-global, no `board`; `resource: "issue"` is the root, and a relation's own `resource` in the response is what to call this again with, one level deeper.
56
56
 
57
- A dedicated tool survives only for something no `danxbot_api` call can do, never a card/plan/dispatch-callback operation: `request_permission` (asks for a permission its key lacks), `plan_connect` (binds the session to a plan; the event-bridge hook keys on it), and the two that read LOCAL files, `attach_file` and `danxbot_slack_reply` (`files[]`, 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.
57
+ A dedicated tool survives only for something about the agent itself, never a card/plan operation: `request_permission` (asks for a permission its key lacks), `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.
58
58
 
59
59
  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`/`session_history`/`worker_dispatches`; `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).
60
60
 
@@ -21,10 +21,6 @@
21
21
  * list example filters by `q`/`include_closed` instead. Two haiku AC ticks
22
22
  * read `ac` (no checklist id) and sent `ac` / `0` as the checklist id: the
23
23
  * card read shows only `checklists`, and both tick ids name it.
24
- * DX-4525 - the card-list and comment examples were trimmed: a list or a comment sent wrong is refused with a body naming
25
- * the fix, and the spec call the description asks for first shows both. The dispatch-callback calls (complete, gate sign-off,
26
- * code review) are NOT shown here: only a dispatch makes them, so every operator session would pay for them in this
27
- * description; each profile's own instructions name its route and body.
28
24
  *
29
25
  * Kept as data, not prose, so danxbot's own tests can check each example
30
26
  * against the live server contract (route table, resource fields, body
@@ -37,6 +33,11 @@ export const DANXBOT_API_EXAMPLES = {
37
33
  path: "/api/issues/<id>",
38
34
  query: { fields: { description: true, comments: true, checklists: true, parent_title: true } },
39
35
  },
36
+ cardList: {
37
+ method: "GET",
38
+ path: "/api/issues",
39
+ query: { filter: { q: "<text>", include_closed: true } },
40
+ },
40
41
  cardCreate: {
41
42
  method: "POST",
42
43
  path: "/api/issues",
@@ -54,6 +55,11 @@ export const DANXBOT_API_EXAMPLES = {
54
55
  path: "/api/issues/<id>/edit",
55
56
  body: { description: "<markdown>", ac: [{ title: "<criterion>" }], content_hash: "<content_hash from your read>" },
56
57
  },
58
+ comment: {
59
+ method: "POST",
60
+ path: "/api/issues/<id>/comments",
61
+ body: { text: "<markdown>" },
62
+ },
57
63
  acTick: {
58
64
  method: "PATCH",
59
65
  path: "/api/issues/<id>/checklists/<checklists[].id>/items/<checklists[].items[].id>",
@@ -88,8 +94,10 @@ function render(example) {
88
94
  export function danxbotApiExamplesText() {
89
95
  const e = DANXBOT_API_EXAMPLES;
90
96
  return (`Correct first calls: card read ${render(e.cardRead)} (\`fields\` is an object, never a comma string); ` +
97
+ `card list ${render(e.cardList)} (filters only inside \`filter\`); ` +
91
98
  `card create ${render(e.cardCreate)} (\`plan\` required: "mine" or null; \`summary\` required, on every phase_children[] item too); ` +
92
99
  `card edit ${render(e.cardEdit)} (only the keys you change; \`ac\` replaces the list); ` +
100
+ `comment ${render(e.comment)}; ` +
93
101
  `AC tick ${render(e.acTick)}; ` +
94
102
  `transition ${render(e.transition)}; ` +
95
103
  `retro, only after \`complete\` or \`cancel\`, ${render(e.retro)} (description "" unless the flag is true; ` +
package/dist/handlers.js CHANGED
@@ -137,7 +137,7 @@ export async function danxbotApi(client, args) {
137
137
  const ROUTES_BASE_PATH = "/api/routes";
138
138
  /**
139
139
  * `danxbot_api_list()` — the routes YOUR credential may currently call,
140
- * `{routes: ["METHOD /path"]}`, one string each (DX-4401). Backed by DX-3528's
140
+ * `{routes: ["METHOD /path"], external: [{route, served_by}]}`, one string each (DX-4401). Backed by DX-3528's
141
141
  * `GET /api/routes`, which filters the served route table through the SAME
142
142
  * `isRouteAuthorizedForPrincipal` check `authorize()` enforces on every
143
143
  * request — a route listed here is always callable, and a route this
package/dist/index.js CHANGED
@@ -108,7 +108,7 @@ import { bodyValueSchema, queryValueSchema } from "./types.js";
108
108
  import { termsAcceptanceCallHalt, termsHaltFor } from "./terms-halt.js";
109
109
  import { danxbotApiExamplesText } from "./danxbot-api-examples.js";
110
110
  import { assertWithinTextLimit } from "./text-limit.js";
111
- import { registerLifecycleTools, presentCallbacksFromEnv } from "./lifecycle.js";
111
+ import { registerLifecycleTools, presentCallbacks, readLifecycleUrlsFromEnv, } from "./lifecycle.js";
112
112
  import { TOOL_ACCESS } from "./tool-access.js";
113
113
  import { ToolAccessSync, ToolSurface } from "./tool-surface.js";
114
114
  import { PermissionWatch } from "./permission-watch.js";
@@ -226,7 +226,7 @@ function boot() {
226
226
  // before `server.connect()`) as a session with no key: the bootstrap set plus the callback tools this dispatch's worker
227
227
  // gave a URL for. `syncToolsAfterSignIn` widens it once the key's permissions are read. The DX-1606 tool-defs
228
228
  // generator imports this module WITHOUT calling `boot()`, so it still sees every tool enabled.
229
- const surface = new ToolSurface(registeredTools, presentCallbacksFromEnv(), () => server.sendToolListChanged());
229
+ const surface = new ToolSurface(registeredTools, presentCallbacks(readLifecycleUrlsFromEnv()), () => server.sendToolListChanged());
230
230
  surface.apply(new Set());
231
231
  toolAccessSync = new ToolAccessSync({
232
232
  surface,
@@ -361,8 +361,7 @@ function readSessionConfig() {
361
361
  }
362
362
  /**
363
363
  * DX-3465 (PLN-11 R-25) — the conventions shared by every `danxbot_api` call,
364
- * stated once here instead of on each tool. DX-4525 / DX-4015: its first paragraph makes `danxbot_api` THE channel and says
365
- * to read the spec first (an agent skipping the spec is a behaviour to fix, not a reason to keep a tool per job).
364
+ * stated once here instead of on each tool.
366
365
  *
367
366
  * DX-4318 — this block is the ONE home of the stale-hash rule and of the
368
367
  * writing standard (PLN-11 R-24): it is the only text every caller of these
@@ -372,9 +371,8 @@ function readSessionConfig() {
372
371
  * below refuses a block that would be cut again. Behaviour only, no rationale.
373
372
  */
374
373
  const SERVER_INSTRUCTIONS = `\
375
- \`danxbot_api\` is your primary channel to the dashboard: every card, plan, Slack and dispatch-callback job (ending
376
- your dispatch included) is a call to it. Find a route with \`danxbot_api_list\`. Read its exact shape with
377
- \`danxbot_api_spec\` before the first call to any route you have not called before; never guess a body.
374
+ Conventions for every \`danxbot_api\` call. Find a route with \`danxbot_api_list\` and its exact shape with
375
+ \`danxbot_api_spec\`.
378
376
 
379
377
  RESOURCES. Most collections take GET (list) and POST (create); a single resource (\`<collection>/<id>\`) takes
380
378
  GET, PATCH or PUT (send only the fields you change) and DELETE. A card is edited at
@@ -478,9 +476,11 @@ function strictTool(name, description, shape, cb) {
478
476
  registeredTools.set(name, tool);
479
477
  return tool;
480
478
  }
481
- // ---------------- lifecycle tool set (DX-3465 / PLN-11 R-25, DX-4525) ----------------
482
- // One tool is left, `danxbot_slack_reply` (it reads local files); every other dispatch callback is a `danxbot_api` call
483
- // (see `lifecycle.ts`). `toolsFor` (`tool-access.ts`) decides whether a session sees it.
479
+ // ---------------- lifecycle tool set (DX-3465 / PLN-11 R-25) ----------------
480
+ // Merged from `src/mcp/danxbot-server.ts` — see `lifecycle.ts` for the full
481
+ // per-tool doc comments and the DX-3394 problem 1682 cleanup this merge
482
+ // folded in. `danxbot_complete` registers unconditionally-visible; the rest
483
+ // register here; `toolsFor` (`tool-access.ts`) decides which a session sees.
484
484
  registerLifecycleTools(strictTool);
485
485
  // Optional per-call board override shared by every tool. Omitted → the
486
486
  // dispatch's env-derived board (`<repo>:<slug>`) is used; provided → that
@@ -511,9 +511,9 @@ strictTool("danxbot_api",
511
511
  // to a plan-connected session; the reminder's text explains itself.
512
512
  "Call any dashboard API route your credential may reach. The answer is the route's JSON body: act on its " +
513
513
  "`reminders[]` when present. " +
514
- // DX-4015 / DX-4525 — narrows SERVER_INSTRUCTIONS' general spec-first rule: a write shown below is
515
- // copied from its example (3 of 3 canary sessions did, with zero 4xx), so only a write NOT shown, and
516
- // always the retro, needs a spec call first.
514
+ // DX-4015 — the ONE statement of which writes need a spec call first; the
515
+ // `work` profile's spec-first sentence defers to this list. A shown write
516
+ // is copied from its example (3 of 3 canary sessions did, with zero 4xx).
517
517
  "Before your first write to a route not shown below, and always before `PUT /api/issues/:id/retro`, " +
518
518
  "call `danxbot_api_spec`. " +
519
519
  // DX-3923 — the shapes agents guessed wrong most often, shown before the
@@ -565,15 +565,13 @@ async (args) => {
565
565
  // ---------------- danxbot_api_list (DX-3465 A5, PLN-11 R-25) ----------------
566
566
  strictTool("danxbot_api_list",
567
567
  // DX-3528 — the list is filtered by the same permission check every request takes.
568
- "Start here to find the route for any job: lists the dashboard API routes your credential may call, `{routes: " +
569
- "[\"METHOD /path/template\", ...]}`, the strings `danxbot_api_spec` takes (`POST /api/issues/:id/transition`). A route " +
570
- "not listed is not callable by you.", {}, async () => guardedBoundedJson(() => danxbotApiList(client)));
568
+ "Lists the dashboard API routes your credential may call: `{routes: [\"METHOD /path/template\", ...], external: [...]}`, the " +
569
+ "strings `danxbot_api_spec` takes (`POST /api/issues/:id/transition`); `external` are worker-served routes. A route not " +
570
+ "listed is not callable by you.", {}, async () => guardedBoundedJson(() => danxbotApiList(client)));
571
571
  // ---------------- danxbot_api_spec (DX-3465, PLN-11 R-25) ----------------
572
572
  strictTool("danxbot_api_spec",
573
- // DX-3527 — the contract is generated from the route table. DX-4015 / DX-4525 — read before the first call to a route you
574
- // have not called: `danxbot_api` is the only channel, and a guessed body is a 4xx or a wrong terminal call.
575
- "Read this before your first `danxbot_api` call to any route you have not called before. The OpenAPI 3.1 contract for the " +
576
- "routes you name: request and response shape, required permission and " +
573
+ // DX-3527 — the contract is generated from the route table.
574
+ "The OpenAPI 3.1 contract for the routes you name: request and response shape, required permission and " +
577
575
  'every documented error. `endpoints`: `"METHOD /path/template"` strings as `danxbot_api_list` reports them ' +
578
576
  '(e.g. `"POST /api/issues/:id/transition"`), never a real path with ids. An endpoint the dashboard does ' +
579
577
  "not serve, or your credential may not call, is refused and named.", {
@@ -14,14 +14,13 @@ export function keyRevocationOf(result) {
14
14
  return { revokedBy: body.revokedBy, revokedAt: body.revokedAt };
15
15
  }
16
16
  /**
17
- * The one halt. `dispatched` is a dispatch's own credential: it makes no further call (every dashboard route, the
18
- * dispatch-complete one included, refuses a revoked key; DX-4525), because the worker finds the revocation itself and ends the
19
- * dispatch as revoked, not as the agent's failure (`src/worker/dispatch-execution.ts`). An operator session ends by telling the
20
- * person; it has no tool that ends it.
17
+ * The one halt. `dispatched` is a dispatch's own credential: it ends with `danxbot_complete` (a worker tool, which does not
18
+ * use the revoked key), and the worker records the end as revoked, not as the agent's failure. An operator session ends by
19
+ * telling the person; it has no tool that ends it.
21
20
  */
22
21
  export function keyRevokedHalt(revocation, dispatched) {
23
22
  const ending = dispatched
24
- ? "Then stop and make no further call: the worker ends this dispatch itself and records it as revoked, not as a failure."
23
+ ? 'End this dispatch with danxbot_complete({status: "failed", summary: "<where your work is committed>"}); the worker records it as revoked, not as a failure.'
25
24
  : "Then end this session: tell the person what you committed and stop.";
26
25
  return (`STOP ALL WORK NOW. ${revocation.revokedBy} revoked your access to the danxbot dashboard at ${revocation.revokedAt}.\n\n` +
27
26
  "Commit your work in progress now (commit what you have; do not run agent-finalize.sh and do not merge). " +
package/dist/lifecycle.js CHANGED
@@ -1,44 +1,111 @@
1
1
  /**
2
- * DX-3465 (PLN-11 R-25) / DX-4525 - the "lifecycle" tool set: the one agent-specific dispatch callback that is
3
- * still a dedicated tool, `danxbot_slack_reply`.
2
+ * DX-3465 (PLN-11 R-25 / plan rule R-25) — the "lifecycle" tool set: agent-
3
+ * specific dispatch callbacks, merged from danxbot's own `src/mcp/danxbot-server.ts`
4
+ * (DX-3459 problem 1754, decision: one server, named tool sets, switched on
5
+ * per profile). These are NOT dashboard CRUD — they are the agent's own
6
+ * self-report channel to the worker/dashboard that spawned it: "I'm done"
7
+ * (`danxbot_complete`), Slack reply/update/recovery, quality-gate sign-off,
8
+ * template-app load/save, and the evaluator summary. Every dashboard CRUD
9
+ * operation these tools used to sit beside (issue_*, plan_* etc.) goes
10
+ * through `danxbot_api` instead (R-25) — see `index.ts`.
4
11
  *
5
- * Every other dispatch callback is a `danxbot_api` call: an agent makes
6
- * the dispatch-complete, gate sign-off, code-review request, evaluator-summary, template-app load / save, Slack update and
7
- * recovery calls through it (`src/dispatch/callback-routes.ts` names each route), so a dispatch learns ONE channel to the dashboard and reads its
8
- * spec instead of carrying a tool schema per job. This one stays a tool because it reads local files (`files[]`) and
9
- * uploads their bytes, which no `danxbot_api` call can do.
12
+ * Registered UNCONDITIONALLY at module load (so the DX-1606 tool-defs
13
+ * generator — which never calls `boot()` — still introspects every one of
14
+ * them for token-cost accounting). Which of them a session SEES is decided by
15
+ * `toolsFor` (`tool-access.ts`) from the key's permissions and the callback
16
+ * URLs present (`presentCallbacks`), applied by `tool-surface.ts` (DX-4523).
10
17
  *
11
- * Registered UNCONDITIONALLY at module load (so the DX-1606 tool-defs generator, which never calls `boot()`, still
12
- * introspects it for token-cost accounting). Whether a session SEES it is decided by `toolsFor` (`tool-access.ts`) from the
13
- * key's permissions and the callback URL present (`presentCallbacksFromEnv`), applied by `tool-surface.ts` (DX-4523).
18
+ * DX-3394 problem 1682 cleanup folded into this merge:
19
+ * - `danxbot_restart_worker` — already removed on `main` before this card
20
+ * (DX-3231 host-mode removal); never carried over.
21
+ * - `quality_gate_skip` folded into `quality_gate_complete` as
22
+ * `status: "skipped"` (reason ≥20 chars, same server-side bar).
23
+ * - `slack_replace_message` + `slack_purge_message` merged into
24
+ * `slack_recovery_message({action: "replace" | "purge", ts, text?})`.
25
+ * - `ready` / `archive` / `review` removed from `AGENT_COMPLETE_STATUSES`
26
+ * (Core Principle 1 — no legacy aliases). The `danx-flesh-out` plugin
27
+ * skill instructed the old aliases; that is Part B (plugin-side prose),
28
+ * not reachable from this repo.
29
+ * - `danxbot_update_router_prompt` DELETED OUTRIGHT (DX-3459 problem 1755,
30
+ * decision 251: the router prompt becomes an ordinary board setting,
31
+ * reached through `danxbot_api` with a scoped permission — no dedicated
32
+ * tool). The slack-agent profile documents the route (Part B).
33
+ * - Descriptions trimmed of DX-card historical narration where cheap to do
34
+ * without re-deriving the byte-for-byte target DX-3394 measured.
14
35
  */
15
36
  import { readFile, stat } from "node:fs/promises";
16
37
  import { basename, isAbsolute } from "node:path";
17
38
  import { z } from "zod";
18
39
  import { CALLBACK_ENV } from "./tool-access.js";
19
- import { CREDENTIAL_TOKEN_ENV } from "./credential.js";
20
- /** The callbacks whose URL the worker injected into this process's environment (an empty value reads as absent): what `toolsFor` takes as this dispatch's callbacks. */
21
- export function presentCallbacksFromEnv() {
22
- return new Set(Object.keys(CALLBACK_ENV).filter((name) => (process.env[CALLBACK_ENV[name]] ?? "") !== ""));
40
+ /**
41
+ * The five canonical agent-visible `danxbot_complete` statuses. DX-3394
42
+ * problem 1682 / Core Principle 1 — the pre-DX-770 deprecated aliases
43
+ * (`completed`, `agent_blocked`) were already gone; this merge additionally
44
+ * drops the `ready` / `archive` / `review` backward-compat aliases that
45
+ * Core Principle 1 forbids as standing legacy tolerance. A dispatch that
46
+ * needs one of those non-terminal lifecycle stamps transitions the card
47
+ * FIRST through `danxbot_api` ({method:'POST', path:'/api/issues/:id/transition',
48
+ * body:{action:'ready'|'archive'}}), then calls
49
+ * `danxbot_complete({status:'complete'})`.
50
+ */
51
+ export const AGENT_COMPLETE_STATUSES = [
52
+ "complete",
53
+ "cancelled",
54
+ "failed",
55
+ "critical_failure",
56
+ "waiting_on_dependency",
57
+ ];
58
+ /** DX-722 — minimum `summary` length for `failed` to count as a legitimate self-block. */
59
+ export const ADEQUATE_SUMMARY_MIN_CHARS = 30;
60
+ /** DX-1369 — minimum reason length for a `quality_gate_complete` "skipped" verdict. */
61
+ export const QUALITY_GATE_SKIP_REASON_MIN_CHARS = 20;
62
+ export function hasAdequateBlockSummary(summary) {
63
+ return typeof summary === "string" && summary.trim().length >= ADEQUATE_SUMMARY_MIN_CHARS;
64
+ }
65
+ /** The callbacks whose URL is present: what `toolsFor` takes as this dispatch's callbacks (`tool-access.ts`). */
66
+ export function presentCallbacks(urls) {
67
+ return new Set(Object.keys(urls).filter((name) => urls[name] !== undefined));
68
+ }
69
+ /** Read the lifecycle callback URLs straight from `process.env`; an empty value reads as absent. */
70
+ export function readLifecycleUrlsFromEnv() {
71
+ const urls = {};
72
+ for (const name of Object.keys(CALLBACK_ENV)) {
73
+ const v = process.env[CALLBACK_ENV[name]];
74
+ if (typeof v === "string" && v !== "")
75
+ urls[name] = v;
76
+ }
77
+ return urls;
23
78
  }
24
79
  async function postJson(url, body, bearer) {
25
80
  return fetch(url, {
26
81
  method: "POST",
27
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${bearer}` },
82
+ headers: {
83
+ "Content-Type": "application/json",
84
+ ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
85
+ },
28
86
  body: JSON.stringify(body),
29
87
  });
30
88
  }
31
- /**
32
- * DX-4525 - the dispatch's own credential, the one `danxbot_api` calls the dashboard with: a dispatch declares it in
33
- * `DANX_DASHBOARD_CREDENTIAL` (`credential.ts`) and it never signs out. Read here, not through the late-bound `SessionAccess`,
34
- * so the tool works wherever the module is imported; a missing one is a loud failure, never an unauthenticated post.
35
- */
36
- function dispatchCredential() {
37
- const token = process.env[CREDENTIAL_TOKEN_ENV];
38
- if (typeof token !== "string" || token.trim() === "") {
39
- throw new Error(`danxbot_slack_reply: ${CREDENTIAL_TOKEN_ENV} is not set for this dispatch`);
89
+ /** DX-925 — bearer for dispatch-internal WORKER routes (stop, quality-gate, template-app, ...). */
90
+ function agentBearer() {
91
+ return process.env.DANX_AGENT_TOKEN;
92
+ }
93
+ /** DX-2246 — bearer for the callbacks that target the DASHBOARD (slack reply/update, link-thread, slack-recovery). */
94
+ function dashboardBearer() {
95
+ return process.env.DANXBOT_DASHBOARD_TOKEN;
96
+ }
97
+ async function postWorkerRoute(url, body, toolName, bearer) {
98
+ const response = await postJson(url, body, bearer);
99
+ const text = await response.text();
100
+ if (!response.ok) {
101
+ throw new Error(`${toolName} worker endpoint returned HTTP ${response.status}: ${text}`);
102
+ }
103
+ try {
104
+ return JSON.parse(text);
105
+ }
106
+ catch {
107
+ return { raw: text };
40
108
  }
41
- return token.trim();
42
109
  }
43
110
  /** Must match `MAX_DECODED_BYTES` in danxbot's `src/issues/attachment-limits.ts` (duplicated, not imported: this package is published on its own). */
44
111
  const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
@@ -73,30 +140,89 @@ async function responseFailure(label, response) {
73
140
  return new Error(`${label} returned HTTP ${response.status}${text ? `: ${text}` : ""}`);
74
141
  }
75
142
  /**
76
- * Registers the lifecycle tool on `server` via the caller's `strictTool` wrapper (so it gets the same DX-3238 strict-shape
77
- * guard the rest of this package's tools use).
143
+ * Registers every lifecycle tool on `server` via the caller's `strictTool`
144
+ * wrapper (so every tool here gets the same DX-3238 strict-shape guard the
145
+ * rest of this package's tools use) and returns the `RegisteredTool` handles
146
+ * for the ones that are conditionally visible — `boot()` disables each based
147
+ * on `readLifecycleUrlsFromEnv()`.
78
148
  */
79
149
  export function registerLifecycleTools(strictTool) {
150
+ // ---------------- danxbot_complete ----------------
151
+ // DX-3465 (code-quality gate finding, at 275f82c0d) — this tool used to be
152
+ // unconditionally visible on the theory that "every dispatch ends with
153
+ // it", but an OPERATOR session is not a dispatch: it has no
154
+ // DANXBOT_STOP_URL, so `danxbot_complete` was always advertised there and
155
+ // every call threw. It is now gated on the stop URL exactly like every
156
+ // other lifecycle tool below (`toolsFor`, `tool-access.ts`) — a real
157
+ // dispatch always has the URL, so nothing is lost there; an operator
158
+ // session simply never sees a tool it could never successfully call.
159
+ strictTool("danxbot_complete", "Finalize the current dispatch. Releases the worker's per-dispatch scope and ends the dispatch row. " +
160
+ "Does NOT move any card — if you're working an issue-bound dispatch and want the card to move " +
161
+ "(Done / Blocked / Cancelled / etc.), transition the card FIRST through danxbot_api " +
162
+ "({method:'POST', path:'/api/issues/:id/transition', body:{action:...}}), then call this tool. " +
163
+ "Always call this when done instead of simply stopping output.", {
164
+ status: z.enum(AGENT_COMPLETE_STATUSES).describe("complete = work shipped, ends the dispatch row (does NOT move the card — transition it first); " +
165
+ "failed = the dispatch stopped without shipping: an environment failure, a card-specific block, " +
166
+ "or a stop for an operator decision. Ends the dispatch row failed; it strikes your agent only " +
167
+ `when the summary is under ${ADEQUATE_SUMMARY_MIN_CHARS} characters. Does NOT block the card — if the ` +
168
+ "cause is card-specific, first transition the card with action 'block' and a reason through " +
169
+ "danxbot_api ({method:'POST', path:'/api/issues/:id/transition', body:{action:'block', reason}}). " +
170
+ "Waiting on other work is a depends_on edge, not a block; when a human is 100% required, first " +
171
+ "add a problem with solutions (POST /api/issues/:id/problems), then end with this status; " +
172
+ "cancelled = agent voluntarily abandons the dispatch (does NOT cancel the card — transition it first); " +
173
+ "critical_failure = environment broken in a way that affects every dispatch (MCP tools not loading, " +
174
+ "Bash unavailable, Claude auth missing) — halts the poller until a human clears it; " +
175
+ // DX-4318 — the steps live in each profile that grants this ending (`work` and `failure-repair`,
176
+ // `required-profiles.yaml`), never repeated here.
177
+ "waiting_on_dependency = blocked on a card that must land on another board or repo: do your " +
178
+ "profile's steps for work that needs another repo or board's change first."),
179
+ summary: z
180
+ .string()
181
+ .describe("Brief summary of what happened. For failed, why the dispatch stopped, " +
182
+ `≥${ADEQUATE_SUMMARY_MIN_CHARS} chars or your agent takes a strike. For ` +
183
+ "critical_failure, the specific environment issue. For complete, a one-line summary of what shipped. " +
184
+ "Card-side context belongs on the transition call (through danxbot_api), not here."),
185
+ }, async (args) => {
186
+ const stopUrl = readLifecycleUrlsFromEnv().stop;
187
+ if (!stopUrl) {
188
+ throw new Error("danxbot_complete: DANXBOT_STOP_URL not configured for this dispatch");
189
+ }
190
+ let response;
191
+ try {
192
+ response = await postJson(stopUrl, { status: args.status, summary: args.summary }, agentBearer());
193
+ }
194
+ catch (err) {
195
+ const detail = err instanceof Error ? err.message : String(err);
196
+ throw new Error(`danxbot_complete: POST to worker stop endpoint failed (${detail}) — terminal transition NOT applied.`);
197
+ }
198
+ if (!response.ok) {
199
+ const text = await response.text();
200
+ throw new Error(`danxbot_complete: worker stop endpoint returned HTTP ${response.status}: ${text} — terminal transition NOT applied.`);
201
+ }
202
+ return {
203
+ content: [{ type: "text", text: `Agent signaled ${args.status}: ${args.summary}` }],
204
+ };
205
+ });
80
206
  // ---------------- danxbot_slack_reply ----------------
81
207
  strictTool("danxbot_slack_reply",
82
208
  // DX-4253 — no ordering against another tool here: the Slack persona (`src/agent/persona.ts`) and the
83
209
  // `slack-agent` profile own the order (reply, link, attach, complete).
84
210
  "Post the final user-facing reply to the originating Slack thread, once per dispatch. Files in `files[]` " +
85
- "post with the text as one thread message. For a progress update, `danxbot_api` `POST /api/slack/update` `{text}`.", {
211
+ "post with the text as one thread message. For progress updates use danxbot_slack_post_update.", {
86
212
  text: z.string().describe("The final reply text to post to the Slack thread."),
87
213
  files: z
88
214
  .array(z.string())
89
215
  .optional()
90
216
  .describe("Absolute paths of your own files to attach; bytes are uploaded."),
91
217
  }, async (args) => {
92
- const url = process.env[CALLBACK_ENV.slackReply];
218
+ const url = readLifecycleUrlsFromEnv().slackReply;
93
219
  if (!url)
94
220
  throw new Error("danxbot_slack_reply called outside a Slack dispatch");
95
221
  const files = await readAttachments(args.files);
96
222
  const body = { text: args.text };
97
223
  if (files.length > 0)
98
224
  body.files = files;
99
- const response = await postJson(url, body, dispatchCredential());
225
+ const response = await postJson(url, body, dashboardBearer());
100
226
  if (!response.ok)
101
227
  throw await responseFailure("Slack reply API", response);
102
228
  return {
@@ -110,4 +236,225 @@ export function registerLifecycleTools(strictTool) {
110
236
  ],
111
237
  };
112
238
  });
239
+ // ---------------- danxbot_slack_post_update ----------------
240
+ strictTool("danxbot_slack_post_update", "Post an intermediate progress update to the originating Slack thread. Use SPARINGLY — only for " +
241
+ "meaningful status the user cares about. Do NOT post for every file read or trivial step.", { text: z.string().describe("The intermediate status update text.") }, async (args) => {
242
+ const url = readLifecycleUrlsFromEnv().slackUpdate;
243
+ if (!url)
244
+ throw new Error("danxbot_slack_post_update called outside a Slack dispatch");
245
+ const response = await postJson(url, { text: args.text }, dashboardBearer());
246
+ if (!response.ok)
247
+ throw await responseFailure("Slack update API", response);
248
+ return { content: [{ type: "text", text: "Update posted to Slack thread" }] };
249
+ });
250
+ // ---------------- link_thread_to_issue ----------------
251
+ strictTool("link_thread_to_issue", "Upgrade THIS Slack thread into a tracked card: bind the thread to ONE issue (immutable 1:1) and " +
252
+ "register the Slack mirror so future card updates re-render the thread's card-view message. Thread " +
253
+ "scope is IMPLICIT (channel + thread_ts come from the dispatch) — pass only the issue id. Call this " +
254
+ "AFTER posting your substantive answer with danxbot_slack_reply (that reply becomes the card-view " +
255
+ '"message #1"). A second call on an already-linked thread fails loud with a 409 — never a silent re-link.', { issue_id: z.string().describe('The card id to bind this thread to (e.g. "ABC-123").') }, async (args) => {
256
+ const url = readLifecycleUrlsFromEnv().linkThreadToIssue;
257
+ if (!url)
258
+ throw new Error("link_thread_to_issue called outside a Slack dispatch");
259
+ const result = await postWorkerRoute(url, { issue_id: args.issue_id }, "link_thread_to_issue", dashboardBearer());
260
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
261
+ });
262
+ // ---------------- slack_recovery_list ----------------
263
+ strictTool("slack_recovery_list", "Read-only. Returns the Slack thread's full stored message history plus failures[], the recorded " +
264
+ "router failures projected out of those same messages, so you can reason about what happened before " +
265
+ "mutating anything. Only available on a slack-recovery dispatch.", {}, async () => {
266
+ const url = readLifecycleUrlsFromEnv().slackRecoveryList;
267
+ if (!url)
268
+ throw new Error("slack_recovery_list called outside a slack-recovery dispatch");
269
+ const result = await postWorkerRoute(url, {}, "slack_recovery_list", dashboardBearer());
270
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
271
+ });
272
+ // ---------------- slack_recovery_message (merged replace+purge, DX-3394 problem 1682) ----------------
273
+ strictTool("slack_recovery_message", "Replace or delete ONE message you posted in this thread. `action: 'replace'` (`chat.update`) rewrites " +
274
+ "the message text — the replaced message becomes this thread's card-view anchor ('message #1'): a " +
275
+ "later link_thread_to_issue registers THIS ts, and danxbot re-renders the real card view into this " +
276
+ "exact slot, so write a short human line, never a hand-written card view, in `text`. `action: 'purge'` " +
277
+ "(`chat.delete` + prunes the matching thread-history entry in ONE call — never post-and-hope these " +
278
+ "happen separately). Both REFUSE any ts you did not author (server-side) and are idempotent: replacing " +
279
+ "or purging an already-gone ts succeeds as a no-op.", {
280
+ action: z.enum(["replace", "purge"]),
281
+ ts: z
282
+ .string()
283
+ .describe("The Slack message timestamp to act on. Must be a message this bot authored (from " +
284
+ "slack_recovery_list's messages[] with isBot:true — every failures[] postedTs is one)."),
285
+ text: z
286
+ .string()
287
+ .optional()
288
+ .describe("Required for action:'replace' — the new message text. Ignored for 'purge'."),
289
+ }, async (args) => {
290
+ const url = readLifecycleUrlsFromEnv().slackRecoveryMessage;
291
+ if (!url)
292
+ throw new Error("slack_recovery_message called outside a slack-recovery dispatch");
293
+ if (args.action === "replace" && args.text === undefined) {
294
+ throw new Error("slack_recovery_message: text is required for action:'replace'");
295
+ }
296
+ const body = { action: args.action, ts: args.ts };
297
+ if (args.text !== undefined)
298
+ body.text = args.text;
299
+ const result = await postWorkerRoute(url, body, "slack_recovery_message", dashboardBearer());
300
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
301
+ });
302
+ // ---------------- slack_recovery_create_or_link ----------------
303
+ strictTool("slack_recovery_create_or_link", "The ONLY way to get a card id for this thread on a slack-recovery dispatch (danxbot_api's card-create " +
304
+ "route, POST /api/issues, is not available here). Resolves SERVER-SIDE whether the thread already " +
305
+ "resolves to a card before creating " +
306
+ "anything: a hit returns that existing card's id (created:false) and creates NOTHING; a miss creates a " +
307
+ "fresh card with the fields you pass (created:true) — a duplicate card is structurally impossible. " +
308
+ "Either way, follow up with link_thread_to_issue({issue_id}) using the returned id.", {
309
+ type: z
310
+ .enum(["Bug", "Story", "Chore"])
311
+ .describe("The card type. Never Epic/Feature — a single Slack request never warrants a container card."),
312
+ title: z.string().describe("Card title."),
313
+ // DX-4515: the create route refuses a card with no summary.
314
+ summary: z
315
+ .string()
316
+ .min(1)
317
+ .describe("REQUIRED: one plain line saying what the card is, shown at the top of the card page."),
318
+ description: z
319
+ .string()
320
+ .describe("Card description — sourced from the triggering human message's content."),
321
+ ac: z
322
+ .array(z.object({ title: z.string() }).strict())
323
+ .optional()
324
+ .describe("Optional acceptance-criteria items."),
325
+ effort_level: z.string().optional().describe("Optional effort level."),
326
+ priority: z.number().optional().describe("Optional priority in [0, 6)."),
327
+ triage_enabled: z.boolean().optional().describe("Optional auto-triage opt-in; absent → false."),
328
+ }, async (args) => {
329
+ const url = readLifecycleUrlsFromEnv().slackRecoveryCreateOrLink;
330
+ if (!url)
331
+ throw new Error("slack_recovery_create_or_link called outside a slack-recovery dispatch");
332
+ const body = {
333
+ type: args.type,
334
+ title: args.title,
335
+ summary: args.summary,
336
+ description: args.description,
337
+ };
338
+ if (args.ac !== undefined)
339
+ body.ac = args.ac;
340
+ if (args.effort_level !== undefined)
341
+ body.effort_level = args.effort_level;
342
+ if (args.priority !== undefined)
343
+ body.priority = args.priority;
344
+ if (args.triage_enabled !== undefined)
345
+ body.triage_enabled = args.triage_enabled;
346
+ const result = await postWorkerRoute(url, body, "slack_recovery_create_or_link", dashboardBearer());
347
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
348
+ });
349
+ // ---------------- quality_gate_complete (absorbs quality_gate_skip, DX-3394 problem 1682) ----------------
350
+ strictTool("quality_gate_complete",
351
+ // DX-3741 — every gate runs as its own gate-reviewer dispatch, the only one that sees this tool. DX-4318 —
352
+ // what a status does, per phase; how to review (read-only, no transition) is the gate-reviewer profile's
353
+ // (`required-profiles.yaml`). The "3-pass cap" mirrors CODE_GATE_MAX_REVIEW_PASSES
354
+ // (`src/issues/quality-gates/registry.ts`), which this separately published package cannot import;
355
+ // `src/tokens/tool-defs.test.ts` compares the two.
356
+ "Sign off the quality gate you are reviewing. It is your dispatch's TERMINAL call: never follow it with " +
357
+ "danxbot_complete. PLAN gate: `pass` releases the card to the next gate or the work agent; `fail` blocks " +
358
+ "the card with `message` as the reason; `retry` means you edited the card into a sounder plan and want " +
359
+ "a fresh pass. CODE gate: report every finding in `findings[]`; `fail` returns the findings to the " +
360
+ "author, who fixes them and requests review again, and at the 3-pass cap blocks the card instead (sign " +
361
+ "on the findings alone; the dispatcher enforces the cap); `pass` releases toward the next code gate or " +
362
+ "the author's finalize; `retry` is refused. `skipped`: the gate obviously does not apply to this diff " +
363
+ "(one line, docs only, nothing in its lane), with `message` as the mandatory justification of at least " +
364
+ `${QUALITY_GATE_SKIP_REASON_MIN_CHARS} characters; never skip to dodge a real review.`, {
365
+ gate: z.string().describe("Registered gate name, e.g. 'plan-architecture' or 'code-quality'."),
366
+ status: z.enum(["pass", "fail", "retry", "skipped"]),
367
+ message: z
368
+ .string()
369
+ .describe("Sign-off rationale / verdict summary. On a plan-gate fail this becomes the card's block reason. " +
370
+ `On 'skipped' this is the mandatory ≥${QUALITY_GATE_SKIP_REASON_MIN_CHARS}-char justification.`),
371
+ findings: z
372
+ .array(z
373
+ .object({
374
+ title: z.string(),
375
+ description: z.string().optional(),
376
+ status: z.string().optional(),
377
+ reasoning: z.string().optional(),
378
+ })
379
+ .strict())
380
+ .optional()
381
+ .describe("Code gates only — structured findings, one object per finding. Omit for plan gates."),
382
+ }, async (args) => {
383
+ const url = readLifecycleUrlsFromEnv().qualityGate;
384
+ if (!url)
385
+ throw new Error("quality_gate_complete called outside a quality-gate-capable dispatch");
386
+ if (args.status === "skipped" &&
387
+ args.message.trim().length < QUALITY_GATE_SKIP_REASON_MIN_CHARS) {
388
+ throw new Error(`quality_gate_complete: message must be at least ${QUALITY_GATE_SKIP_REASON_MIN_CHARS} characters ` +
389
+ `for status:'skipped' — it is the accountability record for skipping the "${args.gate}" gate`);
390
+ }
391
+ const body = { gate: args.gate, status: args.status, message: args.message };
392
+ if (args.findings !== undefined)
393
+ body.findings = args.findings;
394
+ const result = await postWorkerRoute(url, body, "quality_gate_complete", agentBearer());
395
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
396
+ });
397
+ // ---------------- request_code_review (DX-3741) ----------------
398
+ strictTool("request_code_review", "TERMINAL for this dispatch — call it when your diff is done and its own tests are green, instead of " +
399
+ "danxbot_complete. Pushes your branch (card/<id> to origin/card/<id>), then dispatches every code " +
400
+ "gate this card carries as its OWN independent gate-reviewer dispatch (you never sign or review your " +
401
+ "own diff). Ends this dispatch 'completed' and releases the card to ToDo; when the card is " +
402
+ "re-dispatched to you, read its quality_gates (through danxbot_api): every code gate pass/skipped → " +
403
+ "run agent-finalize.sh and complete normally; any fail → fix its findings, then call " +
404
+ "request_code_review again (never sign or remove a code gate yourself — removal is refused, 409). " +
405
+ "Takes no arguments. A card with no code gates has nothing to request — finalize directly instead.", {}, async () => {
406
+ const url = readLifecycleUrlsFromEnv().requestCodeReview;
407
+ if (!url)
408
+ throw new Error("request_code_review called outside a work dispatch");
409
+ const result = await postWorkerRoute(url, {}, "request_code_review", agentBearer());
410
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
411
+ });
412
+ // ---------------- danxbot_set_evaluator_summary ----------------
413
+ strictTool("danxbot_set_evaluator_summary", "Write the root-cause summary for a 3-strike broken agent. ONLY the system-evaluator dispatch sees this " +
414
+ "tool. Call exactly once at the END of the dispatch, immediately before danxbot_complete. A dispatch " +
415
+ "that exits without calling this tool is treated as evaluator failure.", {
416
+ reason: z
417
+ .string()
418
+ .describe("Markdown body the dashboard banner renders: ## Root cause(s) (1-3 bullets), ## Per-strike detail " +
419
+ "(one bullet per strike), ## Recommended human action (one paragraph)."),
420
+ suggested_steps: z
421
+ .array(z.string())
422
+ .optional()
423
+ .describe("Ordered list of concrete operator actions. Empty/absent is allowed."),
424
+ }, async (args) => {
425
+ const url = readLifecycleUrlsFromEnv().evaluatorSummary;
426
+ if (!url)
427
+ throw new Error("danxbot_set_evaluator_summary called without an evaluator dispatch");
428
+ const result = await postWorkerRoute(url, { reason: args.reason, suggested_steps: args.suggested_steps ?? [] }, "danxbot_set_evaluator_summary", agentBearer());
429
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
430
+ });
431
+ // ---------------- save_template_app ----------------
432
+ strictTool("save_template_app", "Build and ship ONE template app, keyed by template id. Walks <cwd>/templates/<template_id>/source/, " +
433
+ "builds, packs the bundle, then on success fires an async callback to the repo's configured save_url. " +
434
+ "Returns the synchronous build verdict. Call once per app after its edits are complete.", {
435
+ template_id: z
436
+ .string()
437
+ .describe("Receiver-side template id — selects the source location and keys the output bundle."),
438
+ }, async (args) => {
439
+ const url = readLifecycleUrlsFromEnv().saveTemplateApp;
440
+ if (!url)
441
+ throw new Error("save_template_app called without a template_app-configured dispatch");
442
+ const result = await postWorkerRoute(url, { template_id: args.template_id }, "save_template_app", agentBearer());
443
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
444
+ });
445
+ // ---------------- load_template_app ----------------
446
+ strictTool("load_template_app", "Set up ONE template app on demand and bring its live HMR preview up. On a FRESH dispatch this WHOLESALE-" +
447
+ "RESETS <cwd>/templates/<template_id>/source/ (pulling the consumer's source or scaffolding a default) " +
448
+ "— call this BEFORE you start editing, never after. On a RESUMED dispatch it touches nothing. Returns " +
449
+ "{template_id, hmr_url, source_dir} — source_dir is the ONLY path to edit; the build reads it directly.", {
450
+ template_id: z
451
+ .string()
452
+ .describe("Receiver-side template id — defines the source location and is echoed to consumer callbacks."),
453
+ }, async (args) => {
454
+ const url = readLifecycleUrlsFromEnv().loadTemplateApp;
455
+ if (!url)
456
+ throw new Error("load_template_app called without a template_app-configured dispatch");
457
+ const result = await postWorkerRoute(url, { template_id: args.template_id }, "load_template_app", agentBearer());
458
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
459
+ });
113
460
  }
@@ -38,6 +38,7 @@ export const GET_ROUTE_BOARD_SCOPE = [
38
38
  ["/api/self-repair/errors", "refuse"],
39
39
  ["/api/sync-root", "refuse"],
40
40
  ["/api/tasks", "refuse"],
41
+ ["/api/team/audit-events", "refuse"],
41
42
  ["/api/team/invitations", "refuse"],
42
43
  ["/api/team/members", "refuse"],
43
44
  ["/api/team/roles", "refuse"],
@@ -12,8 +12,8 @@ export function haltText(acceptUrl, owner) {
12
12
  " accepts the current Terms of Use.\n\n" +
13
13
  `Give the person this link to accept them: ${acceptUrl}\n\n` +
14
14
  "Do not retry, do not look for another route, and never accept the Terms yourself. " +
15
- "A dispatched agent ends with danxbot_api POST /api/dispatch-callbacks/complete {status: \"failed\", summary} " +
16
- "naming this link (stop if that call is refused too), and never blocks the card.");
15
+ "A dispatched agent ends with danxbot_complete({status: \"failed\", summary}) naming this link, " +
16
+ "and never blocks the card.");
17
17
  }
18
18
  /** The halt text for a `403 terms_not_accepted` result, or null for any other result. */
19
19
  export function termsHaltFor(result) {
@@ -12,8 +12,10 @@
12
12
  * `plan_connect` (sign in, bind to a plan), `request_permission`, and the `danxbot_api` trio.
13
13
  * - permission `{permission}` - the dashboard route the tool calls needs this permission, so a key without it can
14
14
  * never succeed. Shown once the key holds it, which includes a grant a person approves mid-session.
15
- * - callback `{callback}` - the tool posts to a per-dispatch URL the worker injected (`lifecycle.ts`), so it is usable
16
- * only when that URL exists. It also names the `permission` its dashboard route needs (both must hold).
15
+ * - callback `{callback}` - the tool posts to a per-dispatch URL the worker injected (`lifecycle.ts`). The worker
16
+ * authenticates it with the dispatch's own agent token, so no dashboard permission is involved: the tool
17
+ * is usable exactly when its URL exists. A tool whose route is a dashboard route also names its
18
+ * `permission` (both must hold).
17
19
  *
18
20
  * This module is pure data and one function, with no imports, because the danxbot service imports it directly and
19
21
  * cannot take this package's MCP-SDK or zod dependencies.
@@ -23,10 +25,22 @@
23
25
  * (`src/dispatch/dispatch-callbacks.ts`) writes them, so the name of a callback exists once.
24
26
  */
25
27
  export const CALLBACK_ENV = {
28
+ stop: "DANXBOT_STOP_URL",
26
29
  slackReply: "DANXBOT_SLACK_REPLY_URL",
30
+ slackUpdate: "DANXBOT_SLACK_UPDATE_URL",
31
+ linkThreadToIssue: "DANXBOT_LINK_THREAD_TO_ISSUE_URL",
32
+ slackRecoveryList: "DANXBOT_SLACK_RECOVERY_LIST_URL",
33
+ slackRecoveryMessage: "DANXBOT_SLACK_RECOVERY_MESSAGE_URL",
34
+ slackRecoveryCreateOrLink: "DANXBOT_SLACK_RECOVERY_CREATE_OR_LINK_URL",
35
+ qualityGate: "DANXBOT_QUALITY_GATE_URL",
36
+ requestCodeReview: "DANXBOT_REQUEST_CODE_REVIEW_URL",
37
+ evaluatorSummary: "DANXBOT_EVALUATOR_SUMMARY_URL",
38
+ saveTemplateApp: "DANXBOT_TEMPLATE_APP_SAVE_URL",
39
+ loadTemplateApp: "DANXBOT_TEMPLATE_APP_LOAD_URL",
27
40
  };
28
41
  const BOOTSTRAP = { permission: null, callback: null };
29
42
  const needs = (permission) => ({ permission, callback: null });
43
+ const postsTo = (callback) => ({ permission: null, callback });
30
44
  const needsAndPostsTo = (permission, callback) => ({ permission, callback });
31
45
  /**
32
46
  * Every tool this server registers, with what makes it usable. `index.ts` refuses to register a tool that is not
@@ -40,9 +54,20 @@ export const TOOL_ACCESS = {
40
54
  danxbot_api_spec: BOOTSTRAP,
41
55
  // `POST /api/issues/:id/attachments`
42
56
  attach_file: needs("cards.edit"),
43
- // The one dispatch callback that stays a tool (DX-4525): it reads local files. Its dashboard route's own policy names the
44
- // permission (`route-policies/index.ts`); every other callback is a `danxbot_api` call to a route under `callback-routes.ts`.
57
+ // The dispatch's own terminal and sign-off calls: worker routes, authenticated by the dispatch's agent token.
58
+ danxbot_complete: postsTo("stop"),
59
+ quality_gate_complete: postsTo("qualityGate"),
60
+ request_code_review: postsTo("requestCodeReview"),
61
+ danxbot_set_evaluator_summary: postsTo("evaluatorSummary"),
62
+ save_template_app: postsTo("saveTemplateApp"),
63
+ load_template_app: postsTo("loadTemplateApp"),
64
+ // Dashboard callback routes; each route's own policy names the permission (`route-policies/index.ts`).
45
65
  danxbot_slack_reply: needsAndPostsTo("slack.threads.post", "slackReply"),
66
+ danxbot_slack_post_update: needsAndPostsTo("slack.threads.post", "slackUpdate"),
67
+ link_thread_to_issue: needsAndPostsTo("cards.edit", "linkThreadToIssue"),
68
+ slack_recovery_list: needsAndPostsTo("slack.recovery.manage", "slackRecoveryList"),
69
+ slack_recovery_message: needsAndPostsTo("slack.recovery.manage", "slackRecoveryMessage"),
70
+ slack_recovery_create_or_link: needsAndPostsTo("slack.recovery.manage", "slackRecoveryCreateOrLink"),
46
71
  };
47
72
  /** True when a session holding `grant` can call the tool declared by `access`. */
48
73
  export function isUsable(access, grant) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.263",
4
- "description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, request_permission, attach_file, and danxbot_slack_reply) (DX-3465, PLN-11 R-25).",
3
+ "version": "0.1.264",
4
+ "description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, danxbot_complete, and other per-dispatch lifecycle callbacks) (DX-3465, PLN-11 R-25).",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",