@ishlabs/cli 0.17.7 → 0.18.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 (62) 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 +295 -271
  20. package/dist/commands/study.js +89 -66
  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 +560 -386
  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 +13 -12
  51. package/dist/lib/output.js +244 -184
  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 +215 -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/types.d.ts +105 -34
  60. package/package.json +1 -1
  61. package/dist/commands/profile.d.ts +0 -5
  62. 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`).
@@ -809,14 +810,14 @@ export function formatStudyList(studies, json) {
809
810
  function detectStudyStatusInconsistency(study) {
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(study);
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,6 +825,60 @@ 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
  }
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
+ }
827
882
  export function formatStudyDetail(study, json, options = {}) {
828
883
  const inconsistency = detectStudyStatusInconsistency(study);
829
884
  if (inconsistency)
@@ -860,6 +915,7 @@ export function formatStudyDetail(study, json, options = {}) {
860
915
  if (a.instructions) {
861
916
  console.log(` ${truncate(String(a.instructions), 80)}`);
862
917
  }
918
+ renderAssignmentSteps(a);
863
919
  }
864
920
  }
865
921
  // Interview Questions
@@ -872,12 +928,12 @@ export function formatStudyDetail(study, json, options = {}) {
872
928
  console.log(` ${i + 1}. ${q.question || "Untitled"} ${typeStr}`);
873
929
  }
874
930
  }
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,
931
+ // Participants summary
932
+ const allParticipants = collectParticipants(study);
933
+ if (allParticipants.length > 0) {
934
+ console.log(`\nParticipants (${allParticipants.length}):`);
935
+ printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS"], allParticipants.map((t) => [
936
+ t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
881
937
  t.name,
882
938
  t.iterationLabel,
883
939
  t.status,
@@ -891,15 +947,15 @@ export function formatStudyDetail(study, json, options = {}) {
891
947
  * Agents can rely on the keys always being present (M4).
892
948
  */
893
949
  function buildStudyResultsEnvelope(study) {
894
- const allTesters = collectTesters(study);
950
+ const allParticipants = collectParticipants(study);
895
951
  const studyAlias = study.id
896
952
  ? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
897
953
  : null;
898
- const completedCount = allTesters.filter((t) => t.status === "completed" || t.status === "complete").length;
899
- // Aggregate sentiment across all interactions on all testers.
954
+ const completedCount = allParticipants.filter((t) => t.status === "completed" || t.status === "complete").length;
955
+ // Aggregate sentiment across all interactions on all participants.
900
956
  const sentimentCounts = {};
901
957
  let sentimentTotal = 0;
902
- for (const t of allTesters) {
958
+ for (const t of allParticipants) {
903
959
  for (const [label, count] of Object.entries(t.sentimentCounts)) {
904
960
  sentimentCounts[label] = (sentimentCounts[label] || 0) + count;
905
961
  sentimentTotal += count;
@@ -912,18 +968,18 @@ function buildStudyResultsEnvelope(study) {
912
968
  }
913
969
  : null;
914
970
  // 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.
971
+ // includes the participant's session-level `sentiment` (M10) so agents can read
972
+ // sentiment per answer without round-tripping `study participant <id>` per row.
917
973
  const questions = Array.isArray(study.interview_questions) ? study.interview_questions : [];
918
974
  const interviewAnswers = questions.map((q) => {
919
975
  const qObj = q;
920
976
  const answers = [];
921
- for (const t of allTesters) {
977
+ for (const t of allParticipants) {
922
978
  const a = t.interviewAnswers.find((x) => x.questionId === qObj.id);
923
979
  if (a) {
924
980
  answers.push({
925
- tester_alias: t.alias || null,
926
- tester_name: t.name,
981
+ participant_alias: t.alias || null,
982
+ participant_name: t.name,
927
983
  iteration: t.iterationLabel,
928
984
  answer: a.answer,
929
985
  sentiment: t.summarySentiment,
@@ -940,10 +996,10 @@ function buildStudyResultsEnvelope(study) {
940
996
  // field when the backend reports failed-with-data; agents can branch on
941
997
  // either the original status or status_inferred.
942
998
  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) => ({
999
+ // Pattern B2 (cli half): per-participant rows expose status + error_message so
1000
+ // agents can act on a failed run without re-fetching every participant.
1001
+ const failedCount = allParticipants.filter((t) => t.status.toLowerCase() === "failed").length;
1002
+ const participantRows = allParticipants.map((t) => ({
947
1003
  alias: t.alias || null,
948
1004
  name: t.name,
949
1005
  iteration: t.iterationLabel,
@@ -961,12 +1017,12 @@ function buildStudyResultsEnvelope(study) {
961
1017
  ...(inconsistency && { status_inferred: inconsistency.inferred }),
962
1018
  modality: study.modality || null,
963
1019
  },
964
- tester_count: allTesters.length,
1020
+ participant_count: allParticipants.length,
965
1021
  completed_count: completedCount,
966
1022
  failed_count: failedCount,
967
1023
  sentiment,
968
1024
  interview_answers: interviewAnswers,
969
- testers: testerRows,
1025
+ participants: participantRows,
970
1026
  };
971
1027
  }
972
1028
  export function formatStudyResults(study, json) {
@@ -980,14 +1036,14 @@ export function formatStudyResults(study, json) {
980
1036
  console.log(jsonOutput(buildStudyResultsEnvelope(study), { preProjected: true }));
981
1037
  return;
982
1038
  }
983
- const allTesters = collectTesters(study);
984
- const totalInteractions = allTesters.reduce((sum, t) => sum + t.interactionCount, 0);
1039
+ const allParticipants = collectParticipants(study);
1040
+ const totalInteractions = allParticipants.reduce((sum, t) => sum + t.interactionCount, 0);
985
1041
  // Header
986
1042
  console.log(`${study.name || "Untitled"} — Results`);
987
- console.log(`${allTesters.length} tester${allTesters.length !== 1 ? "s" : ""} · ${totalInteractions} total interactions`);
1043
+ console.log(`${allParticipants.length} participant${allParticipants.length !== 1 ? "s" : ""} · ${totalInteractions} total interactions`);
988
1044
  // Sentiment summary
989
1045
  const totalSentiment = {};
990
- for (const t of allTesters) {
1046
+ for (const t of allParticipants) {
991
1047
  for (const [label, count] of Object.entries(t.sentimentCounts)) {
992
1048
  totalSentiment[label] = (totalSentiment[label] || 0) + count;
993
1049
  }
@@ -1002,7 +1058,7 @@ export function formatStudyResults(study, json) {
1002
1058
  const qObj = q;
1003
1059
  const typeStr = formatQuestionType(qObj);
1004
1060
  console.log(`\n "${qObj.question}" ${typeStr}`);
1005
- for (const t of allTesters) {
1061
+ for (const t of allParticipants) {
1006
1062
  const answer = t.interviewAnswers.find((a) => a.questionId === qObj.id);
1007
1063
  if (answer) {
1008
1064
  const answerStr = typeof answer.answer === "string"
@@ -1013,13 +1069,13 @@ export function formatStudyResults(study, json) {
1013
1069
  }
1014
1070
  }
1015
1071
  }
1016
- // Testers table
1017
- if (allTesters.length > 0) {
1018
- console.log("\nTesters:");
1019
- printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS", "SENTIMENT"], allTesters.map((t) => {
1072
+ // Participants table
1073
+ if (allParticipants.length > 0) {
1074
+ console.log("\nParticipants:");
1075
+ printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS", "SENTIMENT"], allParticipants.map((t) => {
1020
1076
  const parts = Object.entries(t.sentimentCounts).map(([label, count]) => `${count} ${label.toLowerCase()}`);
1021
1077
  return [
1022
- t.id ? deterministicAlias(ALIAS_PREFIX.tester, t.id) : t.id,
1078
+ t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
1023
1079
  t.name,
1024
1080
  t.iterationLabel,
1025
1081
  t.status,
@@ -1028,41 +1084,41 @@ export function formatStudyResults(study, json) {
1028
1084
  ];
1029
1085
  }));
1030
1086
  // 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);
1087
+ // a run failed without drilling into `study participant <id>`.
1088
+ const failedRows = allParticipants.filter((t) => t.status.toLowerCase() === "failed" && t.errorMessage);
1033
1089
  if (failedRows.length > 0) {
1034
- console.log("\nFailed testers:");
1090
+ console.log("\nFailed participants:");
1035
1091
  for (const t of failedRows) {
1036
- const alias = t.id ? deterministicAlias(ALIAS_PREFIX.tester, t.id) : t.id;
1092
+ const alias = t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id;
1037
1093
  console.log(` ${alias} (${t.name}): ${truncate(t.errorMessage, 200)}`);
1038
1094
  }
1039
1095
  }
1040
- console.log("\nRun `ish tester get <id> --json` for full interaction details.");
1096
+ console.log("\nRun `ish participant get <id> --json` for full interaction details.");
1041
1097
  }
1042
1098
  }
1043
1099
  /**
1044
- * `study results --summary` projection. Drops interview_answers + per-tester
1100
+ * `study results --summary` projection. Drops interview_answers + per-participant
1045
1101
  * interaction breakdowns; keeps headline counters, sentiment histogram, and a
1046
- * per-tester {alias, status, sentiment, comment} row. Useful for agents that
1102
+ * per-participant {alias, status, sentiment, comment} row. Useful for agents that
1047
1103
  * need to branch on outcome without paying for the full envelope.
1048
1104
  */
1049
1105
  export function buildStudyResultsSummary(study) {
1050
- const allTesters = collectTesters(study);
1106
+ const allParticipants = collectParticipants(study);
1051
1107
  const studyAlias = study.id
1052
1108
  ? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
1053
1109
  : 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;
1110
+ const completedCount = allParticipants.filter((t) => t.status === "completed" || t.status === "complete").length;
1111
+ const failedCount = allParticipants.filter((t) => t.status.toLowerCase() === "failed").length;
1056
1112
  const sentimentCounts = {};
1057
1113
  let sentimentTotal = 0;
1058
- for (const t of allTesters) {
1114
+ for (const t of allParticipants) {
1059
1115
  for (const [label, count] of Object.entries(t.sentimentCounts)) {
1060
1116
  sentimentCounts[label] = (sentimentCounts[label] || 0) + count;
1061
1117
  sentimentTotal += count;
1062
1118
  }
1063
1119
  }
1064
1120
  const sentiment = sentimentTotal > 0 ? { counts: sentimentCounts, total: sentimentTotal } : null;
1065
- const testers = allTesters.map((t) => ({
1121
+ const participants = allParticipants.map((t) => ({
1066
1122
  alias: t.alias || null,
1067
1123
  name: t.name,
1068
1124
  status: t.status,
@@ -1076,27 +1132,27 @@ export function buildStudyResultsSummary(study) {
1076
1132
  name: study.name || null,
1077
1133
  modality: study.modality || null,
1078
1134
  },
1079
- tester_count: allTesters.length,
1135
+ participant_count: allParticipants.length,
1080
1136
  completed_count: completedCount,
1081
1137
  failed_count: failedCount,
1082
1138
  sentiment,
1083
- testers,
1139
+ participants,
1084
1140
  };
1085
1141
  }
1086
1142
  /**
1087
- * `study results --transcript <tester_id>` projection. Mirrors the schema
1143
+ * `study results --transcript <participant_id>` projection. Mirrors the schema
1088
1144
  * MCP's `get_chat_transcript` returns (`src/ish_mcp/projections.py:
