@ishlabs/cli 0.18.0 → 0.20.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.
@@ -224,7 +224,11 @@ function wrapList(items, existing, opts = {}) {
224
224
  const limit = typeof existing?.limit === "number" ? existing.limit : returned;
225
225
  const offset = typeof existing?.offset === "number" ? existing.offset : 0;
226
226
  const has_more = total > offset + returned;
227
- const leanItems = _verbose || opts.preProjectedItems
227
+ // ISSUE-031: if the user explicitly named fields via --fields or --get,
228
+ // skip the per-item lean-strip so the requested fields actually survive
229
+ // to the output. Otherwise `--fields id,alias` silently drops `id`.
230
+ const userSpecifiedFields = (_fields && _fields.length > 0) || (typeof _getField === "string" && _getField.length > 0);
231
+ const leanItems = _verbose || opts.preProjectedItems || userSpecifiedFields
228
232
  ? items
229
233
  : leanJson(items) ?? [];
230
234
  return { items: leanItems, total, returned, limit, offset, has_more };
@@ -277,9 +281,16 @@ function pickFields(data, fields) {
277
281
  /** Serialize data as JSON, applying lean transform and field selection. */
278
282
  function jsonOutput(data, options = {}) {
279
283
  let out;
280
- if (_verbose || options.preProjected) {
281
- // Verbose: full payload. preProjected: caller already chose the fields,
282
- // so don't strip again (otherwise leanJson would drop e.g. created_at).
284
+ // ISSUE-031: when the user explicitly names fields via --fields or
285
+ // --get, their request takes precedence over the default lean-strip.
286
+ // Previously --fields id,alias would silently drop `id` because
287
+ // leanJson ran first and stripped UUID-valued fields. The user
288
+ // asking for a field is unambiguous intent — bypass the strip.
289
+ const userSpecifiedFields = (_fields && _fields.length > 0) || (typeof _getField === "string" && _getField.length > 0);
290
+ if (_verbose || options.preProjected || userSpecifiedFields) {
291
+ // Verbose: full payload. preProjected: caller already chose the fields.
292
+ // userSpecifiedFields: caller said exactly what they want — don't
293
+ // second-guess by stripping UUIDs they asked for.
283
294
  out = data;
284
295
  }
285
296
  else {
@@ -461,6 +472,29 @@ function suggestionsForError(err) {
461
472
  }
462
473
  return [];
463
474
  }
475
+ /**
476
+ * Pattern F (ISSUE-023): rewrite server-side internal entity names to the
477
+ * user-facing CLI surface names so messages like "Product not found" don't
478
+ * leak the backend's internal vocabulary to a first-time CLI user.
479
+ *
480
+ * Mapping reflects the rename audit: server still calls workspaces
481
+ * "products" and people "profiles" / "tester profiles" in some response
482
+ * messages; the CLI surface is `workspace` / `person`. Source is
483
+ * `attachment` server-side.
484
+ *
485
+ * Word-boundary anchored to avoid touching e.g. "productivity" or
486
+ * "Attachments must be ..." (the latter is genuinely about attachments).
487
+ */
488
+ function remapEntityName(message) {
489
+ return message
490
+ .replace(/\bProduct not found\b/g, "Workspace not found")
491
+ .replace(/\bproduct not found\b/g, "workspace not found")
492
+ .replace(/\bTester [Pp]rofile not found\b/g, (m) => m.includes("P") ? "Person not found" : "person not found")
493
+ .replace(/\bProfile not found\b/g, "Person not found")
494
+ .replace(/\bprofile not found\b/g, "person not found")
495
+ .replace(/\bAttachment not found\b/g, "Source not found")
496
+ .replace(/\battachment not found\b/g, "source not found");
497
+ }
464
498
  export function outputError(err, json) {
465
499
  const suggestions = suggestionsForError(err);
466
500
  if (err instanceof ApiError) {
@@ -505,7 +539,7 @@ export function outputError(err, json) {
505
539
  : undefined;
506
540
  if (json) {
507
541
  console.error(JSON.stringify({
508
- error: err.message,
542
+ error: remapEntityName(err.message),
509
543
  error_code: err.error_code,
510
544
  status: err.status,
511
545
  retryable: err.retryable,
@@ -527,7 +561,7 @@ export function outputError(err, json) {
527
561
  console.error("Error: Insufficient credits. Purchase more at https://app.ishlabs.io");
528
562
  }
529
563
  else {
530
- console.error(`Error: ${err.message}`);
564
+ console.error(`Error: ${remapEntityName(err.message)}`);
531
565
  }
532
566
  if (Array.isArray(bodyErrors)) {
533
567
  for (const entry of bodyErrors) {
@@ -549,6 +583,12 @@ export function outputError(err, json) {
549
583
  else if (err instanceof ValidationError) {
550
584
  if (json) {
551
585
  console.error(JSON.stringify({
586
+ // ValidationError is CLI-thrown (we control its message), so we
587
+ // don't apply remapEntityName — that helper exists to translate
588
+ // server-side internal vocabulary ("Product"/"Profile"/"Attachment")
589
+ // back to user-facing names. Applying it here risks mangling
590
+ // user-supplied content (e.g. a workspace name containing "Profile
591
+ // not found"). Restrict the remap to the ApiError branch.
552
592
  error: err.message,
553
593
  error_code: "validation_error",
554
594
  retryable: false,
@@ -597,6 +637,10 @@ export function outputError(err, json) {
597
637
  const mergedSuggestions = [...new Set([...suggestions, ...taggedSuggestions])];
598
638
  if (json) {
599
639
  console.error(JSON.stringify({
640
+ // Generic Error: CLI-thrown (we control the message), so we don't
641
+ // apply remapEntityName — see ValidationError branch above for the
642
+ // same reasoning. Server-side names only leak through the ApiError
643
+ // branch where remapEntityName IS applied.
600
644
  error: err.message,
601
645
  error_code: errorCode,
602
646
  retryable,
@@ -702,7 +746,7 @@ export function formatWorkspaceList(workspaces, json) {
702
746
  return;
703
747
  }
704
748
  const aliasMap = getAliasMap(ALIAS_PREFIX.workspace);
705
- printTable(["#", "NAME", "ROOM", "STUDIES", "ASKS", "PARTICIPANTS", "LAST ACTIVITY"], workspaces.map((w) => [
749
+ printTable(["ALIAS", "NAME", "ROOM", "STUDIES", "ASKS", "PARTICIPANTS", "LAST ACTIVITY"], workspaces.map((w) => [
706
750
  aliasMap.get(String(w.id)) || String(w.id || ""),
707
751
  String(w.name || ""),
708
752
  formatHeadroom(w.has_headroom),
@@ -784,7 +828,7 @@ export function formatStudyList(studies, json) {
784
828
  return;
785
829
  }
786
830
  const aliasMap = getAliasMap(ALIAS_PREFIX.study);
787
- printTable(["#", "NAME", "MODALITY", "TYPE", "STATUS", "PARTICIPANTS"], studies.map((s) => [
831
+ printTable(["ALIAS", "NAME", "MODALITY", "TYPE", "STATUS", "PARTICIPANTS"], studies.map((s) => [
788
832
  aliasMap.get(String(s.id)) || String(s.id || ""),
789
833
  String(s.name || ""),
790
834
  String(s.modality || "-"),
@@ -807,10 +851,10 @@ export function formatStudyList(studies, json) {
807
851
  *
808
852
  * Returns null when status is consistent; no warning emitted.
809
853
  */
810
- function detectStudyStatusInconsistency(study) {
854
+ function detectStudyStatusInconsistency(study, participants) {
811
855
  if (study.status !== "failed")
812
856
  return null;
813
- const allParticipants = collectParticipants(study);
857
+ const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
814
858
  const completedCount = allParticipants.filter((t) => t.status === "completed" || t.status === "complete").length;
815
859
  const totalInteractions = allParticipants.reduce((sum, t) => sum + t.interactionCount, 0);
816
860
  if (completedCount === 0 && totalInteractions === 0)
@@ -879,14 +923,16 @@ function renderAssignmentSteps(a) {
879
923
  }
880
924
  }
881
925
  }
882
- export function formatStudyDetail(study, json, options = {}) {
883
- const inconsistency = detectStudyStatusInconsistency(study);
926
+ export function formatStudyDetail(study, json, options = {}, participants) {
927
+ const inconsistency = detectStudyStatusInconsistency(study, participants);
884
928
  if (inconsistency)
885
929
  emitStatusInconsistencyWarning(inconsistency);
886
930
  if (json) {
887
- const payload = inconsistency
888
- ? { ...study, status_inferred: inconsistency.inferred }
889
- : study;
931
+ const payload = { ...study };
932
+ if (participants !== undefined)
933
+ payload.participants = participants;
934
+ if (inconsistency)
935
+ payload.status_inferred = inconsistency.inferred;
890
936
  console.log(jsonOutput(payload, options));
891
937
  return;
892
938
  }
@@ -929,10 +975,10 @@ export function formatStudyDetail(study, json, options = {}) {
929
975
  }
930
976
  }
931
977
  // Participants summary
932
- const allParticipants = collectParticipants(study);
978
+ const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
933
979
  if (allParticipants.length > 0) {
934
980
  console.log(`\nParticipants (${allParticipants.length}):`);
935
- printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS"], allParticipants.map((t) => [
981
+ printTable(["ALIAS", "NAME", "ITERATION", "STATUS", "INTERACTIONS"], allParticipants.map((t) => [
936
982
  t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
937
983
  t.name,
938
984
  t.iterationLabel,
@@ -946,8 +992,8 @@ export function formatStudyDetail(study, json, options = {}) {
946
992
  * study state — fields default to `null`, `0`, or `[]` when nothing has run.
947
993
  * Agents can rely on the keys always being present (M4).
948
994
  */
949
- function buildStudyResultsEnvelope(study) {
950
- const allParticipants = collectParticipants(study);
995
+ function buildStudyResultsEnvelope(study, participants) {
996
+ const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
951
997
  const studyAlias = study.id
952
998
  ? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
953
999
  : null;
@@ -995,7 +1041,7 @@ function buildStudyResultsEnvelope(study) {
995
1041
  // CLI-side sanity check (Pattern E / Issue #2). Surface a status_inferred
996
1042
  // field when the backend reports failed-with-data; agents can branch on
997
1043
  // either the original status or status_inferred.
998
- const inconsistency = detectStudyStatusInconsistency(study);
1044
+ const inconsistency = detectStudyStatusInconsistency(study, participants);
999
1045
  // Pattern B2 (cli half): per-participant rows expose status + error_message so
1000
1046
  // agents can act on a failed run without re-fetching every participant.
1001
1047
  const failedCount = allParticipants.filter((t) => t.status.toLowerCase() === "failed").length;
@@ -1025,18 +1071,18 @@ function buildStudyResultsEnvelope(study) {
1025
1071
  participants: participantRows,
1026
1072
  };
1027
1073
  }
1028
- export function formatStudyResults(study, json) {
1029
- const inconsistency = detectStudyStatusInconsistency(study);
1074
+ export function formatStudyResults(study, participants, json) {
1075
+ const inconsistency = detectStudyStatusInconsistency(study, participants);
1030
1076
  if (inconsistency)
1031
1077
  emitStatusInconsistencyWarning(inconsistency);
1032
1078
  if (json) {
1033
1079
  // preProjected: bypass leanJson so the stable envelope keeps documented
1034
1080
  // empty defaults (sentiment: null, interview_answers[].answers: []) rather
1035
1081
  // than having them stripped by the lean transform.
1036
- console.log(jsonOutput(buildStudyResultsEnvelope(study), { preProjected: true }));
1082
+ console.log(jsonOutput(buildStudyResultsEnvelope(study, participants), { preProjected: true }));
1037
1083
  return;
1038
1084
  }
1039
- const allParticipants = collectParticipants(study);
1085
+ const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
1040
1086
  const totalInteractions = allParticipants.reduce((sum, t) => sum + t.interactionCount, 0);
1041
1087
  // Header
1042
1088
  console.log(`${study.name || "Untitled"} — Results`);
@@ -1072,7 +1118,7 @@ export function formatStudyResults(study, json) {
1072
1118
  // Participants table
1073
1119
  if (allParticipants.length > 0) {
1074
1120
  console.log("\nParticipants:");
1075
- printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS", "SENTIMENT"], allParticipants.map((t) => {
1121
+ printTable(["ALIAS", "NAME", "ITERATION", "STATUS", "INTERACTIONS", "SENTIMENT"], allParticipants.map((t) => {
1076
1122
  const parts = Object.entries(t.sentimentCounts).map(([label, count]) => `${count} ${label.toLowerCase()}`);
1077
1123
  return [
1078
1124
  t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
@@ -1102,8 +1148,8 @@ export function formatStudyResults(study, json) {
1102
1148
  * per-participant {alias, status, sentiment, comment} row. Useful for agents that
1103
1149
  * need to branch on outcome without paying for the full envelope.
1104
1150
  */
1105
- export function buildStudyResultsSummary(study) {
1106
- const allParticipants = collectParticipants(study);
1151
+ export function buildStudyResultsSummary(study, participants) {
1152
+ const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
1107
1153
  const studyAlias = study.id
1108
1154
  ? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
1109
1155
  : null;
@@ -1118,7 +1164,7 @@ export function buildStudyResultsSummary(study) {
1118
1164
  }
1119
1165
  }
1120
1166
  const sentiment = sentimentTotal > 0 ? { counts: sentimentCounts, total: sentimentTotal } : null;
1121
- const participants = allParticipants.map((t) => ({
1167
+ const participantRows = allParticipants.map((t) => ({
1122
1168
  alias: t.alias || null,
1123
1169
  name: t.name,
1124
1170
  status: t.status,
@@ -1136,7 +1182,7 @@ export function buildStudyResultsSummary(study) {
1136
1182
  completed_count: completedCount,
1137
1183
  failed_count: failedCount,
1138
1184
  sentiment,
1139
- participants,
1185
+ participants: participantRows,
1140
1186
  };
1141
1187
  }
1142
1188
  /**
@@ -1300,49 +1346,52 @@ export function buildParticipantSummary(participant) {
1300
1346
  out.error_kind = String(participant.error_kind);
1301
1347
  return out;
1302
1348
  }
1303
- function collectParticipants(study) {
1304
- const iterations = Array.isArray(study.iterations) ? study.iterations : [];
1305
- const participants = [];
1306
- for (const iter of iterations) {
1307
- const it = iter;
1308
- const iterLabel = String(it.label || it.name || "-");
1309
- const iterParticipants = Array.isArray(it.participants) ? it.participants : [];
1310
- for (const participant of iterParticipants) {
1311
- const t = participant;
1312
- const profile = t.person;
1313
- const interactions = Array.isArray(t.interactions) ? t.interactions : [];
1314
- const sentimentCounts = {};
1315
- for (const interaction of interactions) {
1316
- const ix = interaction;
1317
- const sentiment = ix.sentiment;
1318
- if (sentiment?.label) {
1319
- const label = String(sentiment.label);
1320
- sentimentCounts[label] = (sentimentCounts[label] || 0) + 1;
1321
- }
1349
+ function collectParticipants(participants, iterations) {
1350
+ const iterationLabels = new Map();
1351
+ for (const iter of iterations ?? []) {
1352
+ const id = iter.id ? String(iter.id) : "";
1353
+ if (id) {
1354
+ iterationLabels.set(id, String(iter.label || iter.name || "-"));
1355
+ }
1356
+ }
1357
+ const rows = [];
1358
+ for (const participant of participants ?? []) {
1359
+ const t = participant;
1360
+ const profile = t.person;
1361
+ const interactions = Array.isArray(t.interactions) ? t.interactions : [];
1362
+ const sentimentCounts = {};
1363
+ for (const interaction of interactions) {
1364
+ const ix = interaction;
1365
+ const sentiment = ix.sentiment;
1366
+ if (sentiment?.label) {
1367
+ const label = String(sentiment.label);
1368
+ sentimentCounts[label] = (sentimentCounts[label] || 0) + 1;
1322
1369
  }
1323
- const answers = Array.isArray(t.interview_answers) ? t.interview_answers : [];
1324
- const summary = t.participant_summary;
1325
- const summarySentimentObj = summary?.sentiment;
1326
- const id = String(t.id || "");
1327
- participants.push({
1328
- id,
1329
- name: String(profile?.name || t.instance_name || "Unknown"),
1330
- alias: id ? deterministicAlias(ALIAS_PREFIX.participant, id) : "",
1331
- iterationLabel: iterLabel,
1332
- status: String(t.status || "-"),
1333
- errorMessage: t.error_message ? String(t.error_message) : null,
1334
- interactionCount: interactions.length,
1335
- sentimentCounts,
1336
- summarySentiment: summarySentimentObj?.label ? String(summarySentimentObj.label) : null,
1337
- summaryComment: summary?.comment ? String(summary.comment) : null,
1338
- interviewAnswers: answers.map((a) => ({
1339
- questionId: String(a.question_id || ""),
1340
- answer: a.answer,
1341
- })),
1342
- });
1343
1370
  }
1371
+ const answers = Array.isArray(t.interview_answers) ? t.interview_answers : [];
1372
+ const summary = t.participant_summary;
1373
+ const summarySentimentObj = summary?.sentiment;
1374
+ const id = String(t.id || "");
1375
+ const iterationId = t.iteration_id ? String(t.iteration_id) : "";
1376
+ const iterationLabel = (iterationId && iterationLabels.get(iterationId)) || "-";
1377
+ rows.push({
1378
+ id,
1379
+ name: String(profile?.name || t.instance_name || "Unknown"),
1380
+ alias: id ? deterministicAlias(ALIAS_PREFIX.participant, id) : "",
1381
+ iterationLabel,
1382
+ status: String(t.status || "-"),
1383
+ errorMessage: t.error_message ? String(t.error_message) : null,
1384
+ interactionCount: interactions.length,
1385
+ sentimentCounts,
1386
+ summarySentiment: summarySentimentObj?.label ? String(summarySentimentObj.label) : null,
1387
+ summaryComment: summary?.comment ? String(summary.comment) : null,
1388
+ interviewAnswers: answers.map((a) => ({
1389
+ questionId: String(a.question_id || ""),
1390
+ answer: a.answer,
1391
+ })),
1392
+ });
1344
1393
  }
1345
- return participants;
1394
+ return rows;
1346
1395
  }
1347
1396
  function formatQuestionType(q) {
1348
1397
  if (!q.type)
@@ -1370,7 +1419,7 @@ export function formatIterationList(iterations, json) {
1370
1419
  return;
1371
1420
  }
1372
1421
  const aliasMap = getAliasMap(ALIAS_PREFIX.iteration);
1373
- printTable(["#", "LABEL", "NAME", "PARTICIPANTS", "CREATED"], iterations.map((it) => {
1422
+ printTable(["ALIAS", "LABEL", "NAME", "PARTICIPANTS", "CREATED"], iterations.map((it) => {
1374
1423
  const participants = Array.isArray(it.participants) ? it.participants.length : 0;
1375
1424
  return [
1376
1425
  aliasMap.get(String(it.id)) || String(it.id || ""),
@@ -1444,7 +1493,7 @@ export function formatPersonList(profiles, json, limit) {
1444
1493
  console.log("No participant profiles.");
1445
1494
  return;
1446
1495
  }
1447
- printTable(["#", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
1496
+ printTable(["ALIAS", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
1448
1497
  String(p.alias || p.id || ""),
1449
1498
  String(p.name || ""),
1450
1499
  String(p.occupation || "-"),
@@ -1499,7 +1548,7 @@ export function formatGeneratedProfileList(profiles, json) {
1499
1548
  console.log(jsonOutput(list));
1500
1549
  return;
1501
1550
  }
1502
- printTable(["#", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
1551
+ printTable(["ALIAS", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
1503
1552
  String(p.alias || p.id || ""),
1504
1553
  String(p.name || ""),
1505
1554
  String(p.occupation || "-"),
@@ -1524,7 +1573,7 @@ export function formatSimulationPoll(results, json, isMedia = false) {
1524
1573
  }
1525
1574
  const aliasMap = getAliasMap(ALIAS_PREFIX.participant);
1526
1575
  const countHeader = isMedia ? "SEGMENTS" : "INTERACTIONS";
1527
- printTable(["#", "PARTICIPANT", "STATUS", countHeader], results.map((r) => {
1576
+ printTable(["ALIAS", "PARTICIPANT", "STATUS", countHeader], results.map((r) => {
1528
1577
  const id = String(r.id || r.participant_id || "");
1529
1578
  return [
1530
1579
  aliasMap.get(id) || id,
@@ -1569,7 +1618,7 @@ export function formatAskList(asks, json) {
1569
1618
  return;
1570
1619
  }
1571
1620
  const aliasMap = getAliasMap(ALIAS_PREFIX.ask);
1572
- printTable(["#", "NAME", "STATUS", "PARTICIPANTS", "ROUNDS", "LAST ROUND", "ARCHIVED"], asks.map((a) => [
1621
+ printTable(["ALIAS", "NAME", "STATUS", "PARTICIPANTS", "ROUNDS", "LAST ROUND", "ARCHIVED"], asks.map((a) => [
1573
1622
  aliasMap.get(String(a.id)) || String(a.id || ""),
1574
1623
  String(a.name || ""),
1575
1624
  String(a.status || "-"),
@@ -1670,7 +1719,7 @@ export function formatAskDetail(ask, json) {
1670
1719
  String(obj.status || "-"),
1671
1720
  ];
1672
1721
  });
1673
- printTable(["#", "NAME", "STATUS"], rows);
1722
+ printTable(["ALIAS", "NAME", "STATUS"], rows);
1674
1723
  if (participants.length > 20)
1675
1724
  console.log(` … and ${participants.length - 20} more`);
1676
1725
  }
@@ -2140,7 +2189,7 @@ export function formatConfigList(configs, json) {
2140
2189
  return;
2141
2190
  }
2142
2191
  const aliasMap = getAliasMap(ALIAS_PREFIX.config);
2143
- printTable(["#", "NAME", "SOURCE", "CREATED"], configs.map((c) => [
2192
+ printTable(["ALIAS", "NAME", "SOURCE", "CREATED"], configs.map((c) => [
2144
2193
  aliasMap.get(String(c.id)) || String(c.id || ""),
2145
2194
  String(c.name || ""),
2146
2195
  String(c.source_type || "manual"),
@@ -164,6 +164,11 @@ export async function resolveSourceRef(client, value, opts) {
164
164
  }
165
165
  // --- Agentic generation jobs ---
166
166
  const GENERATION_POLL_INTERVAL_MS = 2_500;
167
+ /** NEW-CP-3: emit a "still running" heartbeat at least this often even if
168
+ * status/progress_message hasn't changed. Backend often stays at
169
+ * `running` with no message for tens of seconds; without a breadcrumb
170
+ * the CLI looks frozen. */
171
+ const GENERATION_HEARTBEAT_MS = 15_000;
167
172
  const GENERATION_TERMINAL_STATUSES = new Set(["completed", "failed"]);
168
173
  /**
169
174
  * Thrown when a generation job doesn't reach a terminal status before the
@@ -190,7 +195,9 @@ export class GenerationTimeoutError extends Error {
190
195
  export async function pollGenerationJobUntilDone(client, jobId, opts = {}) {
191
196
  const timeoutMs = opts.timeoutMs ?? 600_000;
192
197
  const deadline = Date.now() + timeoutMs;
198
+ const startedAt = Date.now();
193
199
  let lastReported = "";
200
+ let lastHeartbeatAt = Date.now();
194
201
  while (true) {
195
202
  const job = await client.get(`/people/generation-jobs/${jobId}`, undefined, { timeout: 60_000 });
196
203
  if (!opts.quiet) {
@@ -198,8 +205,19 @@ export async function pollGenerationJobUntilDone(client, jobId, opts = {}) {
198
205
  ? `${job.status}: ${job.progress_message}`
199
206
  : job.status;
200
207
  if (line !== lastReported) {
208
+ // Status / message changed — emit it as a fresh breadcrumb.
201
209
  process.stderr.write(` ${line}\n`);
202
210
  lastReported = line;
211
+ lastHeartbeatAt = Date.now();
212
+ }
213
+ else if (Date.now() - lastHeartbeatAt >= GENERATION_HEARTBEAT_MS) {
214
+ // NEW-CP-3: the backend often holds at `status=running` with no
215
+ // `progress_message` for tens of seconds. Without a heartbeat the
216
+ // CLI looks frozen. Emit a "still running" line every 15s so the
217
+ // operator knows we're polling and not hung.
218
+ const elapsedSec = Math.round((Date.now() - startedAt) / 1000);
219
+ process.stderr.write(` still ${job.status} (${elapsedSec}s elapsed)…\n`);
220
+ lastHeartbeatAt = Date.now();
203
221
  }
204
222
  }
205
223
  if (GENERATION_TERMINAL_STATUSES.has(job.status))
@@ -219,6 +219,11 @@ When in doubt: side-by-side comparison usually beats in-place edits. Ids are che
219
219
  - **Chatbot auth drift**: tokens/sessions baked into \`--from-curl\` expire. If transcripts come back as identical short error strings, re-run \`chat_endpoint_test\` and refresh the curl spec.
220
220
  - **401 surfaces as fake blocker**: an unauthenticated endpoint produces "participant got stuck on auth screen" — looks like a UX blocker but is config. Always confirm endpoint auth before reading transcripts as user-research data.
221
221
  - **No per-page/per-timestamp scoping for media**: there's no "evaluate just slide 14" or "react to seconds 0-30" API. State the focus explicitly in the \`assignment\` text, or pre-stitch the artifact (e.g. replace one slide locally, upload as a new iteration).
222
+ - **\`study get --json\` participants live at the top level**, not nested under \`iterations[*].participants\`. The backend split made \`/studies/{id}\` lite (metadata + iteration shells, no participant graph) and added \`/studies/{id}/participants\`; the CLI joins them so \`study get --json\` carries a flat \`participants[]\` with \`iteration_id\` on each row. Read \`.participants[]\`, not \`.iterations[].participants[]\`.
223
+ - **All destructive deletes require \`--yes\` in non-TTY mode**: \`ish workspace delete\`, \`study delete\`, \`ask delete\`, \`person delete\`, \`source delete\`, \`chat endpoint delete\`. In \`--json\` mode (or any piped/non-TTY invocation), omitting \`--yes\` refuses with \`error_kind: "ConfirmationRequired"\` + an \`example\` field showing the same command with \`--yes\` appended. \`workspace delete\` is the highest-blast-radius: it removes ALL nested studies, asks, people, secrets, configs, sources, and chat endpoints — the prompt names them explicitly.
224
+ - **\`ish login\` is idempotent**: with a valid saved token, \`ish login\` short-circuits with "Already logged in" and **does not open a new browser tab**. Use \`--force\` (or \`-f\`) only when actually switching accounts.
225
+ - **\`ish person create\` accepts inline flags** (mirrors \`person update\`): the file-only API (\`--file <path>\`) is preserved as an escape hatch but the common path is \`ish person create --name "X" --type ai --country US ...\` — \`--type\` defaults to \`ai\` when \`--file\` is omitted. See \`ish person create --help\` for the full inline-flag set including \`--household\` (MECE rule applies) and \`--accessibility-profile\`.
226
+ - **\`ish status\` now surfaces \`chat_endpoint\`** alongside \`workspace\`/\`study\`/\`ask\`. Stale or orphan active refs get a \`warning\` + \`hint\` field on the affected ref (instead of silently dropping the \`name\`). On \`workspace use <other>\`, the CLI cascade-clears \`study\`/\`ask\`/\`chat_endpoint\` (they belong to the previous workspace).
222
227
 
223
228
  ## When in doubt
224
229
 
@@ -375,9 +380,13 @@ ish person suggest-scenarios \\
375
380
  # [{"text":"...","source":"situation","scenario_prompt":"..."}, ...]
376
381
  # Valid source values: situation, voice, binary, micro-story
377
382
 
378
- # 3. Save the person shell
383
+ # 3. Save the person shell — either from file:
379
384
  ish person create --file ./persona.json
380
385
  # → p-d4e
386
+ #
387
+ # …or inline (mirror of person update):
388
+ # ish person create --name "Alice" --type ai --country US \\
389
+ # --occupation founder --household single --bio "..."
381
390
 
382
391
  # 4. Persist the answers as structured evidence
383
392
  ish person evidence add p-d4e --traces-file ./answers.json
@@ -1041,7 +1050,7 @@ ish <command> --help
1041
1050
  | \`mcp\` | Wire the hosted ish MCP server into local AI | guides/mcp-add |
1042
1051
  | | clients (Cursor, VS Code, Claude Code, | |
1043
1052
  | | Claude Desktop, Windsurf). Idempotent. | |
1044
- | \`login\` | Browser-based auth | — |
1053
+ | \`login\` | Browser-based auth. Idempotent: short-circuits on valid saved token. \`--force\` to switch accounts. | — |
1045
1054
  | \`logout\` | Clear saved credentials | — |
1046
1055
  | \`status\` | Show active session (user, workspace, | concepts/active-context |
1047
1056
  | | study, ask, token validity) — alias \`whoami\` | |
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Wrapper for the post-split `GET /studies/{id}/participants` endpoint.
3
+ *
4
+ * Returns a flat list of participants for one study with an `iteration_id`
5
+ * discriminator on each row. Each row carries the rich per-participant graph
6
+ * (person, interactions[], participant_summary, interview_answers, …) that
7
+ * used to be embedded under `study.iterations[*].participants[*]` on the
8
+ * legacy `GET /studies/{id}` response.
9
+ */
10
+ import type { ApiClient } from "./api-client.js";
11
+ import type { Participant } from "./types.js";
12
+ export interface StudyParticipant extends Participant {
13
+ person?: {
14
+ id?: string;
15
+ name?: string;
16
+ };
17
+ interactions?: unknown[];
18
+ participant_summary?: Record<string, unknown> | null;
19
+ interview_answers?: Array<{
20
+ question_id?: string;
21
+ answer?: unknown;
22
+ }>;
23
+ participant_files?: unknown[];
24
+ participant_assignments?: unknown[];
25
+ conversation_id?: string | null;
26
+ error_message?: string | null;
27
+ error_kind?: string | null;
28
+ [k: string]: unknown;
29
+ }
30
+ export declare function fetchStudyParticipants(client: ApiClient, studyId: string, opts?: {
31
+ timeout?: number;
32
+ }): Promise<StudyParticipant[]>;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Wrapper for the post-split `GET /studies/{id}/participants` endpoint.
3
+ *
4
+ * Returns a flat list of participants for one study with an `iteration_id`
5
+ * discriminator on each row. Each row carries the rich per-participant graph
6
+ * (person, interactions[], participant_summary, interview_answers, …) that
7
+ * used to be embedded under `study.iterations[*].participants[*]` on the
8
+ * legacy `GET /studies/{id}` response.
9
+ */
10
+ export async function fetchStudyParticipants(client, studyId, opts) {
11
+ return await client.get(`/studies/${studyId}/participants`, undefined, opts);
12
+ }
@@ -155,7 +155,6 @@ export interface Iteration {
155
155
  description?: string;
156
156
  label?: string;
157
157
  details?: Record<string, unknown>;
158
- participants?: Participant[];
159
158
  created_at: string;
160
159
  updated_at: string;
161
160
  }
package/dist/upgrade.js CHANGED
@@ -32,11 +32,18 @@ export async function upgrade(currentVersion, targetVersion) {
32
32
  const scriptUrl = import.meta.url;
33
33
  const runningFromNodeModules = scriptUrl.includes("/node_modules/");
34
34
  if (looksLikeNode || runningFromNodeModules) {
35
- throw new Error("Cannot self-upgrade an npm-installed CLI (would overwrite the node binary). " +
35
+ // Pattern C: tag as ValidationError so exitCodeFromError maps to 2
36
+ // (usage_error). Otherwise this would fall through to the generic
37
+ // exit 1 even with the wrapper in place (ISSUE-012).
38
+ const err = new Error("Cannot self-upgrade an npm-installed CLI (would overwrite the node binary). " +
36
39
  "Run `npm install -g @ishlabs/cli@latest` instead.");
40
+ err.name = "ValidationError";
41
+ throw err;
37
42
  }
38
43
  if (targetVersion && !/^\d+\.\d+\.\d+/.test(targetVersion)) {
39
- throw new Error(`Invalid version format: ${targetVersion}`);
44
+ const err = new Error(`Invalid version format: ${targetVersion}`);
45
+ err.name = "ValidationError";
46
+ throw err;
40
47
  }
41
48
  const latest = targetVersion || (await getLatestVersion());
42
49
  if (latest === currentVersion) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ishlabs/cli",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "The command-line interface for ish",
5
5
  "type": "module",
6
6
  "bin": {