@longtable/mcp 0.1.32 → 0.1.34

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
@@ -15,7 +15,7 @@ longtable-state
15
15
  Run:
16
16
 
17
17
  ```bash
18
- npx -y @longtable/mcp@0.1.32
18
+ npx -y @longtable/mcp@0.1.34
19
19
  ```
20
20
 
21
21
  Self-test:
@@ -0,0 +1,25 @@
1
+ import type { QuestionOption } from "@longtable/core";
2
+ export interface FirstResearchShape {
3
+ handle: string;
4
+ currentGoal: string;
5
+ currentBlocker?: string;
6
+ researchObject?: string;
7
+ gapRisk?: string;
8
+ protectedDecision?: string;
9
+ openQuestions: string[];
10
+ nextAction: string;
11
+ confidence: "low" | "medium" | "high";
12
+ sourceHookId?: string;
13
+ confirmedAt?: string;
14
+ }
15
+ export interface FirstResearchShapeQuestionSpec {
16
+ prompt: string;
17
+ title: string;
18
+ question: string;
19
+ checkpointKey: string;
20
+ options: QuestionOption[];
21
+ displayReason: string;
22
+ }
23
+ export declare function buildFirstResearchShapeQuestion(shape: FirstResearchShape): FirstResearchShapeQuestionSpec;
24
+ export declare function firstResearchShapeAnswerConfirms(answer: string): boolean;
25
+ export declare function firstResearchShapeAnswerStatus(answer: string): "confirmed" | "active" | "deferred";
@@ -0,0 +1,111 @@
1
+ function questionTitle(shape) {
2
+ if (shape.protectedDecision) {
3
+ return "Protected Research Decision";
4
+ }
5
+ if (shape.currentBlocker) {
6
+ return "Main Unresolved Issue";
7
+ }
8
+ return "Emerging Research Direction";
9
+ }
10
+ function questionText(shape) {
11
+ if (shape.protectedDecision) {
12
+ return "Before LongTable moves forward, what protected research decision should stay explicit?";
13
+ }
14
+ if (shape.currentBlocker) {
15
+ return "What should LongTable do with the main unresolved issue in this emerging study?";
16
+ }
17
+ if (shape.openQuestions.length > 0) {
18
+ return "What should LongTable keep explicit as the next research move?";
19
+ }
20
+ return "How should LongTable carry this emerging study forward?";
21
+ }
22
+ function displayReason(shape) {
23
+ if (shape.protectedDecision) {
24
+ return `The interview surfaced a protected decision: ${shape.protectedDecision}. LongTable should not let it settle silently.`;
25
+ }
26
+ if (shape.currentBlocker) {
27
+ return `The main blocker is still open: ${shape.currentBlocker}. The next move should make that uncertainty explicit.`;
28
+ }
29
+ if (shape.openQuestions.length > 0) {
30
+ return `The interview surfaced open research questions. Choose the next move that keeps the most important one visible.`;
31
+ }
32
+ return "LongTable has enough context to propose a provisional research direction, but the next research move should still be explicit.";
33
+ }
34
+ function recommendedValue(shape) {
35
+ if (shape.protectedDecision) {
36
+ return "protect_decision";
37
+ }
38
+ if (shape.confidence === "high" && !shape.currentBlocker) {
39
+ return "stabilize_shape";
40
+ }
41
+ if (shape.currentBlocker || shape.confidence === "low") {
42
+ return "gather_context";
43
+ }
44
+ return "stabilize_shape";
45
+ }
46
+ function baseOptions(shape) {
47
+ const recommended = recommendedValue(shape);
48
+ const options = [];
49
+ if (shape.protectedDecision) {
50
+ options.push({
51
+ value: "protect_decision",
52
+ label: "Keep the protected decision open",
53
+ description: `Treat this as the guarded judgment while the broader direction stays provisional: ${shape.protectedDecision}`,
54
+ recommended: recommended === "protect_decision"
55
+ });
56
+ }
57
+ options.push({
58
+ value: "stabilize_shape",
59
+ label: "Use this as the provisional direction",
60
+ description: "Carry this research shape forward as the current working direction.",
61
+ recommended: recommended === "stabilize_shape"
62
+ }, {
63
+ value: "gather_context",
64
+ label: "Gather one more concrete case",
65
+ description: "Ask for one more scene, source, dataset, or example before stabilizing it.",
66
+ recommended: recommended === "gather_context"
67
+ }, {
68
+ value: "revise_shape",
69
+ label: "Revise the emerging shape",
70
+ description: "Change the handle, blocker, or next action before LongTable treats it as usable."
71
+ });
72
+ if (!shape.protectedDecision) {
73
+ options.push({
74
+ value: "keep_open",
75
+ label: "Keep the study open longer",
76
+ description: "Do not stabilize this direction yet."
77
+ });
78
+ }
79
+ return options;
80
+ }
81
+ export function buildFirstResearchShapeQuestion(shape) {
82
+ const summaryLines = [
83
+ "LongTable has enough context to propose a provisional research direction.",
84
+ `Handle: ${shape.handle}`,
85
+ `Goal: ${shape.currentGoal}`,
86
+ shape.currentBlocker ? `Blocker: ${shape.currentBlocker}` : undefined,
87
+ shape.protectedDecision ? `Protected decision: ${shape.protectedDecision}` : undefined,
88
+ shape.openQuestions.length > 0 ? `Open question: ${shape.openQuestions[0]}` : undefined,
89
+ `Next action: ${shape.nextAction}`
90
+ ].filter(Boolean);
91
+ return {
92
+ prompt: summaryLines.join("\n"),
93
+ title: questionTitle(shape),
94
+ question: questionText(shape),
95
+ checkpointKey: "first_research_shape_confirmation",
96
+ options: baseOptions(shape),
97
+ displayReason: displayReason(shape)
98
+ };
99
+ }
100
+ export function firstResearchShapeAnswerConfirms(answer) {
101
+ return answer === "stabilize_shape" || answer === "protect_decision";
102
+ }
103
+ export function firstResearchShapeAnswerStatus(answer) {
104
+ if (firstResearchShapeAnswerConfirms(answer)) {
105
+ return "confirmed";
106
+ }
107
+ if (answer === "keep_open") {
108
+ return "deferred";
109
+ }
110
+ return "active";
111
+ }
package/dist/index.d.ts CHANGED
@@ -1 +1,2 @@
1
+ export * from "./first-research-shape.js";
1
2
  export * from "./server.js";
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
+ export * from "./first-research-shape.js";
1
2
  export * from "./server.js";
