@thehammer/danx-dashboard-mcp 0.1.96 → 0.1.98

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/index.js CHANGED
@@ -811,7 +811,7 @@ async (args) => {
811
811
  }),
812
812
  });
813
813
  });
814
- server.tool("plan_add_record", "Add a goal, rule or caveat to your connected plan (POST /api/plans/mine/records). A GOAL is an outcome the work is measured against. A RULE is a constraint that must hold while it is worked. A CAVEAT is a lasting trade-off or limitation of the ARCHITECTURE — never progress, status or a session note (those are comments on the card). `body` is ONE plain statement of at most 250 characters; the evidence, history and detail go in `context` (markdown). An overlong body is refused with a 400 naming its length. The server allocates a permanent reference (`G-1`, `R-4`, `CAV-12`). Takes no plan id; `session_not_connected` → `plan_connect` first. Returns the record plus that kind's list.", {
814
+ server.tool("plan_add_record", "Add a goal, rule or caveat to your connected plan (POST /api/plans/mine/records). A GOAL is an outcome the work is measured against. A RULE is a constraint that must hold while it is worked. A CAVEAT is a lasting trade-off or limitation of the ARCHITECTURE — never progress, status or a session note (those are comments on the card). `body` is ONE plain statement of at most 250 characters; the evidence, history and detail go in `context` (markdown). An overlong body is refused with a 400 naming its length. The server allocates a permanent reference (`G-1`, `R-4`, `CAV-12`). Takes no plan id; `session_not_connected` → `plan_connect` first. DX-3072 — returns the created record plus `records_count` (that kind's live count, not the whole list, which can grow unboundedly over a plan's life); read the list itself with `plan_get({fields:[\"records:<kind>\"]})` or the dedicated GET.", {
815
815
  kind: z.enum(["goal", "rule", "caveat"]).describe("goal = outcome, rule = constraint, caveat = architecture trade-off."),
816
816
  body: z.string().min(1).describe("One plain statement, at most 250 characters. Details go in `context`."),
817
817
  context: z.string().optional().describe("Markdown detail behind the statement: evidence, history, examples."),
@@ -819,7 +819,7 @@ server.tool("plan_add_record", "Add a goal, rule or caveat to your connected pla
819
819
  server.tool("plan_get_record", "Read one goal/rule/caveat of your connected plan (GET /api/plans/mine/records/:rid) without pulling the whole plan. Takes no plan id; an unknown or another plan's record id → 404. Returns `{record: {id, planId, kind, refNum, ref, body, context, contentHash, createdAt, updatedAt}}` — `context` is the markdown detail, or null.", {
820
820
  record_id: z.number().int().positive().describe("A record id, from `plan_add_record`, `plan_get` or `plan_get_record` itself."),
821
821
  }, async (args) => jsonResult(await planGetRecord(client, args)));
822
- server.tool("plan_update_record", 'Edit a goal/rule/caveat of your connected plan (PATCH /api/plans/mine/records/:rid). The reference never moves. `body` stays ONE plain statement of at most 250 characters (400 otherwise); detail belongs in markdown `context`. `content_hash` must be the record\'s `contentHash` from your last read, and covers body AND context. On a mismatch nothing is written and you get `{error: "stale_plan_record", currentHash, currentBody, currentContext}`: merge into those and retry with `content_hash: currentHash`, never blindly. Takes no plan id. Returns the record plus that kind\'s list.', {
822
+ server.tool("plan_update_record", 'Edit a goal/rule/caveat of your connected plan (PATCH /api/plans/mine/records/:rid). The reference never moves. `body` stays ONE plain statement of at most 250 characters (400 otherwise); detail belongs in markdown `context`. `content_hash` must be the record\'s `contentHash` from your last read, and covers body AND context. On a mismatch nothing is written and you get `{error: "stale_plan_record", currentHash, currentBody, currentContext}`: merge into those and retry with `content_hash: currentHash`, never blindly. Takes no plan id. DX-3072 — returns the edited record plus `records_count` (that kind\'s live count, not the whole list — see `plan_add_record`).', {
823
823
  record_id: z.number().int().positive().describe("The record id to edit."),
824
824
  content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
825
825
  body: z.string().min(1).describe("The new statement, at most 250 characters. Plain text."),
@@ -829,11 +829,11 @@ server.tool("plan_update_record", 'Edit a goal/rule/caveat of your connected pla
829
829
  .optional()
830
830
  .describe("New markdown detail. Omit to keep the stored context; null clears it."),
831
831
  }, async (args) => jsonResult(await planUpdateRecord(client, args)));
832
- server.tool("plan_delete_record", 'Soft-delete a goal/rule/caveat of your connected plan (DELETE /api/plans/mine/records/:rid). Its reference is retired permanently, never reused. `content_hash` must be the record\'s `contentHash` from your last read; a stale hash deletes nothing and returns `{error: "stale_plan_record", currentHash, currentBody, currentContext}`. Takes no plan id. Unknown or already-deleted id → 404. Returns that kind\'s remaining list.', {
832
+ server.tool("plan_delete_record", 'Soft-delete a goal/rule/caveat of your connected plan (DELETE /api/plans/mine/records/:rid). Its reference is retired permanently, never reused. `content_hash` must be the record\'s `contentHash` from your last read; a stale hash deletes nothing and returns `{error: "stale_plan_record", currentHash, currentBody, currentContext}`. Takes no plan id. Unknown or already-deleted id → 404. DX-3072 — returns `{records_count}`, that kind\'s remaining live count, not the whole list (see `plan_add_record`).', {
833
833
  record_id: z.number().int().positive().describe("The record id to delete."),
834
834
  content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
835
835
  }, async (args) => jsonResult(await planDeleteRecord(client, args)));
836
- server.tool("plan_add_note", "Write a milestone note to a plan's timeline, via POST /api/plans/:plan_id/notes (DX-2915). A note is a MILESTONE, not a log — write one for a card (or related group of cards) finishing, an important decision, or a meaningful goal/rule/caveat/architecture-section change; routine step progress stays a card comment, never a note. Terse tone: `title` at most 60 characters, `body` (the wrap-up) at most 250 (both 400 if too long, naming the limit and actual length). Links resolve on read into what they point at (a card's title, a record's ref+body, a section's title): an unknown card, an unparseable or foreign record ref, or an unknown or foreign section id is refused 400 naming exactly which one. A card link does NOT require the card to be a member of this plan; a record/section link MUST belong to THIS plan. `author` is stamped from your identity server-side — there is no field for it. TAKES AN EXPLICIT `plan_id` (like `plan_remove_card`/`plan_rename`), so a dispatched worker with no plan connection can still write. Unknown plan → 404. Returns the new note plus the plan's latest notes page.", {
836
+ server.tool("plan_add_note", "Write a milestone note to a plan's timeline, via POST /api/plans/:plan_id/notes (DX-2915). A note is a MILESTONE, not a log — write one for a card (or related group of cards) finishing, an important decision, or a meaningful goal/rule/caveat/architecture-section change; routine step progress stays a card comment, never a note. Terse tone: `title` at most 60 characters, `body` (the wrap-up) at most 250 (both 400 if too long, naming the limit and actual length). Links resolve on read into what they point at (a card's title, a record's ref+body, a section's title): an unknown card, an unparseable or foreign record ref, or an unknown or foreign section id is refused 400 naming exactly which one. A card link does NOT require the card to be a member of this plan; a record/section link MUST belong to THIS plan. `author` is stamped from your identity server-side — there is no field for it. TAKES AN EXPLICIT `plan_id` (like `plan_remove_card`/`plan_rename`), so a dispatched worker with no plan connection can still write. Unknown plan → 404. DX-3072 — returns the new note plus `notes_count` (the plan's total live note count, not the latest page); read the timeline itself with `plan_get({fields:[\"notes\"]})` or the dedicated GET.", {
837
837
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
838
838
  title: z.string().min(1).describe("At most 60 characters."),
839
839
  body: z.string().min(1).describe("The wrap-up, at most 250 characters."),
@@ -844,7 +844,7 @@ server.tool("plan_add_note", "Write a milestone note to a plan's timeline, via P
844
844
  .describe("Goal/rule/caveat references this note announces, e.g. `[\"G-1\", \"R-3\", \"CAV-2\"]`."),
845
845
  section_ids: z.array(z.number().int().positive()).optional().describe("Architecture section ids this note announces."),
846
846
  }, async (args) => jsonResult(await planAddNote(client, args)));
847
- server.tool("plan_update_note", 'Edit a plan note, via PATCH /api/plans/:plan_id/notes/:note_id (DX-2915). `content_hash` MUST be the note\'s `contentHash` from your last read; a mismatch writes NOTHING and returns `{error: "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}` — merge into those and retry with `content_hash: currentHash`, never blindly. `title`/`body` are each optional and keep their stored value when omitted. The link fields (`card_ids`/`record_refs`/`section_ids`) are all-or-nothing AS A GROUP: omit all three to leave the stored link set untouched; send ANY one of them to REPLACE THE WHOLE SET — there is no per-link add/remove. The hash covers title, body AND the link set. TAKES AN EXPLICIT `plan_id`, same reason as `plan_add_note`. Unknown plan or note id → 404. Returns the edited note plus the plan\'s latest notes page.', {
847
+ server.tool("plan_update_note", 'Edit a plan note, via PATCH /api/plans/:plan_id/notes/:note_id (DX-2915). `content_hash` MUST be the note\'s `contentHash` from your last read; a mismatch writes NOTHING and returns `{error: "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}` — merge into those and retry with `content_hash: currentHash`, never blindly. `title`/`body` are each optional and keep their stored value when omitted. The link fields (`card_ids`/`record_refs`/`section_ids`) are all-or-nothing AS A GROUP: omit all three to leave the stored link set untouched; send ANY one of them to REPLACE THE WHOLE SET — there is no per-link add/remove. The hash covers title, body AND the link set. TAKES AN EXPLICIT `plan_id`, same reason as `plan_add_note`. Unknown plan or note id → 404. DX-3072 — returns the edited note plus `notes_count` (see `plan_add_note`), not the latest page.', {
848
848
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
849
849
  note_id: z.number().int().positive().describe("The note id to edit."),
850
850
  content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
@@ -854,15 +854,15 @@ server.tool("plan_update_note", 'Edit a plan note, via PATCH /api/plans/:plan_id
854
854
  record_refs: z.array(z.string().min(1)).optional().describe("REPLACES the whole link set when sent (with card_ids/section_ids)."),
855
855
  section_ids: z.array(z.number().int().positive()).optional().describe("REPLACES the whole link set when sent (with card_ids/record_refs)."),
856
856
  }, async (args) => jsonResult(await planUpdateNote(client, args)));
857
- server.tool("plan_delete_note", 'Soft-delete a plan note, via DELETE /api/plans/:plan_id/notes/:note_id (DX-2915). `content_hash` must be the note\'s `contentHash` from your last read; a stale hash deletes nothing and returns `{error: "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}` — the same shape `plan_update_note` uses. TAKES AN EXPLICIT `plan_id`, same reason as `plan_add_note`. Unknown plan, unknown note, or an already-deleted note → 404. Returns the plan\'s remaining latest notes page.', {
857
+ server.tool("plan_delete_note", 'Soft-delete a plan note, via DELETE /api/plans/:plan_id/notes/:note_id (DX-2915). `content_hash` must be the note\'s `contentHash` from your last read; a stale hash deletes nothing and returns `{error: "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}` — the same shape `plan_update_note` uses. TAKES AN EXPLICIT `plan_id`, same reason as `plan_add_note`. Unknown plan, unknown note, or an already-deleted note → 404. DX-3072 — returns `{notes_count}`, the plan\'s remaining live note count, not the latest page.', {
858
858
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
859
859
  note_id: z.number().int().positive().describe("The note id to delete."),
860
860
  content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
861
861
  }, async (args) => jsonResult(await planDeleteNote(client, args)));
862
- server.tool("plan_add_card", "Add an existing card to the plan this session is connected to, via POST /api/plans/mine/cards (DX-2683). The card may live on ANY board — that is what a plan is for. Idempotent: re-adding a card already on the plan is a no-op, not an error, and a card may sit in several plans at once. This adds MEMBERSHIP only; it never edits the card. TAKES NO PLAN ID: the plan is resolved from your connected session. Not connected → `{error: \"session_not_connected\"}`. Unknown card → 404. Returns the plan's full member list.", {
862
+ server.tool("plan_add_card", "Add an existing card to the plan this session is connected to, via POST /api/plans/mine/cards (DX-2683). The card may live on ANY board — that is what a plan is for. Idempotent: re-adding a card already on the plan is a no-op, not an error, and a card may sit in several plans at once. This adds MEMBERSHIP only; it never edits the card. TAKES NO PLAN ID: the plan is resolved from your connected session. Not connected → `{error: \"session_not_connected\"}`. Unknown card → 404. DX-3072 — returns `{card_id, member: true, cards_count}` (the attachment's own confirmation plus the plan's total member-card count), not the full member list — a plan can hold hundreds of cards, and the whole list was a ~500KB reply that a caller could not read. Read the list itself with `plan_get({fields:[\"cards\"]})` (paged) when you actually need it.", {
863
863
  card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
864
864
  }, async (args) => jsonResult(await planAddCard(client, args)));
865
- server.tool("plan_remove_card", "Remove a card from a plan via DELETE /api/plans/:plan_id/cards/:card_id (DX-2740) — the sibling of `plan_add_card`. The card may live on ANY board. Idempotent: removing a card that was never a member is a no-op, not an error — the same idempotent-toggle contract `issue_dependency` add/remove uses. This removes MEMBERSHIP only; it never edits or deletes the card itself, and its membership in every OTHER plan is untouched. Unlike `plan_add_card`, this takes an EXPLICIT `plan_id` rather than acting on your connected session's plan — you may remove a card from any plan you can name. Unknown plan → 404. Returns the plan's remaining member list.", {
865
+ server.tool("plan_remove_card", "Remove a card from a plan via DELETE /api/plans/:plan_id/cards/:card_id (DX-2740) — the sibling of `plan_add_card`. The card may live on ANY board. Idempotent: removing a card that was never a member is a no-op, not an error — the same idempotent-toggle contract `issue_dependency` add/remove uses. This removes MEMBERSHIP only; it never edits or deletes the card itself, and its membership in every OTHER plan is untouched. Unlike `plan_add_card`, this takes an EXPLICIT `plan_id` rather than acting on your connected session's plan — you may remove a card from any plan you can name. Unknown plan → 404. DX-3072 — returns `{card_id, member: false, cards_count}`, the plan's remaining member-card count, not the full list (see `plan_add_card`).", {
866
866
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
867
867
  card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
868
868
  }, async (args) => jsonResult(await planRemoveCard(client, args)));
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.98",
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",