@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.
Files changed (64) hide show
  1. package/README.md +54 -54
  2. package/dist/commands/ask.d.ts +4 -4
  3. package/dist/commands/ask.js +66 -66
  4. package/dist/commands/chat.js +10 -10
  5. package/dist/commands/config.js +1 -1
  6. package/dist/commands/docs.js +1 -1
  7. package/dist/commands/iteration.js +57 -57
  8. package/dist/commands/mcp.d.ts +23 -0
  9. package/dist/commands/mcp.js +676 -0
  10. package/dist/commands/person.d.ts +5 -0
  11. package/dist/commands/{profile.js → person.js} +197 -162
  12. package/dist/commands/source.d.ts +6 -2
  13. package/dist/commands/source.js +35 -30
  14. package/dist/commands/study-analyze.d.ts +1 -1
  15. package/dist/commands/study-analyze.js +3 -3
  16. package/dist/commands/study-participant.d.ts +8 -0
  17. package/dist/commands/{study-tester.js → study-participant.js} +50 -50
  18. package/dist/commands/study-run.d.ts +6 -6
  19. package/dist/commands/study-run.js +341 -290
  20. package/dist/commands/study.js +106 -72
  21. package/dist/commands/workspace.js +13 -13
  22. package/dist/connect.js +5 -5
  23. package/dist/index.js +6 -4
  24. package/dist/lib/accessibility-profile.d.ts +1 -1
  25. package/dist/lib/accessibility-profile.js +1 -1
  26. package/dist/lib/alias-hydrate.js +4 -4
  27. package/dist/lib/alias-store.d.ts +5 -5
  28. package/dist/lib/alias-store.js +8 -8
  29. package/dist/lib/api-client.d.ts +1 -1
  30. package/dist/lib/api-client.js +1 -1
  31. package/dist/lib/billing.d.ts +11 -11
  32. package/dist/lib/billing.js +16 -16
  33. package/dist/lib/chat-endpoint-templates.js +1 -1
  34. package/dist/lib/command-helpers.d.ts +18 -18
  35. package/dist/lib/command-helpers.js +49 -37
  36. package/dist/lib/docs.js +570 -387
  37. package/dist/lib/enums.d.ts +2 -2
  38. package/dist/lib/enums.js +2 -2
  39. package/dist/lib/local-sim/browser.d.ts +1 -1
  40. package/dist/lib/local-sim/browser.js +1 -1
  41. package/dist/lib/local-sim/debug-report.d.ts +2 -2
  42. package/dist/lib/local-sim/debug-report.js +3 -3
  43. package/dist/lib/local-sim/loop.d.ts +5 -5
  44. package/dist/lib/local-sim/loop.js +38 -38
  45. package/dist/lib/local-sim/types.d.ts +12 -12
  46. package/dist/lib/mcp-clients.d.ts +51 -0
  47. package/dist/lib/mcp-clients.js +175 -0
  48. package/dist/lib/modality.d.ts +10 -10
  49. package/dist/lib/modality.js +46 -46
  50. package/dist/lib/output.d.ts +16 -15
  51. package/dist/lib/output.js +291 -226
  52. package/dist/lib/profile-sources.d.ts +64 -16
  53. package/dist/lib/profile-sources.js +91 -30
  54. package/dist/lib/skill-content.js +216 -168
  55. package/dist/lib/study-events.d.ts +3 -3
  56. package/dist/lib/study-events.js +1 -1
  57. package/dist/lib/study-inputs.d.ts +11 -1
  58. package/dist/lib/study-inputs.js +68 -17
  59. package/dist/lib/study-participants.d.ts +32 -0
  60. package/dist/lib/study-participants.js +12 -0
  61. package/dist/lib/types.d.ts +104 -34
  62. package/package.json +1 -1
  63. package/dist/commands/profile.d.ts +0 -5
  64. package/dist/commands/study-tester.d.ts +0 -8
@@ -7,6 +7,7 @@
7
7
  * for the full API response.
8
8
  */
9
9
  import { ApiError } from "./api-client.js";
10
+ import { c } from "./colors.js";
10
11
  import { deterministicAlias, getAliasMap, ALIAS_PREFIX } from "./alias-store.js";
11
12
  // --- Lean JSON: strip noise for agent-friendly output ---
12
13
  let _verbose = false;
