@ishlabs/cli 0.18.0 → 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.
@@ -12,6 +12,7 @@ import * as readline from "node:readline/promises";
12
12
  import { withClient, getWebUrl, terminalLink, resolveWorkspace, resolveStudy, parseWaitTimeout, resolvePersonIds, addPersonFilterFlags, hasPersonFlags, readFileOrStdin, } from "../lib/command-helpers.js";
13
13
  import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
14
14
  import { output, formatSimulationPoll } from "../lib/output.js";
15
+ import { fetchStudyParticipants } from "../lib/study-participants.js";
15
16
  import { streamStudyEvents } from "../lib/study-events.js";
16
17
  import { isMediaModality, isChatModality, iterationHasContent, describeRequiredContentFlag, readChatMode, readParticipantPairConfig, summarizeRoleCriteria, } from "../lib/modality.js";
17
18
  // NOTE: local-sim modules are loaded via dynamic import at the `--local`
@@ -107,24 +108,27 @@ const POLL_INTERVAL_MS = 5_000;
107
108
  // transparently reverts to POLL_INTERVAL_MS.
108
109
  const SSE_BACKSTOP_INTERVAL_MS = 30_000;
109
110
  const TERMINAL_STATUSES = new Set(["completed", "errored", "failed", "cancelled", "canceled"]);
