@longtable/mcp 0.1.31 → 0.1.33
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 +14 -4
- package/dist/first-research-shape.d.ts +25 -0
- package/dist/first-research-shape.js +111 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/server.js +538 -4
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# @longtable/mcp
|
|
2
2
|
|
|
3
|
-
MCP transport for LongTable workspace state and
|
|
3
|
+
MCP transport for LongTable workspace state, `$longtable-interview`, and
|
|
4
|
+
Researcher Checkpoints.
|
|
4
5
|
|
|
5
6
|
This package does not own LongTable state. It exposes structured tools over the
|
|
6
7
|
existing `.longtable/` source of truth.
|
|
@@ -14,7 +15,7 @@ longtable-state
|
|
|
14
15
|
Run:
|
|
15
16
|
|
|
16
17
|
```bash
|
|
17
|
-
npx -y @longtable/mcp@0.1.
|
|
18
|
+
npx -y @longtable/mcp@0.1.33
|
|
18
19
|
```
|
|
19
20
|
|
|
20
21
|
Self-test:
|
|
@@ -32,5 +33,14 @@ longtable mcp install --provider codex --checkpoint-ui strong --write
|
|
|
32
33
|
If MCP elicitation is unavailable or not approved, the server returns the same
|
|
33
34
|
pending `QuestionRecord` as a numbered fallback.
|
|
34
35
|
|
|
35
|
-
Provider guidance should use
|
|
36
|
-
|
|
36
|
+
Provider guidance should use interview tools for `$longtable-interview`:
|
|
37
|
+
|
|
38
|
+
- `create_workspace`
|
|
39
|
+
- `begin_interview`
|
|
40
|
+
- `append_interview_turn`
|
|
41
|
+
- `summarize_interview`
|
|
42
|
+
- `confirm_first_research_shape`
|
|
43
|
+
|
|
44
|
+
For later Researcher Checkpoints, provider guidance should use
|
|
45
|
+
`elicit_question` first when the MCP tool is available. `longtable
|
|
46
|
+
question --print` is only the CLI fallback transport.
|
|
@@ -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 should stay explicitly open about ${shape.protectedDecision}?`;
|
|
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 ${shape.protectedDecision} open`,
|
|
53
|
+
description: "Treat this as the guarded judgment while the broader direction stays provisional.",
|
|
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
package/dist/index.js
CHANGED
package/dist/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { resolve } from "node:path";
|
|
3
|
+
import { basename, resolve } from "node:path";
|
|
4
4
|
import { cwd, exit } from "node:process";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -9,13 +9,20 @@ import { z } from "zod";
|
|
|
9
9
|
import { classifyCheckpointTrigger } from "@longtable/checkpoints";
|
|
10
10
|
import { renderQuestionRecordInput } from "@longtable/provider-claude";
|
|
11
11
|
import { renderQuestionRecordPrompt } from "@longtable/provider-codex";
|
|
12
|
-
import {
|
|
12
|
+
import { loadSetupOutput } from "@longtable/setup";
|
|
13
|
+
import { answerWorkspaceQuestion, createOrUpdateProjectWorkspace, createWorkspaceQuestion, inspectProjectWorkspace, loadProjectContextFromDirectory, loadWorkspaceState, syncCurrentWorkspaceView } from "@longtable/cli";
|
|
14
|
+
import { buildFirstResearchShapeQuestion, firstResearchShapeAnswerConfirms, firstResearchShapeAnswerStatus } from "./first-research-shape.js";
|
|
13
15
|
const SERVER_NAME = "longtable-state";
|
|
14
|
-
const SERVER_VERSION = "0.1.
|
|
16
|
+
const SERVER_VERSION = "0.1.33";
|
|
15
17
|
const TOOL_NAMES = [
|
|
16
18
|
"read_project",
|
|
17
19
|
"read_session",
|
|
18
20
|
"inspect_workspace",
|
|
21
|
+
"create_workspace",
|
|
22
|
+
"begin_interview",
|
|
23
|
+
"append_interview_turn",
|
|
24
|
+
"summarize_interview",
|
|
25
|
+
"confirm_first_research_shape",
|
|
19
26
|
"pending_questions",
|
|
20
27
|
"evaluate_checkpoint",
|
|
21
28
|
"create_question",
|
|
@@ -33,6 +40,19 @@ const questionOptionSchema = z.object({
|
|
|
33
40
|
description: z.string().optional(),
|
|
34
41
|
recommended: z.boolean().optional()
|
|
35
42
|
});
|
|
43
|
+
const firstResearchShapeSchema = z.object({
|
|
44
|
+
handle: z.string().min(1),
|
|
45
|
+
currentGoal: z.string().min(1),
|
|
46
|
+
currentBlocker: z.string().optional(),
|
|
47
|
+
researchObject: z.string().optional(),
|
|
48
|
+
gapRisk: z.string().optional(),
|
|
49
|
+
protectedDecision: z.string().optional(),
|
|
50
|
+
openQuestions: z.array(z.string().min(1)).default([]),
|
|
51
|
+
nextAction: z.string().min(1),
|
|
52
|
+
confidence: z.enum(["low", "medium", "high"]).default("medium"),
|
|
53
|
+
sourceHookId: z.string().optional(),
|
|
54
|
+
confirmedAt: z.string().optional()
|
|
55
|
+
});
|
|
36
56
|
function textResult(structuredContent) {
|
|
37
57
|
return {
|
|
38
58
|
content: [
|
|
@@ -65,6 +85,260 @@ async function requireContext(startPath) {
|
|
|
65
85
|
}
|
|
66
86
|
return context;
|
|
67
87
|
}
|
|
88
|
+
function createId(prefix) {
|
|
89
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
90
|
+
}
|
|
91
|
+
function asInterviewState(state) {
|
|
92
|
+
return state;
|
|
93
|
+
}
|
|
94
|
+
function isInterviewHookRun(hook) {
|
|
95
|
+
return hook?.kind === "longtable_interview";
|
|
96
|
+
}
|
|
97
|
+
function activeInterviewHook(state, hookId) {
|
|
98
|
+
const hooks = state.hooks ?? [];
|
|
99
|
+
if (hookId) {
|
|
100
|
+
const hook = hooks.find((candidate) => candidate.id === hookId);
|
|
101
|
+
return isInterviewHookRun(hook) ? hook : undefined;
|
|
102
|
+
}
|
|
103
|
+
return [...hooks].reverse().find((hook) => hook.kind === "longtable_interview" &&
|
|
104
|
+
(hook.status === "pending" || hook.status === "active" || hook.status === "ready_to_confirm"));
|
|
105
|
+
}
|
|
106
|
+
function upsertInterviewHook(state, hook) {
|
|
107
|
+
const hooks = state.hooks ?? [];
|
|
108
|
+
const nextHooks = hooks.some((candidate) => candidate.id === hook.id)
|
|
109
|
+
? hooks.map((candidate) => candidate.id === hook.id ? hook : candidate)
|
|
110
|
+
: [...hooks, hook];
|
|
111
|
+
return {
|
|
112
|
+
...state,
|
|
113
|
+
hooks: nextHooks
|
|
114
|
+
};
|
|
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
|
+
}
|
|
171
|
+
function interviewDepth(turns) {
|
|
172
|
+
const usableTurns = turns.filter((turn) => turn.quality !== "thin").length;
|
|
173
|
+
if (usableTurns >= 3)
|
|
174
|
+
return "ready_to_summarize";
|
|
175
|
+
if (usableTurns >= 1)
|
|
176
|
+
return "forming_first_handle";
|
|
177
|
+
return "gathering_context";
|
|
178
|
+
}
|
|
179
|
+
function normalizeInterviewQuality(answer, quality) {
|
|
180
|
+
if (quality)
|
|
181
|
+
return quality;
|
|
182
|
+
const trimmed = answer.trim();
|
|
183
|
+
const wordCount = trimmed.split(/\s+/).filter(Boolean).length;
|
|
184
|
+
if (trimmed.length < 12 || wordCount < 3)
|
|
185
|
+
return "thin";
|
|
186
|
+
if (trimmed.length > 80 || wordCount >= 12)
|
|
187
|
+
return "rich";
|
|
188
|
+
return "usable";
|
|
189
|
+
}
|
|
190
|
+
function defaultFollowUpQuestion(answer) {
|
|
191
|
+
return answer.trim().length < 12
|
|
192
|
+
? "Say one more sentence about where this problem appears or why it matters before LongTable tries to classify it."
|
|
193
|
+
: "What concrete scene, case, material, text, dataset, or decision would make that problem easier to inspect first?";
|
|
194
|
+
}
|
|
195
|
+
async function beginInterviewHook(context, options) {
|
|
196
|
+
const state = asInterviewState(await loadWorkspaceState(context));
|
|
197
|
+
const existing = activeInterviewHook(state);
|
|
198
|
+
if (existing) {
|
|
199
|
+
return { hook: existing, state };
|
|
200
|
+
}
|
|
201
|
+
const timestamp = new Date().toISOString();
|
|
202
|
+
const hook = {
|
|
203
|
+
id: createId("hook_interview"),
|
|
204
|
+
kind: "longtable_interview",
|
|
205
|
+
status: "active",
|
|
206
|
+
createdAt: timestamp,
|
|
207
|
+
updatedAt: timestamp,
|
|
208
|
+
targetOutcome: "first_research_handle",
|
|
209
|
+
depth: "gathering_context",
|
|
210
|
+
provider: options.provider,
|
|
211
|
+
turns: [],
|
|
212
|
+
qualityNotes: [],
|
|
213
|
+
rationale: [
|
|
214
|
+
"Official LongTable research start surface is provider-native `$longtable-interview`, not the CLI start questionnaire."
|
|
215
|
+
]
|
|
216
|
+
};
|
|
217
|
+
const updated = upsertInterviewHook(state, hook);
|
|
218
|
+
updated.workingState = {
|
|
219
|
+
...updated.workingState,
|
|
220
|
+
activeInterviewHookId: hook.id,
|
|
221
|
+
interviewSurface: "$longtable-interview",
|
|
222
|
+
...(options.openingQuestion ? { interviewOpeningQuestion: options.openingQuestion } : {}),
|
|
223
|
+
...(options.seedAnswer ? { interviewSeedAnswer: options.seedAnswer } : {})
|
|
224
|
+
};
|
|
225
|
+
await writeFile(context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
|
|
226
|
+
await syncCurrentWorkspaceView(context);
|
|
227
|
+
return { hook, state: updated };
|
|
228
|
+
}
|
|
229
|
+
async function appendInterviewTurn(context, options) {
|
|
230
|
+
const state = asInterviewState(await loadWorkspaceState(context));
|
|
231
|
+
const existing = activeInterviewHook(state, options.hookId);
|
|
232
|
+
if (!existing) {
|
|
233
|
+
throw new Error("No active LongTable interview hook was found. Run begin_interview first.");
|
|
234
|
+
}
|
|
235
|
+
const quality = normalizeInterviewQuality(options.answer, options.quality);
|
|
236
|
+
const needsFollowUp = options.needsFollowUp ?? quality === "thin";
|
|
237
|
+
const followUpQuestion = needsFollowUp
|
|
238
|
+
? options.followUpQuestion ?? defaultFollowUpQuestion(options.answer)
|
|
239
|
+
: options.followUpQuestion;
|
|
240
|
+
const timestamp = new Date().toISOString();
|
|
241
|
+
const turn = {
|
|
242
|
+
id: createId("interview_turn"),
|
|
243
|
+
index: existing.turns.length + 1,
|
|
244
|
+
createdAt: timestamp,
|
|
245
|
+
question: options.question.trim(),
|
|
246
|
+
answer: options.answer.trim(),
|
|
247
|
+
...(options.reflection?.trim() ? { reflection: options.reflection.trim() } : {}),
|
|
248
|
+
quality,
|
|
249
|
+
needsFollowUp,
|
|
250
|
+
...(followUpQuestion?.trim() ? { followUpQuestion: followUpQuestion.trim() } : {}),
|
|
251
|
+
...(options.rationale && options.rationale.length > 0 ? { rationale: options.rationale } : {})
|
|
252
|
+
};
|
|
253
|
+
const turns = [...existing.turns, turn];
|
|
254
|
+
const depth = interviewDepth(turns);
|
|
255
|
+
const hook = {
|
|
256
|
+
...existing,
|
|
257
|
+
status: depth === "ready_to_summarize" ? "ready_to_confirm" : "active",
|
|
258
|
+
updatedAt: timestamp,
|
|
259
|
+
depth,
|
|
260
|
+
turns,
|
|
261
|
+
qualityNotes: [
|
|
262
|
+
...existing.qualityNotes,
|
|
263
|
+
...(needsFollowUp ? [`Turn ${turn.index} needs follow-up: ${followUpQuestion}`] : [])
|
|
264
|
+
]
|
|
265
|
+
};
|
|
266
|
+
const updated = upsertInterviewHook(state, hook);
|
|
267
|
+
await writeFile(context.stateFilePath, JSON.stringify(updated, null, 2), "utf8");
|
|
268
|
+
await syncCurrentWorkspaceView(context);
|
|
269
|
+
return { hook, turn, state: updated };
|
|
270
|
+
}
|
|
271
|
+
async function summarizeInterviewHook(context, options) {
|
|
272
|
+
const state = asInterviewState(await loadWorkspaceState(context));
|
|
273
|
+
const existing = activeInterviewHook(state, options.hookId);
|
|
274
|
+
if (!existing) {
|
|
275
|
+
throw new Error("No active LongTable interview hook was found. Run begin_interview first.");
|
|
276
|
+
}
|
|
277
|
+
const timestamp = new Date().toISOString();
|
|
278
|
+
const shape = {
|
|
279
|
+
...options.shape,
|
|
280
|
+
handle: options.shape.handle.trim(),
|
|
281
|
+
currentGoal: options.shape.currentGoal.trim(),
|
|
282
|
+
openQuestions: options.shape.openQuestions.map((question) => question.trim()).filter(Boolean),
|
|
283
|
+
nextAction: options.shape.nextAction.trim(),
|
|
284
|
+
sourceHookId: existing.id
|
|
285
|
+
};
|
|
286
|
+
const hook = {
|
|
287
|
+
...existing,
|
|
288
|
+
status: "ready_to_confirm",
|
|
289
|
+
updatedAt: timestamp,
|
|
290
|
+
depth: "ready_to_summarize",
|
|
291
|
+
firstResearchShape: shape
|
|
292
|
+
};
|
|
293
|
+
const session = {
|
|
294
|
+
...context.session,
|
|
295
|
+
lastUpdatedAt: timestamp,
|
|
296
|
+
currentGoal: shape.currentGoal,
|
|
297
|
+
...(shape.currentBlocker ? { currentBlocker: shape.currentBlocker } : {}),
|
|
298
|
+
...(shape.researchObject ? { researchObject: shape.researchObject } : {}),
|
|
299
|
+
...(shape.gapRisk ? { gapRisk: shape.gapRisk } : {}),
|
|
300
|
+
...(shape.protectedDecision ? { protectedDecision: shape.protectedDecision } : {}),
|
|
301
|
+
nextAction: shape.nextAction,
|
|
302
|
+
openQuestions: shape.openQuestions,
|
|
303
|
+
firstResearchShape: shape,
|
|
304
|
+
resumeHint: `I want to continue from the First Research Shape: ${shape.handle}.`
|
|
305
|
+
};
|
|
306
|
+
context.session = session;
|
|
307
|
+
const updated = upsertInterviewHook(state, hook);
|
|
308
|
+
updated.firstResearchShape = shape;
|
|
309
|
+
updated.workingState = {
|
|
310
|
+
...updated.workingState,
|
|
311
|
+
currentGoal: shape.currentGoal,
|
|
312
|
+
...(shape.currentBlocker ? { currentBlocker: shape.currentBlocker } : {}),
|
|
313
|
+
...(shape.researchObject ? { researchObject: shape.researchObject } : {}),
|
|
314
|
+
...(shape.gapRisk ? { gapRisk: shape.gapRisk } : {}),
|
|
315
|
+
...(shape.protectedDecision ? { protectedDecision: shape.protectedDecision } : {}),
|
|
316
|
+
openQuestions: shape.openQuestions,
|
|
317
|
+
nextAction: shape.nextAction,
|
|
318
|
+
firstResearchShape: shape
|
|
319
|
+
};
|
|
320
|
+
if (shape.currentBlocker && !updated.openTensions.includes(shape.currentBlocker)) {
|
|
321
|
+
updated.openTensions.push(shape.currentBlocker);
|
|
322
|
+
}
|
|
323
|
+
updated.narrativeTraces.push({
|
|
324
|
+
id: createId("narrative_trace"),
|
|
325
|
+
timestamp,
|
|
326
|
+
source: "$longtable-interview",
|
|
327
|
+
traceType: "judgment",
|
|
328
|
+
summary: `First Research Shape: ${shape.handle}.`,
|
|
329
|
+
visibility: "explicit",
|
|
330
|
+
importance: shape.confidence
|
|
331
|
+
});
|
|
332
|
+
const questionSpec = buildFirstResearchShapeQuestion(shape);
|
|
333
|
+
const withObligation = ensureFirstResearchShapeObligation(updated, shape, {
|
|
334
|
+
prompt: questionSpec.question,
|
|
335
|
+
reason: questionSpec.displayReason
|
|
336
|
+
});
|
|
337
|
+
await writeFile(context.sessionFilePath, JSON.stringify(session, null, 2), "utf8");
|
|
338
|
+
await writeFile(context.stateFilePath, JSON.stringify(withObligation, null, 2), "utf8");
|
|
339
|
+
await syncCurrentWorkspaceView(context);
|
|
340
|
+
return { hook, shape, state: withObligation, session };
|
|
341
|
+
}
|
|
68
342
|
function findQuestion(records, questionId) {
|
|
69
343
|
if (questionId) {
|
|
70
344
|
return records.find((record) => record.id === questionId) ?? null;
|
|
@@ -77,7 +351,7 @@ function renderQuestionFallback(record, provider = "codex") {
|
|
|
77
351
|
: renderQuestionRecordPrompt(record);
|
|
78
352
|
}
|
|
79
353
|
async function markQuestionTransport(context, questionId, status, message) {
|
|
80
|
-
const state = await loadWorkspaceState(context);
|
|
354
|
+
const state = asInterviewState(await loadWorkspaceState(context));
|
|
81
355
|
let updatedQuestion = null;
|
|
82
356
|
state.questionLog = (state.questionLog ?? []).map((record) => {
|
|
83
357
|
if (record.id !== questionId) {
|
|
@@ -148,6 +422,51 @@ function acceptedAnswer(result) {
|
|
|
148
422
|
answer
|
|
149
423
|
};
|
|
150
424
|
}
|
|
425
|
+
async function markFirstResearchShapeConfirmation(context, shape, answer, questionId, decisionId) {
|
|
426
|
+
const state = asInterviewState(await loadWorkspaceState(context));
|
|
427
|
+
const timestamp = new Date().toISOString();
|
|
428
|
+
const confirmedShape = firstResearchShapeAnswerConfirms(answer)
|
|
429
|
+
? { ...shape, confirmedAt: timestamp }
|
|
430
|
+
: shape;
|
|
431
|
+
state.firstResearchShape = confirmedShape;
|
|
432
|
+
state.workingState = {
|
|
433
|
+
...state.workingState,
|
|
434
|
+
firstResearchShape: confirmedShape
|
|
435
|
+
};
|
|
436
|
+
state.hooks = (state.hooks ?? []).map((hook) => {
|
|
437
|
+
if (hook.id !== shape.sourceHookId || !isInterviewHookRun(hook)) {
|
|
438
|
+
return hook;
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
...hook,
|
|
442
|
+
status: firstResearchShapeAnswerStatus(answer),
|
|
443
|
+
updatedAt: timestamp,
|
|
444
|
+
firstResearchShape: confirmedShape,
|
|
445
|
+
linkedQuestionRecordIds: questionId
|
|
446
|
+
? [...(hook.linkedQuestionRecordIds ?? []), questionId]
|
|
447
|
+
: hook.linkedQuestionRecordIds,
|
|
448
|
+
linkedDecisionRecordIds: decisionId
|
|
449
|
+
? [...(hook.linkedDecisionRecordIds ?? []), decisionId]
|
|
450
|
+
: hook.linkedDecisionRecordIds
|
|
451
|
+
};
|
|
452
|
+
});
|
|
453
|
+
const nextState = resolveFirstResearchShapeObligation(state, {
|
|
454
|
+
sourceHookId: shape.sourceHookId,
|
|
455
|
+
questionId,
|
|
456
|
+
decisionId,
|
|
457
|
+
status: "satisfied"
|
|
458
|
+
});
|
|
459
|
+
const session = {
|
|
460
|
+
...context.session,
|
|
461
|
+
firstResearchShape: confirmedShape,
|
|
462
|
+
lastUpdatedAt: timestamp
|
|
463
|
+
};
|
|
464
|
+
context.session = session;
|
|
465
|
+
await writeFile(context.sessionFilePath, JSON.stringify(session, null, 2), "utf8");
|
|
466
|
+
await writeFile(context.stateFilePath, JSON.stringify(nextState, null, 2), "utf8");
|
|
467
|
+
await syncCurrentWorkspaceView(context);
|
|
468
|
+
return { state: nextState, session, shape: confirmedShape };
|
|
469
|
+
}
|
|
151
470
|
function statusForElicitationError(error) {
|
|
152
471
|
const message = error instanceof Error ? error.message : String(error);
|
|
153
472
|
if (/timed?\s*out|timeout/i.test(message)) {
|
|
@@ -238,6 +557,221 @@ export function createLongTableMcpServer() {
|
|
|
238
557
|
return errorResult(error instanceof Error ? error.message : String(error));
|
|
239
558
|
}
|
|
240
559
|
});
|
|
560
|
+
server.registerTool("create_workspace", {
|
|
561
|
+
title: "Create LongTable Workspace",
|
|
562
|
+
description: "Create a .longtable workspace in the current folder for provider-native $longtable-interview.",
|
|
563
|
+
inputSchema: cwdSchema.extend({
|
|
564
|
+
projectName: z.string().optional(),
|
|
565
|
+
projectPath: z.string().optional(),
|
|
566
|
+
seedGoal: z.string().optional(),
|
|
567
|
+
setupPath: z.string().optional(),
|
|
568
|
+
provider: z.enum(["codex", "claude"]).default("codex")
|
|
569
|
+
})
|
|
570
|
+
}, async ({ cwd: inputCwd, projectName, projectPath, seedGoal, setupPath, provider }) => {
|
|
571
|
+
try {
|
|
572
|
+
const targetPath = resolveStartPath(projectPath ?? inputCwd);
|
|
573
|
+
const setup = await loadSetupOutput(setupPath);
|
|
574
|
+
const context = await createOrUpdateProjectWorkspace({
|
|
575
|
+
projectName: projectName?.trim() || basename(targetPath) || "LongTable Research",
|
|
576
|
+
projectPath: targetPath,
|
|
577
|
+
currentGoal: seedGoal?.trim() || "First research handle pending",
|
|
578
|
+
requestedPerspectives: [],
|
|
579
|
+
disagreementPreference: setup.profileSeed.panelPreference ?? "show_on_conflict",
|
|
580
|
+
setup
|
|
581
|
+
});
|
|
582
|
+
const interview = await beginInterviewHook(context, {
|
|
583
|
+
provider: provider,
|
|
584
|
+
openingQuestion: "What do you want to research? If it is not clear yet, describe the problem in its rough form.",
|
|
585
|
+
seedAnswer: seedGoal
|
|
586
|
+
});
|
|
587
|
+
return textResult({
|
|
588
|
+
project: context.project,
|
|
589
|
+
session: context.session,
|
|
590
|
+
hook: interview.hook,
|
|
591
|
+
files: {
|
|
592
|
+
project: context.projectFilePath,
|
|
593
|
+
session: context.sessionFilePath,
|
|
594
|
+
state: context.stateFilePath,
|
|
595
|
+
current: context.currentFilePath
|
|
596
|
+
},
|
|
597
|
+
nextQuestion: "What do you want to research? If it is not clear yet, describe the problem in its rough form."
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
server.registerTool("begin_interview", {
|
|
605
|
+
title: "Begin LongTable Interview",
|
|
606
|
+
description: "Create or resume the active $longtable-interview hook in an existing workspace.",
|
|
607
|
+
inputSchema: cwdSchema.extend({
|
|
608
|
+
openingQuestion: z.string().optional(),
|
|
609
|
+
seedAnswer: z.string().optional(),
|
|
610
|
+
provider: z.enum(["codex", "claude"]).default("codex")
|
|
611
|
+
})
|
|
612
|
+
}, async ({ cwd: inputCwd, openingQuestion, seedAnswer, provider }) => {
|
|
613
|
+
try {
|
|
614
|
+
const context = await requireContext(inputCwd);
|
|
615
|
+
const result = await beginInterviewHook(context, {
|
|
616
|
+
provider: provider,
|
|
617
|
+
openingQuestion,
|
|
618
|
+
seedAnswer
|
|
619
|
+
});
|
|
620
|
+
return textResult(result);
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
server.registerTool("append_interview_turn", {
|
|
627
|
+
title: "Append LongTable Interview Turn",
|
|
628
|
+
description: "Record one natural-language interview question and answer, including quality/follow-up metadata.",
|
|
629
|
+
inputSchema: cwdSchema.extend({
|
|
630
|
+
hookId: z.string().optional(),
|
|
631
|
+
question: z.string().min(1),
|
|
632
|
+
answer: z.string().min(1),
|
|
633
|
+
reflection: z.string().optional(),
|
|
634
|
+
quality: z.enum(["thin", "usable", "rich"]).optional(),
|
|
635
|
+
needsFollowUp: z.boolean().optional(),
|
|
636
|
+
followUpQuestion: z.string().optional(),
|
|
637
|
+
rationale: z.array(z.string()).optional()
|
|
638
|
+
})
|
|
639
|
+
}, async ({ cwd: inputCwd, hookId, question, answer, reflection, quality, needsFollowUp, followUpQuestion, rationale }) => {
|
|
640
|
+
try {
|
|
641
|
+
const context = await requireContext(inputCwd);
|
|
642
|
+
const result = await appendInterviewTurn(context, {
|
|
643
|
+
hookId,
|
|
644
|
+
question,
|
|
645
|
+
answer,
|
|
646
|
+
reflection,
|
|
647
|
+
quality: quality,
|
|
648
|
+
needsFollowUp,
|
|
649
|
+
followUpQuestion,
|
|
650
|
+
rationale
|
|
651
|
+
});
|
|
652
|
+
return textResult(result);
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
server.registerTool("summarize_interview", {
|
|
659
|
+
title: "Summarize LongTable Interview",
|
|
660
|
+
description: "Store the provisional First Research Shape after enough interview context has accumulated.",
|
|
661
|
+
inputSchema: cwdSchema.extend({
|
|
662
|
+
hookId: z.string().optional(),
|
|
663
|
+
shape: firstResearchShapeSchema
|
|
664
|
+
})
|
|
665
|
+
}, async ({ cwd: inputCwd, hookId, shape }) => {
|
|
666
|
+
try {
|
|
667
|
+
const context = await requireContext(inputCwd);
|
|
668
|
+
const result = await summarizeInterviewHook(context, {
|
|
669
|
+
hookId,
|
|
670
|
+
shape: shape
|
|
671
|
+
});
|
|
672
|
+
return textResult(result);
|
|
673
|
+
}
|
|
674
|
+
catch (error) {
|
|
675
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
server.registerTool("confirm_first_research_shape", {
|
|
679
|
+
title: "Confirm First Research Shape",
|
|
680
|
+
description: "Use MCP form elicitation to confirm, revise, defer, or request more context for the First Research Shape.",
|
|
681
|
+
inputSchema: cwdSchema.extend({
|
|
682
|
+
shape: firstResearchShapeSchema.optional(),
|
|
683
|
+
provider: z.enum(["codex", "claude"]).default("codex"),
|
|
684
|
+
fallbackOnly: z.boolean().default(false)
|
|
685
|
+
})
|
|
686
|
+
}, async ({ cwd: inputCwd, shape: inputShape, provider, fallbackOnly }) => {
|
|
687
|
+
try {
|
|
688
|
+
const context = await requireContext(inputCwd);
|
|
689
|
+
const state = asInterviewState(await loadWorkspaceState(context));
|
|
690
|
+
const shape = inputShape ?? state.firstResearchShape;
|
|
691
|
+
if (!shape) {
|
|
692
|
+
return errorResult("No First Research Shape was found to confirm. Run summarize_interview first.");
|
|
693
|
+
}
|
|
694
|
+
const spec = buildFirstResearchShapeQuestion(shape);
|
|
695
|
+
const created = await createWorkspaceQuestion({
|
|
696
|
+
context,
|
|
697
|
+
prompt: spec.prompt,
|
|
698
|
+
title: spec.title,
|
|
699
|
+
question: spec.question,
|
|
700
|
+
checkpointKey: spec.checkpointKey,
|
|
701
|
+
questionOptions: spec.options,
|
|
702
|
+
displayReason: spec.displayReason,
|
|
703
|
+
provider: provider,
|
|
704
|
+
required: true
|
|
705
|
+
});
|
|
706
|
+
const createdState = ensureFirstResearchShapeObligation(asInterviewState(await loadWorkspaceState(context)), shape, {
|
|
707
|
+
prompt: spec.question,
|
|
708
|
+
reason: spec.displayReason,
|
|
709
|
+
questionId: created.question.id
|
|
710
|
+
});
|
|
711
|
+
await writeFile(context.stateFilePath, JSON.stringify(createdState, null, 2), "utf8");
|
|
712
|
+
await syncCurrentWorkspaceView(context);
|
|
713
|
+
const fallback = renderQuestionFallback(created.question, provider);
|
|
714
|
+
if (fallbackOnly) {
|
|
715
|
+
const marked = await markQuestionTransport(context, created.question.id, "fallback_rendered", "MCP elicitation skipped by fallbackOnly.");
|
|
716
|
+
return textResult({
|
|
717
|
+
question: marked ?? created.question,
|
|
718
|
+
shape,
|
|
719
|
+
elicitation: { attempted: false, reason: "fallbackOnly" },
|
|
720
|
+
fallback
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
try {
|
|
724
|
+
await markQuestionTransport(context, created.question.id, "attempted");
|
|
725
|
+
const elicited = await server.server.elicitInput(buildElicitationParams(created.question));
|
|
726
|
+
const accepted = acceptedAnswer(elicited);
|
|
727
|
+
if (!accepted) {
|
|
728
|
+
const status = elicited.action === "decline" || elicited.action === "cancel"
|
|
729
|
+
? "declined"
|
|
730
|
+
: "fallback_rendered";
|
|
731
|
+
const marked = await markQuestionTransport(context, created.question.id, status, `MCP elicitation returned action: ${elicited.action}.`);
|
|
732
|
+
return textResult({
|
|
733
|
+
question: marked ?? created.question,
|
|
734
|
+
shape,
|
|
735
|
+
elicitation: { attempted: true, action: elicited.action },
|
|
736
|
+
fallback
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
const decided = await answerWorkspaceQuestion({
|
|
740
|
+
context,
|
|
741
|
+
questionId: created.question.id,
|
|
742
|
+
answer: accepted.answer,
|
|
743
|
+
provider: provider,
|
|
744
|
+
surface: "mcp_elicitation"
|
|
745
|
+
});
|
|
746
|
+
const marked = await markQuestionTransport(context, created.question.id, "accepted");
|
|
747
|
+
const confirmation = await markFirstResearchShapeConfirmation(context, shape, accepted.answer, created.question.id, decided.decision.id);
|
|
748
|
+
return textResult({
|
|
749
|
+
shape: confirmation.shape,
|
|
750
|
+
question: marked ? { ...decided.question, transportStatus: marked.transportStatus } : decided.question,
|
|
751
|
+
decision: decided.decision,
|
|
752
|
+
elicitation: { attempted: true, action: elicited.action }
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
catch (elicitationError) {
|
|
756
|
+
const status = statusForElicitationError(elicitationError);
|
|
757
|
+
const message = elicitationError instanceof Error ? elicitationError.message : String(elicitationError);
|
|
758
|
+
const marked = await markQuestionTransport(context, created.question.id, status, message);
|
|
759
|
+
return textResult({
|
|
760
|
+
question: marked ?? created.question,
|
|
761
|
+
shape,
|
|
762
|
+
elicitation: {
|
|
763
|
+
attempted: true,
|
|
764
|
+
supported: status !== "unsupported" ? undefined : false,
|
|
765
|
+
error: message
|
|
766
|
+
},
|
|
767
|
+
fallback
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
catch (error) {
|
|
772
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
773
|
+
}
|
|
774
|
+
});
|
|
241
775
|
server.registerTool("pending_questions", {
|
|
242
776
|
title: "List Pending Researcher Checkpoints",
|
|
243
777
|
description: "List pending LongTable QuestionRecords.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@longtable/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "LongTable MCP transport for workspace state and Researcher Checkpoints",
|
|
6
6
|
"type": "module",
|
|
@@ -26,11 +26,12 @@
|
|
|
26
26
|
"self-test": "node ./dist/server.js --self-test"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@longtable/checkpoints": "0.1.
|
|
30
|
-
"@longtable/cli": "0.1.
|
|
31
|
-
"@longtable/core": "0.1.
|
|
32
|
-
"@longtable/provider-claude": "0.1.
|
|
33
|
-
"@longtable/provider-codex": "0.1.
|
|
29
|
+
"@longtable/checkpoints": "0.1.33",
|
|
30
|
+
"@longtable/cli": "0.1.33",
|
|
31
|
+
"@longtable/core": "0.1.33",
|
|
32
|
+
"@longtable/provider-claude": "0.1.33",
|
|
33
|
+
"@longtable/provider-codex": "0.1.33",
|
|
34
|
+
"@longtable/setup": "0.1.33",
|
|
34
35
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
35
36
|
"zod": "^4.0.0"
|
|
36
37
|
},
|