@@ -117,15 +118,15 @@ const PAGINATION_KEYS = new Set(["items", "total", "returned", "limit", "offset"
117
118
  // same shape leanJson strips elsewhere. These are load-bearing for agent
118
119
  // follow-up calls and were forcing agents to pass `--verbose` (C5-Bug4).
119
120
  const UUID_KEYS_TO_KEEP = new Set([
120
- // ask: which variant the tester picked — the load-bearing field for "who picked what".
121
+ // ask: which variant the participant picked — the load-bearing field for "who picked what".
121
122
  "variant_pick_id",
122
123
  ]);
123
124
  // Keys whose value must pass through leanJson untouched (no UUID stripping,
124
125
  // no empty-array drop, no nested recursion). The entire shape is contract:
125
- // every variant id key and every tester id in its array is load-bearing,
126
+ // every variant id key and every participant id in its array is load-bearing,
126
127
  // and unpicked variants must surface as `[]` rather than disappear.
127
128
  const LEAN_PASSTHROUGH_KEYS = new Set([
128
- // Pattern H: variant_id → [tester_id, ...] for drill-in audience discovery.
129
+ // Pattern H: variant_id → [participant_id, ...] for drill-in subset discovery.
129
130
  "pick_buckets",
130
131
  // Pattern A: workspace cold-start fields. Agents need these without --verbose
131
132
  // to spot a safe reuse target (has_headroom) and order workspaces by recency.
@@ -133,7 +134,7 @@ const LEAN_PASSTHROUGH_KEYS = new Set([
133
134
  // child_counts is a dict — passthrough preserves the inner counts verbatim.
134
135
  "study_count",
135
136
  "ask_count",
136
- "tester_profile_count",
137
+ "person_count",
137
138
  "child_counts",
138
139
  "has_headroom",
139
140
  "last_activity_at",
@@ -491,7 +492,7 @@ export function outputError(err, json) {
491
492
  ...suggestions,
492
493
  ]));
493
494
  const limitDetail = err.error_code === "usage_limit_reached" ? structuredDetail(err) : undefined;
494
- // B7 / Pattern G: dispatch-attempt failures tag the seeded testers
495
+ // B7 / Pattern G: dispatch-attempt failures tag the seeded participants
495
496
  // onto the thrown ApiError so the agent can resume without
496
497
  // re-seeding (which would create duplicates). Surface alongside
497
498
  // the error envelope so machine-readable consumers see them.
@@ -666,7 +667,7 @@ const WORKSPACE_DEFAULT_FIELDS = [
666
667
  "created_at",
667
668
  "study_count",
668
669
  "ask_count",
669
- "tester_profile_count",
670
+ "person_count",
670
671
  "child_counts",
671
672
  "has_headroom",
672
673
  "last_activity_at",
@@ -701,13 +702,13 @@ export function formatWorkspaceList(workspaces, json) {
701
702
  return;
702
703
  }
703
704
  const aliasMap = getAliasMap(ALIAS_PREFIX.workspace);
704
- printTable(["#", "NAME", "ROOM", "STUDIES", "ASKS", "TESTERS", "LAST ACTIVITY"], workspaces.map((w) => [
705
+ printTable(["#", "NAME", "ROOM", "STUDIES", "ASKS", "PARTICIPANTS", "LAST ACTIVITY"], workspaces.map((w) => [
705
706
  aliasMap.get(String(w.id)) || String(w.id || ""),
706
707
  String(w.name || ""),
707
708
  formatHeadroom(w.has_headroom),
708
709
  formatChildCount(w, "studies", "study_count"),
709
710
  formatChildCount(w, "asks", "ask_count"),
710
- formatChildCount(w, "tester_profiles", "tester_profile_count"),
711
+ formatChildCount(w, "persons", "person_count"),
711
712
  formatDate(w.last_activity_at),
712
713
  ]));
713
714
  }
@@ -751,7 +752,7 @@ export function formatWorkspaceDetail(workspace, json, options = {}) {
751
752
  Headroom: formatHeadroom(workspace.has_headroom),
752
753
  Studies: formatChildCount(workspace, "studies", "study_count"),
753
754
  Asks: formatChildCount(workspace, "asks", "ask_count"),
754
- Testers: formatChildCount(workspace, "tester_profiles", "tester_profile_count"),
755
+ Participants: formatChildCount(workspace, "persons", "person_count"),
755
756
  };
756
757
  if (workspace.reused !== undefined)
757
758
  display.Reused = workspace.reused ? "yes" : "no";
@@ -783,20 +784,20 @@ export function formatStudyList(studies, json) {
783
784
  return;
784
785
  }
785
786
  const aliasMap = getAliasMap(ALIAS_PREFIX.study);
786
- printTable(["#", "NAME", "MODALITY", "TYPE", "STATUS", "TESTERS"], studies.map((s) => [
787
+ printTable(["#", "NAME", "MODALITY", "TYPE", "STATUS", "PARTICIPANTS"], studies.map((s) => [
787
788
  aliasMap.get(String(s.id)) || String(s.id || ""),
788
789
  String(s.name || ""),
789
790
  String(s.modality || "-"),
790
791
  String(s.content_type || "-"),
791
792
  String(s.status || "draft"),
792
- String(s.tester_count ?? "0"),
793
+ String(s.participant_count ?? "0"),
793
794
  ]));
794
795
  }
795
796
  /**
796
797
  * CLI-side sanity check for ALL-ISSUES Issue #2 / backend Pattern Bk2.
797
798
  *
798
799
  * Backend sometimes reports `status: "failed"` even when results are
799
- * populated (testers completed, interactions present). Until the backend
800
+ * populated (participants completed, interactions present). Until the backend
800
801
  * root-cause is fixed, the CLI surfaces the inconsistency rather than
801
802
  * letting agents trust a misleading status field:
802
803
  * - JSON: adds a `status_inferred` field (e.g. `completed_with_errors`).
@@ -806,17 +807,17 @@ export function formatStudyList(studies, json) {
806
807
  *
807
808
  * Returns null when status is consistent; no warning emitted.
808
809
  */
809
- function detectStudyStatusInconsistency(study) {
810
+ function detectStudyStatusInconsistency(study, participants) {
810
811
  if (study.status !== "failed")
811
812
  return null;
812
- const allTesters = collectTesters(study);
813
- const completedCount = allTesters.filter((t) => t.status === "completed" || t.status === "complete").length;
814
- const totalInteractions = allTesters.reduce((sum, t) => sum + t.interactionCount, 0);
813
+ const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
814
+ const completedCount = allParticipants.filter((t) => t.status === "completed" || t.status === "complete").length;
815
+ const totalInteractions = allParticipants.reduce((sum, t) => sum + t.interactionCount, 0);
815
816
  if (completedCount === 0 && totalInteractions === 0)
816
817
  return null;
817
818
  return {
818
819
  inferred: "completed_with_errors",
819
- reason: `${completedCount}/${allTesters.length} testers completed, ${totalInteractions} total interactions`,
820
+ reason: `${completedCount}/${allParticipants.length} participants completed, ${totalInteractions} total interactions`,
820
821
  };
821
822
  }
822
823
  function emitStatusInconsistencyWarning(inconsistency) {
@@ -824,14 +825,70 @@ function emitStatusInconsistencyWarning(inconsistency) {
824
825
  `CLI inferring status_inferred="${inconsistency.inferred}". ` +
825
826
  `Backend root-cause tracked as Issue #2 (Pattern Bk2).\n`);
826
827
  }
827
- export function formatStudyDetail(study, json, options = {}) {
828
- const inconsistency = detectStudyStatusInconsistency(study);
828
+ /**
829
+ * Render an assignment's `steps` checklist and, once a run has graded them, the
830
+ * per-step `step_completion` rollup (pass rate + a few sample failure reasons).
831
+ * Both are printed indented under the assignment in `study get` human output;
832
+ * absent on draft studies and on modalities that don't support steps.
833
+ */
834
+ function renderAssignmentSteps(a) {
835
+ const steps = Array.isArray(a.steps) ? a.steps : [];
836
+ const completion = Array.isArray(a.step_completion)
837
+ ? a.step_completion
838
+ : [];
839
+ if (steps.length === 0 && completion.length === 0)
840
+ return;
841
+ // Index completion rows by step name so we can pair them with authored steps.
842
+ const byName = new Map();
843
+ for (const sc of completion) {
844
+ if (typeof sc.name === "string")
845
+ byName.set(sc.name, sc);
846
+ }
847
+ // Prefer the authored step order; fall back to completion rows if a study
848
+ // somehow returns completion without resolved steps.
849
+ const rows = steps.length > 0 ? steps : completion;
850
+ console.log(" Steps:");
851
+ for (let i = 0; i < rows.length; i++) {
852
+ const step = rows[i];
853
+ const name = String(step.name || "Untitled");
854
+ const sc = byName.get(name) ?? (steps.length === 0 ? step : undefined);
855
+ let line = ` ${i + 1}. ${name}`;
856
+ if (sc) {
857
+ const total = Number(sc.total ?? 0);
858
+ const passed = Number(sc.passed ?? 0);
859
+ const rate = sc.rate;
860
+ if (total === 0 || rate === null || rate === undefined) {
861
+ line += ` ${c.dim}— not yet graded${c.reset}`;
862
+ }
863
+ else {
864
+ const pct = Math.round(Number(rate) * 100);
865
+ const mark = passed === total ? `${c.green}✓${c.reset}` : `${c.red}✗${c.reset}`;
866
+ line += ` ${mark} ${passed}/${total} (${pct}%)`;
867
+ }
868
+ }
869
+ console.log(line);
870
+ const desc = step.description ?? (sc ? sc.description : undefined);
871
+ if (typeof desc === "string" && desc.trim()) {
872
+ console.log(` ${c.dim}${truncate(desc, 72)}${c.reset}`);
873
+ }
874
+ const failures = sc && Array.isArray(sc.sample_failures) ? sc.sample_failures : [];
875
+ for (const f of failures) {
876
+ if (typeof f.reason === "string" && f.reason.trim()) {
877
+ console.log(` ${c.dim}✗ ${truncate(f.reason, 72)}${c.reset}`);
878
+ }
879
+ }
880
+ }
881
+ }
882
+ export function formatStudyDetail(study, json, options = {}, participants) {
883
+ const inconsistency = detectStudyStatusInconsistency(study, participants);
829
884
  if (inconsistency)
830
885
  emitStatusInconsistencyWarning(inconsistency);
831
886
  if (json) {
832
- const payload = inconsistency
833
- ? { ...study, status_inferred: inconsistency.inferred }
834
- : study;
887
+ const payload = { ...study };
888
+ if (participants !== undefined)
889
+ payload.participants = participants;
890
+ if (inconsistency)
891
+ payload.status_inferred = inconsistency.inferred;
835
892
  console.log(jsonOutput(payload, options));
836
893
  return;
837
894
  }
@@ -860,6 +917,7 @@ export function formatStudyDetail(study, json, options = {}) {
860
917
  if (a.instructions) {
861
918
  console.log(` ${truncate(String(a.instructions), 80)}`);
862
919
  }
920
+ renderAssignmentSteps(a);
863
921
  }
864
922
  }
865
923
  // Interview Questions
@@ -872,12 +930,12 @@ export function formatStudyDetail(study, json, options = {}) {
872
930
  console.log(` ${i + 1}. ${q.question || "Untitled"} ${typeStr}`);
873
931
  }
874
932
  }
875
- // Testers summary
876
- const allTesters = collectTesters(study);
877
- if (allTesters.length > 0) {
878
- console.log(`\nTesters (${allTesters.length}):`);
879
- printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS"], allTesters.map((t) => [
880
- t.id ? deterministicAlias(ALIAS_PREFIX.tester, t.id) : t.id,
933
+ // Participants summary
934
+ const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
935
+ if (allParticipants.length > 0) {
936
+ console.log(`\nParticipants (${allParticipants.length}):`);
937
+ printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS"], allParticipants.map((t) => [
938
+ t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
881
939
  t.name,
882
940
  t.iterationLabel,
883
941
  t.status,
@@ -890,16 +948,16 @@ export function formatStudyDetail(study, json, options = {}) {
890
948
  * study state — fields default to `null`, `0`, or `[]` when nothing has run.
891
949
  * Agents can rely on the keys always being present (M4).
892
950
  */
893
- function buildStudyResultsEnvelope(study) {
894
- const allTesters = collectTesters(study);
951
+ function buildStudyResultsEnvelope(study, participants) {
952
+ const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
895
953
  const studyAlias = study.id
896
954
  ? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
897
955
  : null;
898
- const completedCount = allTesters.filter((t) => t.status === "completed" || t.status === "complete").length;
899
- // Aggregate sentiment across all interactions on all testers.
956
+ const completedCount = allParticipants.filter((t) => t.status === "completed" || t.status === "complete").length;
957
+ // Aggregate sentiment across all interactions on all participants.
900
958
  const sentimentCounts = {};
901
959
  let sentimentTotal = 0;
902
- for (const t of allTesters) {
960
+ for (const t of allParticipants) {
903
961
  for (const [label, count] of Object.entries(t.sentimentCounts)) {
904
962
  sentimentCounts[label] = (sentimentCounts[label] || 0) + count;
905
963
  sentimentTotal += count;
@@ -912,18 +970,18 @@ function buildStudyResultsEnvelope(study) {
912
970
  }
913
971
  : null;
914
972
  // Group interview answers by question for easy parsing. Each answer row
915
- // includes the tester's session-level `sentiment` (M10) so agents can read
916
- // sentiment per answer without round-tripping `study tester <id>` per row.
973
+ // includes the participant's session-level `sentiment` (M10) so agents can read
974
+ // sentiment per answer without round-tripping `study participant <id>` per row.
917
975
  const questions = Array.isArray(study.interview_questions) ? study.interview_questions : [];
918
976
  const interviewAnswers = questions.map((q) => {
919
977
  const qObj = q;
920
978
  const answers = [];
921
- for (const t of allTesters) {
979
+ for (const t of allParticipants) {
922
980
  const a = t.interviewAnswers.find((x) => x.questionId === qObj.id);
923
981
  if (a) {
924
982
  answers.push({
925
- tester_alias: t.alias || null,
926
- tester_name: t.name,
983
+ participant_alias: t.alias || null,
984
+ participant_name: t.name,
927
985
  iteration: t.iterationLabel,
928
986
  answer: a.answer,
929
987
  sentiment: t.summarySentiment,
@@ -939,11 +997,11 @@ function buildStudyResultsEnvelope(study) {
939
997
  // CLI-side sanity check (Pattern E / Issue #2). Surface a status_inferred
940
998
  // field when the backend reports failed-with-data; agents can branch on
941
999
  // either the original status or status_inferred.
942
- const inconsistency = detectStudyStatusInconsistency(study);
943
- // Pattern B2 (cli half): per-tester rows expose status + error_message so
944
- // agents can act on a failed run without re-fetching every tester.
945
- const failedCount = allTesters.filter((t) => t.status.toLowerCase() === "failed").length;
946
- const testerRows = allTesters.map((t) => ({
1000
+ const inconsistency = detectStudyStatusInconsistency(study, participants);
1001
+ // Pattern B2 (cli half): per-participant rows expose status + error_message so
1002
+ // agents can act on a failed run without re-fetching every participant.
1003
+ const failedCount = allParticipants.filter((t) => t.status.toLowerCase() === "failed").length;
1004
+ const participantRows = allParticipants.map((t) => ({
947
1005
  alias: t.alias || null,
948
1006
  name: t.name,
949
1007
  iteration: t.iterationLabel,
@@ -961,33 +1019,33 @@ function buildStudyResultsEnvelope(study) {
961
1019
  ...(inconsistency && { status_inferred: inconsistency.inferred }),
962
1020
  modality: study.modality || null,
963
1021
  },
964
- tester_count: allTesters.length,
1022
+ participant_count: allParticipants.length,
965
1023
  completed_count: completedCount,
966
1024
  failed_count: failedCount,
967
1025
  sentiment,
968
1026
  interview_answers: interviewAnswers,
969
- testers: testerRows,
1027
+ participants: participantRows,
970
1028
  };
971
1029
  }
972
- export function formatStudyResults(study, json) {
973
- const inconsistency = detectStudyStatusInconsistency(study);
1030
+ export function formatStudyResults(study, participants, json) {
1031
+ const inconsistency = detectStudyStatusInconsistency(study, participants);
974
1032
  if (inconsistency)
975
1033
  emitStatusInconsistencyWarning(inconsistency);
976
1034
  if (json) {
977
1035
  // preProjected: bypass leanJson so the stable envelope keeps documented
978
1036
  // empty defaults (sentiment: null, interview_answers[].answers: []) rather
979
1037
  // than having them stripped by the lean transform.
980
- console.log(jsonOutput(buildStudyResultsEnvelope(study), { preProjected: true }));
1038
+ console.log(jsonOutput(buildStudyResultsEnvelope(study, participants), { preProjected: true }));
981
1039
  return;
982
1040
  }
983
- const allTesters = collectTesters(study);
984
- const totalInteractions = allTesters.reduce((sum, t) => sum + t.interactionCount, 0);
1041
+ const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
1042
+ const totalInteractions = allParticipants.reduce((sum, t) => sum + t.interactionCount, 0);
985
1043
  // Header
986
1044
  console.log(`${study.name || "Untitled"} — Results`);
987
- console.log(`${allTesters.length} tester${allTesters.length !== 1 ? "s" : ""} · ${totalInteractions} total interactions`);
1045
+ console.log(`${allParticipants.length} participant${allParticipants.length !== 1 ? "s" : ""} · ${totalInteractions} total interactions`);
988
1046
  // Sentiment summary
989
1047
  const totalSentiment = {};
990
- for (const t of allTesters) {
1048
+ for (const t of allParticipants) {
991
1049
  for (const [label, count] of Object.entries(t.sentimentCounts)) {
992
1050
  totalSentiment[label] = (totalSentiment[label] || 0) + count;
993
1051
  }
@@ -1002,7 +1060,7 @@ export function formatStudyResults(study, json) {
1002
1060
  const qObj = q;
1003
1061
  const typeStr = formatQuestionType(qObj);
1004
1062
  console.log(`\n "${qObj.question}" ${typeStr}`);
1005
- for (const t of allTesters) {
1063
+ for (const t of allParticipants) {
1006
1064
  const answer = t.interviewAnswers.find((a) => a.questionId === qObj.id);
1007
1065
  if (answer) {
1008
1066
  const answerStr = typeof answer.answer === "string"
@@ -1013,13 +1071,13 @@ export function formatStudyResults(study, json) {
1013
1071
  }
1014
1072
  }
1015
1073
  }
1016
- // Testers table
1017
- if (allTesters.length > 0) {
1018
- console.log("\nTesters:");
1019
- printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS", "SENTIMENT"], allTesters.map((t) => {
1074
+ // Participants table
1075
+ if (allParticipants.length > 0) {
1076
+ console.log("\nParticipants:");
1077
+ printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS", "SENTIMENT"], allParticipants.map((t) => {
1020
1078
  const parts = Object.entries(t.sentimentCounts).map(([label, count]) => `${count} ${label.toLowerCase()}`);
1021
1079
  return [
1022
- t.id ? deterministicAlias(ALIAS_PREFIX.tester, t.id) : t.id,
1080
+ t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
1023
1081
  t.name,
1024
1082
  t.iterationLabel,
1025
1083
  t.status,
@@ -1028,41 +1086,41 @@ export function formatStudyResults(study, json) {
1028
1086
  ];
1029
1087
  }));
1030
1088
  // Pattern B2: list any failure reasons under the table so agents see why
1031
- // a run failed without drilling into `study tester <id>`.
1032
- const failedRows = allTesters.filter((t) => t.status.toLowerCase() === "failed" && t.errorMessage);
1089
+ // a run failed without drilling into `study participant <id>`.
1090
+ const failedRows = allParticipants.filter((t) => t.status.toLowerCase() === "failed" && t.errorMessage);
1033
1091
  if (failedRows.length > 0) {
1034
- console.log("\nFailed testers:");
1092
+ console.log("\nFailed participants:");
1035
1093
  for (const t of failedRows) {
1036
- const alias = t.id ? deterministicAlias(ALIAS_PREFIX.tester, t.id) : t.id;
1094
+ const alias = t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id;
1037
1095
  console.log(` ${alias} (${t.name}): ${truncate(t.errorMessage, 200)}`);
1038
1096
  }
1039
1097
  }
1040
- console.log("\nRun `ish tester get <id> --json` for full interaction details.");
1098
+ console.log("\nRun `ish participant get <id> --json` for full interaction details.");
1041
1099
  }
1042
1100
  }
1043
1101
  /**
1044
- * `study results --summary` projection. Drops interview_answers + per-tester
1102
+ * `study results --summary` projection. Drops interview_answers + per-participant
1045
1103
  * interaction breakdowns; keeps headline counters, sentiment histogram, and a
1046
- * per-tester {alias, status, sentiment, comment} row. Useful for agents that
1104
+ * per-participant {alias, status, sentiment, comment} row. Useful for agents that
1047
1105
  * need to branch on outcome without paying for the full envelope.
1048
1106
  */
1049
- export function buildStudyResultsSummary(study) {
1050
- const allTesters = collectTesters(study);
1107
+ export function buildStudyResultsSummary(study, participants) {
1108
+ const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
1051
1109
  const studyAlias = study.id
1052
1110
  ? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
1053
1111
  : null;
1054
- const completedCount = allTesters.filter((t) => t.status === "completed" || t.status === "complete").length;
1055
- const failedCount = allTesters.filter((t) => t.status.toLowerCase() === "failed").length;
1112
+ const completedCount = allParticipants.filter((t) => t.status === "completed" || t.status === "complete").length;
1113
+ const failedCount = allParticipants.filter((t) => t.status.toLowerCase() === "failed").length;
1056
1114
  const sentimentCounts = {};
1057
1115
  let sentimentTotal = 0;
1058
- for (const t of allTesters) {
1116
+ for (const t of allParticipants) {
1059
1117
  for (const [label, count] of Object.entries(t.sentimentCounts)) {
1060
1118
  sentimentCounts[label] = (sentimentCounts[label] || 0) + count;
1061
1119
  sentimentTotal += count;
1062
1120
  }
1063
1121
  }
1064
1122
  const sentiment = sentimentTotal > 0 ? { counts: sentimentCounts, total: sentimentTotal } : null;
1065
- const testers = allTesters.map((t) => ({
1123
+ const participantRows = allParticipants.map((t) => ({
1066
1124
  alias: t.alias || null,
1067
1125
  name: t.name,
1068
1126
  status: t.status,
@@ -1076,27 +1134,27 @@ export function buildStudyResultsSummary(study) {
1076
1134
  name: study.name || null,
1077
1135
  modality: study.modality || null,
1078
1136
  },
1079
- tester_count: allTesters.length,
1137
+ participant_count: allParticipants.length,
1080
1138
  completed_count: completedCount,
1081
1139
  failed_count: failedCount,
1082
1140
  sentiment,
1083
- testers,
1141
+ participants: participantRows,
1084
1142
  };
1085
1143
  }
1086
1144
  /**
1087
- * `study results --transcript <tester_id>` projection. Mirrors the schema
1145
+ * `study results --transcript <participant_id>` projection. Mirrors the schema
1088
1146
  * MCP's `get_chat_transcript` returns (`src/ish_mcp/projections.py:
1089
1147
  * build_chat_transcript`) so callers see the same shape regardless of
1090
- * surface. Tester turns whose action carries no text (e.g. select_option)
1148
+ * surface. Participant turns whose action carries no text (e.g. select_option)
1091
1149
  * surface `text: null`; intent lives on `action_type` + `option_label`.
1092
1150
  * Bot turns with a `bot_reply.failure` block surface `failure` and
1093
1151
  * `text: null` and don't count toward `unique_bot_replies`.
1094
1152
  */
1095
- export function buildChatTranscript(tester) {
1096
- const id = String(tester.id || "");
1097
- const alias = id ? deterministicAlias(ALIAS_PREFIX.tester, id) : null;
1098
- const profile = tester.tester_profile;
1099
- const interactions = Array.isArray(tester.interactions) ? tester.interactions : [];
1153
+ export function buildChatTranscript(participant) {
1154
+ const id = String(participant.id || "");
1155
+ const alias = id ? deterministicAlias(ALIAS_PREFIX.participant, id) : null;
1156
+ const profile = participant.person;
1157
+ const interactions = Array.isArray(participant.interactions) ? participant.interactions : [];
1100
1158
  // Sort by timestamp then created_at so agent doesn't need to re-sort.
1101
1159
  const sorted = [...interactions].sort((a, b) => {
1102
1160
  const aIx = a;
@@ -1115,14 +1173,14 @@ export function buildChatTranscript(tester) {
1115
1173
  const uniqueBotReplies = new Set();
1116
1174
  for (const interaction of sorted) {
1117
1175
  const ix = interaction;
1118
- // Tester turn — derive role/action from the interaction itself.
1176
+ // Participant turn — derive role/action from the interaction itself.
1119
1177
  // Backend shape (post a880eba rename):
1120
1178
  // ix.actor in {"ai", "human", "user"} (user is the actual end-user;
1121
1179
  // we don't surface those in the transcript).
1122
1180
  // ix.actions: [{action_type, data: {type, turn_index, text?, wire_text?,
1123
1181
  // option_label?, said_instead?, ...}}]
1124
1182
  // ix.bot_reply: {text?, failure?}
1125
- // The tester's actual message text is nested under `action.data` —
1183
+ // The participant's actual message text is nested under `action.data` —
1126
1184
  // earlier versions of this builder read off the action top-level
1127
1185
  // (`action.text`, `action.type`), which silently produced
1128
1186
  // `text: null` on every turn (PC-C3 finding #3).
@@ -1157,7 +1215,7 @@ export function buildChatTranscript(tester) {
1157
1215
  }
1158
1216
  const actor = String(ix.actor ?? ix.interaction_type ?? "");
1159
1217
  if (actor === "ai" || actor === "human") {
1160
- // Resolve the tester's literal text from action.data, preferring
1218
+ // Resolve the participant's literal text from action.data, preferring
1161
1219
  // the canonical wire_text the backend exposes for every action
1162
1220
  // shape (send_text, select_option, ignore_offered, …) so the
1163
1221
  // transcript carries the actual content on every turn — D2.
@@ -1188,7 +1246,7 @@ export function buildChatTranscript(tester) {
1188
1246
  : null));
1189
1247
  const sentimentObj = ix.sentiment;
1190
1248
  transcript.push({
1191
- role: "tester",
1249
+ role: "participant",
1192
1250
  text,
1193
1251
  turn_index: turnIndex,
1194
1252
  action_type: actionType,
@@ -1197,15 +1255,15 @@ export function buildChatTranscript(tester) {
1197
1255
  });
1198
1256
  }
1199
1257
  }
1200
- const summary = tester.tester_summary;
1258
+ const summary = participant.participant_summary;
1201
1259
  return {
1202
- tester_id: id || null,
1203
- tester_alias: alias,
1204
- instance_name: tester.instance_name ?? null,
1260
+ participant_id: id || null,
1261
+ participant_alias: alias,
1262
+ instance_name: participant.instance_name ?? null,
1205
1263
  modality: "chat",
1206
1264
  transcript,
1207
1265
  unique_bot_replies: uniqueBotReplies.size,
1208
- tester_summary: summary
1266
+ participant_summary: summary
1209
1267
  ? {
1210
1268
  comment: summary.comment ?? null,
1211
1269
  sentiment: summary.sentiment ?? null,
@@ -1215,78 +1273,81 @@ export function buildChatTranscript(tester) {
1215
1273
  };
1216
1274
  }
1217
1275
  /**
1218
- * `study tester --summary` projection. Drops the action timeline; keeps the
1276
+ * `study participant --summary` projection. Drops the action timeline; keeps the
1219
1277
  * headline (alias, status, sentiment, comment, error_message). Useful for
1220
- * the common "did this tester finish, what did they say" check that's
1278
+ * the common "did this participant finish, what did they say" check that's
1221
1279
  * currently buried under the full interactions array.
1222
1280
  */
1223
- export function buildTesterSummary(tester) {
1224
- const id = String(tester.id || "");
1225
- const alias = id ? deterministicAlias(ALIAS_PREFIX.tester, id) : null;
1226
- const profile = tester.tester_profile;
1227
- const summary = tester.tester_summary;
1281
+ export function buildParticipantSummary(participant) {
1282
+ const id = String(participant.id || "");
1283
+ const alias = id ? deterministicAlias(ALIAS_PREFIX.participant, id) : null;
1284
+ const profile = participant.person;
1285
+ const summary = participant.participant_summary;
1228
1286
  const summarySentiment = summary?.sentiment;
1229
- const interactions = Array.isArray(tester.interactions) ? tester.interactions : [];
1287
+ const interactions = Array.isArray(participant.interactions) ? participant.interactions : [];
1230
1288
  const out = {
1231
- tester: {
1289
+ participant: {
1232
1290
  alias,
1233
- name: profile?.name ?? tester.instance_name ?? null,
1234
- iteration_id: tester.iteration_id ?? null,
1235
- status: tester.status ?? null,
1291
+ name: profile?.name ?? participant.instance_name ?? null,
1292
+ iteration_id: participant.iteration_id ?? null,
1293
+ status: participant.status ?? null,
1236
1294
  },
1237
1295
  interaction_count: interactions.length,
1238
1296
  sentiment: summarySentiment?.label ?? null,
1239
1297
  comment: summary?.comment ?? null,
1240
1298
  };
1241
- if (tester.error_message)
1242
- out.error_message = String(tester.error_message);
1243
- if (tester.error_kind)
1244
- out.error_kind = String(tester.error_kind);
1299
+ if (participant.error_message)
1300
+ out.error_message = String(participant.error_message);
1301
+ if (participant.error_kind)
1302
+ out.error_kind = String(participant.error_kind);
1245
1303
  return out;
1246
1304
  }
1247
- function collectTesters(study) {
1248
- const iterations = Array.isArray(study.iterations) ? study.iterations : [];
1249
- const testers = [];
1250
- for (const iter of iterations) {
1251
- const it = iter;
1252
- const iterLabel = String(it.label || it.name || "-");
1253
- const iterTesters = Array.isArray(it.testers) ? it.testers : [];
1254
- for (const tester of iterTesters) {
1255
- const t = tester;
1256
- const profile = t.tester_profile;
1257
- const interactions = Array.isArray(t.interactions) ? t.interactions : [];
1258
- const sentimentCounts = {};
1259
- for (const interaction of interactions) {
1260
- const ix = interaction;
1261
- const sentiment = ix.sentiment;
1262
- if (sentiment?.label) {
1263
- const label = String(sentiment.label);
1264
- sentimentCounts[label] = (sentimentCounts[label] || 0) + 1;
1265
- }
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;
1266
1325
  }
1267
- const answers = Array.isArray(t.interview_answers) ? t.interview_answers : [];
1268
- const summary = t.tester_summary;
1269
- const summarySentimentObj = summary?.sentiment;
1270
- const id = String(t.id || "");
1271
- testers.push({
1272
- id,
1273
- name: String(profile?.name || t.instance_name || "Unknown"),
1274
- alias: id ? deterministicAlias(ALIAS_PREFIX.tester, id) : "",
1275
- iterationLabel: iterLabel,
1276
- status: String(t.status || "-"),
1277
- errorMessage: t.error_message ? String(t.error_message) : null,
1278
- interactionCount: interactions.length,
1279
- sentimentCounts,
1280
- summarySentiment: summarySentimentObj?.label ? String(summarySentimentObj.label) : null,
1281
- summaryComment: summary?.comment ? String(summary.comment) : null,
1282
- interviewAnswers: answers.map((a) => ({
1283
- questionId: String(a.question_id || ""),
1284
- answer: a.answer,
1285
- })),
1286
- });
1287
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
+ });
1288
1349
  }
1289
- return testers;
1350
+ return rows;
1290
1351
  }
1291
1352
  function formatQuestionType(q) {
1292
1353
  if (!q.type)
@@ -1314,26 +1375,26 @@ export function formatIterationList(iterations, json) {
1314
1375
  return;
1315
1376
  }
1316
1377
  const aliasMap = getAliasMap(ALIAS_PREFIX.iteration);
1317
- printTable(["#", "LABEL", "NAME", "TESTERS", "CREATED"], iterations.map((it) => {
1318
- const testers = Array.isArray(it.testers) ? it.testers.length : 0;
1378
+ printTable(["#", "LABEL", "NAME", "PARTICIPANTS", "CREATED"], iterations.map((it) => {
1379
+ const participants = Array.isArray(it.participants) ? it.participants.length : 0;
1319
1380
  return [
1320
1381
  aliasMap.get(String(it.id)) || String(it.id || ""),
1321
1382
  String(it.label || "-"),
1322
1383
  String(it.name || ""),
1323
- String(testers),
1384
+ String(participants),
1324
1385
  formatDate(it.created_at),
1325
1386
  ];
1326
1387
  }));
1327
1388
  }
1328
- // --- Tester formatting ---
1329
- export function formatTesterDetail(tester, json) {
1389
+ // --- Participant formatting ---
1390
+ export function formatParticipantDetail(participant, json) {
1330
1391
  if (json) {
1331
- console.log(jsonOutput(tester));
1392
+ console.log(jsonOutput(participant));
1332
1393
  return;
1333
1394
  }
1334
- const profile = tester.tester_profile;
1395
+ const profile = participant.person;
1335
1396
  const profileName = profile?.name ? String(profile.name) : "Unknown";
1336
- const interactions = Array.isArray(tester.interactions) ? tester.interactions : [];
1397
+ const interactions = Array.isArray(participant.interactions) ? participant.interactions : [];
1337
1398
  // Count sentiments
1338
1399
  const sentimentCounts = {};
1339
1400
  for (const interaction of interactions) {
@@ -1345,17 +1406,17 @@ export function formatTesterDetail(tester, json) {
1345
1406
  }
1346
1407
  }
1347
1408
  const sentimentParts = Object.entries(sentimentCounts).map(([label, count]) => `${count} ${label.toLowerCase()}`);
1348
- const status = String(tester.status || "-");
1349
- const errorMessage = tester.error_message ? String(tester.error_message) : null;
1409
+ const status = String(participant.status || "-");
1410
+ const errorMessage = participant.error_message ? String(participant.error_message) : null;
1350
1411
  const display = {
1351
- ID: tester.id || "-",
1412
+ ID: participant.id || "-",
1352
1413
  Profile: profileName,
1353
1414
  Status: status,
1354
1415
  ...(errorMessage && status.toLowerCase() === "failed" && {
1355
1416
  Error: errorMessage,
1356
1417
  }),
1357
- Platform: tester.platform || "-",
1358
- Language: tester.language || "-",
1418
+ Platform: participant.platform || "-",
1419
+ Language: participant.language || "-",
1359
1420
  Interactions: `${interactions.length} interactions`,
1360
1421
  ...(sentimentParts.length > 0 && {
1361
1422
  Sentiment: sentimentParts.join(", "),
@@ -1363,8 +1424,8 @@ export function formatTesterDetail(tester, json) {
1363
1424
  };
1364
1425
  printKeyValue(display);
1365
1426
  }
1366
- // --- Tester Profile formatting ---
1367
- export function formatTesterProfileList(profiles, json, limit) {
1427
+ // --- Participant Profile formatting ---
1428
+ export function formatPersonList(profiles, json, limit) {
1368
1429
  // The API may return { items: [...], total, limit, offset } or a flat array.
1369
1430
  const wrapper = profiles;
1370
1431
  const wasWrapper = !Array.isArray(profiles)
@@ -1377,7 +1438,7 @@ export function formatTesterProfileList(profiles, json, limit) {
1377
1438
  : [];
1378
1439
  // Client-side limit (server may not enforce it)
1379
1440
  const list = limit ? fullList.slice(0, limit) : fullList;
1380
- injectAliases(list, ALIAS_PREFIX.testerProfile);
1441
+ injectAliases(list, ALIAS_PREFIX.person);
1381
1442
  if (json) {
1382
1443
  // Pass through server-provided pagination when present; otherwise synthesize.
1383
1444
  const existing = wasWrapper ? wrapper : undefined;
@@ -1385,7 +1446,7 @@ export function formatTesterProfileList(profiles, json, limit) {
1385
1446
  return;
1386
1447
  }
1387
1448
  if (list.length === 0) {
1388
- console.log("No tester profiles.");
1449
+ console.log("No participant profiles.");
1389
1450
  return;
1390
1451
  }
1391
1452
  printTable(["#", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
@@ -1400,31 +1461,35 @@ export function formatTesterProfileList(profiles, json, limit) {
1400
1461
  console.log(`\n Showing ${list.length} of ${fullList.length} profiles. Use --limit and --offset for more.`);
1401
1462
  }
1402
1463
  }
1403
- // --- Audience source formatting ---
1404
- export function formatAudienceSource(source, json) {
1405
- if (source.id) {
1406
- source.alias = deterministicAlias(ALIAS_PREFIX.testerProfileSource, String(source.id));
1464
+ // --- Participant attachment formatting ---
1465
+ //
1466
+ // `formatAttachment` is the canonical printer. `formatPersonSource` is
1467
+ // an alias for callers that reference the person-source name.
1468
+ export function formatAttachment(attachment, json) {
1469
+ if (attachment.id) {
1470
+ attachment.alias = deterministicAlias(ALIAS_PREFIX.personSource, String(attachment.id));
1407
1471
  }
1408
1472
  if (json) {
1409
- console.log(jsonOutput(source));
1473
+ console.log(jsonOutput(attachment));
1410
1474
  return;
1411
1475
  }
1412
1476
  const display = {
1413
- Alias: source.alias || source.id || "-",
1414
- File: source.original_filename || "-",
1415
- Kind: source.kind || "-",
1416
- Status: source.status || "-",
1417
- "Content-Type": source.content_type || "-",
1477
+ Alias: attachment.alias || attachment.id || "-",
1478
+ File: attachment.file_name || attachment.original_filename || "-",
1479
+ Kind: attachment.kind || "-",
1480
+ Status: attachment.status || "-",
1481
+ "Content-Type": attachment.content_type || "-",
1418
1482
  };
1419
- if (source.extracted_text_length != null) {
1420
- display["Text length"] = source.extracted_text_length;
1483
+ if (attachment.extracted_text_length != null) {
1484
+ display["Text length"] = attachment.extracted_text_length;
1421
1485
  }
1422
- if (source.error) {
1423
- display.Error = source.error;
1486
+ if (attachment.error) {
1487
+ display.Error = attachment.error;
1424
1488
  }
1425
1489
  printKeyValue(display);
1426
1490
  }
1427
- // --- Generated profile list (returned by /tester-profiles/generate) ---
1491
+ export const formatPersonSource = formatAttachment;
1492
+ // --- Generated profile list (returned by /people/generate) ---
1428
1493
  export function formatGeneratedProfileList(profiles, json) {
1429
1494
  const list = Array.isArray(profiles) ? profiles : [];
1430
1495
  if (list.length === 0) {
@@ -1434,7 +1499,7 @@ export function formatGeneratedProfileList(profiles, json) {
1434
1499
  console.log("No profiles generated.");
1435
1500
  return;
1436
1501
  }
1437
- injectAliases(list, ALIAS_PREFIX.testerProfile);
1502
+ injectAliases(list, ALIAS_PREFIX.person);
1438
1503
  if (json) {
1439
1504
  console.log(jsonOutput(list));
1440
1505
  return;
@@ -1457,26 +1522,26 @@ export function formatSimulationPoll(results, json, isMedia = false) {
1457
1522
  console.log("No simulations found.");
1458
1523
  return;
1459
1524
  }
1460
- injectAliases(results, ALIAS_PREFIX.tester);
1525
+ injectAliases(results, ALIAS_PREFIX.participant);
1461
1526
  if (json) {
1462
1527
  console.log(jsonOutput(results));
1463
1528
  return;
1464
1529
  }
1465
- const aliasMap = getAliasMap(ALIAS_PREFIX.tester);
1530
+ const aliasMap = getAliasMap(ALIAS_PREFIX.participant);
1466
1531
  const countHeader = isMedia ? "SEGMENTS" : "INTERACTIONS";
1467
- printTable(["#", "TESTER", "STATUS", countHeader], results.map((r) => {
1468
- const id = String(r.id || r.tester_id || "");
1532
+ printTable(["#", "PARTICIPANT", "STATUS", countHeader], results.map((r) => {
1533
+ const id = String(r.id || r.participant_id || "");
1469
1534
  return [
1470
1535
  aliasMap.get(id) || id,
1471
- String(r.tester_name || "Unknown"),
1536
+ String(r.participant_name || "Unknown"),
1472
1537
  String(r.status || "UNKNOWN"),
1473
1538
  String(r.interaction_count ?? "0"),
1474
1539
  ];
1475
1540
  }));
1476
- // Pattern A (cli half): list per-tester error_message under the table so
1477
- // agents see why a simulation failed without re-fetching every tester.
1541
+ // Pattern A (cli half): list per-participant error_message under the table so
1542
+ // agents see why a simulation failed without re-fetching every participant.
1478
1543
  // Truncate to 200 chars; full text is available via --json or
1479
- // `ish study tester get <id>`.
1544
+ // `ish study participant get <id>`.
1480
1545
  const failedRows = results.filter((r) => {
1481
1546
  const status = String(r.status || "").toLowerCase();
1482
1547
  return (status === "failed" || status === "errored") && r.error_message;
@@ -1484,9 +1549,9 @@ export function formatSimulationPoll(results, json, isMedia = false) {
1484
1549
  if (failedRows.length > 0) {
1485
1550
  console.log("\nFailed simulations:");
1486
1551
  for (const r of failedRows) {
1487
- const id = String(r.id || r.tester_id || "");
1552
+ const id = String(r.id || r.participant_id || "");
1488
1553
  const alias = aliasMap.get(id) || id;
1489
- const name = String(r.tester_name || "Unknown");
1554
+ const name = String(r.participant_name || "Unknown");
1490
1555
  console.log(` ${alias} (${name}): ${truncate(String(r.error_message), 200)}`);
1491
1556
  }
1492
1557
  }
@@ -1509,11 +1574,11 @@ export function formatAskList(asks, json) {
1509
1574
  return;
1510
1575
  }
1511
1576
  const aliasMap = getAliasMap(ALIAS_PREFIX.ask);
1512
- printTable(["#", "NAME", "STATUS", "AUDIENCE", "ROUNDS", "LAST ROUND", "ARCHIVED"], asks.map((a) => [
1577
+ printTable(["#", "NAME", "STATUS", "PARTICIPANTS", "ROUNDS", "LAST ROUND", "ARCHIVED"], asks.map((a) => [
1513
1578
  aliasMap.get(String(a.id)) || String(a.id || ""),
1514
1579
  String(a.name || ""),
1515
1580
  String(a.status || "-"),
1516
- String(a.audience_count ?? "0"),
1581
+ String(a.participant_count ?? "0"),
1517
1582
  String(a.round_count ?? "0"),
1518
1583
  formatDate(a.last_round_at),
1519
1584
  a.is_archived ? "yes" : "no",
@@ -1550,7 +1615,7 @@ function denormalizeRoundCounts(round) {
1550
1615
  * Layer denormalized counts onto an ask detail so agents reading
1551
1616
  * `ask get`, `ask create --wait`, `ask run --wait`, etc. don't need to
1552
1617
  * count nested arrays:
1553
- * - testers_count: ask.testers.length
1618
+ * - participants_count: ask.participants.length
1554
1619
  * - responses_total: sum across rounds (only when > 0)
1555
1620
  * - responses_complete: sum across rounds
1556
1621
  * - responses_errored: sum across rounds (only when > 0)
@@ -1558,9 +1623,9 @@ function denormalizeRoundCounts(round) {
1558
1623
  */
1559
1624
  function denormalizeAskCounts(ask) {
1560
1625
  const enriched = { ...ask };
1561
- const testers = Array.isArray(ask.testers) ? ask.testers : null;
1562
- if (testers)
1563
- enriched.testers_count = testers.length;
1626
+ const participants = Array.isArray(ask.participants) ? ask.participants : null;
1627
+ if (participants)
1628
+ enriched.participants_count = participants.length;
1564
1629
  const rounds = Array.isArray(ask.rounds) ? ask.rounds : null;
1565
1630
  if (rounds) {
1566
1631
  let total = 0;
@@ -1597,22 +1662,22 @@ export function formatAskDetail(ask, json) {
1597
1662
  meta.push("archived");
1598
1663
  meta.push(formatDate(ask.created_at));
1599
1664
  console.log(meta.join(" · "));
1600
- const testers = Array.isArray(ask.testers) ? ask.testers : [];
1601
- console.log(`\nAudience (${testers.length}):`);
1602
- if (testers.length > 0) {
1603
- const rows = testers.slice(0, 20).map((t) => {
1665
+ const participants = Array.isArray(ask.participants) ? ask.participants : [];
1666
+ console.log(`\nParticipants (${participants.length}):`);
1667
+ if (participants.length > 0) {
1668
+ const rows = participants.slice(0, 20).map((t) => {
1604
1669
  const obj = t;
1605
- const profile = obj.tester_profile;
1670
+ const profile = obj.person;
1606
1671
  const name = String(profile?.name || obj.instance_name || "Unknown");
1607
1672
  return [
1608
- obj.id ? deterministicAlias(ALIAS_PREFIX.tester, String(obj.id)) : "-",
1673
+ obj.id ? deterministicAlias(ALIAS_PREFIX.participant, String(obj.id)) : "-",
1609
1674
  name,
1610
1675
  String(obj.status || "-"),
1611
1676
  ];
1612
1677
  });
1613
1678
  printTable(["#", "NAME", "STATUS"], rows);
1614
- if (testers.length > 20)
1615
- console.log(` … and ${testers.length - 20} more`);
1679
+ if (participants.length > 20)
1680
+ console.log(` … and ${participants.length - 20} more`);
1616
1681
  }
1617
1682
  const rounds = Array.isArray(ask.rounds) ? ask.rounds : [];
1618
1683
  if (rounds.length > 0) {
@@ -1709,18 +1774,18 @@ function computeVariantStats(round) {
1709
1774
  const ERROR_RATE_REFUSE_THRESHOLD = 0.5;
1710
1775
  const N_HIGH_CONFIDENCE_FLOOR = 10;
1711
1776
  const N_MEDIUM_CONFIDENCE_FLOOR = 3;
1712
- // When tester_profile and tester_profile_snapshot share all overlapping fields
1777
+ // When person and person_snapshot share all overlapping fields
1713
1778
  // (the common case — snapshot only diverges if the profile was edited after
1714
1779
  // dispatch), drop the redundant content from the snapshot and keep only the
1715
- // snapshot-specific metadata. Saves ~500-1000 bytes per tester in JSON output.
1716
- function dedupeTesterSnapshot(tester) {
1717
- const tp = tester.tester_profile;
1718
- const tps = tester.tester_profile_snapshot;
1780
+ // snapshot-specific metadata. Saves ~500-1000 bytes per participant in JSON output.
1781
+ function dedupeParticipantSnapshot(participant) {
1782
+ const tp = participant.person;
1783
+ const tps = participant.person_snapshot;
1719
1784
  if (!tp || !tps)
1720
- return tester;
1785
+ return participant;
1721
1786
  const shared = Object.keys(tps).filter((k) => k in tp);
1722
1787
  if (shared.length === 0)
1723
- return tester;
1788
+ return participant;
1724
1789
  const isEmpty = (v) => {
1725
1790
  if (v === null || v === undefined)
1726
1791
  return true;
@@ -1738,15 +1803,15 @@ function dedupeTesterSnapshot(tester) {
1738
1803
  return JSON.stringify(a) === JSON.stringify(b);
1739
1804
  });
1740
1805
  if (!allMatch)
1741
- return tester;
1806
+ return participant;
1742
1807
  const snapshotOnly = {};
1743
1808
  for (const k of Object.keys(tps)) {
1744
1809
  if (!(k in tp))
1745
1810
  snapshotOnly[k] = tps[k];
1746
1811
  }
1747
1812
  return {
1748
- ...tester,
1749
- tester_profile_snapshot: { ...snapshotOnly, _matches_tester_profile: true },
1813
+ ...participant,
1814
+ person_snapshot: { ...snapshotOnly, _matches_person: true },
1750
1815
  };
1751
1816
  }
1752
1817
  // Shape per-variant stats into a machine-readable aggregates object so agents
@@ -1796,11 +1861,11 @@ function buildAggregates(round, stats) {
1796
1861
  }
1797
1862
  out.picks = picks;
1798
1863
  // Pattern H: pick_buckets keyed by variant id (the value `add_ask_round
1799
- // --subset-variant` accepts) → tester_ids that picked it. Pre-seed with
1864
+ // --subset-variant` accepts) → participant_ids that picked it. Pre-seed with
1800
1865
  // every declared variant id so unpicked variants surface as empty
1801
1866
  // arrays. Only completed responses with a resolvable variant_pick_id
1802
1867
  // contribute; an errored response carrying a stale variant_pick_id
1803
- // would otherwise drag a tester into a drill-in audience whose pick
1868
+ // would otherwise drag a participant into a drill-in subset whose pick
1804
1869
  // we can't trust.
1805
1870
  const variants = Array.isArray(round.variants) ? round.variants : [];
1806
1871
  const variantIdSet = new Set();
@@ -1818,7 +1883,7 @@ function buildAggregates(round, stats) {
1818
1883
  if (resp.status !== "completed")
1819
1884
  continue;
1820
1885
  const vpid = resp.variant_pick_id;
1821
- const tid = resp.tester_id;
1886
+ const tid = resp.participant_id;
1822
1887
  if (typeof vpid === "string"
1823
1888
  && variantIdSet.has(vpid)
1824
1889
  && typeof tid === "string"
@@ -1831,7 +1896,7 @@ function buildAggregates(round, stats) {
1831
1896
  if (topCount > 0) {
1832
1897
  // Refuse the winner when more than half of dispatched responses errored.
1833
1898
  // Calling A or B with a 4/5 failure rate would mislead the agent into
1834
- // treating one tester's pick as a verdict.
1899
+ // treating one participant's pick as a verdict.
1835
1900
  if (dispatchedTotal > 0
1836
1901
  && erroredTotal / dispatchedTotal > ERROR_RATE_REFUSE_THRESHOLD) {
1837
1902
  out.winner = {
@@ -1879,7 +1944,7 @@ function buildAggregates(round, stats) {
1879
1944
  * - medium: 3 <= n < 10 (small sample but clean)
1880
1945
  * - high: n >= 10 AND no errored responses AND not tied
1881
1946
  *
1882
- * Tuned for the typical 5-tester ask: a clean 5/5 lands at "medium" (you
1947
+ * Tuned for the typical 5-participant ask: a clean 5/5 lands at "medium" (you
1883
1948
  * can probably trust the lean), 1/5 with no errors lands at "low" (you
1884
1949
  * need more data), 5/5 with a tie lands at "low" (no winner to call).
1885
1950
  */
@@ -1949,15 +2014,15 @@ export function formatAskResults(ask, json, roundFilter) {
1949
2014
  errored += decorated.responses_errored ?? 0;
1950
2015
  return aggregates ? { ...decorated, aggregates } : decorated;
1951
2016
  });
1952
- const testers = Array.isArray(ask.testers) ? ask.testers : undefined;
1953
- const dedupedTesters = testers
1954
- ? testers.map((t) => dedupeTesterSnapshot(t))
2017
+ const participants = Array.isArray(ask.participants) ? ask.participants : undefined;
2018
+ const dedupedParticipants = participants
2019
+ ? participants.map((t) => dedupeParticipantSnapshot(t))
1955
2020
  : undefined;
1956
2021
  const payload = { ...ask, rounds: enrichedRounds };
1957
- if (dedupedTesters)
1958
- payload.testers = dedupedTesters;
1959
- if (testers)
1960
- payload.testers_count = testers.length;
2022
+ if (dedupedParticipants)
2023
+ payload.participants = dedupedParticipants;
2024
+ if (participants)
2025
+ payload.participants_count = participants.length;
1961
2026
  if (total > 0) {
1962
2027
  payload.responses_total = total;
1963
2028
  payload.responses_complete = complete;
@@ -2015,8 +2080,8 @@ export function formatAskResults(ask, json, roundFilter) {
2015
2080
  for (const r of completed.slice(0, 10)) {
2016
2081
  const resp = r;
2017
2082
  const comment = resp.comment ? truncate(String(resp.comment), 120) : "-";
2018
- const tester = resp.tester_id ? deterministicAlias(ALIAS_PREFIX.tester, String(resp.tester_id)) : "-";
2019
- console.log(` ${tester}: ${comment}`);
2083
+ const participant = resp.participant_id ? deterministicAlias(ALIAS_PREFIX.participant, String(resp.participant_id)) : "-";
2084
+ console.log(` ${participant}: ${comment}`);
2020
2085
  }
2021
2086
  if (completed.length > 10) {
2022
2087
  console.log(` … and ${completed.length - 10} more`);