@ishlabs/cli 0.9.0 → 0.11.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 (39) hide show
  1. package/README.md +54 -5
  2. package/dist/commands/ask.d.ts +12 -0
  3. package/dist/commands/ask.js +127 -2
  4. package/dist/commands/chat.d.ts +17 -0
  5. package/dist/commands/chat.js +655 -0
  6. package/dist/commands/iteration.js +134 -14
  7. package/dist/commands/secret.d.ts +20 -0
  8. package/dist/commands/secret.js +246 -0
  9. package/dist/commands/study-run.d.ts +38 -0
  10. package/dist/commands/study-run.js +199 -80
  11. package/dist/commands/study-tester.js +17 -2
  12. package/dist/commands/study.js +309 -37
  13. package/dist/commands/workspace.js +81 -0
  14. package/dist/config.d.ts +3 -0
  15. package/dist/connect.d.ts +3 -0
  16. package/dist/connect.js +346 -22
  17. package/dist/index.js +64 -6
  18. package/dist/lib/alias-hydrate.d.ts +42 -0
  19. package/dist/lib/alias-hydrate.js +175 -0
  20. package/dist/lib/alias-store.d.ts +1 -0
  21. package/dist/lib/alias-store.js +28 -1
  22. package/dist/lib/auth.js +4 -2
  23. package/dist/lib/chat-endpoint-formatters.d.ts +74 -0
  24. package/dist/lib/chat-endpoint-formatters.js +154 -0
  25. package/dist/lib/chat-endpoint-templates.d.ts +35 -0
  26. package/dist/lib/chat-endpoint-templates.js +210 -0
  27. package/dist/lib/command-helpers.d.ts +18 -0
  28. package/dist/lib/command-helpers.js +105 -3
  29. package/dist/lib/docs.js +641 -17
  30. package/dist/lib/modality.d.ts +42 -0
  31. package/dist/lib/modality.js +192 -0
  32. package/dist/lib/output.d.ts +41 -0
  33. package/dist/lib/output.js +453 -19
  34. package/dist/lib/paths.d.ts +1 -0
  35. package/dist/lib/paths.js +3 -0
  36. package/dist/lib/skill-content.d.ts +18 -0
  37. package/dist/lib/skill-content.js +223 -12
  38. package/dist/lib/types.d.ts +15 -0
  39. package/package.json +2 -2
@@ -11,7 +11,7 @@ import * as readline from "node:readline/promises";
11
11
  import { withClient, getWebUrl, terminalLink, resolveWorkspace, resolveStudy, parseWaitTimeout, resolveAudienceProfileIds, addAudienceFilterFlags, hasAudienceFlags, } from "../lib/command-helpers.js";
12
12
  import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
13
13
  import { output, formatSimulationPoll } from "../lib/output.js";
14
- import { MEDIA_MODALITIES } from "../lib/types.js";
14
+ import { isMediaModality, isChatModality, iterationHasContent, describeRequiredContentFlag, } from "../lib/modality.js";
15
15
  import { runLocalSimulations } from "../lib/local-sim/loop.js";
16
16
  import { ensureBrowser } from "../lib/local-sim/install.js";