1089
1145
  * build_chat_transcript`) so callers see the same shape regardless of
1090
- * surface. Tester turns whose action carries no text (e.g. select_option)
1146
+ * surface. Participant turns whose action carries no text (e.g. select_option)
1091
1147
  * surface `text: null`; intent lives on `action_type` + `option_label`.
1092
1148
  * Bot turns with a `bot_reply.failure` block surface `failure` and
1093
1149
  * `text: null` and don't count toward `unique_bot_replies`.
1094
1150
  */
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 : [];
1151
+ export function buildChatTranscript(participant) {
1152
+ const id = String(participant.id || "");
1153
+ const alias = id ? deterministicAlias(ALIAS_PREFIX.participant, id) : null;
1154
+ const profile = participant.person;
1155
+ const interactions = Array.isArray(participant.interactions) ? participant.interactions : [];
1100
1156
  // Sort by timestamp then created_at so agent doesn't need to re-sort.
1101
1157
  const sorted = [...interactions].sort((a, b) => {
1102
1158
  const aIx = a;
@@ -1115,14 +1171,14 @@ export function buildChatTranscript(tester) {
1115
1171
  const uniqueBotReplies = new Set();
1116
1172
  for (const interaction of sorted) {
1117
1173
  const ix = interaction;
1118
- // Tester turn — derive role/action from the interaction itself.
1174
+ // Participant turn — derive role/action from the interaction itself.
1119
1175
  // Backend shape (post a880eba rename):
1120
1176
  // ix.actor in {"ai", "human", "user"} (user is the actual end-user;
1121
1177
  // we don't surface those in the transcript).
1122
1178
  // ix.actions: [{action_type, data: {type, turn_index, text?, wire_text?,
1123
1179
  // option_label?, said_instead?, ...}}]
1124
1180
  // ix.bot_reply: {text?, failure?}
1125
- // The tester's actual message text is nested under `action.data` —
1181
+ // The participant's actual message text is nested under `action.data` —
1126
1182
  // earlier versions of this builder read off the action top-level
1127
1183
  // (`action.text`, `action.type`), which silently produced
1128
1184
  // `text: null` on every turn (PC-C3 finding #3).
@@ -1157,7 +1213,7 @@ export function buildChatTranscript(tester) {
1157
1213
  }
1158
1214
  const actor = String(ix.actor ?? ix.interaction_type ?? "");
1159
1215
  if (actor === "ai" || actor === "human") {
1160
- // Resolve the tester's literal text from action.data, preferring
1216
+ // Resolve the participant's literal text from action.data, preferring
1161
1217
  // the canonical wire_text the backend exposes for every action
1162
1218
  // shape (send_text, select_option, ignore_offered, …) so the
1163
1219
  // transcript carries the actual content on every turn — D2.
@@ -1188,7 +1244,7 @@ export function buildChatTranscript(tester) {
1188
1244
  : null));
1189
1245
  const sentimentObj = ix.sentiment;
1190
1246
  transcript.push({
1191
- role: "tester",
1247
+ role: "participant",
1192
1248
  text,
1193
1249
  turn_index: turnIndex,
1194
1250
  action_type: actionType,
@@ -1197,15 +1253,15 @@ export function buildChatTranscript(tester) {
1197
1253
  });
1198
1254
  }
1199
1255
  }
1200
- const summary = tester.tester_summary;
1256
+ const summary = participant.participant_summary;
1201
1257
  return {
1202
- tester_id: id || null,
1203
- tester_alias: alias,
1204
- instance_name: tester.instance_name ?? null,
1258
+ participant_id: id || null,
1259
+ participant_alias: alias,
1260
+ instance_name: participant.instance_name ?? null,
1205
1261
  modality: "chat",
1206
1262
  transcript,
1207
1263
  unique_bot_replies: uniqueBotReplies.size,
1208
- tester_summary: summary
1264
+ participant_summary: summary
1209
1265
  ? {
1210
1266
  comment: summary.comment ?? null,
1211
1267
  sentiment: summary.sentiment ?? null,
@@ -1215,45 +1271,45 @@ export function buildChatTranscript(tester) {
1215
1271
  };
1216
1272
  }
1217
1273
  /**
1218
- * `study tester --summary` projection. Drops the action timeline; keeps the
1274
+ * `study participant --summary` projection. Drops the action timeline; keeps the
1219
1275
  * headline (alias, status, sentiment, comment, error_message). Useful for
1220
- * the common "did this tester finish, what did they say" check that's
1276
+ * the common "did this participant finish, what did they say" check that's
1221
1277
  * currently buried under the full interactions array.
1222
1278
  */
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;
1279
+ export function buildParticipantSummary(participant) {
1280
+ const id = String(participant.id || "");
1281
+ const alias = id ? deterministicAlias(ALIAS_PREFIX.participant, id) : null;
1282
+ const profile = participant.person;
1283
+ const summary = participant.participant_summary;
1228
1284
  const summarySentiment = summary?.sentiment;
1229
- const interactions = Array.isArray(tester.interactions) ? tester.interactions : [];
1285
+ const interactions = Array.isArray(participant.interactions) ? participant.interactions : [];
1230
1286
  const out = {
1231
- tester: {
1287
+ participant: {
1232
1288
  alias,
1233
- name: profile?.name ?? tester.instance_name ?? null,
1234
- iteration_id: tester.iteration_id ?? null,
1235
- status: tester.status ?? null,
1289
+ name: profile?.name ?? participant.instance_name ?? null,
1290
+ iteration_id: participant.iteration_id ?? null,
1291
+ status: participant.status ?? null,
1236
1292
  },
1237
1293
  interaction_count: interactions.length,
1238
1294
  sentiment: summarySentiment?.label ?? null,
1239
1295
  comment: summary?.comment ?? null,
1240
1296
  };
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);
1297
+ if (participant.error_message)
1298
+ out.error_message = String(participant.error_message);
1299
+ if (participant.error_kind)
1300
+ out.error_kind = String(participant.error_kind);
1245
1301
  return out;
1246
1302
  }
1247
- function collectTesters(study) {
1303
+ function collectParticipants(study) {
1248
1304
  const iterations = Array.isArray(study.iterations) ? study.iterations : [];
1249
- const testers = [];
1305
+ const participants = [];
1250
1306
  for (const iter of iterations) {
1251
1307
  const it = iter;
1252
1308
  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;
1309
+ const iterParticipants = Array.isArray(it.participants) ? it.participants : [];
1310
+ for (const participant of iterParticipants) {
1311
+ const t = participant;
1312
+ const profile = t.person;
1257
1313
  const interactions = Array.isArray(t.interactions) ? t.interactions : [];
1258
1314
  const sentimentCounts = {};
1259
1315
  for (const interaction of interactions) {
@@ -1265,13 +1321,13 @@ function collectTesters(study) {
1265
1321
  }
1266
1322
  }
1267
1323
  const answers = Array.isArray(t.interview_answers) ? t.interview_answers : [];
1268
- const summary = t.tester_summary;
1324
+ const summary = t.participant_summary;
1269
1325
  const summarySentimentObj = summary?.sentiment;
1270
1326
  const id = String(t.id || "");
1271
- testers.push({
1327
+ participants.push({
1272
1328
  id,
1273
1329
  name: String(profile?.name || t.instance_name || "Unknown"),
1274
- alias: id ? deterministicAlias(ALIAS_PREFIX.tester, id) : "",
1330
+ alias: id ? deterministicAlias(ALIAS_PREFIX.participant, id) : "",
1275
1331
  iterationLabel: iterLabel,
1276
1332
  status: String(t.status || "-"),
1277
1333
  errorMessage: t.error_message ? String(t.error_message) : null,
@@ -1286,7 +1342,7 @@ function collectTesters(study) {
1286
1342
  });
1287
1343
  }
1288
1344
  }
1289
- return testers;
1345
+ return participants;
1290
1346
  }
1291
1347
  function formatQuestionType(q) {
1292
1348
  if (!q.type)
@@ -1314,26 +1370,26 @@ export function formatIterationList(iterations, json) {
1314
1370
  return;
1315
1371
  }
1316
1372
  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;
1373
+ printTable(["#", "LABEL", "NAME", "PARTICIPANTS", "CREATED"], iterations.map((it) => {
1374
+ const participants = Array.isArray(it.participants) ? it.participants.length : 0;
1319
1375
  return [
1320
1376
  aliasMap.get(String(it.id)) || String(it.id || ""),
1321
1377
  String(it.label || "-"),
1322
1378
  String(it.name || ""),
1323
- String(testers),
1379
+ String(participants),
1324
1380
  formatDate(it.created_at),
1325
1381
  ];
1326
1382
  }));
1327
1383
  }
1328
- // --- Tester formatting ---
1329
- export function formatTesterDetail(tester, json) {
1384
+ // --- Participant formatting ---
1385
+ export function formatParticipantDetail(participant, json) {
1330
1386
  if (json) {
1331
- console.log(jsonOutput(tester));
1387
+ console.log(jsonOutput(participant));
1332
1388
  return;
1333
1389
  }
1334
- const profile = tester.tester_profile;
1390
+ const profile = participant.person;
1335
1391
  const profileName = profile?.name ? String(profile.name) : "Unknown";
1336
- const interactions = Array.isArray(tester.interactions) ? tester.interactions : [];
1392
+ const interactions = Array.isArray(participant.interactions) ? participant.interactions : [];
1337
1393
  // Count sentiments
1338
1394
  const sentimentCounts = {};
1339
1395
  for (const interaction of interactions) {
@@ -1345,17 +1401,17 @@ export function formatTesterDetail(tester, json) {
1345
1401
  }
1346
1402
  }
1347
1403
  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;
1404
+ const status = String(participant.status || "-");
1405
+ const errorMessage = participant.error_message ? String(participant.error_message) : null;
1350
1406
  const display = {
1351
- ID: tester.id || "-",
1407
+ ID: participant.id || "-",
1352
1408
  Profile: profileName,
1353
1409
  Status: status,
1354
1410
  ...(errorMessage && status.toLowerCase() === "failed" && {
1355
1411
  Error: errorMessage,
1356
1412
  }),
1357
- Platform: tester.platform || "-",
1358
- Language: tester.language || "-",
1413
+ Platform: participant.platform || "-",
1414
+ Language: participant.language || "-",
1359
1415
  Interactions: `${interactions.length} interactions`,
1360
1416
  ...(sentimentParts.length > 0 && {
1361
1417
  Sentiment: sentimentParts.join(", "),
@@ -1363,8 +1419,8 @@ export function formatTesterDetail(tester, json) {
1363
1419
  };
1364
1420
  printKeyValue(display);
1365
1421
  }
1366
- // --- Tester Profile formatting ---
1367
- export function formatTesterProfileList(profiles, json, limit) {
1422
+ // --- Participant Profile formatting ---
1423
+ export function formatPersonList(profiles, json, limit) {
1368
1424
  // The API may return { items: [...], total, limit, offset } or a flat array.
1369
1425
  const wrapper = profiles;
1370
1426
  const wasWrapper = !Array.isArray(profiles)
@@ -1377,7 +1433,7 @@ export function formatTesterProfileList(profiles, json, limit) {
1377
1433
  : [];
1378
1434
  // Client-side limit (server may not enforce it)
1379
1435
  const list = limit ? fullList.slice(0, limit) : fullList;
1380
- injectAliases(list, ALIAS_PREFIX.testerProfile);
1436
+ injectAliases(list, ALIAS_PREFIX.person);
1381
1437
  if (json) {
1382
1438
  // Pass through server-provided pagination when present; otherwise synthesize.
1383
1439
  const existing = wasWrapper ? wrapper : undefined;
@@ -1385,7 +1441,7 @@ export function formatTesterProfileList(profiles, json, limit) {
1385
1441
  return;
1386
1442
  }
1387
1443
  if (list.length === 0) {
1388
- console.log("No tester profiles.");
1444
+ console.log("No participant profiles.");
1389
1445
  return;
1390
1446
  }
1391
1447
  printTable(["#", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
@@ -1400,31 +1456,35 @@ export function formatTesterProfileList(profiles, json, limit) {
1400
1456
  console.log(`\n Showing ${list.length} of ${fullList.length} profiles. Use --limit and --offset for more.`);
1401
1457
  }
1402
1458
  }
1403
- // --- Audience source formatting ---
1404
- export function formatAudienceSource(source, json) {
1405
- if (source.id) {
1406
- source.alias = deterministicAlias(ALIAS_PREFIX.testerProfileSource, String(source.id));
1459
+ // --- Participant attachment formatting ---
1460
+ //
1461
+ // `formatAttachment` is the canonical printer. `formatPersonSource` is
1462
+ // an alias for callers that reference the person-source name.
1463
+ export function formatAttachment(attachment, json) {
1464
+ if (attachment.id) {
1465
+ attachment.alias = deterministicAlias(ALIAS_PREFIX.personSource, String(attachment.id));
1407
1466
  }
1408
1467
  if (json) {
1409
- console.log(jsonOutput(source));
1468
+ console.log(jsonOutput(attachment));
1410
1469
  return;
1411
1470
  }
1412
1471
  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 || "-",
1472
+ Alias: attachment.alias || attachment.id || "-",
1473
+ File: attachment.file_name || attachment.original_filename || "-",
1474
+ Kind: attachment.kind || "-",
1475
+ Status: attachment.status || "-",
1476
+ "Content-Type": attachment.content_type || "-",
1418
1477
  };
1419
- if (source.extracted_text_length != null) {
1420
- display["Text length"] = source.extracted_text_length;
1478
+ if (attachment.extracted_text_length != null) {
1479
+ display["Text length"] = attachment.extracted_text_length;
1421
1480
  }
1422
- if (source.error) {
1423
- display.Error = source.error;
1481
+ if (attachment.error) {
1482
+ display.Error = attachment.error;
1424
1483
  }
1425
1484
  printKeyValue(display);
1426
1485
  }
1427
- // --- Generated profile list (returned by /tester-profiles/generate) ---
1486
+ export const formatPersonSource = formatAttachment;
1487
+ // --- Generated profile list (returned by /people/generate) ---
1428
1488
  export function formatGeneratedProfileList(profiles, json) {
1429
1489
  const list = Array.isArray(profiles) ? profiles : [];
1430
1490
  if (list.length === 0) {
@@ -1434,7 +1494,7 @@ export function formatGeneratedProfileList(profiles, json) {
1434
1494
  console.log("No profiles generated.");
1435
1495
  return;
1436
1496
  }
1437
- injectAliases(list, ALIAS_PREFIX.testerProfile);
1497
+ injectAliases(list, ALIAS_PREFIX.person);
1438
1498
  if (json) {
1439
1499
  console.log(jsonOutput(list));
1440
1500
  return;
@@ -1457,26 +1517,26 @@ export function formatSimulationPoll(results, json, isMedia = false) {
1457
1517
  console.log("No simulations found.");
1458
1518
  return;
1459
1519
  }
1460
- injectAliases(results, ALIAS_PREFIX.tester);
1520
+ injectAliases(results, ALIAS_PREFIX.participant);
1461
1521
  if (json) {
1462
1522
  console.log(jsonOutput(results));
1463
1523
  return;
1464
1524
  }
1465
- const aliasMap = getAliasMap(ALIAS_PREFIX.tester);
1525
+ const aliasMap = getAliasMap(ALIAS_PREFIX.participant);
1466
1526
  const countHeader = isMedia ? "SEGMENTS" : "INTERACTIONS";
1467
- printTable(["#", "TESTER", "STATUS", countHeader], results.map((r) => {
1468
- const id = String(r.id || r.tester_id || "");
1527
+ printTable(["#", "PARTICIPANT", "STATUS", countHeader], results.map((r) => {
1528
+ const id = String(r.id || r.participant_id || "");
1469
1529
  return [
1470
1530
  aliasMap.get(id) || id,
1471
- String(r.tester_name || "Unknown"),
1531
+ String(r.participant_name || "Unknown"),
1472
1532
  String(r.status || "UNKNOWN"),
1473
1533
  String(r.interaction_count ?? "0"),
1474
1534
  ];
1475
1535
  }));
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.
1536
+ // Pattern A (cli half): list per-participant error_message under the table so
1537
+ // agents see why a simulation failed without re-fetching every participant.
1478
1538
  // Truncate to 200 chars; full text is available via --json or
1479
- // `ish study tester get <id>`.
1539
+ // `ish study participant get <id>`.
1480
1540
  const failedRows = results.filter((r) => {
1481
1541
  const status = String(r.status || "").toLowerCase();
1482
1542
  return (status === "failed" || status === "errored") && r.error_message;
@@ -1484,9 +1544,9 @@ export function formatSimulationPoll(results, json, isMedia = false) {
1484
1544
  if (failedRows.length > 0) {
1485
1545
  console.log("\nFailed simulations:");
1486
1546
  for (const r of failedRows) {
1487
- const id = String(r.id || r.tester_id || "");
1547
+ const id = String(r.id || r.participant_id || "");
1488
1548
  const alias = aliasMap.get(id) || id;
1489
- const name = String(r.tester_name || "Unknown");
1549
+ const name = String(r.participant_name || "Unknown");
1490
1550
  console.log(` ${alias} (${name}): ${truncate(String(r.error_message), 200)}`);
1491
1551
  }
1492
1552
  }
@@ -1509,11 +1569,11 @@ export function formatAskList(asks, json) {
1509
1569
  return;
1510
1570
  }
1511
1571
  const aliasMap = getAliasMap(ALIAS_PREFIX.ask);
1512
- printTable(["#", "NAME", "STATUS", "AUDIENCE", "ROUNDS", "LAST ROUND", "ARCHIVED"], asks.map((a) => [
1572
+ printTable(["#", "NAME", "STATUS", "PARTICIPANTS", "ROUNDS", "LAST ROUND", "ARCHIVED"], asks.map((a) => [
1513
1573
  aliasMap.get(String(a.id)) || String(a.id || ""),
1514
1574
  String(a.name || ""),
1515
1575
  String(a.status || "-"),
1516
- String(a.audience_count ?? "0"),
1576
+ String(a.participant_count ?? "0"),
1517
1577
  String(a.round_count ?? "0"),
1518
1578
  formatDate(a.last_round_at),
1519
1579
  a.is_archived ? "yes" : "no",
@@ -1550,7 +1610,7 @@ function denormalizeRoundCounts(round) {
1550
1610
  * Layer denormalized counts onto an ask detail so agents reading
1551
1611
  * `ask get`, `ask create --wait`, `ask run --wait`, etc. don't need to
1552
1612
  * count nested arrays:
1553
- * - testers_count: ask.testers.length
1613
+ * - participants_count: ask.participants.length
1554
1614
  * - responses_total: sum across rounds (only when > 0)
1555
1615
  * - responses_complete: sum across rounds
1556
1616
  * - responses_errored: sum across rounds (only when > 0)
@@ -1558,9 +1618,9 @@ function denormalizeRoundCounts(round) {
1558
1618
  */
1559
1619
  function denormalizeAskCounts(ask) {
1560
1620
  const enriched = { ...ask };
1561
- const testers = Array.isArray(ask.testers) ? ask.testers : null;
1562
- if (testers)
1563
- enriched.testers_count = testers.length;
1621
+ const participants = Array.isArray(ask.participants) ? ask.participants : null;
1622
+ if (participants)
1623
+ enriched.participants_count = participants.length;
1564
1624
  const rounds = Array.isArray(ask.rounds) ? ask.rounds : null;
1565
1625
  if (rounds) {
1566
1626
  let total = 0;
@@ -1597,22 +1657,22 @@ export function formatAskDetail(ask, json) {
1597
1657
  meta.push("archived");
1598
1658
  meta.push(formatDate(ask.created_at));
1599
1659
  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) => {
1660
+ const participants = Array.isArray(ask.participants) ? ask.participants : [];
1661
+ console.log(`\nParticipants (${participants.length}):`);
1662
+ if (participants.length > 0) {
1663
+ const rows = participants.slice(0, 20).map((t) => {
1604
1664
  const obj = t;
1605
- const profile = obj.tester_profile;
1665
+ const profile = obj.person;
1606
1666
  const name = String(profile?.name || obj.instance_name || "Unknown");
1607
1667
  return [
1608
- obj.id ? deterministicAlias(ALIAS_PREFIX.tester, String(obj.id)) : "-",
1668
+ obj.id ? deterministicAlias(ALIAS_PREFIX.participant, String(obj.id)) : "-",
1609
1669
  name,
1610
1670
  String(obj.status || "-"),
1611
1671
  ];
1612
1672
  });
1613
1673
  printTable(["#", "NAME", "STATUS"], rows);
1614
- if (testers.length > 20)
1615
- console.log(` … and ${testers.length - 20} more`);
1674
+ if (participants.length > 20)
1675
+ console.log(` … and ${participants.length - 20} more`);
1616
1676
  }
1617
1677
  const rounds = Array.isArray(ask.rounds) ? ask.rounds : [];
1618
1678
  if (rounds.length > 0) {
@@ -1709,18 +1769,18 @@ function computeVariantStats(round) {
1709
1769
  const ERROR_RATE_REFUSE_THRESHOLD = 0.5;
1710
1770
  const N_HIGH_CONFIDENCE_FLOOR = 10;
1711
1771
  const N_MEDIUM_CONFIDENCE_FLOOR = 3;
1712
- // When tester_profile and tester_profile_snapshot share all overlapping fields
1772
+ // When person and person_snapshot share all overlapping fields
1713
1773
  // (the common case — snapshot only diverges if the profile was edited after
1714
1774
  // 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;
1775
+ // snapshot-specific metadata. Saves ~500-1000 bytes per participant in JSON output.
1776
+ function dedupeParticipantSnapshot(participant) {
1777
+ const tp = participant.person;
1778
+ const tps = participant.person_snapshot;
1719
1779
  if (!tp || !tps)
1720
- return tester;
1780
+ return participant;
1721
1781
  const shared = Object.keys(tps).filter((k) => k in tp);
1722
1782
  if (shared.length === 0)
1723
- return tester;
1783
+ return participant;
1724
1784
  const isEmpty = (v) => {
1725
1785
  if (v === null || v === undefined)
1726
1786
  return true;
@@ -1738,15 +1798,15 @@ function dedupeTesterSnapshot(tester) {
1738
1798
  return JSON.stringify(a) === JSON.stringify(b);
1739
1799
  });
1740
1800
  if (!allMatch)
1741
- return tester;
1801
+ return participant;
1742
1802
  const snapshotOnly = {};
1743
1803
  for (const k of Object.keys(tps)) {
1744
1804
  if (!(k in tp))
1745
1805
  snapshotOnly[k] = tps[k];
1746
1806
  }
1747
1807
  return {
1748
- ...tester,
1749
- tester_profile_snapshot: { ...snapshotOnly, _matches_tester_profile: true },
1808
+ ...participant,
1809
+ person_snapshot: { ...snapshotOnly, _matches_person: true },
1750
1810
  };
1751
1811
  }
1752
1812
  // Shape per-variant stats into a machine-readable aggregates object so agents
@@ -1796,11 +1856,11 @@ function buildAggregates(round, stats) {
1796
1856
  }
1797
1857
  out.picks = picks;
1798
1858
  // 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
1859
+ // --subset-variant` accepts) → participant_ids that picked it. Pre-seed with
1800
1860
  // every declared variant id so unpicked variants surface as empty