package/dist/server.js CHANGED
@@ -10,9 +10,10 @@ import { classifyCheckpointTrigger } from "@longtable/checkpoints";
10
10
  import { renderQuestionRecordInput } from "@longtable/provider-claude";
11
11
  import { renderQuestionRecordPrompt } from "@longtable/provider-codex";
12
12
  import { loadSetupOutput } from "@longtable/setup";
13
- import { answerWorkspaceQuestion, createOrUpdateProjectWorkspace, createWorkspaceQuestion, inspectProjectWorkspace, loadProjectContextFromDirectory, loadWorkspaceState, syncCurrentWorkspaceView } from "@longtable/cli";
13
+ import { answerWorkspaceQuestion, clearWorkspaceQuestion, createOrUpdateProjectWorkspace, createWorkspaceQuestion, inspectProjectWorkspace, loadProjectContextFromDirectory, loadWorkspaceState, syncCurrentWorkspaceView } from "@longtable/cli";
14
+ import { buildFirstResearchShapeQuestion, firstResearchShapeAnswerConfirms, firstResearchShapeAnswerStatus } from "./first-research-shape.js";
14
15
  const SERVER_NAME = "longtable-state";
15
- const SERVER_VERSION = "0.1.32";
16
+ const SERVER_VERSION = "0.1.34";
16
17
  const TOOL_NAMES = [
17
18
  "read_project",
18
19
  "read_session",
@@ -48,7 +49,9 @@ const firstResearchShapeSchema = z.object({
48
49
  protectedDecision: z.string().optional(),
49
50
  openQuestions: z.array(z.string().min(1)).default([]),
50
51
  nextAction: z.string().min(1),
51
- confidence: z.enum(["low", "medium", "high"]).default("medium")
52
+ confidence: z.enum(["low", "medium", "high"]).default("medium"),
53
+ sourceHookId: z.string().optional(),
54
+ confirmedAt: z.string().optional()
52
55
  });
53
56
  function textResult(structuredContent) {
54
57
  return {
@@ -88,10 +91,14 @@ function createId(prefix) {
88
91
  function asInterviewState(state) {
89
92
  return state;
90
93
  }
94
+ function isInterviewHookRun(hook) {
95
+ return hook?.kind === "longtable_interview";
96
+ }
91
97
  function activeInterviewHook(state, hookId) {
92
98
  const hooks = state.hooks ?? [];
93
99
  if (hookId) {
94
- return hooks.find((hook) => hook.id === hookId);
100
+ const hook = hooks.find((candidate) => candidate.id === hookId);
101
+ return isInterviewHookRun(hook) ? hook : undefined;
95
102
  }
96
103
  return [...hooks].reverse().find((hook) => hook.kind === "longtable_interview" &&
97
104
  (hook.status === "pending" || hook.status === "active" || hook.status === "ready_to_confirm"));
@@ -106,6 +113,61 @@ function upsertInterviewHook(state, hook) {
106
113
  hooks: nextHooks
107
114
  };
108
115
  }
116
+ function upsertQuestionObligation(state, obligation) {
117
+ const current = state.questionObligations ?? [];
118
+ const next = current.some((entry) => entry.id === obligation.id)
119
+ ? current.map((entry) => entry.id === obligation.id ? obligation : entry)
120
+ : [...current, obligation];
121
+ return {
122
+ ...state,
123
+ questionObligations: next
124
+ };
125
+ }
126
+ function ensureFirstResearchShapeObligation(state, shape, options) {
127
+ const existing = (state.questionObligations ?? []).find((obligation) => obligation.kind === "first_research_shape_confirmation" &&
128
+ obligation.status === "pending" &&
129
+ obligation.sourceHookId === shape.sourceHookId);
130
+ if (!existing) {
131
+ return upsertQuestionObligation(state, {
132
+ id: createId("question_obligation"),
133
+ kind: "first_research_shape_confirmation",
134
+ status: "pending",
135
+ createdAt: new Date().toISOString(),
136
+ updatedAt: new Date().toISOString(),
137
+ prompt: options.prompt,
138
+ reason: options.reason,
139
+ ...(shape.sourceHookId ? { sourceHookId: shape.sourceHookId } : {}),
140
+ ...(options.questionId ? { questionId: options.questionId } : {})
141
+ });
142
+ }
143
+ return upsertQuestionObligation(state, {
144
+ ...existing,
145
+ updatedAt: new Date().toISOString(),
146
+ prompt: options.prompt,
147
+ reason: options.reason,
148
+ ...(options.questionId ? { questionId: options.questionId } : existing.questionId ? { questionId: existing.questionId } : {}),
149
+ ...(shape.sourceHookId ? { sourceHookId: shape.sourceHookId } : {})
150
+ });
151
+ }
152
+ function resolveFirstResearchShapeObligation(state, options) {
153
+ return {
154
+ ...state,
155
+ questionObligations: (state.questionObligations ?? []).map((obligation) => {
156
+ const matches = obligation.kind === "first_research_shape_confirmation" && ((options.sourceHookId && obligation.sourceHookId === options.sourceHookId) ||
157
+ (options.questionId && obligation.questionId === options.questionId));
158
+ if (!matches || obligation.status !== "pending") {
159
+ return obligation;
160
+ }
161
+ return {
162
+ ...obligation,
163
+ status: options.status ?? "satisfied",
164
+ updatedAt: new Date().toISOString(),
165
+ ...(options.questionId ? { questionId: options.questionId } : obligation.questionId ? { questionId: obligation.questionId } : {}),
166
+ ...(options.decisionId ? { decisionId: options.decisionId } : obligation.decisionId ? { decisionId: obligation.decisionId } : {})
167
+ };
168
+ })
169
+ };
170
+ }
109
171
  function interviewDepth(turns) {
110
172
  const usableTurns = turns.filter((turn) => turn.quality !== "thin").length;
111
173
  if (usableTurns >= 3)
@@ -136,6 +198,19 @@ async function beginInterviewHook(context, options) {
136
198
  if (existing) {
137
199
  return { hook: existing, state };
138
200
  }
201
+ const confirmedShape = state.firstResearchShape?.confirmedAt ? state.firstResearchShape : undefined;
202
+ if (confirmedShape) {
203
+ const confirmedHook = [...(state.hooks ?? [])].reverse().find((hook) => isInterviewHookRun(hook) &&
204
+ hook.status === "confirmed" &&
205
+ hook.firstResearchShape?.handle === confirmedShape.handle);
206
+ return {
207
+ hook: confirmedHook,
208
+ state,
209
+ alreadyConfirmed: true,
210
+ shape: confirmedShape,
211
+ nextQuestion: options.openingQuestion ?? confirmedShape.openQuestions[0] ?? confirmedShape.nextAction
212
+ };
213
+ }
139
214
  const timestamp = new Date().toISOString();
140
215
  const hook = {
141
216
  id: createId("hook_interview"),
@@ -283,6 +358,22 @@ function renderQuestionFallback(record, provider = "codex") {
283
358
  ? renderQuestionRecordInput(record)
284
359
  : renderQuestionRecordPrompt(record);
285
360
  }
361
+ function compactInterviewHook(hook) {
362
+ if (!hook) {
363
+ return undefined;
364
+ }
365
+ return {
366
+ id: hook.id,
367
+ kind: hook.kind,
368
+ status: hook.status,
369
+ depth: hook.depth,
370
+ provider: hook.provider,
371
+ turnCount: hook.turns?.length ?? 0,
372
+ firstResearchShape: hook.firstResearchShape?.handle,
373
+ createdAt: hook.createdAt,
374
+ updatedAt: hook.updatedAt
375
+ };
376
+ }
286
377
  async function markQuestionTransport(context, questionId, status, message) {
287
378
  const state = asInterviewState(await loadWorkspaceState(context));
288
379
  let updatedQuestion = null;
@@ -355,31 +446,10 @@ function acceptedAnswer(result) {
355
446
  answer
356
447
  };
357
448
  }
358
- function buildFirstResearchShapeQuestion(shape) {
359
- return {
360
- prompt: [
361
- "Confirm the LongTable First Research Shape.",
362
- `Handle: ${shape.handle}`,
363
- `Goal: ${shape.currentGoal}`,
364
- shape.currentBlocker ? `Blocker: ${shape.currentBlocker}` : undefined,
365
- `Next action: ${shape.nextAction}`
366
- ].filter(Boolean).join("\n"),
367
- title: "First Research Shape",
368
- question: "How should LongTable treat this first research handle?",
369
- checkpointKey: "first_research_shape_confirmation",
370
- displayReason: "This is the first structured handoff from open interview to durable project state.",
371
- options: [
372
- { value: "confirm", label: "Confirm this handle", description: "Use this as the provisional research handle.", recommended: true },
373
- { value: "revise", label: "Revise the handle", description: "Keep interviewing before recording this shape." },
374
- { value: "gather_context", label: "Gather more context", description: "Ask for one more scene, case, source, or material first." },
375
- { value: "defer", label: "Keep it open", description: "Do not treat the research handle as ready yet." }
376
- ]
377
- };
378
- }
379
449
  async function markFirstResearchShapeConfirmation(context, shape, answer, questionId, decisionId) {
380
450
  const state = asInterviewState(await loadWorkspaceState(context));
381
451
  const timestamp = new Date().toISOString();
382
- const confirmedShape = answer === "confirm"
452
+ const confirmedShape = firstResearchShapeAnswerConfirms(answer)
383
453
  ? { ...shape, confirmedAt: timestamp }
384
454
  : shape;
385
455
  state.firstResearchShape = confirmedShape;
@@ -388,12 +458,12 @@ async function markFirstResearchShapeConfirmation(context, shape, answer, questi
388
458
  firstResearchShape: confirmedShape
389
459
  };
390
460
  state.hooks = (state.hooks ?? []).map((hook) => {
391
- if (hook.id !== shape.sourceHookId) {
461
+ if (hook.id !== shape.sourceHookId || !isInterviewHookRun(hook)) {
392
462
  return hook;
393
463
  }
394
464
  return {
395
465
  ...hook,
396
- status: answer === "confirm" ? "confirmed" : answer === "defer" ? "deferred" : "active",
466
+ status: firstResearchShapeAnswerStatus(answer),
397
467
  updatedAt: timestamp,
398
468
  firstResearchShape: confirmedShape,
399
469
  linkedQuestionRecordIds: questionId
@@ -404,6 +474,12 @@ async function markFirstResearchShapeConfirmation(context, shape, answer, questi
404
474
  : hook.linkedDecisionRecordIds
405
475
  };
406
476
  });
477
+ const nextState = resolveFirstResearchShapeObligation(state, {
478
+ sourceHookId: shape.sourceHookId,
479
+ questionId,
480
+ decisionId,
481
+ status: "satisfied"
482
+ });
407
483
  const session = {
408
484
  ...context.session,
409
485
  firstResearchShape: confirmedShape,
@@ -411,9 +487,49 @@ async function markFirstResearchShapeConfirmation(context, shape, answer, questi
411
487
  };
412
488
  context.session = session;
413
489
  await writeFile(context.sessionFilePath, JSON.stringify(session, null, 2), "utf8");
414
- await writeFile(context.stateFilePath, JSON.stringify(state, null, 2), "utf8");
490
+ await writeFile(context.stateFilePath, JSON.stringify(nextState, null, 2), "utf8");
415
491
  await syncCurrentWorkspaceView(context);
416
- return { state, session, shape: confirmedShape };
492
+ return { state: nextState, session, shape: confirmedShape };
493
+ }
494
+ async function markAlreadyConfirmedFirstResearchShape(context, shape) {
495
+ const state = asInterviewState(await loadWorkspaceState(context));
496
+ const timestamp = new Date().toISOString();
497
+ const confirmedShape = shape.confirmedAt ? shape : { ...shape, confirmedAt: timestamp };
498
+ state.firstResearchShape = confirmedShape;
499
+ state.workingState = {
500
+ ...state.workingState,
501
+ firstResearchShape: confirmedShape
502
+ };
503
+ state.hooks = (state.hooks ?? []).map((hook) => {
504
+ if (!isInterviewHookRun(hook)) {
505
+ return hook;
506
+ }
507
+ const matchesSource = shape.sourceHookId && hook.id === shape.sourceHookId;
508
+ const matchesHandle = !shape.sourceHookId && hook.firstResearchShape?.handle === shape.handle;
509
+ if (!matchesSource && !matchesHandle) {
510
+ return hook;
511
+ }
512
+ return {
513
+ ...hook,
514
+ status: "confirmed",
515
+ updatedAt: timestamp,
516
+ firstResearchShape: confirmedShape
517
+ };
518
+ });
519
+ const nextState = resolveFirstResearchShapeObligation(state, {
520
+ sourceHookId: confirmedShape.sourceHookId,
521
+ status: "satisfied"
522
+ });
523
+ const session = {
524
+ ...context.session,
525
+ firstResearchShape: confirmedShape,
526
+ lastUpdatedAt: timestamp
527
+ };
528
+ context.session = session;
529
+ await writeFile(context.sessionFilePath, JSON.stringify(session, null, 2), "utf8");
530
+ await writeFile(context.stateFilePath, JSON.stringify(nextState, null, 2), "utf8");
531
+ await syncCurrentWorkspaceView(context);
532
+ return { state: nextState, session, shape: confirmedShape };
417
533
  }
418
534
  function statusForElicitationError(error) {
419
535
  const message = error instanceof Error ? error.message : String(error);
@@ -565,7 +681,18 @@ export function createLongTableMcpServer() {
565
681
  openingQuestion,
566
682
  seedAnswer
567
683
  });
568
- return textResult(result);
684
+ return textResult({
685
+ hook: compactInterviewHook(result.hook),
686
+ alreadyConfirmed: "alreadyConfirmed" in result ? Boolean(result.alreadyConfirmed) : false,
687
+ shape: "shape" in result ? result.shape : result.hook?.firstResearchShape,
688
+ nextQuestion: "nextQuestion" in result ? result.nextQuestion : openingQuestion,
689
+ workspace: {
690
+ projectName: context.project.projectName,
691
+ currentGoal: context.session.currentGoal,
692
+ currentBlocker: context.session.currentBlocker,
693
+ nextAction: context.session.nextAction
694
+ }
695
+ });
569
696
  }
570
697
  catch (error) {
571
698
  return errorResult(error instanceof Error ? error.message : String(error));
@@ -617,7 +744,16 @@ export function createLongTableMcpServer() {
617
744
  hookId,
618
745
  shape: shape
619
746
  });
620
- return textResult(result);
747
+ return textResult({
748
+ hook: compactInterviewHook(result.hook),
749
+ shape: result.shape,
750
+ session: {
751
+ currentGoal: result.session.currentGoal,
752
+ currentBlocker: result.session.currentBlocker,
753
+ nextAction: result.session.nextAction,
754
+ firstResearchShape: result.session.firstResearchShape
755
+ }
756
+ });
621
757
  }
622
758
  catch (error) {
623
759
  return errorResult(error instanceof Error ? error.message : String(error));
@@ -639,6 +775,13 @@ export function createLongTableMcpServer() {
639
775
  if (!shape) {
640
776
  return errorResult("No First Research Shape was found to confirm. Run summarize_interview first.");
641
777
  }
778
+ if (shape.confirmedAt) {
779
+ const confirmation = await markAlreadyConfirmedFirstResearchShape(context, shape);
780
+ return textResult({
781
+ shape: confirmation.shape,
782
+ elicitation: { attempted: false, reason: "already_confirmed" }
783
+ });
784
+ }
642
785
  const spec = buildFirstResearchShapeQuestion(shape);
643
786
  const created = await createWorkspaceQuestion({
644
787
  context,
@@ -651,6 +794,13 @@ export function createLongTableMcpServer() {
651
794
  provider: provider,
652
795
  required: true
653
796
  });
797
+ const createdState = ensureFirstResearchShapeObligation(asInterviewState(await loadWorkspaceState(context)), shape, {
798
+ prompt: spec.question,
799
+ reason: spec.displayReason,
800
+ questionId: created.question.id
801
+ });
802
+ await writeFile(context.stateFilePath, JSON.stringify(createdState, null, 2), "utf8");
803
+ await syncCurrentWorkspaceView(context);
654
804
  const fallback = renderQuestionFallback(created.question, provider);
655
805
  if (fallbackOnly) {
656
806
  const marked = await markQuestionTransport(context, created.question.id, "fallback_rendered", "MCP elicitation skipped by fallbackOnly.");
@@ -670,8 +820,15 @@ export function createLongTableMcpServer() {
670
820
  ? "declined"
671
821
  : "fallback_rendered";
672
822
  const marked = await markQuestionTransport(context, created.question.id, status, `MCP elicitation returned action: ${elicited.action}.`);
823
+ const cleared = status === "declined"
824
+ ? await clearWorkspaceQuestion({
825
+ context,
826
+ questionId: created.question.id,
827
+ reason: `MCP elicitation returned action: ${elicited.action}; confirmation was deferred without a research-direction decision.`
828
+ })
829
+ : undefined;
673
830
  return textResult({
674
- question: marked ?? created.question,
831
+ question: cleared?.question ?? marked ?? created.question,
675
832
  shape,
676
833
  elicitation: { attempted: true, action: elicited.action },
677
834
  fallback
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@longtable/mcp",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "private": false,
5
5
  "description": "LongTable MCP transport for workspace state and Researcher Checkpoints",
6
6
  "type": "module",
@@ -26,12 +26,12 @@
26
26
  "self-test": "node ./dist/server.js --self-test"
27
27
  },
28
28
  "dependencies": {
29
- "@longtable/checkpoints": "0.1.32",
30
- "@longtable/cli": "0.1.32",
31
- "@longtable/core": "0.1.32",
32
- "@longtable/provider-claude": "0.1.32",
33
- "@longtable/provider-codex": "0.1.32",
34
- "@longtable/setup": "0.1.32",
29
+ "@longtable/checkpoints": "0.1.34",
30
+ "@longtable/cli": "0.1.34",
31
+ "@longtable/core": "0.1.34",
32
+ "@longtable/provider-claude": "0.1.34",
33
+ "@longtable/provider-codex": "0.1.34",
34
+ "@longtable/setup": "0.1.34",
35
35
  "@modelcontextprotocol/sdk": "^1.29.0",
36
36
  "zod": "^4.0.0"
37
37
  },