17
17
  function parseMaxInteractions(value) {
@@ -26,45 +26,53 @@ function parseSlowMo(value) {
26
26
  throw new Error(`Invalid --slow-mo value: ${value}`);
27
27
  return n;
28
28
  }
29
- function isMediaModality(modality) {
30
- return !!modality && MEDIA_MODALITIES.includes(modality);
31
- }
32
29
  /**
33
- * Pattern F (Issue #9): an iteration without content is a footgun.
34
- * `ish study generate` auto-creates an empty iteration "A" that becomes
35
- * `study run`'s default target unless `--iteration` is explicit; agents
36
- * silently dispatch against it. This predicate is the gate that lets
37
- * study run refuse with a clear suggestion instead of silent failure.
30
+ * M8 / M9 / Pattern G: typed wait-timeout error so `--wait` paths can
31
+ * produce a structured envelope (`error_code: "wait_timeout"`, exit 5
32
+ * transient) distinct from the generic timeout/network/server errors
33
+ * that the api-client wrapper produces. Carries the in-flight progress
34
+ * (testers done / total) so `study wait` always emits final state JSON
35
+ * even when it bails on the timer.
38
36
  */
39
- function iterationHasContent(details, modality) {
40
- if (!details || typeof details !== "object")
41
- return false;
42
- if (modality === "text") {
43
- return typeof details.content_text === "string" && details.content_text.length > 0;
44
- }
45
- if (modality === "video" || modality === "audio" || modality === "document") {
46
- return typeof details.content_url === "string" && details.content_url.length > 0;
47
- }
48
- if (modality === "image") {
49
- const urls = details.image_urls;
50
- if (Array.isArray(urls))
51
- return urls.length > 0;
52
- if (typeof urls === "string")
53
- return urls.length > 0;
54
- return false;
37
+ export class WaitTimeoutError extends Error {
38
+ progress;
39
+ error_code = "wait_timeout";
40
+ retryable = true;
41
+ constructor(message, progress) {
42
+ super(message);
43
+ this.progress = progress;
44
+ this.name = "WaitTimeoutError";
55
45
  }
56
- // interactive (default)
57
- return typeof details.url === "string" && details.url.length > 0;
58
46
  }
59
- function describeRequiredContentFlag(modality) {
60
- if (modality === "text")
61
- return "--content-text <text-or-@file>";
62
- if (modality === "video" || modality === "audio" || modality === "document") {
63
- return "--content-url <url-or-file>";
47
+ /**
48
+ * M13 / Pattern J: collapse the N near-duplicate `simulations[]` rows the
49
+ * batch start endpoint returns (one per tester, all sharing study_id) into a
50
+ * single batch entry with `tester_ids[]` + `tester_aliases[]` + `job_ids[]`.
51
+ * Agents always need the per-tester ids, but the surrounding scaffolding
52
+ * (study_id, message) is shared so repeating it N times costs context for no
53
+ * extra signal. Falls back to the raw rows if the response shape is unfamiliar.
54
+ */
55
+ function dedupeSimulations(simResults) {
56
+ if (!Array.isArray(simResults) || simResults.length === 0)
57
+ return [];
58
+ const studyIds = new Set(simResults.map((r) => r.study_id));
59
+ if (studyIds.size !== 1) {
60
+ // Mixed batches aren't expected today, but if the backend ever returns
61
+ // them, project per-row to keep the data lossless rather than silently
62
+ // collapsing across studies.
63
+ return simResults.map((r) => ({ ...r }));
64
64
  }
65
- if (modality === "image")
66
- return "--image-urls <comma-separated>";
67
- return "--url <url>";
65
+ const studyId = simResults[0].study_id;
66
+ return [
67
+ {
68
+ study_id: studyId,
69
+ tester_ids: simResults.map((r) => r.tester_id),
70
+ tester_aliases: simResults.map((r) => tagAlias(ALIAS_PREFIX.tester, String(r.tester_id))),
71
+ job_ids: simResults.map((r) => r.job_id ?? null),
72
+ count: simResults.length,
73
+ message: simResults[0].message,
74
+ },
75
+ ];
68
76
  }
69
77
  const POLL_INTERVAL_MS = 5_000;
70
78
  const TERMINAL_STATUSES = new Set(["completed", "errored", "failed", "cancelled", "canceled"]);
@@ -74,13 +82,16 @@ function flattenTesterStatuses(iterations, only) {
74
82
  for (const t of iteration.testers ?? []) {
75
83
  if (only && !only.has(t.id))
76
84
  continue;
85
+ // Pattern A (cli half): backend now reports per-tester crash detail at
86
+ // `error_message`. Keep `error` / `failure_reason` as legacy fallbacks
87
+ // until every backend deploy is on the new contract.
88
+ const errorMessage = t.error_message || t.error || t.failure_reason || null;
77
89
  rows.push({
78
90
  id: t.id,
79
91
  status: t.status,
80
92
  tester_name: t.tester_profile?.name || "Unknown",
81
93
  interaction_count: Array.isArray(t.interactions) ? t.interactions.length : 0,
82
- ...(t.error && { error: t.error }),
83
- ...(t.failure_reason && { error: t.failure_reason }),
94
+ ...(errorMessage && { error_message: errorMessage }),
84
95
  });
85
96
  }
86
97
  }
@@ -109,8 +120,16 @@ async function pollStudyUntilDone(client, opts) {
109
120
  return { rows, isMedia };
110
121
  }
111
122
  if (Date.now() - start > opts.timeoutMs) {
112
- throw new Error(`Timed out after ${Math.round(opts.timeoutMs / 1000)}s waiting for simulations. ` +
113
- `Run \`ish study poll --study ${opts.studyId}\` to check status.`);
123
+ throw new WaitTimeoutError(`Timed out after ${Math.round(opts.timeoutMs / 1000)}s waiting for simulations. ` +
124
+ `${done}/${total} done. Run \`ish study poll --study ${opts.studyId}\` to check status.`, {
125
+ study_id: opts.studyId,
126
+ ...(opts.iterationId && { iteration_id: opts.iterationId }),
127
+ timeout_seconds: Math.round(opts.timeoutMs / 1000),
128
+ done,
129
+ total,
130
+ pending: total - done,
131
+ rows,
132
+ });
114
133
  }
115
134
  await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
116
135
  }
@@ -143,9 +162,12 @@ export function attachStudyRunCommands(study) {
143
162
  })
144
163
  .option("--config <id>", "Simulation config ID (required for media unless every profile has one)")
145
164
  .option("--max-interactions <n>", "Max interactions per tester")
165
+ .option("--max-turns <n>", "Max conversation turns per tester (chat studies only)")
166
+ .option("--early-termination", "Allow chat agent to end the conversation early when goals are met (chat studies only)")
146
167
  .option("--language <lang>", "Language code (e.g. en, sv)")
147
168
  .option("--wait", "Wait for all simulations to reach a terminal state before returning")
148
169
  .option("--timeout <s>", "Wait timeout in seconds (default 300; only with --wait)")
170
+ .option("--dispatch-timeout <s>", "Per-POST timeout in seconds for the create-testers + dispatch calls (default 120). Bump if `study run` times out client-side after seeding testers but before dispatch — those testers exist server-side and are surfaced under `seeded_but_not_dispatched_*` in the error envelope so the agent can resume.")
149
171
  .option("-y, --yes", "Skip confirmation prompt")
150
172
  // Local simulation options
151
173
  .option("--local", "Run simulation with local browser (Playwright) instead of remote")
@@ -197,11 +219,49 @@ Examples:
197
219
  const log = (msg) => { if (!globals.quiet)
198
220
  console.error(msg); };
199
221
  const resolvedWorkspace = resolveWorkspace(opts.workspace);
200
- const resolvedStudy = resolveStudy(opts.study);
222
+ // Pattern A (Sprint 2): when `--iteration` is passed without
223
+ // `--study`, derive the parent study from the iteration itself
224
+ // (mirroring `study wait`). The iteration's `study_id` is
225
+ // authoritative — falling back to the active study config here
226
+ // would silently target the wrong study when the active context
227
+ // has drifted, surfacing as the misleading "Iteration X not found
228
+ // on this study" error N1 hit. If `--study` is also passed, they
229
+ // must agree.
230
+ let resolvedStudy;
231
+ if (opts.iteration && !opts.study) {
232
+ // The iteration's `study_id` is authoritative. Falling back to
233
+ // the active study config here would silently target the wrong
234
+ // study when the active context has drifted (Pattern A from N1)
235
+ // and surface as the misleading "Iteration X not found on this
236
+ // study" error. If the lookup fails (404, transport), surface
237
+ // the lookup error directly rather than masking with a stale
238
+ // active-study read.
239
+ const iterId = resolveId(opts.iteration);
240
+ const iter = await client.get(`/iterations/${iterId}`);
241
+ if (!iter.study_id) {
242
+ throw new Error(`Iteration ${opts.iteration} has no study_id.`);
243
+ }
244
+ resolvedStudy = iter.study_id;
245
+ }
246
+ else {
247
+ resolvedStudy = resolveStudy(opts.study);
248
+ }
249
+ // B7: per-POST dispatch timeout (default 120s — long enough to survive
250
+ // a slow-cold backend, short enough that an agent doesn't sit blind
251
+ // for many minutes). Applied to both the testers/batch POST AND the
252
+ // simulation start POST so the seed + dispatch budget is the same.
253
+ const dispatchTimeoutMs = opts.dispatchTimeout
254
+ ? Math.max(1, parseInt(opts.dispatchTimeout, 10)) * 1000
255
+ : 120_000;
256
+ if (opts.dispatchTimeout && (Number.isNaN(parseInt(opts.dispatchTimeout, 10))
257
+ || parseInt(opts.dispatchTimeout, 10) < 1)) {
258
+ throw new Error(`--dispatch-timeout must be a positive integer (seconds), got "${opts.dispatchTimeout}".`);
259
+ }
201
260
  // Step 0: Fetch study (with its iterations + their existing testers)
202
261
  const study = await client.get(`/studies/${resolvedStudy}`);
203
262
  const modality = study.modality || "interactive";
204
263
  const isMedia = isMediaModality(modality);
264
+ const isChat = isChatModality(modality);
205
265
  if (!study.assignments || study.assignments.length === 0) {
206
266
  throw new Error("Study has no assignments. Add tasks with --assignments when creating the study, or use `ish study generate`.");
207
267
  }
@@ -267,10 +327,11 @@ Examples:
267
327
  throw new Error(`Iteration "${iterationLabel}" has no testers and no audience flags were given. ` +
268
328
  "Pass --profile <ids>, or filter flags (--country, --gender, --min-age, --max-age, --search, --visibility) with --sample <N> or --all.");
269
329
  }
270
- // Step 3: Resolve simulation config (per-profile fallback for media)
330
+ // Step 3: Resolve simulation config (per-profile fallback for
331
+ // media + chat, both of which require a config_id per batch item)
271
332
  const resolvedConfigOverride = opts.config ? resolveId(opts.config) : undefined;
272
333
  const profileConfigMap = new Map();
273
- if (isMedia && !resolvedConfigOverride) {
334
+ if ((isMedia || isChat) && !resolvedConfigOverride) {
274
335
  for (const pid of profileIds) {
275
336
  const profile = await client.get(`/tester-profiles/${pid}`);
276
337
  if (profile.simulation_config_id) {
@@ -291,7 +352,17 @@ Examples:
291
352
  log(` Modality: ${modality}`);
292
353
  if (study.content_type)
293
354
  log(` Content type: ${study.content_type}`);
294
- if (!isMedia) {
355
+ if (isChat) {
356
+ const epId = typeof iteration.details?.chatbot_endpoint_id === "string"
357
+ ? iteration.details.chatbot_endpoint_id : undefined;
358
+ if (epId)
359
+ log(` Endpoint: ${epId}`);
360
+ if (opts.maxTurns)
361
+ log(` Max turns: ${opts.maxTurns}`);
362
+ if (opts.earlyTermination)
363
+ log(` Early term: enabled`);
364
+ }
365
+ else if (!isMedia) {
295
366
  log(` Platform: ${detailsView.platform || "browser"}`);
296
367
  log(` Screen format: ${detailsView.screenFormat || "desktop"}`);
297
368
  if (detailsView.url)
@@ -334,16 +405,16 @@ Examples:
334
405
  tester_type: "ai",
335
406
  status: "draft",
336
407
  ...(opts.language && { language: opts.language }),
337
- ...(!isMedia && { platform: detailsView.platform || "browser" }),
408
+ ...(!isMedia && !isChat && { platform: detailsView.platform || "browser" }),
338
409
  }));
339
410
  log(`Creating ${testerInputs.length} tester${testerInputs.length > 1 ? "s" : ""}...`);
340
- const batchResult = await client.post(`/iterations/${iterationId}/testers/batch`, { testers: testerInputs });
411
+ const batchResult = await client.post(`/iterations/${iterationId}/testers/batch`, { testers: testerInputs }, { timeout: dispatchTimeoutMs });
341
412
  createdTesters = batchResult.testers;
342
413
  log(`Created ${createdTesters.length} tester${createdTesters.length > 1 ? "s" : ""}`);
343
414
  }
344
415
  // Step 6: Dispatch
345
416
  if (opts.local) {
346
- if (isMedia) {
417
+ if (isMedia || isChat) {
347
418
  throw new Error("Local mode is only supported for interactive simulations.");
348
419
  }
349
420
  const testerNameMap = new Map();
@@ -386,18 +457,58 @@ Examples:
386
457
  }
387
458
  log(`Starting ${createdTesters.length} simulation${createdTesters.length > 1 ? "s" : ""}...`);
388
459
  let simResults;
389
- if (isMedia) {
460
+ // B7 / Pattern G: tag any failure during the dispatch POST with the
461
+ // testers that already exist server-side. The client doesn't know
462
+ // whether the backend processed our POST or never received it, so
463
+ // re-running `study run` would double-seed (we'd create another
464
+ // batch of testers on top of these). Instead, surface the seeded
465
+ // ids in the error envelope under `seeded_but_not_dispatched_ids`
466
+ // / `_aliases` so the agent can resume by polling them or calling
467
+ // `study tester delete <id>` then retrying.
468
+ const dispatchAttempt = async (fn) => {
469
+ try {
470
+ return await fn();
471
+ }
472
+ catch (err) {
473
+ if (err instanceof Error) {
474
+ const tagged = err;
475
+ tagged.seeded_but_not_dispatched_ids = createdTesters.map((t) => t.id);
476
+ tagged.seeded_but_not_dispatched_aliases = createdTesters.map((t) => tagAlias(ALIAS_PREFIX.tester, String(t.id)));
477
+ }
478
+ throw err;
479
+ }
480
+ };
481
+ if (isChat) {
482
+ const chatBatchItems = createdTesters.map((t, i) => ({
483
+ study_id: resolvedStudy,
484
+ tester_id: t.id,
485
+ config_id: resolvedConfigOverride || profileConfigMap.get(profileIds[i]),
486
+ ...(opts.language && { language: opts.language }),
487
+ }));
488
+ const maxTurns = opts.maxTurns ? parseInt(opts.maxTurns, 10) : undefined;
489
+ if (opts.maxTurns !== undefined && (Number.isNaN(maxTurns) || maxTurns < 1)) {
490
+ throw new Error(`Invalid --max-turns value: ${opts.maxTurns}`);
491
+ }
492
+ const simResult = await dispatchAttempt(() => client.post("/simulation/chat/start/batch", {
493
+ product_id: resolvedWorkspace,
494
+ simulations: chatBatchItems,
495
+ ...(maxTurns !== undefined && { max_turns: maxTurns }),
496
+ ...(opts.earlyTermination && { early_termination: true }),
497
+ }, { timeout: dispatchTimeoutMs }));
498
+ simResults = simResult.results;
499
+ }
500
+ else if (isMedia) {
390
501
  const mediaBatchItems = createdTesters.map((t, i) => ({
391
502
  study_id: resolvedStudy,
392
503
  tester_id: t.id,
393
504
  config_id: resolvedConfigOverride || profileConfigMap.get(profileIds[i]),
394
505
  ...(opts.language && { language: opts.language }),
395
506
  }));
396
- const simResult = await client.post("/simulation/media/start/batch", {
507
+ const simResult = await dispatchAttempt(() => client.post("/simulation/media/start/batch", {
397
508
  product_id: resolvedWorkspace,
398
509
  simulations: mediaBatchItems,
399
510
  ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
400
- }, { timeout: 60_000 });
511
+ }, { timeout: dispatchTimeoutMs }));
401
512
  simResults = simResult.results;
402
513
  }
403
514
  else {
@@ -408,14 +519,14 @@ Examples:
408
519
  ...(opts.language && { language: opts.language }),
409
520
  ...(detailsView.locale && { locale: detailsView.locale }),
410
521
  }));
411
- const simResult = await client.post("/simulation/interactive/start/batch", {
522
+ const simResult = await dispatchAttempt(() => client.post("/simulation/interactive/start/batch", {
412
523
  product_id: resolvedWorkspace,
413
524
  simulations: simItems,
414
525
  platform: detailsView.platform || "browser",
415
526
  ...(detailsView.url && { url: detailsView.url }),
416
527
  screen_format: detailsView.screenFormat || "desktop",
417
528
  ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
418
- }, { timeout: 60_000 });
529
+ }, { timeout: dispatchTimeoutMs }));
419
530
  simResults = simResult.results;
420
531
  }
421
532
  if (!opts.wait) {
@@ -430,7 +541,7 @@ Examples:
430
541
  testers: testersOut,
431
542
  tester_ids: testersOut.map((t) => t.id),
432
543
  tester_aliases: testersOut.map((t) => t.alias),
433
- simulations: simResults,
544
+ simulations: dedupeSimulations(simResults),
434
545
  }, true);
435
546
  }
436
547
  else {
@@ -466,7 +577,7 @@ Examples:
466
577
  testers: testersOut,
467
578
  tester_ids: testersOut.map((t) => t.id),
468
579
  tester_aliases: testersOut.map((t) => t.alias),
469
- simulations: simResults,
580
+ simulations: dedupeSimulations(simResults),
470
581
  results: rows,
471
582
  }, true);
