@clien-ai/mcp 0.8.1 → 0.10.0

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.
@@ -0,0 +1,370 @@
1
+ /**
2
+ * `list_interview_scripts` (FUL-514) — the questions a run says are worth asking.
3
+ *
4
+ * Before this tool, MCP had the ACTION without the PLAN. An agent could call
5
+ * `interview_persona` but could not first read what the research decided was worth
6
+ * asking, so it either invented questions unanchored to the run's open hypotheses
7
+ * — in which case the answers cannot feed back into the claim spine — or
8
+ * re-derived them from `get_report`'s hypothesis list, which is the FUL-510
9
+ * failure again: an agent recomputing something the run already produced and
10
+ * stored. The web app has handed a human this screen for free since FUL-327.
11
+ *
12
+ * ⚠️ THIS TOOL NEVER GENERATES A SCRIPT. Scripts are the deterministic output of a
13
+ * research run (`agent/src/interview-script.ts`), written into the run's report
14
+ * and immutable thereafter. Deriving them on a miss would make a free read do
15
+ * model work and spend money on a GET, so a project with none gets the honest
16
+ * empty state and nothing else. The wording of that empty state matters as much as
17
+ * the guarantee: "no run has written scripts" is not "there is nothing worth
18
+ * asking".
19
+ *
20
+ * Contract:
21
+ * - list_interview_scripts → GET /api/interview-scripts?projectId →
22
+ * { interviewScripts: <script>[] | null, sourceRun: { id, createdAt } | null }
23
+ *
24
+ * ⚠️ VOCABULARY, and why this differs from `collections.ts`. The two collections
25
+ * and `get_market` read PROMOTED TABLES, so their endpoints return raw snake_case
26
+ * rows and those modules map at the tool boundary. Interview scripts are not a
27
+ * table — they live inside `validation_jobs.report_data.interviewScripts` — so
28
+ * `/api/interview-scripts` returns a shape the app has already mapped, ranked and
29
+ * joined to its hypotheses. The mapper below therefore NORMALISES (type-checks
30
+ * every field, degrades a bad one to null) rather than renames. It is still
31
+ * written out field by field, for the same reason the collection mappers are: the
32
+ * field list is part of the public MCP contract and must be readable in review.
33
+ *
34
+ * ⚠️ RANKING AND SELECTION ARE THE SERVER'S. Which run owns a project's scripts
35
+ * (`latestScriptedRunQuery` — newest non-scoped run that actually wrote an array)
36
+ * and what order they come in (`buildRankedScripts` — least-settled verdict first)
37
+ * are decided once, app-side, by the same functions the web screen renders from.
38
+ * Re-sorting here is what would let the two surfaces disagree about which
39
+ * hypothesis is most worth a real answer.
40
+ *
41
+ * Everything else follows its siblings: the shared `apiCall` client, the shared
42
+ * 401/404/400 mapping, and the rule that "I could not read the answer" and "the
43
+ * answer is none" must never look the same. Read-only, so a timeout is always safe
44
+ * to retry — no work lost, no credit spent.
45
+ */
46
+ import { z } from 'zod';
47
+ import { CollectionToolError, formatZodError, makeCtx, throwForResponse, } from './collections.js';
48
+ import { apiCall } from './research.js';
49
+ import { safeId, safeInline, truncate, collapseWhitespace } from './render-safety.js';
50
+ const SCRIPTS_FETCH_TIMEOUT_MS = 30_000;
51
+ export const ListInterviewScriptsInputSchema = z.object({
52
+ project_id: z
53
+ .string()
54
+ .uuid()
55
+ .describe('UUID of the project whose interview scripts to read. Get it from ' +
56
+ '`list_projects` (or `create_project`). Rejected with a 404 if you do not own the ' +
57
+ 'project. Returns the STORED OUTPUT OF ONE SPECIFIC RESEARCH RUN — the scripts that ' +
58
+ 'run wrote when it ran, one per hypothesis, ranked least-settled first — not a live ' +
59
+ 'suggestion and not something generated when you call this. The run is named in the ' +
60
+ 'result; pass its id to `get_report` for the hypotheses behind the questions. A ' +
61
+ 'project can legitimately have NO scripts; that is reported as "no run has written ' +
62
+ 'any", never as "there is nothing worth asking".'),
63
+ });
64
+ function asText(value) {
65
+ return typeof value === 'string' && value.trim().length > 0 ? value : null;
66
+ }
67
+ /**
68
+ * A 0–1 confidence, or null.
69
+ *
70
+ * Out-of-range and non-finite numbers become null rather than being printed. The
71
+ * app drops them for the same reason at its own render seam: a percentage derived
72
+ * from 999 reads as a measurement, and this tool has no way to caption it as
73
+ * nonsense in the one line it gets.
74
+ */
75
+ function asFraction(value) {
76
+ if (typeof value !== 'number' || !Number.isFinite(value))
77
+ return null;
78
+ return value >= 0 && value <= 1 ? value : null;
79
+ }
80
+ export function toInterviewScriptEntry(row) {
81
+ return {
82
+ hypothesisId: asText(row.hypothesisId),
83
+ statement: asText(row.statement),
84
+ status: asText(row.status),
85
+ verdictLabel: asText(row.verdictLabel),
86
+ confidence: asFraction(row.confidence),
87
+ whoToAsk: asText(row.whoToAsk),
88
+ // A drifted null (or a string where an array belongs) costs the questions,
89
+ // never the script: the hypothesis it names is still worth reporting.
90
+ questions: Array.isArray(row.questions)
91
+ ? row.questions.filter((q) => typeof q === 'string')
92
+ : [],
93
+ };
94
+ }
95
+ export function toInterviewScriptsSourceRun(row) {
96
+ return { id: asText(row.id), createdAt: asText(row.createdAt) };
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // Render caps
100
+ // ---------------------------------------------------------------------------
101
+ /**
102
+ * Upper bound on scripts RENDERED, matching `report-digest.ts`'s `LIST_RENDER_CAP`
103
+ * for personas and hypotheses. A run writes one script per hypothesis and the
104
+ * producer's hypothesis cap is far below this, so it should never fire — but an
105
+ * unfired cap that would truncate silently is the thing this package refuses.
106
+ * When it fires it says so, and says where the rest are.
107
+ */
108
+ const SCRIPT_RENDER_CAP = 25;
109
+ /**
110
+ * Questions rendered per script. The generator writes exactly 3; the cap exists
111
+ * for a report written by some other build, and announces itself the same way.
112
+ */
113
+ const QUESTION_RENDER_CAP = 12;
114
+ const STATEMENT_MAX = 240;
115
+ const WHO_TO_ASK_MAX = 240;
116
+ const QUESTION_MAX = 300;
117
+ const VERDICT_MAX = 40;
118
+ const TIMESTAMP_MAX = 40;
119
+ /**
120
+ * GET /api/interview-scripts for one project.
121
+ *
122
+ * ⚠️ THE NULL AND THE THROW ARE DIFFERENT ANSWERS, and the difference is decided by
123
+ * KEY PRESENCE, not truthiness. `{ "interviewScripts": null }` is the endpoint
124
+ * saying "no run has written scripts for this project" — a real, quotable fact. A
125
+ * 200 whose body has no `interviewScripts` key at all (an HTML error page from a
126
+ * proxy, a truncated response, a reshaped envelope) is the endpoint saying nothing,
127
+ * and must not arrive at an agent wearing the empty state's words: that empty state
128
+ * ends with "run `clien_research`", which DEBITS CREDITS. A transport failure read
129
+ * as "this project has no plan" can cost the user real money.
130
+ */
131
+ async function fetchInterviewScripts(deps, projectId) {
132
+ const action = 'read the interview scripts';
133
+ const ctx = makeCtx(deps);
134
+ const query = new URLSearchParams({ projectId });
135
+ let response;
136
+ try {
137
+ response = await apiCall(ctx, 'GET', `/api/interview-scripts?${query.toString()}`, {
138
+ timeoutMs: SCRIPTS_FETCH_TIMEOUT_MS,
139
+ });
140
+ }
141
+ catch (err) {
142
+ const e = err;
143
+ if (e.name === 'AbortError' || e.name === 'TimeoutError') {
144
+ throw new CollectionToolError(`Timed out after ${SCRIPTS_FETCH_TIMEOUT_MS / 1000}s trying to ${action}. ` +
145
+ 'Try again in a moment — this is a read, so nothing was spent and nothing was lost.');
146
+ }
147
+ throw err;
148
+ }
149
+ if (!response.ok)
150
+ await throwForResponse(response, deps, action, projectId);
151
+ const json = (await response.json().catch(() => null));
152
+ const unreadable = new CollectionToolError(`Could not ${action} — the server returned a 200 with an unreadable body ` +
153
+ '(no `interviewScripts` key). This is NOT "the project has no interview scripts": the ' +
154
+ 'request failed and scripts may well exist. Retry — this is a read, so nothing was spent ' +
155
+ 'and nothing was lost. Do not tell anyone this project has no interview plan on the ' +
156
+ 'strength of this, and do not start a paid research run because of it.');
157
+ if (json === null ||
158
+ typeof json !== 'object' ||
159
+ Array.isArray(json) ||
160
+ !('interviewScripts' in json)) {
161
+ throw unreadable;
162
+ }
163
+ const rawRun = json.sourceRun;
164
+ const sourceRun = rawRun !== null && typeof rawRun === 'object' && !Array.isArray(rawRun)
165
+ ? toInterviewScriptsSourceRun(rawRun)
166
+ : null;
167
+ const raw = json.interviewScripts;
168
+ if (raw === null)
169
+ return { scripts: null, sourceRun };
170
+ if (!Array.isArray(raw))
171
+ throw unreadable;
172
+ const scripts = raw
173
+ .filter((entry) => entry !== null && typeof entry === 'object' && !Array.isArray(entry))
174
+ .map(toInterviewScriptEntry);
175
+ return { scripts, sourceRun };
176
+ }
177
+ // ---------------------------------------------------------------------------
178
+ // Render
179
+ // ---------------------------------------------------------------------------
180
+ /**
181
+ * The provenance sentence, in the TEXT channel because that is the one an agent
182
+ * always receives (FUL-243: `_meta` is a mirror, Claude Code drops it).
183
+ *
184
+ * A run id this client cannot state EXACTLY gets its own sentence rather than
185
+ * silence. Silence would read as "there is nothing to say about where these came
186
+ * from", when the truth is that the provenance is unknown — and a question set
187
+ * with unknown provenance is exactly the one an agent must not attribute to
188
+ * "the research".
189
+ */
190
+ function provenanceLine(runId, createdAt) {
191
+ const safe = safeId(runId);
192
+ const when = safeInline(createdAt, TIMESTAMP_MAX);
193
+ if (!safe) {
194
+ return ('PROVENANCE: these scripts name NO source run — either the response omitted it or the ' +
195
+ 'run behind them has since been deleted. Report them as recorded, never as "run X ' +
196
+ 'recommends asking".');
197
+ }
198
+ return (`PROVENANCE: every script above was written by research run ${safe}` +
199
+ (when ? ` (recorded ${when})` : '') +
200
+ '. A report is an immutable snapshot, so the verdicts beside each question are that run\'s, ' +
201
+ `not today's — call \`get_report\` with job_id=${safe} for the hypotheses behind them.`);
202
+ }
203
+ /**
204
+ * The linkage instruction.
205
+ *
206
+ * ⚠️ It names PARAMETERS now, not a thing to remember (FUL-577). This line used to
207
+ * ask the agent to carry `hypothesisId` forward by hand, because `interview_persona`
208
+ * took no hypothesis id and `interviews` had no column for one — the strongest thing
209
+ * a read-only tool could do, and still a written instruction rather than a stored
210
+ * relation. `interview_persona action:start` now accepts the pair and the row keeps
211
+ * it, so telling an agent to remember something it can instead write down would be
212
+ * the worst of both.
213
+ *
214
+ * BOTH halves, because an id alone is ambiguous: hypotheses live inside a run's
215
+ * `report_data`, so the same `H3` recurs across runs of one project meaning
216
+ * different things. That is why `sourceRun.id` is named here and not merely
217
+ * available in the provenance line.
218
+ */
219
+ const LINKAGE_LINE = 'LINKAGE: each script names the `hypothesisId` it was written to test. Pass it to ' +
220
+ '`interview_persona action:start` as `hypothesis_id`, together with this call\'s ' +
221
+ '`sourceRun.id` as `hypothesis_run_id`, and the interview RECORDS which hypothesis it tested — ' +
222
+ 'you no longer have to carry the id through the conversation, and `get_interview` / ' +
223
+ '`list_interviews` hand it back. Both parameters or neither: an id is only meaningful against ' +
224
+ 'the run that defined it.';
225
+ /**
226
+ * The trust caveat.
227
+ *
228
+ * A verdict printed beside a question reads as settled unless something says
229
+ * otherwise (FUL-247). These verdicts are the run's reading of its own hypothesis,
230
+ * not the grading of a claim: the per-claim GROUNDED / SPECULATION / NO_RECEIPT
231
+ * states live in the source run's claim spine, which this separately released
232
+ * client cannot reconstruct.
233
+ */
234
+ const SCRIPTS_TRUST_LINE = 'TRUST: the verdict and confidence on each script are the source run\'s reading of that ' +
235
+ 'hypothesis, not a graded claim. Nothing here carries a GROUNDED / SPECULATION / NO_RECEIPT ' +
236
+ 'state — read the source run with `get_report` for those. A high confidence is a reason to ask ' +
237
+ 'a different question, never a reason to skip asking.';
238
+ const CHANNEL_NOTE = 'The full record is in `structuredContent.interviewScripts` (also mirrored in `_meta`).';
239
+ /** `Weak signal (inconclusive), confidence 52%` — or the honest partial forms. */
240
+ function verdictClause(script) {
241
+ const label = safeInline(script.verdictLabel, VERDICT_MAX);
242
+ const status = safeInline(script.status, VERDICT_MAX);
243
+ const confidence = script.confidence === null ? null : `confidence ${Math.round(script.confidence * 100)}%`;
244
+ const verdict = label && status ? `${label} (${status})` : label ?? status ?? 'verdict not recorded';
245
+ return confidence ? `${verdict}, ${confidence}` : `${verdict}, confidence not recorded`;
246
+ }
247
+ /** The question list for one script, capped and — when capped — saying so. */
248
+ function questionBlock(questions) {
249
+ const flat = questions
250
+ .map((q) => collapseWhitespace(typeof q === 'string' ? q : ''))
251
+ .filter((q) => q.length > 0);
252
+ // Branch on the RENDERED questions, not on `questions.length`: `['']` has
253
+ // length 1 and collapses to nothing, and a "Questions:" header over no
254
+ // questions reads as a rendering bug rather than as a script that lost them.
255
+ if (flat.length === 0) {
256
+ return ' Questions: none readable in the stored script.';
257
+ }
258
+ const shown = flat.slice(0, QUESTION_RENDER_CAP);
259
+ const lines = shown.map((q, i) => ` ${i + 1}. ${truncate(q, QUESTION_MAX)}`);
260
+ const dropped = flat.length - shown.length;
261
+ const more = dropped > 0
262
+ ? `\n (${dropped} more in \`structuredContent.interviewScripts\`)`
263
+ : '';
264
+ return ` Questions:\n${lines.join('\n')}${more}`;
265
+ }
266
+ /** One ranked script as its own block. */
267
+ function renderScript(script, rank) {
268
+ const id = safeId(script.hypothesisId);
269
+ const statement = safeInline(script.statement, STATEMENT_MAX);
270
+ const whoToAsk = safeInline(script.whoToAsk, WHO_TO_ASK_MAX);
271
+ // The heading names the hypothesis, because a question set detached from the
272
+ // hypothesis it serves is the thing this tool exists to stop being handed out.
273
+ const heading = statement
274
+ ? `${rank}. ${statement}`
275
+ : `${rank}. (this run recorded no statement for the hypothesis this script serves)`;
276
+ const lines = [
277
+ heading,
278
+ id
279
+ ? ` Hypothesis: ${id} — ${verdictClause(script)}`
280
+ : ` Hypothesis: NOT IDENTIFIED — ${verdictClause(script)}. Without an id these answers ` +
281
+ 'cannot be tied back to a hypothesis.',
282
+ ];
283
+ if (whoToAsk)
284
+ lines.push(` Who to ask: ${whoToAsk}`);
285
+ lines.push(questionBlock(script.questions));
286
+ return lines.join('\n');
287
+ }
288
+ export async function listInterviewScripts(input, deps) {
289
+ const parsed = ListInterviewScriptsInputSchema.safeParse(input);
290
+ if (!parsed.success) {
291
+ throw new CollectionToolError(`Invalid input — ${formatZodError(parsed.error)}`);
292
+ }
293
+ const { project_id: projectId } = parsed.data;
294
+ const { scripts, sourceRun } = await fetchInterviewScripts(deps, projectId);
295
+ let text;
296
+ if (scripts === null) {
297
+ // ⚠️ THE HONEST EMPTY STATE. "Absent" is not "settled" and it is not "we looked
298
+ // and there was nothing worth asking". A scoped scan or a failed run leaves a
299
+ // project with no scripts while its hypotheses are wide open, so this sentence
300
+ // has to say that nothing is on record rather than imply a finding.
301
+ text =
302
+ `No interview scripts recorded for project ${projectId}.\n\n` +
303
+ 'This means NO completed research run has written interview scripts for this project. It ' +
304
+ 'is NOT a finding about the hypotheses: not "they are settled", not "there is nothing ' +
305
+ 'worth asking", not zero. There is simply nothing on record.\n\n' +
306
+ 'This tool never generates scripts — it reads what a run stored, and it will keep ' +
307
+ 'returning this until one exists. Run `clien_research` (a full run) to produce them. ' +
308
+ '`scan_competitors` and `search_forums` are scoped runs and never write scripts, so ' +
309
+ 'running those will leave this empty.';
310
+ }
311
+ else {
312
+ const runId = safeId(sourceRun?.id ?? null);
313
+ const when = safeInline(sourceRun?.createdAt ?? null, TIMESTAMP_MAX);
314
+ if (scripts.length === 0) {
315
+ // ⚠️ A DIFFERENT FACT FROM THE `null` BRANCH. A run IS on record and it did
316
+ // write a scripts array — the endpoint only names a run that did. Nothing in
317
+ // it could be read, which is a defect in the stored report, not a statement
318
+ // about the hypotheses. Collapsing the two would tell a caller no research
319
+ // has been done when some has.
320
+ text =
321
+ `Project ${projectId} has a research run that wrote interview scripts, and none of ` +
322
+ 'them could be read.\n\n' +
323
+ (runId
324
+ ? `Run ${runId}${when ? ` (recorded ${when})` : ''} stored a scripts array whose ` +
325
+ 'entries are unusable — a malformed report, not an answer about the hypotheses. '
326
+ : 'The run that wrote them stored a scripts array whose entries are unusable — a ' +
327
+ 'malformed report, not an answer about the hypotheses. ') +
328
+ 'Do not treat this as "nothing worth asking". Read the run itself with `get_report` for ' +
329
+ 'its hypotheses, or run `clien_research` again to write a fresh set.\n\n' +
330
+ provenanceLine(sourceRun?.id ?? null, sourceRun?.createdAt ?? null) +
331
+ '\n\n' +
332
+ CHANNEL_NOTE;
333
+ }
334
+ else {
335
+ const shown = scripts.slice(0, SCRIPT_RENDER_CAP);
336
+ const dropped = scripts.length - shown.length;
337
+ const capNote = dropped > 0
338
+ ? `\n\n(${dropped} more script${dropped === 1 ? '' : 's'} in ` +
339
+ '`structuredContent.interviewScripts`.)'
340
+ : '';
341
+ text =
342
+ `Interview scripts for project ${projectId} — ${scripts.length} script` +
343
+ `${scripts.length === 1 ? '' : 's'}` +
344
+ `${runId ? ` from research run ${runId}` : ''}${when ? ` (recorded ${when})` : ''}.\n\n` +
345
+ 'These are the STORED output of that run, written when it ran. Nothing was generated to ' +
346
+ 'answer this call.\n\n' +
347
+ 'RANKED least-settled first: the hypothesis nearest a coin flip goes first, because a ' +
348
+ 'real answer there moves the verdict most. That order is the run\'s, not this ' +
349
+ 'client\'s.\n\n' +
350
+ shown.map((script, i) => renderScript(script, i + 1)).join('\n\n') +
351
+ capNote +
352
+ '\n\n' +
353
+ LINKAGE_LINE +
354
+ '\n\n' +
355
+ provenanceLine(sourceRun?.id ?? null, sourceRun?.createdAt ?? null) +
356
+ '\n\n' +
357
+ SCRIPTS_TRUST_LINE +
358
+ '\n\n' +
359
+ CHANNEL_NOTE;
360
+ }
361
+ }
362
+ return {
363
+ content: [{ type: 'text', text }],
364
+ // Both keys are present even when null — the PRESENCE is what tells a
365
+ // structured consumer "no run has written scripts" apart from "this tool did
366
+ // not answer".
367
+ _meta: { interviewScripts: scripts, sourceRun },
368
+ };
369
+ }
370
+ //# sourceMappingURL=interview-scripts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"interview-scripts.js","sourceRoot":"","sources":["../../src/tools/interview-scripts.ts"],"names":[],"mappings":"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"}
@@ -41,8 +41,26 @@ import { z } from 'zod';
41
41
  import { ToolError } from './errors.js';
42
42
  import { deprecatedAlias } from './param-aliases.js';
43
43
  import { apiCall, categorizeFetchError, } from './research.js';
44
- import { safeBlock, safeInline, safeId, truncate } from './render-safety.js';
44
+ import { safeBlock, safeInline, safeId, truncate, ID_MAX } from './render-safety.js';
45
45
  const INTERVIEW_FETCH_TIMEOUT_MS = 60_000;
46
+ /**
47
+ * The shape a `hypothesis_id` must have to be storable (FUL-577).
48
+ *
49
+ * ⚠️ It is `safeId`'s rule, restated as a Zod check rather than re-derived — the
50
+ * cap IS `ID_MAX` and the character class is the one `safeId` rejects on. That is
51
+ * not a coincidence to be tidied away: `safeId` is what decides whether the value
52
+ * can ever be handed BACK to an agent (it returns the input byte-identical or
53
+ * `null`, never a clipped third thing), so accepting an id it would refuse means
54
+ * writing something no reader can be shown. Rejecting it at the input boundary is
55
+ * the difference between a clear parameter error and a value that silently renders
56
+ * as nothing on every later read.
57
+ *
58
+ * The app enforces the same rule at `HYPOTHESIS_ID_REGEX` in
59
+ * `app/lib/validation/schemas.ts`, and the database again as a CHECK. This copy is
60
+ * the one that gives the agent the error in the turn it made the mistake.
61
+ */
62
+ const HYPOTHESIS_ID_MAX = ID_MAX;
63
+ const HYPOTHESIS_ID_REGEX = /^[^\s\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+$/u;
46
64
  /** Upper bound on a turn's question — matches the server's `MESSAGE_CONTENT_MAX`. */
47
65
  const CONTENT_MAX = 10_000;
48
66
  /** FUL-233: legacy camelCase spellings `interview_persona` still accepts. */
@@ -94,6 +112,26 @@ export const InterviewPersonaInputSchema = z.object({
94
112
  .describe('action=start: set true for a scratch/throwaway interview that should NOT count toward the ' +
95
113
  'project\'s aggregate insights (sets include_in_insights=false). Leave false (the default) ' +
96
114
  'for a real interview you want reflected in project-level analysis.'),
115
+ hypothesis_id: z
116
+ .string()
117
+ .max(HYPOTHESIS_ID_MAX)
118
+ .regex(HYPOTHESIS_ID_REGEX, 'hypothesis_id must be a single token with no spaces')
119
+ .optional()
120
+ .describe('action=start: the `hypothesisId` of the `list_interview_scripts` script you are about to ' +
121
+ 'ask, RECORDED ON THE INTERVIEW. Send it with `hypothesis_run_id` (that call\'s ' +
122
+ '`sourceRun.id`) or not at all — an id alone is ambiguous, because the same "H3" recurs ' +
123
+ 'across runs of one project meaning different things. Optional: omit both for an ' +
124
+ 'interview that tests no particular hypothesis. Once stored it comes back on ' +
125
+ '`get_interview` and `list_interviews`, so the answers stay tied to the hypothesis ' +
126
+ 'without you carrying the id through the conversation.'),
127
+ hypothesis_run_id: z
128
+ .string()
129
+ .uuid()
130
+ .optional()
131
+ .describe('action=start: the research run that defined `hypothesis_id` — `sourceRun.id` from the same ' +
132
+ '`list_interview_scripts` call. Required whenever `hypothesis_id` is sent, and refused ' +
133
+ 'without it. Recorded as provenance, NOT checked against the run: a run can be deleted ' +
134
+ 'and the interview outlives it.'),
97
135
  // --- Deprecated camelCase aliases (FUL-233) — rewritten before parse. ---
98
136
  projectId: deprecatedAlias('project_id', z.string().uuid()),
99
137
  personaId: deprecatedAlias('persona_id', z.string().uuid()),
@@ -112,8 +150,8 @@ function makeCtx(deps) {
112
150
  * insufficient-credit stop with balance/required/buy-url guidance; 401 drops the
113
151
  * cached session (so the next tool call rebuilds from disk) and surfaces a
114
152
  * re-auth message; 404 means the interview/project isn't owned by the caller;
115
- * 400 surfaces the server's validation detail; everything else surfaces a
116
- * generic HTTP error.
153
+ * 403 with a lock code surfaces the upgrade path; 400 surfaces the server's
154
+ * validation detail; everything else surfaces a generic HTTP error.
117
155
  *
118
156
  * FUL-161: the body is read and parsed ONCE up front rather than after the 404
119
157
  * branch, because the 402 branch needs its `balance`/`required`/`buy_url` and a
@@ -159,6 +197,22 @@ async function throwForResponse(response, deps, action, options) {
159
197
  deps.invalidateSession?.();
160
198
  throw new InterviewToolError('Session expired. Re-run any Clien.ai tool to re-authenticate, then try again.');
161
199
  }
200
+ // FUL-503. Without this branch a subscription lock reads as
201
+ // `Failed to start interview (HTTP 403)` — indistinguishable from a bug, so the
202
+ // agent retries something no retry can fix. Mirrors `personas.ts`, including
203
+ // naming the resource that is actually locked.
204
+ //
205
+ // FUL-553 widened where these codes come from: `start` was the only action that
206
+ // could produce them, and now `turn` and `complete`/`reopen` do too, because a
207
+ // locked project is read-only IN FULL rather than merely uncreatable-in. The
208
+ // copy is already action-parameterised, so it needed no change — but note that
209
+ // a PROJECT_LOCKED on a `turn` refers to an interview that DOES exist and is
210
+ // simply frozen, unlike the same code on a `start`.
211
+ if (status === 403 && (parsed?.code === 'PROJECT_LOCKED' || parsed?.code === 'PERSONA_LOCKED')) {
212
+ throw new InterviewToolError(parsed.code === 'PROJECT_LOCKED'
213
+ ? `Could not ${action} — the project is locked due to subscription limits and is read-only. Upgrade to Pro to change it.`
214
+ : `Could not ${action} — the persona is locked due to subscription limits. Upgrade to Pro to interview it.`);
215
+ }
162
216
  if (status === 404) {
163
217
  throw new InterviewToolError(options?.notFound ??
164
218
  `Could not ${action} — interview or project not found, or you don't own it. ` +
@@ -210,6 +264,22 @@ function formatTranscript(interview) {
210
264
  // `list_interviews` already renders the id, so its absence here was pure skew.
211
265
  const personaId = safeId(interview.personaId);
212
266
  const persona = personaId ? `\npersona_id: ${personaId}` : '';
267
+ // FUL-577: the hypothesis this interview was written to test, on the surface an
268
+ // agent is most likely to read back or relay. Before this, the tie between these
269
+ // answers and the hypothesis they test existed only in the calling agent's
270
+ // memory, which is exactly what made it unreportable by anyone else.
271
+ //
272
+ // `safeId` on BOTH halves, and printed only when both survive. The run id is what
273
+ // makes the hypothesis id mean anything, so an id whose run could not be rendered
274
+ // would be an unresolvable reference presented as provenance — worse than
275
+ // silence. `safeId` is exact-or-nothing, so a poisoned value degrades to this
276
+ // same branch rather than minting a header line (FUL-247/253).
277
+ const hypothesisId = safeId(interview.hypothesisId);
278
+ const hypothesisRunId = safeId(interview.hypothesisRunId);
279
+ const hypothesis = hypothesisId && hypothesisRunId
280
+ ? `\nhypothesis: ${hypothesisId} (from research run ${hypothesisRunId} — ` +
281
+ 'call `get_report` with that job_id for the hypothesis behind the questions)'
282
+ : '';
213
283
  // FUL-335 — the disclosure travels WITH the transcript, not just on the tool
214
284
  // description. A transcript is the one result an agent is likely to relay or
215
285
  // paste wholesale, and the `Persona:` label above names a ROLE, not a nature:
@@ -221,18 +291,35 @@ function formatTranscript(interview) {
221
291
  'person. These replies are simulated research signal; never relay them as human testimony.';
222
292
  const header = `Interview ${safeId(interview.id) ?? '?'} — ` +
223
293
  `status: ${safeInline(interview.status, 40) ?? 'unknown'}, ` +
224
- `${messages.length} message${messages.length === 1 ? '' : 's'}.${persona}${disclosure}`;
294
+ `${messages.length} message${messages.length === 1 ? '' : 's'}.${persona}${hypothesis}${disclosure}`;
225
295
  return lines.length > 0 ? `${header}\n\n${lines.join('\n\n')}` : header;
226
296
  }
227
297
  async function startInterview(input, ctx, deps) {
228
298
  if (!input.project_id || !input.persona_id) {
229
299
  throw new InterviewToolError("action 'start' requires project_id and persona_id.");
230
300
  }
301
+ // FUL-577: both or neither, refused here rather than by a 400 from the server.
302
+ // An id with no run is the ambiguity the linkage exists to remove, and an agent
303
+ // that gets the refusal in the turn it made the mistake can fix it; one that gets
304
+ // a generic `400 Invalid request` from an endpoint it cannot see usually retries
305
+ // the same call.
306
+ if ((input.hypothesis_id === undefined) !== (input.hypothesis_run_id === undefined)) {
307
+ throw new InterviewToolError("action 'start' takes hypothesis_id and hypothesis_run_id TOGETHER or neither — a " +
308
+ 'hypothesis id is only meaningful against the run that defined it. Both come from one ' +
309
+ '`list_interview_scripts` call: the script\'s `hypothesisId`, and that call\'s ' +
310
+ '`sourceRun.id`.');
311
+ }
231
312
  const response = await call(ctx, 'POST', '/api/interviews', 'start interview', {
232
313
  projectId: input.project_id,
233
314
  personaId: input.persona_id,
234
315
  withFirstMessage: true,
235
316
  ephemeral: input.ephemeral,
317
+ // Sent only when named. An older app deploy's zod schema STRIPS keys it does
318
+ // not know, so sending `undefined` and sending nothing are the same request —
319
+ // which is precisely why the echo below is checked rather than assumed.
320
+ ...(input.hypothesis_id !== undefined
321
+ ? { hypothesisId: input.hypothesis_id, hypothesisRunId: input.hypothesis_run_id }
322
+ : {}),
236
323
  });
237
324
  if (!response.ok)
238
325
  await throwForResponse(response, deps, 'start interview');
@@ -255,13 +342,56 @@ async function startInterview(input, ctx, deps) {
255
342
  content: [
256
343
  {
257
344
  type: 'text',
258
- text: `Started interview ${safeId(interview.id) ?? 'unknown'}${who}.${insightsNote}${greeting}\n` +
345
+ text: `Started interview ${safeId(interview.id) ?? 'unknown'}${who}.${insightsNote}` +
346
+ `${hypothesisNote(input, interview)}${greeting}\n` +
259
347
  "Ask questions with action='turn'; close it with action='complete' when done.",
260
348
  },
261
349
  ],
262
350
  _meta: { interview, personaName: json?.personaName, firstMessageContent: json?.firstMessageContent },
263
351
  };
264
352
  }
353
+ /**
354
+ * What `start` says about the hypothesis linkage — CONFIRMED FROM THE ECHO, never
355
+ * from the fact that we sent it (FUL-577).
356
+ *
357
+ * ⚠️ This is the version-skew seam that actually costs something here, and it is on
358
+ * the WRITE. `@clien-ai/mcp` ships to npm on its own cadence and routinely meets an
359
+ * app deploy older than itself; that deploy's Zod body schema does not know
360
+ * `hypothesisId`, and Zod STRIPS unknown keys rather than rejecting them. So the
361
+ * request succeeds, the interview is created, nothing anywhere reports a problem —
362
+ * and the linkage this issue exists to store was silently dropped. An agent told
363
+ * "recorded" then reports its answers as anchored to a hypothesis no row mentions,
364
+ * which is a worse outcome than the prose instruction this replaced: at least that
365
+ * one was honest about being unenforced.
366
+ *
367
+ * Comparing the round trip is the only check that distinguishes the two, and it
368
+ * costs nothing — the created interview is already in the response. Silence when
369
+ * the caller asked for a linkage is not an option: it reads as success.
370
+ */
371
+ function hypothesisNote(input, interview) {
372
+ if (input.hypothesis_id === undefined)
373
+ return '';
374
+ const storedId = interview.hypothesisId;
375
+ const storedRunId = interview.hypothesisRunId;
376
+ // ⚠️ The run id is compared CASE-INSENSITIVELY. Postgres renders a `uuid` lowercase
377
+ // whatever case it was written in, while both this schema and the route's
378
+ // `UUID_REGEX` accept an uppercase one — so an agent that pasted `9F2…` would get the
379
+ // loud "not recorded" warning about a linkage that stored perfectly. The hypothesis id
380
+ // is `text` and echoes back byte for byte, so it stays an exact comparison.
381
+ const runIdEchoed = storedRunId != null &&
382
+ input.hypothesis_run_id != null &&
383
+ storedRunId.toLowerCase() === input.hypothesis_run_id.toLowerCase();
384
+ if (storedId === input.hypothesis_id && runIdEchoed) {
385
+ return (` Recorded against hypothesis ${safeId(storedId) ?? 'unknown'} from research run ` +
386
+ `${safeId(storedRunId) ?? 'unknown'} — the answers stay tied to it, so you do not have to ` +
387
+ 'carry the id yourself.');
388
+ }
389
+ return (' ⚠️ THE HYPOTHESIS LINKAGE WAS NOT RECORDED: the server did not echo back the id this call ' +
390
+ 'sent, which usually means the Clien.ai deploy answering predates the field. The interview ' +
391
+ 'itself is fine and every turn will work. Do NOT report its answers as anchored to that ' +
392
+ 'hypothesis on the strength of this call — nothing stored the tie, so carry the id yourself ' +
393
+ 'if you need it.');
394
+ }
265
395
  async function sendTurn(input, ctx, deps) {
266
396
  if (!input.interview_id || !input.content) {
267
397
  throw new InterviewToolError("action 'turn' requires interview_id and content.");
@@ -530,7 +660,19 @@ export async function listInterviews(input, deps) {
530
660
  // off the end of a row.
531
661
  const startedAt = safeInline(iv.startedAt, 40);
532
662
  const started = startedAt ? ` — started ${startedAt}` : '';
533
- return `- ${status} interview${persona} — ${messages}${started} (id: ${safeId(iv.id) ?? 'unknown'})`;
663
+ // FUL-577: the hypothesis a listed interview was written to test. This is the
664
+ // surface where it earns the most — "which interviews tested H3?" was a
665
+ // question nobody could answer from stored data, and the list is where an
666
+ // agent goes looking. Printed only when BOTH halves render, for the reason
667
+ // given on `formatTranscript`: an id without its run is an unresolvable
668
+ // reference dressed as provenance. `safeId` keeps a poisoned value from
669
+ // forging a sibling `- ` row (FUL-253).
670
+ const hypothesisId = safeId(iv.hypothesisId);
671
+ const hypothesisRunId = safeId(iv.hypothesisRunId);
672
+ const hypothesis = hypothesisId && hypothesisRunId
673
+ ? ` — tests hypothesis ${hypothesisId} of run ${hypothesisRunId}`
674
+ : '';
675
+ return `- ${status} interview${persona} — ${messages}${started}${hypothesis} (id: ${safeId(iv.id) ?? 'unknown'})`;
534
676
  });
535
677
  const shown = offset + interviews.length;
536
678
  const more = shown < total ? `\n\n${total - shown} more not shown — page with offset=${shown}.` : '';