110
- function flattenParticipantStatuses(iterations, only) {
111
+ function flattenParticipantStatuses(participants, opts = {}) {
111
112
  const rows = [];
112
- for (const iteration of iterations ?? []) {
113
- for (const t of iteration.participants ?? []) {
114
- if (only && !only.has(t.id))
115
- continue;
116
- // Pattern A (cli half): backend now reports per-participant crash detail at
117
- // `error_message`. Keep `error` / `failure_reason` as legacy fallbacks
118
- // until every backend deploy is on the new contract.
119
- const errorMessage = t.error_message || t.error || t.failure_reason || null;
120
- rows.push({
121
- id: t.id,
122
- status: t.status,
123
- participant_name: t.person?.name || "Unknown",
124
- interaction_count: Array.isArray(t.interactions) ? t.interactions.length : 0,
125
- ...(errorMessage && { error_message: errorMessage }),
126
- });
127
- }
113
+ for (const t of participants ?? []) {
114
+ if (opts.iterationId && t.iteration_id !== opts.iterationId)
115
+ continue;
116
+ if (opts.only && !opts.only.has(t.id))
117
+ continue;
118
+ // Pattern A (cli half): backend now reports per-participant crash detail at
119
+ // `error_message`. Keep `error` / `failure_reason` as legacy fallbacks
120
+ // until every backend deploy is on the new contract.
121
+ const errorMessage = t.error_message ||
122
+ t.error ||
123
+ t.failure_reason ||
124
+ null;
125
+ rows.push({
126
+ id: t.id,
127
+ status: t.status,
128
+ participant_name: t.person?.name || "Unknown",
129
+ interaction_count: Array.isArray(t.interactions) ? t.interactions.length : 0,
130
+ ...(errorMessage && { error_message: String(errorMessage) }),
131
+ });
128
132
  }
129
133
  return rows;
130
134
  }
@@ -146,13 +150,15 @@ async function pollStudyUntilDone(client, opts) {
146
150
  let pendingEvent = eventIter.next();
147
151
  try {
148
152
  while (true) {
149
- const study = await client.get(`/studies/${opts.studyId}`, undefined, { timeout: 60_000 });
153
+ const [study, participants] = await Promise.all([
154
+ client.get(`/studies/${opts.studyId}`, undefined, { timeout: 60_000 }),
155
+ fetchStudyParticipants(client, opts.studyId, { timeout: 60_000 }),
156
+ ]);
150
157
  const isMedia = isMediaModality(study.modality);
151
- let iterations = study.iterations;
152
- if (opts.iterationId) {
153
- iterations = (iterations ?? []).filter((it) => it.id === opts.iterationId);
154
- }
155
- const rows = flattenParticipantStatuses(iterations, opts.participantIds);
158
+ const rows = flattenParticipantStatuses(participants, {
159
+ iterationId: opts.iterationId,
160
+ only: opts.participantIds,
161
+ });
156
162
  const total = rows.length;
157
163
  const done = rows.filter((r) => TERMINAL_STATUSES.has(r.status)).length;
158
164
  const errored = rows.filter((r) => r.status === "errored" || r.status === "failed").length;
@@ -366,7 +372,8 @@ Examples:
366
372
  || parseInt(opts.dispatchTimeout, 10) < 1)) {
367
373
  throw new Error(`--dispatch-timeout must be a positive integer (seconds), got "${opts.dispatchTimeout}".`);
368
374
  }
369
- // Step 0: Fetch study (with its iterations + their existing participants)
375
+ // Step 0: Fetch study metadata (lite participants live on a
376
+ // separate endpoint; we fetch them below only when reuse is possible).
370
377
  const study = await client.get(`/studies/${resolvedStudy}`);
371
378
  const modality = study.modality || "interactive";
372
379
  const isMedia = isMediaModality(modality);
@@ -455,8 +462,15 @@ Examples:
455
462
  const resolved = await resolvePersonIds(client, resolvedWorkspace, opts, { requireSimulatable: false, allFlagName: "--all" });
456
463
  personIds.push(...resolved);
457
464
  }
458
- else if (iteration.participants && iteration.participants.length > 0) {
459
- for (const t of iteration.participants) {
465
+ else if (!isPair) {
466
+ // Reuse-existing path: no person flags, non-pair iteration. Fetch
467
+ // participants from the dedicated endpoint and filter to the
468
+ // current iteration. (Pair iterations don't reuse participant rows
469
+ // — they reuse Conversation refs above.)
470
+ const studyParticipants = await fetchStudyParticipants(client, resolvedStudy);
471
+ for (const t of studyParticipants) {
472
+ if (t.iteration_id !== iteration.id)
473
+ continue;
460
474
  const pid = t.person_id || t.person?.id;
461
475
  const name = t.person?.name;
462
476
  if (pid && !personNames.has(pid)) {
@@ -667,12 +681,22 @@ Examples:
667
681
  // language?: str }
668
682
  // reply : { conversations: [{ conversation_id, pair_index,
669
683
  // participant_a_id, participant_b_id }] }
684
+ //
685
+ // On the LITE study response, the iteration's conversation refs
686
+ // use the storage-shape field names group_a_participant_id /
687
+ // group_b_participant_id. Map them back to the pair-batch reply
688
+ // shape (participant_a_id / participant_b_id) the rest of this
689
+ // function expects.
670
690
  const existingConvs = iteration.conversations ?? [];
671
691
  const reusable = [];
672
692
  for (const c of existingConvs) {
673
693
  const cid = c.conversation_id || c.id;
674
- if (cid && c.participant_a_id && c.participant_b_id) {
675
- reusable.push({ conversation_id: cid, participant_a_id: c.participant_a_id, participant_b_id: c.participant_b_id });
694
+ if (cid && c.group_a_participant_id && c.group_b_participant_id) {
695
+ reusable.push({
696
+ conversation_id: cid,
697
+ participant_a_id: c.group_a_participant_id,
698
+ participant_b_id: c.group_b_participant_id,
699
+ });
676
700
  }
677
701
  }
678
702
  let pairRows;
@@ -1059,9 +1083,12 @@ Examples:
1059
1083
  // an agent that ran `study use s-...` then `study poll` would get a
1060
1084
  // confusing "Provide a participant_id argument or --study flag" error.
1061
1085
  const rid = resolveStudy(opts.study);
1062
- const study = await client.get(`/studies/${rid}`);
1086
+ const [study, participants] = await Promise.all([
1087
+ client.get(`/studies/${rid}`),
1088
+ fetchStudyParticipants(client, rid),
1089
+ ]);
1063
1090
  const isMedia = isMediaModality(study.modality);
1064
- const allParticipants = flattenParticipantStatuses(study.iterations);
1091
+ const allParticipants = flattenParticipantStatuses(participants);
1065
1092
  formatSimulationPoll(allParticipants, globals.json, isMedia);
1066
1093
  if (!globals.json && study.product_id) {
1067
1094
  const url = getWebUrl(globals, `/${study.product_id}/${rid}/timeline`);
@@ -8,6 +8,7 @@ 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 { fetchStudyParticipants } from "../lib/study-participants.js";
11
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";
@@ -616,14 +617,17 @@ list table layout in human mode.`)
616
617
  throw new Error("Provide at least one study id.");
617
618
  if (flat.length === 1) {
618
619
  const rid = resolveId(flat[0]);
619
- const data = await client.get(`/studies/${rid}`);
620
+ const [data, participants] = await Promise.all([
621
+ client.get(`/studies/${rid}`),
622
+ fetchStudyParticipants(client, rid),
623
+ ]);
620
624
  const result = data;
621
625
  if (result.id)
622
626
  result.alias = tagAlias(ALIAS_PREFIX.study, String(result.id));
623
627
  if (data.product_id) {
624
628
  result.url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
625
629
  }
626
- formatStudyDetail(result, globals.json);
630
+ formatStudyDetail(result, globals.json, {}, participants);
627
631
  if (!globals.json && data.product_id) {
628
632
  const url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
629
633
  console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
@@ -632,13 +636,17 @@ list table layout in human mode.`)
632
636
  }
633
637
  const results = await Promise.all(flat.map(async (raw) => {
634
638
  const rid = resolveId(raw);
635
- const data = await client.get(`/studies/${rid}`);
639
+ const [data, participants] = await Promise.all([
640
+ client.get(`/studies/${rid}`),
641
+ fetchStudyParticipants(client, rid),
642
+ ]);
636
643
  const r = data;
637
644
  if (r.id)
638
645
  r.alias = tagAlias(ALIAS_PREFIX.study, String(r.id));
639
646
  if (data.product_id) {
640
647
  r.url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
641
648
  }
649
+ r.participants = participants;
642
650
  return r;
643
651
  }));
644
652
  if (globals.json) {
@@ -751,12 +759,15 @@ When no runs have completed, the default envelope is returned with zero counts a
751
759
  output(buildChatTranscript(participant), globals.json, { preProjected: true });
752
760
  return;
753
761
  }
754
- const data = await client.get(`/studies/${rid}`);
762
+ const [data, participants] = await Promise.all([
763
+ client.get(`/studies/${rid}`),
764
+ fetchStudyParticipants(client, rid),
765
+ ]);
755
766
  if (wantsSummary) {
756
- output(buildStudyResultsSummary(data), globals.json, { preProjected: true });
767
+ output(buildStudyResultsSummary(data, participants), globals.json, { preProjected: true });
757
768
  }
758
769
  else {
759
- formatStudyResults(data, globals.json);
770
+ formatStudyResults(data, participants, globals.json);
760
771
  }
761
772
  if (!globals.json && data.product_id) {
762
773
  const url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
package/dist/lib/docs.js CHANGED
@@ -2059,6 +2059,15 @@ The CLI guarantees these contracts so agents can chain safely:
2059
2059
  sentiment histogram + per-participant {alias, status, sentiment, comment,
2060
2060
  error_message}. Drops \`interview_answers\` and per-interaction
2061
2061
  breakdowns. Cheapest "did this run land?" shape.
2062
+ - **\`study get --json\` carries a flat top-level \`participants[]\`**
2063
+ (post backend-split). Each row carries \`iteration_id\` as a
2064
+ discriminator and the per-participant graph
2065
+ (\`person\`, \`interactions[]\`, \`participant_summary\`,
2066
+ \`interview_answers\`, \`conversation_id\`, …). The previous nesting
2067
+ under \`iterations[*].participants[*]\` is gone — read participants from
2068
+ the top level. The lite iteration list under \`iterations[]\` still
2069
+ carries each iteration's \`details\` and (for pair-mode chat) the
2070
+ conversation refs at \`iterations[*].conversations[]\`.
2062
2071
  - **\`study get --json\` carries assignment step completion** when an
2063
2072
  assignment has a checklist (see \`concepts/assignment\`). Each
2064
2073
  \`assignments[].step_completion[]\` row is
@@ -3079,7 +3088,7 @@ ish study run stu-xyz --sample 5 --wait
3079
3088
 
3080
3089
  Pull raw interactions:
3081
3090
  \`\`\`
3082
- ish study results stu-xyz --json | jq '.interactions'
3091
+ ish study results stu-xyz --json | jq '.participants[].interactions'
3083
3092
  \`\`\`
3084
3093
 
3085
3094
  Note: chat is currently excluded from the LLM-analysis route; the
@@ -47,15 +47,15 @@ export declare function formatWorkspaceList(workspaces: Record<string, unknown>[
47
47
  export declare function formatWorkspaceDetail(workspace: Record<string, unknown>, json: boolean, options?: OutputOptions): void;
48
48
  export declare function formatSiteAccessStatus(summary: import("./site-access.js").SiteAccessSummary, json: boolean): void;
49
49
  export declare function formatStudyList(studies: Record<string, unknown>[], json: boolean): void;
50
- export declare function formatStudyDetail(study: Record<string, unknown>, json: boolean, options?: OutputOptions): void;
51
- export declare function formatStudyResults(study: Record<string, unknown>, json: boolean): void;
50
+ export declare function formatStudyDetail(study: Record<string, unknown>, json: boolean, options?: OutputOptions, participants?: ReadonlyArray<Record<string, unknown>>): void;
51
+ export declare function formatStudyResults(study: Record<string, unknown>, participants: ReadonlyArray<Record<string, unknown>>, json: boolean): void;
52
52
  /**
53
53
  * `study results --summary` projection. Drops interview_answers + per-participant
54
54
  * interaction breakdowns; keeps headline counters, sentiment histogram, and a
55
55
  * per-participant {alias, status, sentiment, comment} row. Useful for agents that
56
56
  * need to branch on outcome without paying for the full envelope.
57
57
  */
58
- export declare function buildStudyResultsSummary(study: Record<string, unknown>): Record<string, unknown>;
58
+ export declare function buildStudyResultsSummary(study: Record<string, unknown>, participants: ReadonlyArray<Record<string, unknown>>): Record<string, unknown>;
59
59
  /**
60
60
  * `study results --transcript <participant_id>` projection. Mirrors the schema
61
61
  * MCP's `get_chat_transcript` returns (`src/ish_mcp/projections.py:
@@ -807,10 +807,10 @@ export function formatStudyList(studies, json) {
807
807
  *
808
808
  * Returns null when status is consistent; no warning emitted.
809
809
  */
810
- function detectStudyStatusInconsistency(study) {
810
+ function detectStudyStatusInconsistency(study, participants) {
811
811
  if (study.status !== "failed")
812
812
  return null;
813
- const allParticipants = collectParticipants(study);
813
+ const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
814
814
  const completedCount = allParticipants.filter((t) => t.status === "completed" || t.status === "complete").length;
815
815
  const totalInteractions = allParticipants.reduce((sum, t) => sum + t.interactionCount, 0);
816
816
  if (completedCount === 0 && totalInteractions === 0)
@@ -879,14 +879,16 @@ function renderAssignmentSteps(a) {
879
879
  }
880
880
  }
881
881
  }
882
- export function formatStudyDetail(study, json, options = {}) {
883
- const inconsistency = detectStudyStatusInconsistency(study);
882
+ export function formatStudyDetail(study, json, options = {}, participants) {
883
+ const inconsistency = detectStudyStatusInconsistency(study, participants);
884
884
  if (inconsistency)
885
885
  emitStatusInconsistencyWarning(inconsistency);
886
886
  if (json) {
887
- const payload = inconsistency
888
- ? { ...study, status_inferred: inconsistency.inferred }
889
- : study;
887
+ const payload = { ...study };
888
+ if (participants !== undefined)
889
+ payload.participants = participants;
890
+ if (inconsistency)
891
+ payload.status_inferred = inconsistency.inferred;
890
892
  console.log(jsonOutput(payload, options));
891
893
  return;
892
894
  }
@@ -929,7 +931,7 @@ export function formatStudyDetail(study, json, options = {}) {
929
931
  }
930
932
  }
931
933
  // Participants summary
932
- const allParticipants = collectParticipants(study);
934
+ const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
933
935
  if (allParticipants.length > 0) {
934
936
  console.log(`\nParticipants (${allParticipants.length}):`);
935
937
  printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS"], allParticipants.map((t) => [
@@ -946,8 +948,8 @@ export function formatStudyDetail(study, json, options = {}) {
946
948
  * study state — fields default to `null`, `0`, or `[]` when nothing has run.
947
949
  * Agents can rely on the keys always being present (M4).
948
950
  */
949
- function buildStudyResultsEnvelope(study) {
950
- const allParticipants = collectParticipants(study);
951
+ function buildStudyResultsEnvelope(study, participants) {
952
+ const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
951
953
  const studyAlias = study.id
952
954
  ? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
953
955
  : null;
@@ -995,7 +997,7 @@ function buildStudyResultsEnvelope(study) {
995
997
  // CLI-side sanity check (Pattern E / Issue #2). Surface a status_inferred
996
998
  // field when the backend reports failed-with-data; agents can branch on
997
999
  // either the original status or status_inferred.
998
- const inconsistency = detectStudyStatusInconsistency(study);
1000
+ const inconsistency = detectStudyStatusInconsistency(study, participants);
999
1001
  // Pattern B2 (cli half): per-participant rows expose status + error_message so
1000
1002
  // agents can act on a failed run without re-fetching every participant.
1001
1003
  const failedCount = allParticipants.filter((t) => t.status.toLowerCase() === "failed").length;
@@ -1025,18 +1027,18 @@ function buildStudyResultsEnvelope(study) {
1025
1027
  participants: participantRows,
1026
1028
  };
1027
1029
  }
1028
- export function formatStudyResults(study, json) {
1029
- const inconsistency = detectStudyStatusInconsistency(study);
1030
+ export function formatStudyResults(study, participants, json) {
1031
+ const inconsistency = detectStudyStatusInconsistency(study, participants);
1030
1032
  if (inconsistency)
1031
1033
  emitStatusInconsistencyWarning(inconsistency);
1032
1034
  if (json) {
1033
1035
  // preProjected: bypass leanJson so the stable envelope keeps documented
1034
1036
  // empty defaults (sentiment: null, interview_answers[].answers: []) rather
1035
1037
  // than having them stripped by the lean transform.
1036
- console.log(jsonOutput(buildStudyResultsEnvelope(study), { preProjected: true }));
1038
+ console.log(jsonOutput(buildStudyResultsEnvelope(study, participants), { preProjected: true }));
1037
1039
  return;
1038
1040
  }
1039
- const allParticipants = collectParticipants(study);
1041
+ const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
1040
1042
  const totalInteractions = allParticipants.reduce((sum, t) => sum + t.interactionCount, 0);
1041
1043
  // Header
1042
1044
  console.log(`${study.name || "Untitled"} — Results`);
@@ -1102,8 +1104,8 @@ export function formatStudyResults(study, json) {
1102
1104
  * per-participant {alias, status, sentiment, comment} row. Useful for agents that
1103
1105
  * need to branch on outcome without paying for the full envelope.
1104
1106
  */
1105
- export function buildStudyResultsSummary(study) {
1106
- const allParticipants = collectParticipants(study);
1107
+ export function buildStudyResultsSummary(study, participants) {
1108
+ const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
1107
1109
  const studyAlias = study.id
1108
1110
  ? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
1109
1111
  : null;
@@ -1118,7 +1120,7 @@ export function buildStudyResultsSummary(study) {
1118
1120
  }
1119
1121
  }
1120
1122
  const sentiment = sentimentTotal > 0 ? { counts: sentimentCounts, total: sentimentTotal } : null;
1121
- const participants = allParticipants.map((t) => ({
1123
+ const participantRows = allParticipants.map((t) => ({
1122
1124
  alias: t.alias || null,
1123
1125
  name: t.name,
1124
1126
  status: t.status,
@@ -1136,7 +1138,7 @@ export function buildStudyResultsSummary(study) {
1136
1138
  completed_count: completedCount,
1137
1139
  failed_count: failedCount,
1138
1140
  sentiment,
1139
- participants,
1141
+ participants: participantRows,
1140
1142
  };
1141
1143
  }
1142
1144
  /**
@@ -1300,49 +1302,52 @@ export function buildParticipantSummary(participant) {
1300
1302
  out.error_kind = String(participant.error_kind);
1301
1303
  return out;
1302
1304
  }
1303
- function collectParticipants(study) {
1304
- const iterations = Array.isArray(study.iterations) ? study.iterations : [];
1305
- const participants = [];
1306
- for (const iter of iterations) {
1307
- const it = iter;
1308
- const iterLabel = String(it.label || it.name || "-");
1309
- const iterParticipants = Array.isArray(it.participants) ? it.participants : [];
1310
- for (const participant of iterParticipants) {
1311
- const t = participant;
1312
- const profile = t.person;
1313
- const interactions = Array.isArray(t.interactions) ? t.interactions : [];
1314
- const sentimentCounts = {};
1315
- for (const interaction of interactions) {
1316
- const ix = interaction;
1317
- const sentiment = ix.sentiment;
1318
- if (sentiment?.label) {
1319
- const label = String(sentiment.label);
1320
- sentimentCounts[label] = (sentimentCounts[label] || 0) + 1;
1321
- }
1305
+ function collectParticipants(participants, iterations) {
1306
+ const iterationLabels = new Map();
1307
+ for (const iter of iterations ?? []) {
1308
+ const id = iter.id ? String(iter.id) : "";
1309
+ if (id) {
1310
+ iterationLabels.set(id, String(iter.label || iter.name || "-"));
1311
+ }
1312
+ }
1313
+ const rows = [];
1314
+ for (const participant of participants ?? []) {
1315
+ const t = participant;
1316
+ const profile = t.person;
1317
+ const interactions = Array.isArray(t.interactions) ? t.interactions : [];
1318
+ const sentimentCounts = {};
1319
+ for (const interaction of interactions) {
1320
+ const ix = interaction;
1321
+ const sentiment = ix.sentiment;
1322
+ if (sentiment?.label) {
1323
+ const label = String(sentiment.label);
1324
+ sentimentCounts[label] = (sentimentCounts[label] || 0) + 1;
1322
1325
  }
1323
- const answers = Array.isArray(t.interview_answers) ? t.interview_answers : [];
1324
- const summary = t.participant_summary;
1325
- const summarySentimentObj = summary?.sentiment;
1326
- const id = String(t.id || "");
1327
- participants.push({
1328
- id,
1329
- name: String(profile?.name || t.instance_name || "Unknown"),
1330
- alias: id ? deterministicAlias(ALIAS_PREFIX.participant, id) : "",
1331
- iterationLabel: iterLabel,
1332
- status: String(t.status || "-"),
1333
- errorMessage: t.error_message ? String(t.error_message) : null,
1334
- interactionCount: interactions.length,
1335
- sentimentCounts,
1336
- summarySentiment: summarySentimentObj?.label ? String(summarySentimentObj.label) : null,
1337
- summaryComment: summary?.comment ? String(summary.comment) : null,
1338
- interviewAnswers: answers.map((a) => ({
1339
- questionId: String(a.question_id || ""),
1340
- answer: a.answer,
1341
- })),
1342
- });
1343
1326
  }
1327
+ const answers = Array.isArray(t.interview_answers) ? t.interview_answers : [];
1328
+ const summary = t.participant_summary;
1329
+ const summarySentimentObj = summary?.sentiment;
1330
+ const id = String(t.id || "");
1331
+ const iterationId = t.iteration_id ? String(t.iteration_id) : "";
1332
+ const iterationLabel = (iterationId && iterationLabels.get(iterationId)) || "-";
1333
+ rows.push({
1334
+ id,
1335
+ name: String(profile?.name || t.instance_name || "Unknown"),
1336
+ alias: id ? deterministicAlias(ALIAS_PREFIX.participant, id) : "",
1337
+ iterationLabel,
1338
+ status: String(t.status || "-"),
1339
+ errorMessage: t.error_message ? String(t.error_message) : null,
1340
+ interactionCount: interactions.length,
1341
+ sentimentCounts,
1342
+ summarySentiment: summarySentimentObj?.label ? String(summarySentimentObj.label) : null,
1343
+ summaryComment: summary?.comment ? String(summary.comment) : null,
1344
+ interviewAnswers: answers.map((a) => ({
1345
+ questionId: String(a.question_id || ""),
1346
+ answer: a.answer,
1347
+ })),
1348
+ });
1344
1349
  }
1345
- return participants;
1350
+ return rows;
1346
1351
  }
1347
1352
  function formatQuestionType(q) {
1348
1353
  if (!q.type)
@@ -219,6 +219,7 @@ When in doubt: side-by-side comparison usually beats in-place edits. Ids are che
219
219
  - **Chatbot auth drift**: tokens/sessions baked into \`--from-curl\` expire. If transcripts come back as identical short error strings, re-run \`chat_endpoint_test\` and refresh the curl spec.
220
220
  - **401 surfaces as fake blocker**: an unauthenticated endpoint produces "participant got stuck on auth screen" — looks like a UX blocker but is config. Always confirm endpoint auth before reading transcripts as user-research data.
221
221
  - **No per-page/per-timestamp scoping for media**: there's no "evaluate just slide 14" or "react to seconds 0-30" API. State the focus explicitly in the \`assignment\` text, or pre-stitch the artifact (e.g. replace one slide locally, upload as a new iteration).
222
+ - **\`study get --json\` participants live at the top level**, not nested under \`iterations[*].participants\`. The backend split made \`/studies/{id}\` lite (metadata + iteration shells, no participant graph) and added \`/studies/{id}/participants\`; the CLI joins them so \`study get --json\` carries a flat \`participants[]\` with \`iteration_id\` on each row. Read \`.participants[]\`, not \`.iterations[].participants[]\`.
222
223
 
223
224
  ## When in doubt
224
225
 
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Wrapper for the post-split `GET /studies/{id}/participants` endpoint.
3
+ *
4
+ * Returns a flat list of participants for one study with an `iteration_id`
5
+ * discriminator on each row. Each row carries the rich per-participant graph
6
+ * (person, interactions[], participant_summary, interview_answers, …) that
7
+ * used to be embedded under `study.iterations[*].participants[*]` on the
8
+ * legacy `GET /studies/{id}` response.
9
+ */
10
+ import type { ApiClient } from "./api-client.js";
11
+ import type { Participant } from "./types.js";
12
+ export interface StudyParticipant extends Participant {
13
+ person?: {
14
+ id?: string;
15
+ name?: string;
16
+ };
17
+ interactions?: unknown[];
18
+ participant_summary?: Record<string, unknown> | null;
19
+ interview_answers?: Array<{
20
+ question_id?: string;
21
+ answer?: unknown;
22
+ }>;
23
+ participant_files?: unknown[];
24
+ participant_assignments?: unknown[];
25
+ conversation_id?: string | null;
26
+ error_message?: string | null;
27
+ error_kind?: string | null;
28
+ [k: string]: unknown;
29
+ }
30
+ export declare function fetchStudyParticipants(client: ApiClient, studyId: string, opts?: {
31
+ timeout?: number;
32
+ }): Promise<StudyParticipant[]>;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Wrapper for the post-split `GET /studies/{id}/participants` endpoint.
3
+ *
4
+ * Returns a flat list of participants for one study with an `iteration_id`
5
+ * discriminator on each row. Each row carries the rich per-participant graph
6
+ * (person, interactions[], participant_summary, interview_answers, …) that
7
+ * used to be embedded under `study.iterations[*].participants[*]` on the
8
+ * legacy `GET /studies/{id}` response.
9
+ */
10
+ export async function fetchStudyParticipants(client, studyId, opts) {
11
+ return await client.get(`/studies/${studyId}/participants`, undefined, opts);
12
+ }
@@ -155,7 +155,6 @@ export interface Iteration {
155
155
  description?: string;
156
156
  label?: string;
157
157
  details?: Record<string, unknown>;
158
- participants?: Participant[];
159
158
  created_at: string;
160
159
  updated_at: string;
161
160
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ishlabs/cli",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "The command-line interface for ish",
5
5
  "type": "module",
6
6
  "bin": {