472
583
  }
@@ -490,32 +601,22 @@ Examples:
490
601
  if (testerId) {
491
602
  const data = await client.get(`/simulation/status/${resolveId(testerId)}`);
492
603
  output(data, globals.json);
604
+ return;
493
605
  }
494
- else if (opts.study) {
495
- const rid = resolveId(opts.study);
496
- const study = await client.get(`/studies/${rid}`);
497
- const isMedia = isMediaModality(study.modality);
498
- const allTesters = [];
499
- for (const iteration of study.iterations || []) {
500
- for (const tester of iteration.testers || []) {
501
- allTesters.push({
502
- id: tester.id,
503
- status: tester.status,
504
- tester_name: tester.tester_profile?.name || "Unknown",
505
- interaction_count: Array.isArray(tester.interactions) ? tester.interactions.length : 0,
506
- ...(tester.error && { error: tester.error }),
507
- ...(tester.failure_reason && { error: tester.failure_reason }),
508
- });
509
- }
510
- }
511
- formatSimulationPoll(allTesters, globals.json, isMedia);
512
- if (!globals.json && study.product_id) {
513
- const url = getWebUrl(globals, `/${study.product_id}/${rid}/timeline`);
514
- console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
515
- }
516
- }
517
- else {
518
- throw new Error("Provide a tester_id argument or --study flag");
606
+ // M7: fall back to the active study (set by `ish study use`) when
607
+ // neither a tester_id nor an explicit --study is passed. This brings
608
+ // poll into parity with `study results` / `study wait` / `study run`,
609
+ // all of which already honor the active study. Without the fallback
610
+ // an agent that ran `study use s-...` then `study poll` would get a
611
+ // confusing "Provide a tester_id argument or --study flag" error.
612
+ const rid = resolveStudy(opts.study);
613
+ const study = await client.get(`/studies/${rid}`);
614
+ const isMedia = isMediaModality(study.modality);
615
+ const allTesters = flattenTesterStatuses(study.iterations);
616
+ formatSimulationPoll(allTesters, globals.json, isMedia);
617
+ if (!globals.json && study.product_id) {
618
+ const url = getWebUrl(globals, `/${study.product_id}/${rid}/timeline`);
619
+ console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
519
620
  }
520
621
  });
