@ishlabs/cli 0.17.7 → 0.19.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.
- package/README.md +54 -54
- package/dist/commands/ask.d.ts +4 -4
- package/dist/commands/ask.js +66 -66
- package/dist/commands/chat.js +10 -10
- package/dist/commands/config.js +1 -1
- package/dist/commands/docs.js +1 -1
- package/dist/commands/iteration.js +57 -57
- package/dist/commands/mcp.d.ts +23 -0
- package/dist/commands/mcp.js +676 -0
- package/dist/commands/person.d.ts +5 -0
- package/dist/commands/{profile.js → person.js} +197 -162
- package/dist/commands/source.d.ts +6 -2
- package/dist/commands/source.js +35 -30
- package/dist/commands/study-analyze.d.ts +1 -1
- package/dist/commands/study-analyze.js +3 -3
- package/dist/commands/study-participant.d.ts +8 -0
- package/dist/commands/{study-tester.js → study-participant.js} +50 -50
- package/dist/commands/study-run.d.ts +6 -6
- package/dist/commands/study-run.js +341 -290
- package/dist/commands/study.js +106 -72
- package/dist/commands/workspace.js +13 -13
- package/dist/connect.js +5 -5
- package/dist/index.js +6 -4
- package/dist/lib/accessibility-profile.d.ts +1 -1
- package/dist/lib/accessibility-profile.js +1 -1
- package/dist/lib/alias-hydrate.js +4 -4
- package/dist/lib/alias-store.d.ts +5 -5
- package/dist/lib/alias-store.js +8 -8
- package/dist/lib/api-client.d.ts +1 -1
- package/dist/lib/api-client.js +1 -1
- package/dist/lib/billing.d.ts +11 -11
- package/dist/lib/billing.js +16 -16
- package/dist/lib/chat-endpoint-templates.js +1 -1
- package/dist/lib/command-helpers.d.ts +18 -18
- package/dist/lib/command-helpers.js +49 -37
- package/dist/lib/docs.js +570 -387
- package/dist/lib/enums.d.ts +2 -2
- package/dist/lib/enums.js +2 -2
- package/dist/lib/local-sim/browser.d.ts +1 -1
- package/dist/lib/local-sim/browser.js +1 -1
- package/dist/lib/local-sim/debug-report.d.ts +2 -2
- package/dist/lib/local-sim/debug-report.js +3 -3
- package/dist/lib/local-sim/loop.d.ts +5 -5
- package/dist/lib/local-sim/loop.js +38 -38
- package/dist/lib/local-sim/types.d.ts +12 -12
- package/dist/lib/mcp-clients.d.ts +51 -0
- package/dist/lib/mcp-clients.js +175 -0
- package/dist/lib/modality.d.ts +10 -10
- package/dist/lib/modality.js +46 -46
- package/dist/lib/output.d.ts +16 -15
- package/dist/lib/output.js +291 -226
- package/dist/lib/profile-sources.d.ts +64 -16
- package/dist/lib/profile-sources.js +91 -30
- package/dist/lib/skill-content.js +216 -168
- package/dist/lib/study-events.d.ts +3 -3
- package/dist/lib/study-events.js +1 -1
- package/dist/lib/study-inputs.d.ts +11 -1
- package/dist/lib/study-inputs.js +68 -17
- package/dist/lib/study-participants.d.ts +32 -0
- package/dist/lib/study-participants.js +12 -0
- package/dist/lib/types.d.ts +104 -34
- package/package.json +1 -1
- package/dist/commands/profile.d.ts +0 -5
- package/dist/commands/study-tester.d.ts +0 -8
package/dist/commands/study.js
CHANGED
|
@@ -8,13 +8,14 @@ import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
|
8
8
|
import { loadConfig, saveConfig } from "../config.js";
|
|
9
9
|
import { formatStudyList, formatStudyDetail, formatStudyResults, buildStudyResultsSummary, buildChatTranscript, output, ValidationError, } from "../lib/output.js";
|
|
10
10
|
import { VALID_CONTENT_TYPES } from "../lib/types.js";
|
|
11
|
-
import {
|
|
11
|
+
import { fetchStudyParticipants } from "../lib/study-participants.js";
|
|
12
|
+
import { parseAssignment, loadAssignmentsFile, validateAssignmentsArray, parseQuestion } from "../lib/study-inputs.js";
|
|
12
13
|
import { loadQuestionsManifest } from "../lib/ask-questions.js";
|
|
13
14
|
import { isLocalPath } from "../lib/upload.js";
|
|
14
15
|
import { normalizeChatMode, validateRoleCriteria } from "../lib/modality.js";
|
|
15
16
|
import { normalizeEnumValue, SCREEN_FORMATS } from "../lib/enums.js";
|
|
16
17
|
import { attachStudyRunCommands } from "./study-run.js";
|
|
17
|
-
import {
|
|
18
|
+
import { attachStudyParticipantCommands } from "./study-participant.js";
|
|
18
19
|
import { attachStudyAnalyzeCommands } from "./study-analyze.js";
|
|
19
20
|
import { attachStudyScreenshotsCommands } from "./study-screenshots.js";
|
|
20
21
|
function collectRepeatable(value, prev = []) {
|
|
@@ -51,12 +52,14 @@ function resolveAssignments(opts) {
|
|
|
51
52
|
return loadAssignmentsFile(opts.assignmentsFile);
|
|
52
53
|
}
|
|
53
54
|
if (opts.assignments) {
|
|
55
|
+
let parsed;
|
|
54
56
|
try {
|
|
55
|
-
|
|
57
|
+
parsed = JSON.parse(opts.assignments);
|
|
56
58
|
}
|
|
57
59
|
catch {
|
|
58
60
|
throw new Error("Invalid --assignments JSON");
|
|
59
61
|
}
|
|
62
|
+
return validateAssignmentsArray(parsed, "--assignments");
|
|
60
63
|
}
|
|
61
64
|
return undefined;
|
|
62
65
|
}
|
|
@@ -120,19 +123,19 @@ Concept pages: ish docs get-page concepts/study
|
|
|
120
123
|
.option("--title <title>", "Content title (text + media modalities — image, video, audio, document; optional). Not used for interactive / chat.")
|
|
121
124
|
.option("--endpoint <id>", "Saved chatbot endpoint id or alias. Creates iteration A inline (chat modality, external_chatbot mode)")
|
|
122
125
|
.option("--endpoint-config <file>", "ChatbotEndpointConfig JSON file (or `-` for stdin); embedded directly. Mutually exclusive with --endpoint (chat modality, external_chatbot mode)")
|
|
123
|
-
.option("--max-turns <n>", "Maximum conversation turns per
|
|
124
|
-
.option("--chat-mode <mode>", "Chat mode: external_chatbot (default) or
|
|
125
|
-
.option("--
|
|
126
|
-
.option("--
|
|
127
|
-
.option("--scenario-a <text-or-@file>", "Side-A scenario + goal — chat
|
|
128
|
-
.option("--scenario-b <text-or-@file>", "Side-B scenario + goal — chat
|
|
129
|
-
.option("--initiator-side <a|b>", "Which side speaks first (default: a) — chat
|
|
130
|
-
.option("--role-criteria-a <json-or-@file>", 'RoleCriteria filter for side A (JSON object or @filepath). Keys: occupation[], min_age, max_age, gender[], country[], education_level_in[], household_in[], locale_type_in[], income_level_in[], employment_status_in[], requires_captions, uses_screen_reader, prefers_reduced_motion, prefers_high_contrast, has_any_accessibility_need. The five *_in arrays accept snake_case spec values; the five accessibility filters are booleans. Use INSTEAD of --
|
|
131
|
-
.option("--role-criteria-b <json-or-@file>", "RoleCriteria filter for side B — same shape as --role-criteria-a. chat
|
|
126
|
+
.option("--max-turns <n>", "Maximum conversation turns per participant (chat modality only; default 12)", (v) => Number(v))
|
|
127
|
+
.option("--chat-mode <mode>", "Chat mode: external_chatbot (default) or participant_pair (two AI groups talk to each other) — chat modality only")
|
|
128
|
+
.option("--group-a <ids>", "Person IDs/aliases for group A (comma-separated or repeatable). Pass a single profile and N on --group-b to broadcast (1×N rehearsal: fix side A, vary side B) — chat participant_pair mode", (value, prev = []) => prev.concat(value.split(",").map((s) => s.trim()).filter(Boolean)), [])
|
|
129
|
+
.option("--group-b <ids>", "Person IDs/aliases for group B. When both sides are explicit they must be equal length, BUT if either side is a singleton it's auto-broadcast to match the other (1×N rehearsal) — chat participant_pair mode", (value, prev = []) => prev.concat(value.split(",").map((s) => s.trim()).filter(Boolean)), [])
|
|
130
|
+
.option("--scenario-a <text-or-@file>", "Side-A scenario + goal — chat participant_pair mode")
|
|
131
|
+
.option("--scenario-b <text-or-@file>", "Side-B scenario + goal — chat participant_pair mode")
|
|
132
|
+
.option("--initiator-side <a|b>", "Which side speaks first (default: a) — chat participant_pair mode")
|
|
133
|
+
.option("--role-criteria-a <json-or-@file>", 'RoleCriteria filter for side A (JSON object or @filepath). Keys: occupation[], min_age, max_age, gender[], country[], education_level_in[], household_in[], locale_type_in[], income_level_in[], employment_status_in[], requires_captions, uses_screen_reader, prefers_reduced_motion, prefers_high_contrast, has_any_accessibility_need. The five *_in arrays accept snake_case spec values; the five accessibility filters are booleans. Use INSTEAD of --group-a or alongside it. chat participant_pair mode.')
|
|
134
|
+
.option("--role-criteria-b <json-or-@file>", "RoleCriteria filter for side B — same shape as --role-criteria-a. chat participant_pair mode.")
|
|
132
135
|
.addHelpText("after", `
|
|
133
136
|
Note: --workspace is optional if set via \`ish workspace use <alias>\`.
|
|
134
137
|
|
|
135
|
-
The questionnaire is the set of questions
|
|
138
|
+
The questionnaire is the set of questions participants answer. Use \`--question\` to
|
|
136
139
|
quickly add simple text questions, or \`--questionnaire <file.json>\` for richer
|
|
137
140
|
types (slider, likert, single-choice, multiple-choice, number) and custom
|
|
138
141
|
timing. The two forms are mutually exclusive — pick one.
|
|
@@ -187,6 +190,23 @@ Examples:
|
|
|
187
190
|
$ ish study create --name "Newsletter" --modality text --content-type email \\
|
|
188
191
|
--assignments-file ./assignments.json
|
|
189
192
|
|
|
193
|
+
# Interactive study whose assignment carries a step-by-step checklist
|
|
194
|
+
# (JSON only — the inline --assignment shorthand can't express steps).
|
|
195
|
+
# Steps are honored for interactive + external_chatbot chat modalities only;
|
|
196
|
+
# the backend rejects them for media (text/video/audio/image/document) and
|
|
197
|
+
# for chat participant_pair. After a run, study get reports per-step completion.
|
|
198
|
+
$ ish study create --name "Checkout" --modality interactive \\
|
|
199
|
+
--assignments-file ./assignments.json
|
|
200
|
+
# assignments.json:
|
|
201
|
+
# [
|
|
202
|
+
# { "name": "Buy", "instructions": "Add to cart and check out",
|
|
203
|
+
# "steps": [
|
|
204
|
+
# { "name": "Find a product", "description": "Browse to any item" },
|
|
205
|
+
# { "name": "Add to cart" },
|
|
206
|
+
# { "name": "Complete checkout" }
|
|
207
|
+
# ] }
|
|
208
|
+
# ]
|
|
209
|
+
|
|
190
210
|
# Chat study targeting a saved chatbot endpoint:
|
|
191
211
|
$ ish study create --name "Onboarding bot" --modality chat \\
|
|
192
212
|
--endpoint ep-abc \\
|
|
@@ -213,7 +233,7 @@ Tips:
|
|
|
213
233
|
\`--fields a,b,c\` to project the JSON output to listed fields.
|
|
214
234
|
|
|
215
235
|
Next: configure a run with \`ish iteration create --study <id>\`,
|
|
216
|
-
then dispatch with \`ish study run\` (
|
|
236
|
+
then dispatch with \`ish study run\` (people are selected on run, not create).`)
|
|
217
237
|
.action(async (opts, cmd) => {
|
|
218
238
|
await withClient(cmd, async (client, globals) => {
|
|
219
239
|
const assignments = resolveAssignments(opts);
|
|
@@ -246,23 +266,23 @@ Next: configure a run with \`ish iteration create --study <id>\`,
|
|
|
246
266
|
// against the freshly-created study.
|
|
247
267
|
const normalizedChatMode = normalizeChatMode(opts.chatMode);
|
|
248
268
|
if (opts.chatMode !== undefined && normalizedChatMode === null) {
|
|
249
|
-
throw new ValidationError(`Invalid --chat-mode "${opts.chatMode}". Expected "external_chatbot" or "
|
|
269
|
+
throw new ValidationError(`Invalid --chat-mode "${opts.chatMode}". Expected "external_chatbot" or "participant_pair" (hyphenated variants accepted).`, ["external_chatbot", "participant_pair"]);
|
|
250
270
|
}
|
|
251
|
-
const pairFlagsSet = (opts.
|
|
252
|
-
|| (opts.
|
|
271
|
+
const pairFlagsSet = (opts.groupA && opts.groupA.length > 0)
|
|
272
|
+
|| (opts.groupB && opts.groupB.length > 0)
|
|
253
273
|
|| opts.scenarioA !== undefined
|
|
254
274
|
|| opts.scenarioB !== undefined
|
|
255
275
|
|| opts.initiatorSide !== undefined
|
|
256
276
|
|| opts.roleCriteriaA !== undefined
|
|
257
277
|
|| opts.roleCriteriaB !== undefined
|
|
258
|
-
|| normalizedChatMode === "
|
|
278
|
+
|| normalizedChatMode === "participant_pair";
|
|
259
279
|
const inlineMediaFlagsSet = [
|
|
260
280
|
opts.contentText !== undefined ? "--content-text" : null,
|
|
261
281
|
opts.url !== undefined ? "--url" : null,
|
|
262
282
|
opts.contentUrl !== undefined ? "--content-url" : null,
|
|
263
283
|
opts.imageUrls !== undefined ? "--image-urls" : null,
|
|
264
284
|
(opts.endpoint !== undefined || opts.endpointConfig !== undefined) ? "--endpoint/--endpoint-config" : null,
|
|
265
|
-
pairFlagsSet ? "--chat-mode
|
|
285
|
+
pairFlagsSet ? "--chat-mode participant_pair (with --group-a/-b or --role-criteria-a/-b plus --scenario-a/-b)" : null,
|
|
266
286
|
].filter((f) => f !== null);
|
|
267
287
|
if (inlineMediaFlagsSet.length > 1) {
|
|
268
288
|
throw new ValidationError(`Pass only one inline-iteration flag: ${inlineMediaFlagsSet.join(", ")}.`, inlineMediaFlagsSet);
|
|
@@ -367,7 +387,7 @@ Next: configure a run with \`ish iteration create --study <id>\`,
|
|
|
367
387
|
throw new ValidationError(`--endpoint / --endpoint-config require --modality chat (got "${opts.modality}").`, ["chat"]);
|
|
368
388
|
}
|
|
369
389
|
if (normalizedChatMode && normalizedChatMode !== "external_chatbot") {
|
|
370
|
-
throw new ValidationError(`--endpoint / --endpoint-config are only valid with --chat-mode external_chatbot (got "${opts.chatMode}"). For
|
|
390
|
+
throw new ValidationError(`--endpoint / --endpoint-config are only valid with --chat-mode external_chatbot (got "${opts.chatMode}"). For participant_pair use --group-a/-b and --scenario-a/-b.`, ["external_chatbot"]);
|
|
371
391
|
}
|
|
372
392
|
let endpointConfig;
|
|
373
393
|
if (opts.endpoint !== undefined) {
|
|
@@ -389,7 +409,7 @@ Next: configure a run with \`ish iteration create --study <id>\`,
|
|
|
389
409
|
throw new Error("Invalid --endpoint-config JSON.");
|
|
390
410
|
}
|
|
391
411
|
}
|
|
392
|
-
const maxTurns = opts.maxTurns ??
|
|
412
|
+
const maxTurns = opts.maxTurns ?? 14;
|
|
393
413
|
inlineIteration = {
|
|
394
414
|
name: "A",
|
|
395
415
|
details: {
|
|
@@ -406,13 +426,13 @@ Next: configure a run with \`ish iteration create --study <id>\`,
|
|
|
406
426
|
}
|
|
407
427
|
else if (pairFlagsSet) {
|
|
408
428
|
if (opts.modality && opts.modality !== "chat") {
|
|
409
|
-
throw new ValidationError(`--chat-mode
|
|
429
|
+
throw new ValidationError(`--chat-mode participant_pair (with --group-a/-b or --role-criteria-a/-b plus --scenario-a/-b) requires --modality chat (got "${opts.modality}").`, ["chat"]);
|
|
410
430
|
}
|
|
411
|
-
if (normalizedChatMode && normalizedChatMode !== "
|
|
412
|
-
throw new ValidationError(`--
|
|
431
|
+
if (normalizedChatMode && normalizedChatMode !== "participant_pair") {
|
|
432
|
+
throw new ValidationError(`--group-a/-b or --role-criteria-a/-b imply --chat-mode participant_pair (got "${opts.chatMode}").`, ["participant_pair"]);
|
|
413
433
|
}
|
|
414
|
-
const audA = (opts.
|
|
415
|
-
const audB = (opts.
|
|
434
|
+
const audA = (opts.groupA ?? []).map(resolveId);
|
|
435
|
+
const audB = (opts.groupB ?? []).map(resolveId);
|
|
416
436
|
// Parse + validate role criteria if supplied (JSON or @filepath).
|
|
417
437
|
const parseCriteria = (raw, flag) => {
|
|
418
438
|
if (raw === undefined)
|
|
@@ -445,21 +465,21 @@ Next: configure a run with \`ish iteration create --study <id>\`,
|
|
|
445
465
|
const sideAHasInput = audA.length > 0 || !!critA;
|
|
446
466
|
const sideBHasInput = audB.length > 0 || !!critB;
|
|
447
467
|
if (!sideAHasInput || !sideBHasInput) {
|
|
448
|
-
throw new Error("
|
|
468
|
+
throw new Error("participant_pair chat iterations require, for each side, either explicit people (--group-a / --group-b) or a role-criteria filter (--role-criteria-a / --role-criteria-b).");
|
|
449
469
|
}
|
|
450
470
|
// 1×N broadcast: canonical "rehearse one side against N
|
|
451
471
|
// variations" shape. See iteration.ts buildIterationDetails
|
|
452
|
-
//
|
|
472
|
+
// participant_pair arm for the rationale.
|
|
453
473
|
let audA_final = audA;
|
|
454
474
|
let audB_final = audB;
|
|
455
475
|
let broadcastMsg;
|
|
456
476
|
if (audA.length === 1 && audB.length > 1 && !critA && !critB) {
|
|
457
477
|
audA_final = Array(audB.length).fill(audA[0]);
|
|
458
|
-
broadcastMsg = `Broadcasting --
|
|
478
|
+
broadcastMsg = `Broadcasting --group-a (1 profile) to length ${audB.length} to match --group-b — same side-A profile across all ${audB.length} conversations.`;
|
|
459
479
|
}
|
|
460
480
|
else if (audB.length === 1 && audA.length > 1 && !critA && !critB) {
|
|
461
481
|
audB_final = Array(audA.length).fill(audB[0]);
|
|
462
|
-
broadcastMsg = `Broadcasting --
|
|
482
|
+
broadcastMsg = `Broadcasting --group-b (1 profile) to length ${audA.length} to match --group-a — same side-B profile across all ${audA.length} conversations.`;
|
|
463
483
|
}
|
|
464
484
|
if (broadcastMsg) {
|
|
465
485
|
console.error(broadcastMsg);
|
|
@@ -469,13 +489,13 @@ Next: configure a run with \`ish iteration create --study <id>\`,
|
|
|
469
489
|
// CLI's 1×N broadcast (above) already cloned the singleton side,
|
|
470
490
|
// so this branch only fires when both sides ship >1 with
|
|
471
491
|
// mismatched counts. Server rejects the same way.
|
|
472
|
-
throw new ValidationError(`--
|
|
492
|
+
throw new ValidationError(`--group-a (${audA_final.length}) and --group-b (${audB_final.length}) cannot be paired. ` +
|
|
473
493
|
`Pick the same number on each side (1:1 by index), or pass exactly one profile on one side to broadcast ` +
|
|
474
|
-
`(e.g. --
|
|
475
|
-
`or use --role-criteria-a/-b on either side to let the backend resolve the pool.`, ["--
|
|
494
|
+
`(e.g. --group-a p-rep --group-b p-cto1,p-cto2,p-cto3), ` +
|
|
495
|
+
`or use --role-criteria-a/-b on either side to let the backend resolve the pool.`, ["--group-a", "--group-b"]);
|
|
476
496
|
}
|
|
477
497
|
if (!opts.scenarioA || !opts.scenarioB) {
|
|
478
|
-
throw new Error("
|
|
498
|
+
throw new Error("participant_pair chat iterations require --scenario-a <text-or-@file> and --scenario-b <text-or-@file>.");
|
|
479
499
|
}
|
|
480
500
|
const scenarioA = opts.scenarioA.startsWith("@")
|
|
481
501
|
? readFileSync(opts.scenarioA.slice(1), "utf8")
|
|
@@ -490,15 +510,15 @@ Next: configure a run with \`ish iteration create --study <id>\`,
|
|
|
490
510
|
if (initiator !== "a" && initiator !== "b") {
|
|
491
511
|
throw new ValidationError(`Invalid --initiator-side "${opts.initiatorSide}". Expected "a" or "b".`, ["a", "b"]);
|
|
492
512
|
}
|
|
493
|
-
const maxTurns = opts.maxTurns ??
|
|
513
|
+
const maxTurns = opts.maxTurns ?? 14;
|
|
494
514
|
inlineIteration = {
|
|
495
515
|
name: "A",
|
|
496
516
|
details: {
|
|
497
517
|
type: "chat",
|
|
498
518
|
mode_details: {
|
|
499
|
-
mode: "
|
|
500
|
-
|
|
501
|
-
|
|
519
|
+
mode: "participant_pair",
|
|
520
|
+
group_a: audA_final,
|
|
521
|
+
group_b: audB_final,
|
|
502
522
|
scenario_a: scenarioA,
|
|
503
523
|
scenario_b: scenarioB,
|
|
504
524
|
initiator_side: initiator,
|
|
@@ -597,14 +617,17 @@ list table layout in human mode.`)
|
|
|
597
617
|
throw new Error("Provide at least one study id.");
|
|
598
618
|
if (flat.length === 1) {
|
|
599
619
|
const rid = resolveId(flat[0]);
|
|
600
|
-
const data = await
|
|
620
|
+
const [data, participants] = await Promise.all([
|
|
621
|
+
client.get(`/studies/${rid}`),
|
|
622
|
+
fetchStudyParticipants(client, rid),
|
|
623
|
+
]);
|
|
601
624
|
const result = data;
|
|
602
625
|
if (result.id)
|
|
603
626
|
result.alias = tagAlias(ALIAS_PREFIX.study, String(result.id));
|
|
604
627
|
if (data.product_id) {
|
|
605
628
|
result.url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
|
|
606
629
|
}
|
|
607
|
-
formatStudyDetail(result, globals.json);
|
|
630
|
+
formatStudyDetail(result, globals.json, {}, participants);
|
|
608
631
|
if (!globals.json && data.product_id) {
|
|
609
632
|
const url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
|
|
610
633
|
console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
@@ -613,13 +636,17 @@ list table layout in human mode.`)
|
|
|
613
636
|
}
|
|
614
637
|
const results = await Promise.all(flat.map(async (raw) => {
|
|
615
638
|
const rid = resolveId(raw);
|
|
616
|
-
const data = await
|
|
639
|
+
const [data, participants] = await Promise.all([
|
|
640
|
+
client.get(`/studies/${rid}`),
|
|
641
|
+
fetchStudyParticipants(client, rid),
|
|
642
|
+
]);
|
|
617
643
|
const r = data;
|
|
618
644
|
if (r.id)
|
|
619
645
|
r.alias = tagAlias(ALIAS_PREFIX.study, String(r.id));
|
|
620
646
|
if (data.product_id) {
|
|
621
647
|
r.url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
|
|
622
648
|
}
|
|
649
|
+
r.participants = participants;
|
|
623
650
|
return r;
|
|
624
651
|
}));
|
|
625
652
|
if (globals.json) {
|
|
@@ -632,84 +659,84 @@ list table layout in human mode.`)
|
|
|
632
659
|
});
|
|
633
660
|
study
|
|
634
661
|
.command("results")
|
|
635
|
-
.description("View aggregated results:
|
|
662
|
+
.description("View aggregated results: participant counts, sentiment, interview answers. Returns a stable envelope with empty fields when no runs have completed.")
|
|
636
663
|
.argument("<id>", "Study ID")
|
|
637
664
|
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
|
|
638
|
-
.option("--summary", "Lean summary projection: counts + sentiment + per-
|
|
665
|
+
.option("--summary", "Lean summary projection: counts + sentiment + per-participant {alias, status, sentiment, comment}. Drops interview_answers + per-interaction breakdowns.")
|
|
639
666
|
// PC-N4: agents reach for `--summarize` (verb) by analogy with the MCP
|
|
640
667
|
// `summarize` action; accept it as a hidden alias of --summary so the
|
|
641
668
|
// canonical flag stays the documented one but the muscle-memory variant
|
|
642
669
|
// works without a round-trip.
|
|
643
670
|
.addOption(new Option("--summarize", "Hidden alias for --summary").hideHelp())
|
|
644
|
-
.option("--transcript <
|
|
671
|
+
.option("--transcript <participant_id>", "Chat transcript projection for one participant: flat role/text/turn-index array (chat-modality only). Mirrors the MCP `get_chat_transcript` shape.")
|
|
645
672
|
.addHelpText("after", `
|
|
646
673
|
Examples:
|
|
647
674
|
$ ish study results <id>
|
|
648
675
|
$ ish study results <id> --json
|
|
649
676
|
$ ish study results <id> --summary --json
|
|
650
|
-
$ ish study results <id> --transcript
|
|
677
|
+
$ ish study results <id> --transcript pt-d4e --json
|
|
651
678
|
|
|
652
679
|
Default --json envelope (M10: per-answer sentiment now included):
|
|
653
680
|
{
|
|
654
681
|
"study": { "alias": "s-...", "name": "...", "modality": "..." },
|
|
655
|
-
"
|
|
682
|
+
"participant_count": 12,
|
|
656
683
|
"completed_count": 8,
|
|
657
684
|
"failed_count": 0,
|
|
658
685
|
"sentiment": { "counts": { "Satisfied": 5, "Frustrated": 2 }, "total": 7 },
|
|
659
686
|
"interview_answers": [
|
|
660
687
|
{ "question": "...", "type": "text",
|
|
661
688
|
"answers": [
|
|
662
|
-
{ "
|
|
689
|
+
{ "participant_alias": "pt-...", "participant_name": "...", "iteration": "A",
|
|
663
690
|
"answer": "...", "sentiment": "Satisfied" }
|
|
664
691
|
] }
|
|
665
692
|
],
|
|
666
|
-
"
|
|
667
|
-
{ "alias": "
|
|
693
|
+
"participants": [
|
|
694
|
+
{ "alias": "pt-...", "name": "...", "iteration": "A", "status": "completed",
|
|
668
695
|
"interaction_count": 12, "sentiment": "Satisfied", "comment": "...",
|
|
669
696
|
"error_message": "..." }
|
|
670
697
|
]
|
|
671
698
|
}
|
|
672
699
|
|
|
673
700
|
--summary projection (M2-friction-7: drops the interview_answers payload):
|
|
674
|
-
{ study,
|
|
701
|
+
{ study, participant_count, completed_count, failed_count, sentiment, participants: [...] }
|
|
675
702
|
|
|
676
|
-
--transcript <
|
|
703
|
+
--transcript <participant_id> projection (M2-friction-12, chat modality):
|
|
677
704
|
{
|
|
678
|
-
"
|
|
705
|
+
"participant_id": "...", "participant_alias": "pt-...",
|
|
679
706
|
"instance_name": "...", "modality": "chat",
|
|
680
707
|
"transcript": [
|
|
681
708
|
{ "role": "bot", "text": "Hi…", "turn_index": 0, "failure": null },
|
|
682
|
-
{ "role": "
|
|
709
|
+
{ "role": "participant", "text": "Pricing?", "turn_index": 0,
|
|
683
710
|
"action_type": "send_text", "option_label": null, "sentiment": null }
|
|
684
711
|
],
|
|
685
712
|
"unique_bot_replies": 2,
|
|
686
|
-
"
|
|
713
|
+
"participant_summary": { "comment": "...", "sentiment": {...} }
|
|
687
714
|
}
|
|
688
715
|
|
|
689
716
|
Tips:
|
|
690
|
-
Use \`--get <path>\` for a single value (e.g. \`--get
|
|
717
|
+
Use \`--get <path>\` for a single value (e.g. \`--get participant_count\`),
|
|
691
718
|
\`--fields a,b,c\` to project the JSON output further.
|
|
692
719
|
|
|
693
720
|
Common --get paths (default envelope):
|
|
694
|
-
--get
|
|
721
|
+
--get participant_count # how many participants ran
|
|
695
722
|
--get completed_count # how many finished
|
|
696
723
|
--get failed_count # how many errored
|
|
697
724
|
--get sentiment # {counts, total} histogram
|
|
698
725
|
--get sentiment.counts # bare label→count map
|
|
699
726
|
--get sentiment.total # total sentiment-tagged answers
|
|
700
727
|
--get study.modality # interactive | text | image | …
|
|
701
|
-
--get
|
|
702
|
-
--get
|
|
703
|
-
--get
|
|
728
|
+
--get participants.alias # one alias per line
|
|
729
|
+
--get participants.0.comment # first participant's narrative comment
|
|
730
|
+
--get participants.0.sentiment # first participant's aggregate sentiment
|
|
704
731
|
--get interview_answers # full per-question payload
|
|
705
732
|
--get interview_answers.0.question # text of the first question
|
|
706
733
|
--get interview_answers.0.answers.0.answer # first answer to the first question
|
|
707
734
|
|
|
708
|
-
Common --get paths (--transcript <
|
|
735
|
+
Common --get paths (--transcript <participant_id> envelope):
|
|
709
736
|
--get transcript # full role/text/turn array
|
|
710
737
|
--get transcript.text # one text per turn
|
|
711
|
-
--get
|
|
712
|
-
--get
|
|
738
|
+
--get participant_summary.comment # narrative comment
|
|
739
|
+
--get participant_summary.sentiment # aggregate sentiment map
|
|
713
740
|
--get unique_bot_replies # bot-side message count
|
|
714
741
|
|
|
715
742
|
When no runs have completed, the default envelope is returned with zero counts and empty arrays.`)
|
|
@@ -724,20 +751,23 @@ When no runs have completed, the default envelope is returned with zero counts a
|
|
|
724
751
|
}
|
|
725
752
|
const rid = resolveId(id);
|
|
726
753
|
if (opts.transcript) {
|
|
727
|
-
// --transcript <
|
|
728
|
-
// the named
|
|
754
|
+
// --transcript <participant_id>: bypass the study aggregator; fetch
|
|
755
|
+
// the named participant directly. Cheaper (one GET, no nested
|
|
729
756
|
// iterations payload) and shapes 1:1 with the MCP transcript.
|
|
730
|
-
const
|
|
731
|
-
const
|
|
732
|
-
output(buildChatTranscript(
|
|
757
|
+
const participantId = resolveId(opts.transcript);
|
|
758
|
+
const participant = await client.get(`/participants/${participantId}`);
|
|
759
|
+
output(buildChatTranscript(participant), globals.json, { preProjected: true });
|
|
733
760
|
return;
|
|
734
761
|
}
|
|
735
|
-
const data = await
|
|
762
|
+
const [data, participants] = await Promise.all([
|
|
763
|
+
client.get(`/studies/${rid}`),
|
|
764
|
+
fetchStudyParticipants(client, rid),
|
|
765
|
+
]);
|
|
736
766
|
if (wantsSummary) {
|
|
737
|
-
output(buildStudyResultsSummary(data), globals.json, { preProjected: true });
|
|
767
|
+
output(buildStudyResultsSummary(data, participants), globals.json, { preProjected: true });
|
|
738
768
|
}
|
|
739
769
|
else {
|
|
740
|
-
formatStudyResults(data, globals.json);
|
|
770
|
+
formatStudyResults(data, participants, globals.json);
|
|
741
771
|
}
|
|
742
772
|
if (!globals.json && data.product_id) {
|
|
743
773
|
const url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
|
|
@@ -770,7 +800,11 @@ Examples:
|
|
|
770
800
|
$ ish study update <id> --assignment "Sign up:Complete the signup flow" \\
|
|
771
801
|
--question "How easy was it?"
|
|
772
802
|
$ ish study update <id> --questionnaire ./questionnaire.json
|
|
773
|
-
$ ish study update <id> --assignments-file ./assignments.json
|
|
803
|
+
$ ish study update <id> --assignments-file ./assignments.json
|
|
804
|
+
|
|
805
|
+
Replacing assignments replaces the full list (no additive edit). Assignment
|
|
806
|
+
checklists ("steps") ride along when present in the JSON forms
|
|
807
|
+
(--assignments-file / --assignments) — see docs get-page concepts/assignment.`)
|
|
774
808
|
.action(async (id, opts, cmd) => {
|
|
775
809
|
await withClient(cmd, async (client, globals) => {
|
|
776
810
|
const assignments = resolveAssignments(opts);
|
|
@@ -852,7 +886,7 @@ Examples:
|
|
|
852
886
|
});
|
|
853
887
|
});
|
|
854
888
|
attachStudyRunCommands(study);
|
|
855
|
-
|
|
889
|
+
attachStudyParticipantCommands(study);
|
|
856
890
|
attachStudyAnalyzeCommands(study);
|
|
857
891
|
attachStudyScreenshotsCommands(study);
|
|
858
892
|
}
|
|
@@ -152,7 +152,7 @@ existing workspace was returned. On creation, \`reused: false\`.`)
|
|
|
152
152
|
.addHelpText("after", `
|
|
153
153
|
Usage counters:
|
|
154
154
|
studies_used / studies_max — current study count vs the user's plan cap
|
|
155
|
-
|
|
155
|
+
people_used / people_max — workspace-private participant profile count vs cap
|
|
156
156
|
|
|
157
157
|
Caps fall back to null when the user's plan grants unlimited (math.inf). The
|
|
158
158
|
account tier is read from /account/me; limit tables come from /billing/limits.
|
|
@@ -174,7 +174,7 @@ Examples:
|
|
|
174
174
|
if (usage.tier)
|
|
175
175
|
console.log(`Plan: ${usage.tier}`);
|
|
176
176
|
console.log(`Studies: ${usage.studies_used} / ${renderCap(usage.studies_max)}`);
|
|
177
|
-
console.log(`Custom
|
|
177
|
+
console.log(`Custom people: ${usage.people_used} / ${renderCap(usage.people_max)}`);
|
|
178
178
|
});
|
|
179
179
|
});
|
|
180
180
|
workspace
|
|
@@ -213,11 +213,11 @@ async function collectWorkspaceUsage(client, workspaceId) {
|
|
|
213
213
|
const studiesPromise = client
|
|
214
214
|
.get(`/products/${workspaceId}/studies`)
|
|
215
215
|
.catch(() => []);
|
|
216
|
-
//
|
|
217
|
-
// gates `
|
|
216
|
+
// people_used: paginated list returns { total, items, ... }. Backend
|
|
217
|
+
// gates `maxCustomPersons` on visibility=workspace (rows owned by
|
|
218
218
|
// this workspace; was `private` before the visibility rename).
|
|
219
|
-
const
|
|
220
|
-
.get("/
|
|
219
|
+
const peoplePromise = client
|
|
220
|
+
.get("/people", {
|
|
221
221
|
product_id: workspaceId,
|
|
222
222
|
visibility: "workspace",
|
|
223
223
|
type: "ai",
|
|
@@ -232,18 +232,18 @@ async function collectWorkspaceUsage(client, workspaceId) {
|
|
|
232
232
|
const limitsPromise = client
|
|
233
233
|
.get("/billing/limits")
|
|
234
234
|
.catch(() => ({ tiers: {} }));
|
|
235
|
-
const [product, studies,
|
|
235
|
+
const [product, studies, participants, account, limits] = await Promise.all([
|
|
236
236
|
productPromise,
|
|
237
237
|
studiesPromise,
|
|
238
|
-
|
|
238
|
+
peoplePromise,
|
|
239
239
|
acctPromise,
|
|
240
240
|
limitsPromise,
|
|
241
241
|
]);
|
|
242
242
|
const tier = typeof account.credits?.tier === "string" ? account.credits.tier : null;
|
|
243
243
|
const tierTable = tier ? limits.tiers?.[tier] ?? null : null;
|
|
244
244
|
const studiesMax = tierTable && "maxStudiesPerProduct" in tierTable ? tierTable.maxStudiesPerProduct : null;
|
|
245
|
-
const
|
|
246
|
-
? tierTable.
|
|
245
|
+
const peopleMax = tierTable && "maxCustomPersons" in tierTable
|
|
246
|
+
? tierTable.maxCustomPersons
|
|
247
247
|
: null;
|
|
248
248
|
return {
|
|
249
249
|
id: workspaceId,
|
|
@@ -252,8 +252,8 @@ async function collectWorkspaceUsage(client, workspaceId) {
|
|
|
252
252
|
tier,
|
|
253
253
|
studies_used: Array.isArray(studies) ? studies.length : 0,
|
|
254
254
|
studies_max: studiesMax,
|
|
255
|
-
|
|
256
|
-
|
|
255
|
+
people_used: typeof participants.total === "number" ? participants.total : 0,
|
|
256
|
+
people_max: peopleMax,
|
|
257
257
|
};
|
|
258
258
|
}
|
|
259
259
|
// ---------------------------------------------------------------------------
|
|
@@ -364,7 +364,7 @@ function registerSiteAccessCommands(workspace) {
|
|
|
364
364
|
});
|
|
365
365
|
sa
|
|
366
366
|
.command("login")
|
|
367
|
-
.description("Set login form credentials a
|
|
367
|
+
.description("Set login form credentials a participant will type into the site")
|
|
368
368
|
.requiredOption("--username <u>", 'Username (or "-" to read from stdin)')
|
|
369
369
|
.requiredOption("--password <p>", 'Password (or "-" to read from stdin)')
|
|
370
370
|
.option("--workspace <id>", "Workspace ID; defaults to active workspace")
|
package/dist/connect.js
CHANGED
|
@@ -46,7 +46,7 @@ function row(content, inner) {
|
|
|
46
46
|
}
|
|
47
47
|
function renderCard(sim) {
|
|
48
48
|
const inner = CARD_WIDTH - 4;
|
|
49
|
-
const name = truncate(sim.
|
|
49
|
+
const name = truncate(sim.participant_name ?? sim.instance_name ?? sim.participant_id.slice(0, 8), inner - 2);
|
|
50
50
|
// Top border with name
|
|
51
51
|
const nameSegment = `─ ${name} `;
|
|
52
52
|
const topPad = CARD_WIDTH - 2 - nameSegment.length;
|
|
@@ -160,16 +160,16 @@ function renderSimulationCards(simulations) {
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
// --- Local storage for completed simulations ---
|
|
163
|
-
const
|
|
163
|
+
const storedParticipantIds = new Set();
|
|
164
164
|
function storeCompletedSimulation(sim) {
|
|
165
|
-
if (
|
|
165
|
+
if (storedParticipantIds.has(sim.participant_id))
|
|
166
166
|
return;
|
|
167
|
-
|
|
167
|
+
storedParticipantIds.add(sim.participant_id);
|
|
168
168
|
const dir = simulationsDir();
|
|
169
169
|
mkdirSync(dir, { recursive: true });
|
|
170
170
|
const logFile = join(dir, "history.jsonl");
|
|
171
171
|
const entry = {
|
|
172
|
-
|
|
172
|
+
participant_id: sim.participant_id,
|
|
173
173
|
instance_name: sim.instance_name,
|
|
174
174
|
status: sim.status,
|
|
175
175
|
study_name: sim.study_name,
|
package/dist/index.js
CHANGED
|
@@ -10,13 +10,14 @@ import { upgrade } from "./upgrade.js";
|
|
|
10
10
|
import { registerWorkspaceCommands } from "./commands/workspace.js";
|
|
11
11
|
import { registerStudyCommands } from "./commands/study.js";
|
|
12
12
|
import { registerIterationCommands } from "./commands/iteration.js";
|
|
13
|
-
import {
|
|
13
|
+
import { registerPersonCommands } from "./commands/person.js";
|
|
14
14
|
import { registerSourceCommands } from "./commands/source.js";
|
|
15
15
|
import { registerConfigCommands } from "./commands/config.js";
|
|
16
16
|
import { registerAskCommands } from "./commands/ask.js";
|
|
17
17
|
import { registerChatCommand } from "./commands/chat.js";
|
|
18
18
|
import { registerDocsCommands } from "./commands/docs.js";
|
|
19
19
|
import { registerInitCommands } from "./commands/init.js";
|
|
20
|
+
import { registerMcpCommands } from "./commands/mcp.js";
|
|
20
21
|
import { registerSecretCommands } from "./commands/secret.js";
|
|
21
22
|
import { AGENT_HELP_FOOTER } from "./lib/docs.js";
|
|
22
23
|
import { runInline, EXIT_USAGE, injectGlobalWorkspaceOption } from "./lib/command-helpers.js";
|
|
@@ -43,7 +44,7 @@ catch {
|
|
|
43
44
|
}
|
|
44
45
|
program
|
|
45
46
|
.name("ish")
|
|
46
|
-
.description("ish CLI — run studies and asks
|
|
47
|
+
.description("ish CLI — run studies and asks with AI people")
|
|
47
48
|
.version(version)
|
|
48
49
|
.addHelpText("after", AGENT_HELP_FOOTER);
|
|
49
50
|
// Unified error envelope for Commander-level failures (unknown command,
|
|
@@ -91,7 +92,7 @@ program
|
|
|
91
92
|
.addOption(new Option("--dev", "Use local dev API (http://localhost:8000)").hideHelp())
|
|
92
93
|
.option("--workspace <id>", "Default workspace ID; per-subcommand --workspace overrides")
|
|
93
94
|
.option("--json", "Output as JSON (auto-enabled when piped)")
|
|
94
|
-
.option("--get <field>", "Extract a single field from the JSON response and print only its value (implies --json internally; supports dotted paths e.g.
|
|
95
|
+
.option("--get <field>", "Extract a single field from the JSON response and print only its value (implies --json internally; supports dotted paths e.g. person.name)")
|
|
95
96
|
.option("--human", "Force human-readable output even when stdout is piped (overrides JSON-when-piped auto-detection)")
|
|
96
97
|
.option("--fields <fields>", "Comma-separated fields to include in JSON output (e.g. alias,name,status)")
|
|
97
98
|
.option("--verbose", "Include full UUIDs and timestamps in JSON output")
|
|
@@ -305,13 +306,14 @@ program
|
|
|
305
306
|
registerWorkspaceCommands(program);
|
|
306
307
|
registerStudyCommands(program);
|
|
307
308
|
registerIterationCommands(program);
|
|
308
|
-
|
|
309
|
+
registerPersonCommands(program);
|
|
309
310
|
registerSourceCommands(program);
|
|
310
311
|
registerConfigCommands(program);
|
|
311
312
|
registerAskCommands(program);
|
|
312
313
|
registerChatCommand(program);
|
|
313
314
|
registerDocsCommands(program);
|
|
314
315
|
registerInitCommands(program);
|
|
316
|
+
registerMcpCommands(program);
|
|
315
317
|
registerSecretCommands(program);
|
|
316
318
|
program
|
|
317
319
|
.command("upgrade")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client-side validator for the AccessibilityProfile v1.0 JSONB shape on
|
|
3
|
-
*
|
|
3
|
+
* Person.accessibility_profile. Mirrors
|
|
4
4
|
* `ish-mcp/spec/accessibility-profile-schema.v1.json` (additionalProperties:
|
|
5
5
|
* false at every level except `extensions`). An empty object `{}` is the
|
|
6
6
|
* canonical default. When non-empty, `version` is required and must be
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client-side validator for the AccessibilityProfile v1.0 JSONB shape on
|
|
3
|
-
*
|
|
3
|
+
* Person.accessibility_profile. Mirrors
|
|
4
4
|
* `ish-mcp/spec/accessibility-profile-schema.v1.json` (additionalProperties:
|
|
5
5
|
* false at every level except `extensions`). An empty object `{}` is the
|
|
6
6
|
* canonical default. When non-empty, `version` is required and must be
|
|
@@ -116,10 +116,10 @@ export async function hydrateForAlias(client, alias, hints = {}) {
|
|
|
116
116
|
await hydrateList(client, ALIAS_PREFIX.iteration, `/studies/${study}/iterations`);
|
|
117
117
|
return;
|
|
118
118
|
}
|
|
119
|
-
case ALIAS_PREFIX.
|
|
119
|
+
case ALIAS_PREFIX.person: {
|
|
120
120
|
if (!ws)
|
|
121
121
|
return;
|
|
122
|
-
await hydrateList(client, ALIAS_PREFIX.
|
|
122
|
+
await hydrateList(client, ALIAS_PREFIX.person, "/people", { workspace_id: ws, type: "all", limit: "200" });
|
|
123
123
|
return;
|
|
124
124
|
}
|
|
125
125
|
case ALIAS_PREFIX.ask: {
|
|
@@ -135,9 +135,9 @@ export async function hydrateForAlias(client, alias, hints = {}) {
|
|
|
135
135
|
return;
|
|
136
136
|
}
|
|
137
137
|
// No cheap single-call hydrate for the rest:
|
|
138
|
-
//
|
|
138
|
+
// participant (`pt-`) — scoped to iteration
|
|
139
139
|
// ask round (`r-`) — nested on the ask
|
|
140
|
-
//
|
|
140
|
+
// person source (`ps-`) — fetched per-id
|
|
141
141
|
// simulation config (`c-`) — no list endpoint yet
|
|
142
142
|
default:
|
|
143
143
|
return;
|