1801
1861
  // arrays. Only completed responses with a resolvable variant_pick_id
1802
1862
  // contribute; an errored response carrying a stale variant_pick_id
1803
- // would otherwise drag a tester into a drill-in audience whose pick
1863
+ // would otherwise drag a participant into a drill-in subset whose pick
1804
1864
  // we can't trust.
1805
1865
  const variants = Array.isArray(round.variants) ? round.variants : [];
1806
1866
  const variantIdSet = new Set();
@@ -1818,7 +1878,7 @@ function buildAggregates(round, stats) {
1818
1878
  if (resp.status !== "completed")
1819
1879
  continue;
1820
1880
  const vpid = resp.variant_pick_id;
1821
- const tid = resp.tester_id;
1881
+ const tid = resp.participant_id;
1822
1882
  if (typeof vpid === "string"
1823
1883
  && variantIdSet.has(vpid)
1824
1884
  && typeof tid === "string"
@@ -1831,7 +1891,7 @@ function buildAggregates(round, stats) {
1831
1891
  if (topCount > 0) {
1832
1892
  // Refuse the winner when more than half of dispatched responses errored.
1833
1893
  // Calling A or B with a 4/5 failure rate would mislead the agent into
1834
- // treating one tester's pick as a verdict.
1894
+ // treating one participant's pick as a verdict.
1835
1895
  if (dispatchedTotal > 0
1836
1896
  && erroredTotal / dispatchedTotal > ERROR_RATE_REFUSE_THRESHOLD) {
1837
1897
  out.winner = {
@@ -1879,7 +1939,7 @@ function buildAggregates(round, stats) {
1879
1939
  * - medium: 3 <= n < 10 (small sample but clean)
1880
1940
  * - high: n >= 10 AND no errored responses AND not tied
1881
1941
  *
1882
- * Tuned for the typical 5-tester ask: a clean 5/5 lands at "medium" (you
1942
+ * Tuned for the typical 5-participant ask: a clean 5/5 lands at "medium" (you
1883
1943
  * can probably trust the lean), 1/5 with no errors lands at "low" (you
1884
1944
  * need more data), 5/5 with a tie lands at "low" (no winner to call).
1885
1945
  */
@@ -1949,15 +2009,15 @@ export function formatAskResults(ask, json, roundFilter) {
1949
2009
  errored += decorated.responses_errored ?? 0;
1950
2010
  return aggregates ? { ...decorated, aggregates } : decorated;
1951
2011
  });
1952
- const testers = Array.isArray(ask.testers) ? ask.testers : undefined;
1953
- const dedupedTesters = testers
1954
- ? testers.map((t) => dedupeTesterSnapshot(t))
2012
+ const participants = Array.isArray(ask.participants) ? ask.participants : undefined;
2013
+ const dedupedParticipants = participants
2014
+ ? participants.map((t) => dedupeParticipantSnapshot(t))
1955
2015
  : undefined;
1956
2016
  const payload = { ...ask, rounds: enrichedRounds };
1957
- if (dedupedTesters)
1958
- payload.testers = dedupedTesters;
1959
- if (testers)
1960
- payload.testers_count = testers.length;
2017
+ if (dedupedParticipants)
2018
+ payload.participants = dedupedParticipants;
2019
+ if (participants)
2020
+ payload.participants_count = participants.length;
1961
2021
  if (total > 0) {
1962
2022
  payload.responses_total = total;
1963
2023
  payload.responses_complete = complete;
@@ -2015,8 +2075,8 @@ export function formatAskResults(ask, json, roundFilter) {
2015
2075
  for (const r of completed.slice(0, 10)) {
2016
2076
  const resp = r;
2017
2077
  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}`);
2078
+ const participant = resp.participant_id ? deterministicAlias(ALIAS_PREFIX.participant, String(resp.participant_id)) : "-";
2079
+ console.log(` ${participant}: ${comment}`);
2020
2080
  }
2021
2081
  if (completed.length > 10) {
2022
2082
  console.log(` … and ${completed.length - 10} more`);