521
622
  });
@@ -535,8 +636,9 @@ Examples:
535
636
  if (testerId) {
536
637
  const start = Date.now();
537
638
  let lastStatus = "";
639
+ const resolvedTester = resolveId(testerId);
538
640
  while (true) {
539
- const data = await client.get(`/simulation/status/${resolveId(testerId)}`, undefined, { timeout: 60_000 });
641
+ const data = await client.get(`/simulation/status/${resolvedTester}`, undefined, { timeout: 60_000 });
540
642
  const status = String(data.status ?? "unknown");
541
643
  if (!globals.quiet && status !== lastStatus) {
542
644
  process.stderr.write(` ${status}\n`);
@@ -547,7 +649,24 @@ Examples:
547
649
  return;
548
650
  }
549
651
  if (Date.now() - start > timeoutMs) {
550
- throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for tester ${testerId}.`);
652
+ // M8 + M9 (per-tester wait): structured wait_timeout with the
653
+ // current status as `progress.rows[0]` so `study wait <id>`
654
+ // always emits machine-readable final state.
655
+ throw new WaitTimeoutError(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for tester ${testerId}. Last status: ${status}.`, {
656
+ study_id: resolvedTester,
657
+ timeout_seconds: Math.round(timeoutMs / 1000),
658
+ done: 0,
659
+ total: 1,
660
+ pending: 1,
661
+ rows: [
662
+ {
663
+ id: resolvedTester,
664
+ status,
665
+ tester_name: String(data.tester_name ?? "Unknown"),
666
+ interaction_count: 0,
667
+ },
668
+ ],
669
+ });
551
670
  }
552
671
  await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
553
672
  }
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { withClient, readJsonFileOrStdin, resolveWorkspace } from "../lib/command-helpers.js";
8
8
  import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
