@ishlabs/cli 0.19.0 → 0.21.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.
@@ -48,6 +48,12 @@ export declare function formatWorkspaceDetail(workspace: Record<string, unknown>
48
48
  export declare function formatSiteAccessStatus(summary: import("./site-access.js").SiteAccessSummary, json: boolean): void;
49
49
  export declare function formatStudyList(studies: Record<string, unknown>[], json: boolean): void;
50
50
  export declare function formatStudyDetail(study: Record<string, unknown>, json: boolean, options?: OutputOptions, participants?: ReadonlyArray<Record<string, unknown>>): void;
51
+ /**
52
+ * Stable JSON envelope for `study results`. Schema is fixed regardless of
53
+ * study state — fields default to `null`, `0`, or `[]` when nothing has run.
54
+ * Agents can rely on the keys always being present (M4).
55
+ */
56
+ export declare function buildStudyResultsEnvelope(study: Record<string, unknown>, participants: ReadonlyArray<Record<string, unknown>>): Record<string, unknown>;
51
57
  export declare function formatStudyResults(study: Record<string, unknown>, participants: ReadonlyArray<Record<string, unknown>>, json: boolean): void;
52
58
  /**
53
59
  * `study results --summary` projection. Drops interview_answers + per-participant
@@ -102,3 +108,15 @@ export declare function deriveWinnerConfidence(args: {
102
108
  }): "low" | "medium" | "high";
103
109
  export declare function formatAskResults(ask: Record<string, unknown>, json: boolean, roundFilter?: number): void;
104
110
  export declare function formatConfigList(configs: Record<string, unknown>[], json: boolean): void;
111
+ export type StudyResultsGroupByKind = "iteration" | "frame" | "segment" | "turn" | "assignment" | "step";
112
+ /**
113
+ * Render a `--group-by <kind>` projection. JSON mode is a thin pass-through
114
+ * to jsonOutput with `preProjected: true` so the lean transform doesn't
115
+ * strip our stable empties. Human mode renders one section per slice plus
116
+ * a small ASCII sentiment histogram.
117
+ *
118
+ * The renderer accepts both the wrapped `{study, slices, ...}` shape (per-
119
+ * iteration) and the bare-array shape (every other --group-by); the
120
+ * surface (T5) doesn't need to know the difference.
121
+ */
122
+ export declare function formatStudyResultsGroupBy(projection: unknown, kind: StudyResultsGroupByKind, json: boolean): void;
@@ -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 || "-"),
@@ -934,7 +978,7 @@ export function formatStudyDetail(study, json, options = {}, participants) {
934
978
  const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
935
979
  if (allParticipants.length > 0) {
936
980
  console.log(`\nParticipants (${allParticipants.length}):`);
937
- printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS"], allParticipants.map((t) => [
981
+ printTable(["ALIAS", "NAME", "ITERATION", "STATUS", "INTERACTIONS"], allParticipants.map((t) => [
938
982
  t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
939
983
  t.name,
940
984
  t.iterationLabel,
@@ -948,7 +992,7 @@ export function formatStudyDetail(study, json, options = {}, participants) {
948
992
  * study state — fields default to `null`, `0`, or `[]` when nothing has run.
949
993
  * Agents can rely on the keys always being present (M4).
950
994
  */
951
- function buildStudyResultsEnvelope(study, participants) {
995
+ export function buildStudyResultsEnvelope(study, participants) {
952
996
  const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
953
997
  const studyAlias = study.id
954
998
  ? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
@@ -1074,7 +1118,7 @@ export function formatStudyResults(study, participants, json) {
1074
1118
  // Participants table
1075
1119
  if (allParticipants.length > 0) {
1076
1120
  console.log("\nParticipants:");
1077
- printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS", "SENTIMENT"], allParticipants.map((t) => {
1121
+ printTable(["ALIAS", "NAME", "ITERATION", "STATUS", "INTERACTIONS", "SENTIMENT"], allParticipants.map((t) => {
1078
1122
  const parts = Object.entries(t.sentimentCounts).map(([label, count]) => `${count} ${label.toLowerCase()}`);
1079
1123
  return [
1080
1124
  t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
@@ -1375,7 +1419,7 @@ export function formatIterationList(iterations, json) {
1375
1419
  return;
1376
1420
  }
1377
1421
  const aliasMap = getAliasMap(ALIAS_PREFIX.iteration);
1378
- printTable(["#", "LABEL", "NAME", "PARTICIPANTS", "CREATED"], iterations.map((it) => {
1422
+ printTable(["ALIAS", "LABEL", "NAME", "PARTICIPANTS", "CREATED"], iterations.map((it) => {
1379
1423
  const participants = Array.isArray(it.participants) ? it.participants.length : 0;
1380
1424
  return [
1381
1425
  aliasMap.get(String(it.id)) || String(it.id || ""),
@@ -1449,7 +1493,7 @@ export function formatPersonList(profiles, json, limit) {
1449
1493
  console.log("No participant profiles.");
1450
1494
  return;
1451
1495
  }
1452
- printTable(["#", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
1496
+ printTable(["ALIAS", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
1453
1497
  String(p.alias || p.id || ""),
1454
1498
  String(p.name || ""),
1455
1499
  String(p.occupation || "-"),
@@ -1504,7 +1548,7 @@ export function formatGeneratedProfileList(profiles, json) {
1504
1548
  console.log(jsonOutput(list));
1505
1549
  return;
1506
1550
  }
1507
- printTable(["#", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
1551
+ printTable(["ALIAS", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
1508
1552
  String(p.alias || p.id || ""),
1509
1553
  String(p.name || ""),
1510
1554
  String(p.occupation || "-"),
@@ -1529,7 +1573,7 @@ export function formatSimulationPoll(results, json, isMedia = false) {
1529
1573
  }
1530
1574
  const aliasMap = getAliasMap(ALIAS_PREFIX.participant);
1531
1575
  const countHeader = isMedia ? "SEGMENTS" : "INTERACTIONS";
1532
- printTable(["#", "PARTICIPANT", "STATUS", countHeader], results.map((r) => {
1576
+ printTable(["ALIAS", "PARTICIPANT", "STATUS", countHeader], results.map((r) => {
1533
1577
  const id = String(r.id || r.participant_id || "");
1534
1578
  return [
1535
1579
  aliasMap.get(id) || id,
@@ -1574,7 +1618,7 @@ export function formatAskList(asks, json) {
1574
1618
  return;
1575
1619
  }
1576
1620
  const aliasMap = getAliasMap(ALIAS_PREFIX.ask);
1577
- printTable(["#", "NAME", "STATUS", "PARTICIPANTS", "ROUNDS", "LAST ROUND", "ARCHIVED"], asks.map((a) => [
1621
+ printTable(["ALIAS", "NAME", "STATUS", "PARTICIPANTS", "ROUNDS", "LAST ROUND", "ARCHIVED"], asks.map((a) => [
1578
1622
  aliasMap.get(String(a.id)) || String(a.id || ""),
1579
1623
  String(a.name || ""),
1580
1624
  String(a.status || "-"),
@@ -1675,7 +1719,7 @@ export function formatAskDetail(ask, json) {
1675
1719
  String(obj.status || "-"),
1676
1720
  ];
1677
1721
  });
1678
- printTable(["#", "NAME", "STATUS"], rows);
1722
+ printTable(["ALIAS", "NAME", "STATUS"], rows);
1679
1723
  if (participants.length > 20)
1680
1724
  console.log(` … and ${participants.length - 20} more`);
1681
1725
  }
@@ -2145,7 +2189,7 @@ export function formatConfigList(configs, json) {
2145
2189
  return;
2146
2190
  }
2147
2191
  const aliasMap = getAliasMap(ALIAS_PREFIX.config);
2148
- printTable(["#", "NAME", "SOURCE", "CREATED"], configs.map((c) => [
2192
+ printTable(["ALIAS", "NAME", "SOURCE", "CREATED"], configs.map((c) => [
2149
2193
  aliasMap.get(String(c.id)) || String(c.id || ""),
2150
2194
  String(c.name || ""),
2151
2195
  String(c.source_type || "manual"),
@@ -2182,3 +2226,219 @@ function formatDate(value) {
2182
2226
  return str.slice(0, 10);
2183
2227
  }
2184
2228
  }
2229
+ const POSITIVE_SENTIMENT = new Set(["satisfied", "curious", "engaged", "confident", "delighted"]);
2230
+ const NEGATIVE_SENTIMENT = new Set(["frustrated", "confused", "blocked", "anxious", "disappointed"]);
2231
+ function sentimentColor(label) {
2232
+ const l = label.toLowerCase();
2233
+ if (POSITIVE_SENTIMENT.has(l))
2234
+ return c.green;
2235
+ if (NEGATIVE_SENTIMENT.has(l))
2236
+ return c.red;
2237
+ return c.dim;
2238
+ }
2239
+ function asciiHistogram(hist, options = {}) {
2240
+ const width = options.width ?? 20;
2241
+ const indent = options.indent ?? " ";
2242
+ const entries = Object.entries(hist).filter(([, v]) => v > 0);
2243
+ if (entries.length === 0)
2244
+ return [];
2245
+ const max = entries.reduce((acc, [, v]) => (v > acc ? v : acc), 0);
2246
+ const labelWidth = entries.reduce((acc, [k]) => (k.length > acc ? k.length : acc), 0);
2247
+ return entries
2248
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
2249
+ .map(([label, count]) => {
2250
+ const bars = max > 0 ? Math.max(1, Math.round((count / max) * width)) : 0;
2251
+ const color = sentimentColor(label);
2252
+ return `${indent}${label.padEnd(labelWidth)} ${color}${"█".repeat(bars)}${c.reset} ${count}`;
2253
+ });
2254
+ }
2255
+ function slicesFromProjection(projection) {
2256
+ // Iteration projection wraps `{ study, slices, totals_unfiltered, warnings }`;
2257
+ // all others are bare arrays. Both come through here.
2258
+ if (Array.isArray(projection)) {
2259
+ return projection.filter((s) => Boolean(s) && typeof s === "object" && !Array.isArray(s));
2260
+ }
2261
+ if (projection && typeof projection === "object") {
2262
+ const wrapped = projection;
2263
+ const slices = wrapped.slices;
2264
+ if (Array.isArray(slices)) {
2265
+ return slices.filter((s) => Boolean(s) && typeof s === "object" && !Array.isArray(s));
2266
+ }
2267
+ }
2268
+ return [];
2269
+ }
2270
+ function totalInteractionsFromSlices(slices) {
2271
+ let total = 0;
2272
+ for (const s of slices) {
2273
+ const n = typeof s.interaction_count === "number" ? s.interaction_count : 0;
2274
+ total += n;
2275
+ }
2276
+ return total;
2277
+ }
2278
+ function totalsUnfilteredFromProjection(projection) {
2279
+ if (projection && typeof projection === "object" && !Array.isArray(projection)) {
2280
+ const t = projection.totals_unfiltered;
2281
+ if (t && typeof t === "object" && !Array.isArray(t)) {
2282
+ return t;
2283
+ }
2284
+ }
2285
+ return null;
2286
+ }
2287
+ function renderIterationSlice(slice) {
2288
+ const label = String(slice.iteration_label ?? slice.iteration_id ?? "?");
2289
+ const pCount = Number(slice.participant_count ?? 0);
2290
+ const iCount = Number(slice.interaction_count ?? 0);
2291
+ console.log(`\n ${c.bold}Iteration ${label}${c.reset} ${c.dim}${pCount} participant${pCount !== 1 ? "s" : ""} · ${iCount} interaction${iCount !== 1 ? "s" : ""}${c.reset}`);
2292
+ const hist = slice.sentiment ?? {};
2293
+ for (const line of asciiHistogram(hist, { indent: " " }))
2294
+ console.log(line);
2295
+ const top = Array.isArray(slice.top_actions) ? slice.top_actions : [];
2296
+ if (top.length > 0) {
2297
+ const parts = top.map((a) => `${a.action_type} ×${a.count}`);
2298
+ console.log(` ${c.dim}Top actions:${c.reset} ${parts.join(", ")}`);
2299
+ }
2300
+ const comments = Array.isArray(slice.sample_comments) ? slice.sample_comments : [];
2301
+ for (const ccomment of comments) {
2302
+ console.log(` ${c.dim}"${ccomment}"${c.reset}`);
2303
+ }
2304
+ }
2305
+ function renderFrameSlice(slice) {
2306
+ const label = slice.frame_label ? String(slice.frame_label) : String(slice.frame_id);
2307
+ const iCount = Number(slice.interaction_count ?? 0);
2308
+ const aliases = Array.isArray(slice.participant_aliases) ? slice.participant_aliases : [];
2309
+ console.log(`\n ${c.bold}${label}${c.reset} ${c.dim}${iCount} interaction${iCount !== 1 ? "s" : ""} · ${aliases.length} participant${aliases.length !== 1 ? "s" : ""}${c.reset}`);
2310
+ const hist = slice.sentiment_histogram ?? {};
2311
+ for (const line of asciiHistogram(hist, { indent: " " }))
2312
+ console.log(line);
2313
+ const comments = Array.isArray(slice.sample_comments) ? slice.sample_comments : [];
2314
+ for (const ccomment of comments) {
2315
+ console.log(` ${c.dim}"${ccomment}"${c.reset}`);
2316
+ }
2317
+ }
2318
+ function renderSegmentSlice(slice) {
2319
+ const idx = slice.segment_index;
2320
+ const label = slice.segment_label ? String(slice.segment_label) : null;
2321
+ const header = idx !== null && idx !== undefined
2322
+ ? `Segment ${idx}${label ? ` — ${label}` : ""}`
2323
+ : (label ?? "Segment ?");
2324
+ const iCount = Number(slice.interaction_count ?? 0);
2325
+ console.log(`\n ${c.bold}${header}${c.reset} ${c.dim}${iCount} interaction${iCount !== 1 ? "s" : ""}${c.reset}`);
2326
+ const hist = slice.sentiment_histogram ?? {};
2327
+ for (const line of asciiHistogram(hist, { indent: " " }))
2328
+ console.log(line);
2329
+ const engagement = slice.engagement_histogram ?? {};
2330
+ if (Object.keys(engagement).length > 0) {
2331
+ const parts = Object.entries(engagement).map(([k, v]) => `${v} ${k}`);
2332
+ console.log(` ${c.dim}Engagement:${c.reset} ${parts.join(", ")}`);
2333
+ }
2334
+ const comments = Array.isArray(slice.sample_comments) ? slice.sample_comments : [];
2335
+ for (const ccomment of comments) {
2336
+ console.log(` ${c.dim}"${ccomment}"${c.reset}`);
2337
+ }
2338
+ }
2339
+ function renderTurnSlice(slice) {
2340
+ const turn = Number(slice.turn_index ?? 0);
2341
+ const iCount = Number(slice.interaction_count ?? 0);
2342
+ const failures = Number(slice.failures ?? 0);
2343
+ const failPart = failures > 0 ? ` ${c.red}${failures} failure${failures !== 1 ? "s" : ""}${c.reset}` : "";
2344
+ console.log(`\n ${c.bold}Turn ${turn}${c.reset} ${c.dim}${iCount} interaction${iCount !== 1 ? "s" : ""}${c.reset}${failPart}`);
2345
+ const hist = slice.sentiment_histogram ?? {};
2346
+ for (const line of asciiHistogram(hist, { indent: " " }))
2347
+ console.log(line);
2348
+ const replies = Array.isArray(slice.sample_replies) ? slice.sample_replies : [];
2349
+ for (const r of replies) {
2350
+ console.log(` ${c.dim}"${r}"${c.reset}`);
2351
+ }
2352
+ }
2353
+ function renderAssignmentSlice(slice) {
2354
+ const name = String(slice.assignment_name ?? slice.assignment_id ?? "?");
2355
+ const iCount = Number(slice.interaction_count ?? 0);
2356
+ console.log(`\n ${c.bold}${name}${c.reset} ${c.dim}${iCount} interaction${iCount !== 1 ? "s" : ""}${c.reset}`);
2357
+ const hist = slice.sentiment_histogram ?? {};
2358
+ for (const line of asciiHistogram(hist, { indent: " " }))
2359
+ console.log(line);
2360
+ const sc = Array.isArray(slice.step_completion) ? slice.step_completion : [];
2361
+ if (sc.length > 0) {
2362
+ const rows = sc.map((s) => [
2363
+ String(s.name ?? s.step_id ?? "?"),
2364
+ String(s.passed ?? 0),
2365
+ String(s.inconclusive ?? 0),
2366
+ String(s.failed ?? 0),
2367
+ typeof s.rate === "number" ? s.rate.toFixed(2) : "-",
2368
+ ]);
2369
+ console.log(` ${c.dim}Steps:${c.reset}`);
2370
+ printTable(["STEP", "PASSED", "INCONCLUSIVE", "FAILED", "RATE"], rows);
2371
+ }
2372
+ }
2373
+ function renderStepSlice(slice) {
2374
+ const name = String(slice.step_name ?? slice.step_id ?? "?");
2375
+ const assignment = String(slice.assignment_name ?? "?");
2376
+ const total = Number(slice.total ?? 0);
2377
+ const passed = Number(slice.passed ?? 0);
2378
+ const inconclusive = Number(slice.inconclusive ?? 0);
2379
+ const failed = Number(slice.failed ?? 0);
2380
+ const rate = typeof slice.rate === "number" ? slice.rate.toFixed(2) : "-";
2381
+ const rateColor = failed > passed ? c.red : (passed > failed ? c.green : c.dim);
2382
+ console.log(`\n ${c.bold}${assignment} › ${name}${c.reset} ${rateColor}${passed}/${total} passed${c.reset} ${c.dim}(${inconclusive} inconclusive, ${failed} failed, rate ${rate})${c.reset}`);
2383
+ const verdicts = Array.isArray(slice.participant_verdicts)
2384
+ ? slice.participant_verdicts
2385
+ : [];
2386
+ if (verdicts.length > 0) {
2387
+ const rows = verdicts.map((v) => [
2388
+ String(v.participant_alias ?? "-"),
2389
+ String(v.verdict ?? "-"),
2390
+ v.reason ? truncate(String(v.reason), 60) : "-",
2391
+ ]);
2392
+ printTable(["PARTICIPANT", "VERDICT", "REASON"], rows);
2393
+ }
2394
+ }
2395
+ /**
2396
+ * Render a `--group-by <kind>` projection. JSON mode is a thin pass-through
2397
+ * to jsonOutput with `preProjected: true` so the lean transform doesn't
2398
+ * strip our stable empties. Human mode renders one section per slice plus
2399
+ * a small ASCII sentiment histogram.
2400
+ *
2401
+ * The renderer accepts both the wrapped `{study, slices, ...}` shape (per-
2402
+ * iteration) and the bare-array shape (every other --group-by); the
2403
+ * surface (T5) doesn't need to know the difference.
2404
+ */
2405
+ export function formatStudyResultsGroupBy(projection, kind, json) {
2406
+ if (json) {
2407
+ console.log(jsonOutput(projection, { preProjected: true }));
2408
+ return;
2409
+ }
2410
+ const slices = slicesFromProjection(projection);
2411
+ const totalInteractions = totalInteractionsFromSlices(slices);
2412
+ const unfiltered = totalsUnfilteredFromProjection(projection);
2413
+ const totalUnfiltered = unfiltered && typeof unfiltered.interaction_count === "number"
2414
+ ? unfiltered.interaction_count
2415
+ : null;
2416
+ const headline = `Sliced by ${kind}: ${slices.length} group${slices.length !== 1 ? "s" : ""} (${totalInteractions}${totalUnfiltered !== null ? `/${totalUnfiltered}` : ""} interaction${totalInteractions !== 1 ? "s" : ""})`;
2417
+ console.log(`${c.bold}${headline}${c.reset}`);
2418
+ if (slices.length === 0) {
2419
+ console.log(` ${c.dim}(no groups matched)${c.reset}`);
2420
+ return;
2421
+ }
2422
+ for (const slice of slices) {
2423
+ switch (kind) {
2424
+ case "iteration":
2425
+ renderIterationSlice(slice);
2426
+ break;
2427
+ case "frame":
2428
+ renderFrameSlice(slice);
2429
+ break;
2430
+ case "segment":
2431
+ renderSegmentSlice(slice);
2432
+ break;
2433
+ case "turn":
2434
+ renderTurnSlice(slice);
2435
+ break;
2436
+ case "assignment":
2437
+ renderAssignmentSlice(slice);
2438
+ break;
2439
+ case "step":
2440
+ renderStepSlice(slice);
2441
+ break;
2442
+ }
2443
+ }
2444
+ }
@@ -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))
@@ -220,6 +220,10 @@ When in doubt: side-by-side comparison usually beats in-place edits. Ids are che
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
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).
223
227
 
224
228
  ## When in doubt
225
229
 
@@ -376,9 +380,13 @@ ish person suggest-scenarios \\
376
380
  # [{"text":"...","source":"situation","scenario_prompt":"..."}, ...]
377
381
  # Valid source values: situation, voice, binary, micro-story
378
382
 
379
- # 3. Save the person shell
383
+ # 3. Save the person shell — either from file:
380
384
  ish person create --file ./persona.json
381
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 "..."
382
390
 
383
391
  # 4. Persist the answers as structured evidence
384
392
  ish person evidence add p-d4e --traces-file ./answers.json
@@ -909,6 +917,70 @@ Rules to remember:
909
917
  See \`ish docs get-page concepts/extending-a-simulation\` for the full
910
918
  mental model (cancel + extend as a pair, error envelopes, cost model).
911
919
 
920
+ ## 12. Slice study results by frame / segment / turn / sentiment
921
+
922
+ Goal: ask narrower questions of a finished run than the kitchen-sink
923
+ \`ish study results\` envelope answers. The canonical use case:
924
+ **"what differed on the login screen across these five iterations?"**.
925
+
926
+ \`\`\`bash
927
+ # 12a. Across-iterations comparison on one frame (the canonical question).
928
+ # --frame matches frame names by case-insensitive substring; pass
929
+ # a full Frame UUID or an f-… alias when the name is ambiguous.
930
+ ish study results s-b2c --frame login --group-by iteration --json
931
+
932
+ # 12b. Frustrated reactions to one segment of a video study:
933
+ ish study results s-b2c --segment 3 --sentiment Frustrated
934
+
935
+ # 12c. Who failed the "verify email" step, and why?
936
+ # --group-by step exposes per-participant verdicts inline so you
937
+ # don't fan out across participants.
938
+ ish study results s-b2c --assignment "Sign up" --step verify-email \\
939
+ --group-by step --json
940
+
941
+ # 12d. Pair-mode chat: only side A turn 4.
942
+ ish study results s-b2c --side a --turn 4
943
+
944
+ # 12e. Sanity-check coverage when a filter narrows the slice:
945
+ ish study results s-b2c --frame checkout --json \\
946
+ | jq '{matched: .participant_count, total: .totals_unfiltered.participant_count}'
947
+
948
+ # 12f. A filter that matches zero interactions still returns the stable
949
+ # envelope shape — participant_count: 0, totals_unfiltered populated,
950
+ # exit code 0 (not 4). Never error on no-match.
951
+ ish study results s-b2c --frame doesnotexist --json
952
+ # → ValidationError because "doesnotexist" matches no frame names; pass
953
+ # --include-unmatched only when --frame DID resolve and you want the
954
+ # degraded captures (frame_version_id: null) back.
955
+ \`\`\`
956
+
957
+ Rules to remember:
958
+ - **Filters compose with AND across flags; OR within \`--sentiment\`.**
959
+ \`--frame login --sentiment Frustrated,Confused\` keeps only login-frame
960
+ interactions whose sentiment is Frustrated OR Confused.
961
+ - **Modality mismatch is not an error.** \`--segment 0\` on an
962
+ interactive study emits a stderr warning and is ignored. The
963
+ exception is **\`--group-by\`** — \`--group-by frame\` on a chat study,
964
+ \`--group-by turn\` on a video study, etc. error at the router (exit 2).
965
+ - **Empty-slice contract: exit 0, not 4.** Zero matches return a
966
+ stable envelope with \`participant_count: 0\` and
967
+ \`totals_unfiltered\` populated. Agents key on
968
+ \`totals_unfiltered.participant_count\` to ask "is the filter too
969
+ tight, or did the run not produce data?".
970
+ - \`--frame\` accepts a name substring, a Frame UUID, an \`f-…\` alias,
971
+ or a \`frame_version_id\` UUID. Ambiguous substring (matches >1
972
+ frame) errors with the candidate list.
973
+ - \`--summary\` is orthogonal to filters and narrows the summary over
974
+ the filtered set. \`--transcript\` is single-participant and errors
975
+ (exit 2) when **any** filter or \`--group-by\` is set.
976
+ - Per-step output exposes \`participant_verdicts: [{participant_alias,
977
+ verdict, reason, evidence_interaction_ids}]\` — not
978
+ \`per_participant_verdicts\`. The verdict enum is \`passed\` /
979
+ \`inconclusive\` / \`failed\`.
980
+
981
+ See \`ish docs get-page guides/slicing-results\` for the full filter
982
+ table, projection shapes, and the defensive null-handling rules.
983
+
912
984
  ## Tips for chaining commands as an agent
913
985
 
914
986
  - Capture aliases from JSON: \`ITER=$(ish iteration create --url … --json | jq -r .alias)\`
@@ -1002,6 +1074,10 @@ mental model (cancel + extend as a pair, error envelopes, cost model).
1002
1074
  | List of participants from \`study run\` | \`--json \\| jq '.participants[].id'\` | \`--get participant_aliases\` (or \`participant_ids\` for UUIDs) |
1003
1075
  | Per-answer sentiment | \`--json \\| jq '...'\` per participant | \`ish study results <id> --json\` (sentiment is on every answer row) |
1004
1076
  | "Did this run land?" headline | \`study results --json\` + jq filtering | \`ish study results <id> --summary --json\` |
1077
+ | Across-iterations comparison on one frame | \`study results --json\` + jq per iteration | \`ish study results <id> --frame login --group-by iteration --json\` |
1078
+ | Per-step pass/fail with reasons inline | \`study participant --json\` per participant + jq | \`ish study results <id> --step verify-email --group-by step --json\` |
1079
+ | Frustrated reactions to one media segment | \`study results --json\` + jq | \`ish study results <id> --segment 3 --sentiment Frustrated --json\` |
1080
+ | Sanity-check filter coverage | hand-count \`.participants\` vs total | \`--get totals_unfiltered.participant_count\` (set on every sliced envelope) |
1005
1081
  | Chat transcript for one participant (external_chatbot) | \`study participant --json\` + jq | \`ish study results <id> --transcript <participant_id> --json\` |
1006
1082
  | Pair-mode conversation transcripts | \`study participant --json\` per participant | \`ish iteration get <iter-id> --json \\| jq '.conversations[]'\` |
1007
1083
  | Participant headline only (no action timeline) | \`study participant --json\` + jq | \`ish study participant <id> --summary --json\` |
@@ -1042,7 +1118,7 @@ ish <command> --help
1042
1118
  | \`mcp\` | Wire the hosted ish MCP server into local AI | guides/mcp-add |
1043
1119
  | | clients (Cursor, VS Code, Claude Code, | |
1044
1120
  | | Claude Desktop, Windsurf). Idempotent. | |
1045
- | \`login\` | Browser-based auth | — |
1121
+ | \`login\` | Browser-based auth. Idempotent: short-circuits on valid saved token. \`--force\` to switch accounts. | — |
1046
1122
  | \`logout\` | Clear saved credentials | — |
1047
1123
  | \`status\` | Show active session (user, workspace, | concepts/active-context |
1048
1124
  | | study, ask, token validity) — alias \`whoami\` | |
@@ -6,6 +6,19 @@
6
6
  * (person, interactions[], participant_summary, interview_answers, …) that
7
7
  * used to be embedded under `study.iterations[*].participants[*]` on the
8
8
  * legacy `GET /studies/{id}` response.
9
+ *
10
+ * Audit (study-results-slice plan, T4): the flat endpoint already returns
11
+ * everything the new `ish study results --frame/--segment/--step/...` filter
12
+ * pipeline needs in a single round-trip — no per-participant fan-out:
13
+ * - `interactions[]` (modality-discriminated via `ParticipantWithAttributesPublicResponse`)
14
+ * - `participant_assignments[].step_results[]` with `{step_id, name,
15
+ * description, verdict, reason, evidence_interaction_ids[]}`, hydrated
16
+ * by `attach_participant_step_results_flat` in the study repository
17
+ * before serialisation (`ish-backend/app/api/study/repository.py:315`)
18
+ * - `participant_summary`, `interview_answers`
19
+ * If a future filter ever needs `conversation_id` on each interaction (for
20
+ * `--group-by conversation`), that's a backend-side addition on
21
+ * `_InteractionResponseBase`, not a CLI change.
9
22
  */
10
23
  import type { ApiClient } from "./api-client.js";
11
24
  import type { Participant } from "./types.js";