@thehammer/danx-dashboard-mcp 0.1.96 → 0.1.97

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/bridge.js CHANGED
@@ -568,14 +568,23 @@ function classifyAdmissionRefusal(refusal) {
568
568
  }
569
569
  return null;
570
570
  }
571
- const EXIT_NEVER_RESTART = new Set([
571
+ // DX-3028 (code-review + architecture-review finding) — typed against
572
+ // `ListenStopReason` (the closed union `scope_narrowed` aside — see that
573
+ // type's own docblock), NOT bare `Set<string>`: a typo'd member here used to
574
+ // have zero compile-time signal, silently falling into the generic
575
+ // `degrade-server-fixable` default below — exactly the kind of drift this
576
+ // safety-critical classification can least afford. `satisfies readonly
577
+ // ListenStopReason[]` on each array is what makes a typo a build error.
578
+ const EXIT_NEVER_RESTART_LIST = [
572
579
  "no_connection_record",
573
580
  "not_connected",
574
581
  "superseded",
575
582
  "replaced",
576
583
  "session_is_worker",
577
- ]);
578
- const DEGRADE_NO_HEARTBEAT = new Set(["credential_unavailable", "credential_mismatch"]);
584
+ ];
585
+ const EXIT_NEVER_RESTART = new Set(EXIT_NEVER_RESTART_LIST);
586
+ const DEGRADE_NO_HEARTBEAT_LIST = ["credential_unavailable", "credential_mismatch"];
587
+ const DEGRADE_NO_HEARTBEAT = new Set(DEGRADE_NO_HEARTBEAT_LIST);
579
588
  export function degradePolicyFor(reason) {
580
589
  if (EXIT_NEVER_RESTART.has(reason))
581
590
  return "exit";
@@ -796,6 +805,14 @@ export async function runBridge(options, deps) {
796
805
  if (!readyEmitted) {
797
806
  readyEmitted = true;
798
807
  const inventory = await fetchPlanInventory(options, deps);
808
+ // DX-3028 (AC1, code-review + architecture-review finding) — this is
809
+ // the ONE place `lastInventoryOk` is ever written; the 15s heartbeat
810
+ // reads it on every "streaming" tick (see `heartbeatTick` above). Must
811
+ // agree with the SAME `ready` record's own `degraded` decision below —
812
+ // a heartbeat reporting `inventory:"ok"` while the `ready` record just
813
+ // told the operator `degraded:true` would be exactly the untruthful
814
+ // state AC1 exists to prevent.
815
+ lastInventoryOk = inventory.kind === "ok";
799
816
  if (inventory.kind === "ok") {
800
817
  deps.write({
801
818
  type: "ready",
@@ -919,6 +936,18 @@ export async function runBridge(options, deps) {
919
936
  }
920
937
  }
921
938
  }
939
+ /**
940
+ * DX-3028 — the ONE real `scheduleInterval` implementation, shared by
941
+ * `runBridge`'s deps AND `runBridgeCommand`'s own start-time
942
+ * `degrade-no-heartbeat` keep-alive (see the comment at that call site for
943
+ * why the latter needs one too). A single real `setInterval`/`clearInterval`
944
+ * pair, so the two call sites can never drift into two different ideas of
945
+ * what "the real process's interval" means.
946
+ */
947
+ function realScheduleInterval(tick, ms) {
948
+ const timer = setInterval(tick, ms);
949
+ return { stop: () => clearInterval(timer) };
950
+ }
922
951
  /** The bin's `bridge` subcommand, wired to the real process. */
923
952
  export async function runBridgeCommand(argv, env = process.env,
924
953
  /** Test seam: the home the session's connection record is read from. */
@@ -950,14 +979,28 @@ resolveFrom = {}) {
950
979
  process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
951
980
  if (!degraded)
952
981
  return 1;
953
- // DX-3028 (AC2) — `credential_unavailable` / `credential_mismatch`:
954
- // this process has no credential it can authenticate a heartbeat with
955
- // (see `settle`'s identical `degrade-no-heartbeat` branch), so it sends
956
- // none and idles until the plugin kills and restarts it fresh on a
957
- // watched-path change. `return`ed directly (never `await`ed then fallen
958
- // through) so TypeScript's definite-assignment check for `options` below
959
- // sees this branch as never reaching that line, which is also true at
960
- // runtime — this promise never settles.
982
+ // DX-3028 (AC2, architecture-review finding) — `credential_unavailable` /
983
+ // `credential_mismatch`: this process has no credential it can
984
+ // authenticate a heartbeat with (see `settle`'s identical
985
+ // `degrade-no-heartbeat` branch), so it sends none — but it MUST still
986
+ // genuinely idle until the plugin kills and restarts it fresh on a
987
+ // watched-path change, exactly like the runtime `settle()` path does.
988
+ // The runtime path stays alive because ITS heartbeat `setInterval` (in
989
+ // `runBridge`, started before its mint loop) is already registered with
990
+ // libuv by the time it degrades; THIS path is reached before `runBridge`
991
+ // (and its interval) ever exists, so a bare `return new Promise(() => {})`
992
+ // here registers no handle at all — Node has nothing left pending once
993
+ // the call stack unwinds and the process exits almost immediately,
994
+ // defeating the "idles forever" contract `BridgeOptions.watchedPaths`
995
+ // exists to serve (DX-2953's watchdog only ever gets a chance to act on
996
+ // a process that is actually still running). `realScheduleInterval()`
997
+ // gives it the same kind of keep-alive handle the runtime path gets for
998
+ // free, ticking a no-op (there is nothing truthful to heartbeat here).
999
+ realScheduleInterval(() => { }, HEARTBEAT_INTERVAL_MS);
1000
+ // `return`ed directly (never `await`ed then fallen through) so
1001
+ // TypeScript's definite-assignment check for `options` below sees this
1002
+ // branch as never reaching that line, which is also true at runtime —
1003
+ // this promise never settles.
961
1004
  return new Promise(() => {
962
1005
  /* deliberately never settles */
963
1006
  });
@@ -969,9 +1012,6 @@ resolveFrom = {}) {
969
1012
  now: Date.now,
970
1013
  readIdleTimeoutMs: READ_IDLE_TIMEOUT_MS,
971
1014
  requestTimeoutMs: REQUEST_TIMEOUT_MS,
972
- scheduleInterval: (tick, ms) => {
973
- const timer = setInterval(tick, ms);
974
- return { stop: () => clearInterval(timer) };
975
- },
1015
+ scheduleInterval: realScheduleInterval,
976
1016
  });
977
1017
  }
package/dist/handlers.js CHANGED
@@ -232,8 +232,8 @@ export async function issueCreate(client, args, defaultBoard) {
232
232
  body.effort_level = args.effort_level;
233
233
  if (args.list_id !== undefined)
234
234
  body.list_id = args.list_id;
235
- if (args.gate_decisions !== undefined)
236
- body.gate_decisions = args.gate_decisions;
235
+ if (args.quality_gates !== undefined)
236
+ body.quality_gates = args.quality_gates;
237
237
  // DX-1895 (explicit-only) — forward the caller's explicit auto-triage
238
238
  // decision; the server stamps false when absent (never auto-triaged).
239
239
  // Per-child flags ride along inside phase_children entries verbatim.
@@ -683,42 +683,41 @@ export async function issueSolution(client, args) {
683
683
  }
684
684
  }
685
685
  /**
686
- * Flip a single card's per-card quality-gate `required` flag via
687
- * POST /api/issues/:id/quality-gates/:gate {required} — the same write the
686
+ * Put ONE quality gate on a card, or take it off, via
687
+ * POST /api/issues/:id/quality-gates/:gate {action} — the same write the
688
688
  * dashboard drawer's Quality Gates tab performs (DX-1181). This is the ONLY
689
- * post-create path to mark a gate required/not — `issue_create` carries
690
- * `gate_decisions` at birth, and `issue_edit` rejects gate keys; without
691
- * this tool an agent that created a card cannot turn a gate on afterward.
689
+ * post-create path to change a card's gates: `issue_create` names them at
690
+ * birth via `quality_gates`, and `issue_edit` rejects gate keys.
692
691
  *
693
692
  * `gate` is a registry name (`plan-dependency` | `plan-architecture` |
694
693
  * `plan-tdd` | `code-test-quality` | `code-architecture` | `code-quality`).
695
- * An unknown gate is a 400 from the server (never a silent no-op).
694
+ * An unknown gate is a 400 from the server (never a silent no-op), as is a
695
+ * card whose type is never gated (`Epic` / `Feature` / `Task`).
696
696
  *
697
- * Board requirement is TRI-STATE per gate, NOT a binary on/off
698
- * (`board_quality_gate_settings.default_state`): `required` = always runs
699
- * (this per-card flag is irrelevant); `optional` = ENABLED, runs WHEN this
700
- * per-card flag is true (per-card opt-in — `optional` is NOT "off");
701
- * `disabled` = never runs (this flag is inert). So flipping `required:true`
702
- * here launches the gate when the board state is `required` OR `optional`;
703
- * it is inert ONLY when the board state is `disabled`. Source of truth:
704
- * `isGateEffectivelyRequired` in `src/issues/quality-gates/read.ts`.
697
+ * DX-3015 — there is no board tri-state to reason about any more. A gate is on
698
+ * the card or it is not, and every gate on a card is required, so `add` always
699
+ * means "this gate now runs on this card" and `remove` always means it does
700
+ * not. What the board still owns is the DEFAULT SET a NEW card is born with;
701
+ * it has no say over a card that already exists.
705
702
  *
706
- * `effort_level` (DX-1760) is an independent sibling write: present (incl.
707
- * `null`) sets `card_quality_gates.effort_level`; omitted leaves it untouched.
703
+ * Because of that, the response is plain `{issue}`. The old body carried
704
+ * `{applied, effective, reason}` because the per-card flag was only one of
705
+ * three inputs and the board could silently make the write a no-op — with the
706
+ * tri-state gone, the write IS the outcome and those fields would be constants.
708
707
  *
709
- * **Effectiveness echo.** The server response body is `{issue, applied: true,
710
- * effective, reason}`, not just `{issue}` — `client.request()` passes it
711
- * through VERBATIM (see `http-client.ts`'s header doc: "the envelope
712
- * passthrough is load-bearing"), so no transform is needed here for the
713
- * calling agent to see `effective` (what `isGateEffectivelyRequired` resolves
714
- * to right after this write, per the tri-state rule above) and `reason`
715
- * (`null` when it matches the `required` value just sent, otherwise which
716
- * board state overrode it). This closes the gap where a 200 alone could not
717
- * tell a fully-honored write from one the board's `required`/`disabled`
718
- * state made a complete no-op.
708
+ * `note` records why the gate is on the card; `effort_level` (DX-1760) sets
709
+ * `card_quality_gates.effort_level` (`null` clears a prior override). Both are
710
+ * `add`-only — the server 400s either one alongside `remove`, since a removed
711
+ * gate has no row to carry them.
712
+ *
713
+ * Re-adding a gate the card already carries updates `note`/`effort_level` and
714
+ * leaves any recorded verdict untouched, so `add` is never a way to quietly
715
+ * clear a `fail`. `remove` DOES discard the row and its verdict.
719
716
  */
720
717
  export async function issueQualityGate(client, args) {
721
- const body = { required: args.required };
718
+ const body = { action: args.action };
719
+ if (args.note !== undefined)
720
+ body.note = args.note;
722
721
  if (args.effort_level !== undefined)
723
722
  body.effort_level = args.effort_level;
724
723
  return client.request({
package/dist/index.js CHANGED
@@ -363,7 +363,7 @@ server.tool("issue_get",
363
363
  }, async (args) => jsonResult(await issueGet(client, args)));
364
364
  // ---------------- issue_create ----------------
365
365
  server.tool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass "mine" to put the card on THIS session\'s connected plan, or null when it deliberately belongs to no plan. There is no default and no inference — a card that names no plan is one nobody following the work can see, which is why the answer has to be given rather than omitted. "mine" while this session is on no plan is refused (409 session_not_connected) and creates NO card; a plan id is not accepted (a card is only ever created onto your own connected plan). This replaces the plan_add_card follow-up at creation time; plan_add_card remains for putting an EXISTING card on a plan. ' +
366
- 'Create a card via POST /api/issues. 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. `gate_decisions` is REQUIRED when the board has an OPTIONAL quality gate for the card\'s type: a missing one fails closed with 400 `{error, required_gate_decisions:[...]}` naming each gate — retry with one `{gate, enabled, note}` per listed gate. ALWAYS pass `triage_enabled` explicitly on the root card and every phase child: true only when it should enter automatic triage/dispatch without human review; absent → false.', {
366
+ 'Create a card via POST /api/issues. 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` names the gates this card carries BEYOND the board\'s default set for its type — one `{gate, note?}` each. Omit it for just the board defaults; a gate you do not name simply is not on the card (there is no optional gate and nothing fails closed for going unnamed). Add one later with `issue_quality_gate`. ALWAYS pass `triage_enabled` explicitly on the root card and every phase child: true only when it should enter automatic triage/dispatch without human review; absent → false.', {
367
367
  type: z.enum(ISSUE_TYPES),
368
368
  title: z.string().min(1).describe(TITLE_DESCRIBE),
369
369
  summary: z.string().min(1).optional().describe(SUMMARY_DESCRIBE),
@@ -380,15 +380,14 @@ server.tool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass
380
380
  ac: z.array(z.object({ title: z.string().min(1) })).optional(),
381
381
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
382
382
  list_id: z.string().min(1).nullable().optional(),
383
- gate_decisions: z
383
+ quality_gates: z
384
384
  .array(z.object({
385
385
  gate: z.string().min(1),
386
- enabled: z.boolean(),
387
- note: z.string(),
386
+ note: z.string().optional(),
388
387
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
389
388
  }))
390
389
  .optional()
391
- .describe("One {gate, enabled, note} per board-OPTIONAL gate of the card's type: `enabled` = does it run on this card, `note` = why. Board `required` gates always run and `disabled` never do; neither takes a decision (naming one → 400). Optional `effort_level` overrides a `plan-*` gate's reviewer rung."),
390
+ .describe("The gates this card carries BEYOND the board's default set for its type — one {gate, note?} each, `note` = why it applies here. Every gate on a card is required; a gate you do not name is not on the card at all (not displayed, not counted, never run), and omitting this field entirely is normal. Optional `effort_level` overrides a `plan-*` gate's reviewer rung."),
392
391
  phase_children: z
393
392
  .array(z.object({
394
393
  type: z.enum(NON_EPIC_TYPES),
@@ -401,15 +400,14 @@ server.tool("issue_create", '`plan` is REQUIRED on every create (DX-3006): pass
401
400
  description: z.string().describe(DESCRIPTION_DESCRIBE),
402
401
  ac: z.array(z.object({ title: z.string().min(1) })).optional(),
403
402
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
404
- gate_decisions: z
403
+ quality_gates: z
405
404
  .array(z.object({
406
405
  gate: z.string().min(1),
407
- enabled: z.boolean(),
408
- note: z.string(),
406
+ note: z.string().optional(),
409
407
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
410
408
  }))
411
409
  .optional()
412
- .describe("Same as the root gate_decisions, resolved against THIS child's type."),
410
+ .describe("Same as the root quality_gates, resolved against THIS child's type."),
413
411
  triage_enabled: z
414
412
  .boolean()
415
413
  .optional()
@@ -602,7 +600,7 @@ server.tool("issue_retire_branch", "Mark a card's own `card/<id>` branch RETIRED
602
600
  ...boardField,
603
601
  }, async (args) => jsonResult(await issueRetireBranch(client, args)));
604
602
  // ---------------- issue_quality_gate ----------------
605
- server.tool("issue_quality_gate", "Set one card's per-gate `required` flag via POST /api/issues/:id/quality-gates/:gate {required} — the only post-create way (issue_create takes gate_decisions; issue_edit refuses gate keys). PRE `plan-*` gates run before the work dispatch; POST `code-*` gates block complete. Unknown gate → 400. The board state per gate is tri-state: `required` always runs, `optional` runs WHEN this flag is true (optional is NOT off), `disabled` never runs. Optional `effort_level` overrides a `plan-*` gate's reviewer rung (null clears it). The write always succeeds and returns `{issue, applied: true, effective, reason}` — read `effective` (does the gate now run) and `reason` (why the board overrode your value), not just the 200. Board-scoped; see `board`.", {
603
+ server.tool("issue_quality_gate", "Put one quality gate ON a card, or take it OFF, via POST /api/issues/:id/quality-gates/:gate {action} — the only post-create way (issue_create names gates in quality_gates; issue_edit refuses gate keys). A gate is on the card or it does not exist for it; every gate on a card is required, so `add` means it now runs and `remove` means it is gone (not displayed, not counted). Adding a gate at any time is fully supported — that is what this tool is for. PRE `plan-*` gates run before the work dispatch; POST `code-*` gates block complete. Unknown gate → 400; a never-gated card type (Epic/Feature/Task) → 400. `note` (why it applies) and `effort_level` (overrides a `plan-*` gate's reviewer rung; null clears it) are `add`-only — passing either with `remove` → 400. Re-adding a gate the card already has updates note/effort and KEEPS its verdict; `remove` discards the row and any verdict on it. Board-scoped; see `board`.", {
606
604
  id: z.string().min(1),
607
605
  gate: z.enum([
608
606
  "plan-dependency",
@@ -612,12 +610,18 @@ server.tool("issue_quality_gate", "Set one card's per-gate `required` flag via P
612
610
  "code-architecture",
613
611
  "code-quality",
614
612
  ]),
615
- required: z.boolean(),
613
+ action: z
614
+ .enum(["add", "remove"])
615
+ .describe("add = put this gate on the card; remove = take it off."),
616
+ note: z
617
+ .string()
618
+ .optional()
619
+ .describe("Why this gate applies to this card. `add` only."),
616
620
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
617
621
  ...boardField,
618
622
  }, async (args) => jsonResult(await issueQualityGate(client, args)));
619
623
  // ---------------- issue_quality_gate_verdict ----------------
620
- server.tool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate VERDICT via PATCH /api/issues/:id/quality-gates/:gate {status, message} — the same write the dashboard Gates-tab Pass / Fail / Revert controls perform (DX-1373). SIBLING of `issue_quality_gate`, not a replacement: that one flips the per-card `required` FLAG (does this gate run at all), THIS one records the VERDICT (did it pass) — POST vs PATCH on the same resource, neither substitutes for the other. **Use this to close out a card you picked up with `issue_transition pickup {manual:true}`** (DX-946 operator-session self-pickup): `issue_transition complete` REFUSES 409 (`failed_gate: \"quality_gate_post\"`, `failed_post_gates[]`) while any required POST gate (`code-quality` / `code-test-quality` / `code-architecture`) is not `pass`, so without a verdict a manually-claimed card can never reach Done — it strands In Progress and its `conflict_on` / `waiting_on` edges then stall OTHER cards' dispatch. `status`: `pass` | `fail` | `pending` (revert a prior verdict, clears the message). `message` is the accountability record for the override, REQUIRED at >= 20 characters for `pass`/`fail` (shorter → 400), ignored for `pending`. Record the REAL reviewer finding, not a rubber stamp — a human-attributed override, stamped with the operator actor, standing in for a reviewer dispatch. A manual verdict is a PURE row write: no side effects — a manual `fail` never blocks the card and a manual `pass` never releases a dispatch. Unknown gate → 400; bad status → 400; unknown card → 404. Board-scoped; see `board`.", {
624
+ server.tool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate VERDICT via PATCH /api/issues/:id/quality-gates/:gate {status, message} — the same write the dashboard Gates-tab Pass / Fail / Revert controls perform (DX-1373). SIBLING of `issue_quality_gate`, not a replacement: that one puts a gate ON or OFF the card (does this gate apply at all), THIS one records the VERDICT (did it pass) — POST vs PATCH on the same resource, neither substitutes for the other. **Use this to close out a card you picked up with `issue_transition pickup {manual:true}`** (DX-946 operator-session self-pickup): `issue_transition complete` REFUSES 409 (`failed_gate: \"quality_gate_post\"`, `failed_post_gates[]`) while any required POST gate (`code-quality` / `code-test-quality` / `code-architecture`) is not `pass`, so without a verdict a manually-claimed card can never reach Done — it strands In Progress and its `conflict_on` / `waiting_on` edges then stall OTHER cards' dispatch. `status`: `pass` | `fail` | `pending` (revert a prior verdict, clears the message). `message` is the accountability record for the override, REQUIRED at >= 20 characters for `pass`/`fail` (shorter → 400), ignored for `pending`. Record the REAL reviewer finding, not a rubber stamp — a human-attributed override, stamped with the operator actor, standing in for a reviewer dispatch. A manual verdict is a PURE row write: no side effects — a manual `fail` never blocks the card and a manual `pass` never releases a dispatch. Unknown gate → 400; bad status → 400; unknown card → 404. Board-scoped; see `board`.", {
621
625
  id: z.string().min(1),
622
626
  gate: z.enum([
623
627
  "plan-dependency",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.96",
3
+ "version": "0.1.97",
4
4
  "description": "Stdio MCP server wrapping danxbot's dashboard /api/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
5
5
  "license": "MIT",
6
6
  "type": "module",