9
- import { formatTesterDetail, output } from "../lib/output.js";
9
+ import { formatTesterDetail, buildTesterSummary, output } from "../lib/output.js";
10
10
  /** Pick the latest iteration on a study (highest order_index, falling back to last). */
11
11
  async function latestIterationForStudy(client, studyId) {
12
12
  const study = await client.get(`/studies/${studyId}`);
@@ -53,7 +53,18 @@ export function attachStudyTesterCommands(study) {
53
53
  .description("Inspect or manage testers (low-level; usually created via `study run`)")
54
54
  .argument("[id]", "Tester ID — pass directly to view tester details and results")
55
55
  .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the tester)")
56
- .addHelpText("after", "\nExamples:\n $ ish study tester t-d4e # show tester details\n $ ish study tester create --iteration <id> --profile <id>\n $ ish study tester batch-create --iteration <id> --file testers.json\n $ ish study tester delete t-d4e")
56
+ .option("--summary", "Lean projection: alias + status + sentiment + comment + error_message. Drops the action timeline / interactions array.")
57
+ .addHelpText("after", `
58
+ Examples:
59
+ $ ish study tester t-d4e # show tester details
60
+ $ ish study tester t-d4e --summary --json # headline only (sentiment + comment)
61
+ $ ish study tester create --iteration <id> --profile <id>
62
+ $ ish study tester batch-create --iteration <id> --file testers.json
63
+ $ ish study tester delete t-d4e
64
+
65
+ Tips:
66
+ Use \`--get <path>\` to grab one value (e.g. \`--get sentiment\`),
67
+ \`--fields a,b,c\` to project further.`)
57
68
  .action(async (id, opts, cmd) => {
58
69
  if (!id) {
59
70
  cmd.help();
@@ -65,6 +76,10 @@ export function attachStudyTesterCommands(study) {
65
76
  const result = data;
66
77
  if (result.id)
67
78
  result.alias = tagAlias(ALIAS_PREFIX.tester, String(result.id));
79
+ if (opts.summary) {
80
+ output(buildTesterSummary(result), globals.json, { preProjected: true });
81
+ return;
82
+ }
68
83
  formatTesterDetail(result, globals.json);
69
84
  });
70
85
  });