@ishlabs/cli 0.13.0 → 0.14.1

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.
@@ -56,6 +56,13 @@ export interface ForwardEntry {
56
56
  type: string;
57
57
  content: string;
58
58
  }
59
+ export interface LocalTabInfo {
60
+ id: string;
61
+ url: string;
62
+ title: string;
63
+ active: boolean;
64
+ opener_id: string | null;
65
+ }
59
66
  export interface LocalSimStepRequest {
60
67
  tester_id: string;
61
68
  product_id: string;
@@ -76,6 +83,7 @@ export interface LocalSimStepRequest {
76
83
  dom_model: string | null;
77
84
  llm_provider: string | null;
78
85
  user_instruction?: string | null;
86
+ tabs?: LocalTabInfo[];
79
87
  }
80
88
  export interface LocalStepAction {
81
89
  type: string;
@@ -93,6 +101,9 @@ export interface LocalStepAction {
93
101
  count: number | null;
94
102
  duration_ms: number | null;
95
103
  thoughts: string | null;
104
+ modifiers: string[] | null;
105
+ key: string | null;
106
+ tab_id: string | null;
96
107
  }
97
108
  /** Raw backend step response — output is nested, actions are separate. */
98
109
  export interface LocalSimStepResponseRaw {
@@ -121,6 +132,9 @@ export interface LocalSimStepResponseRaw {
121
132
  count?: number;
122
133
  duration_ms?: number;
123
134
  thoughts?: string;
135
+ modifiers?: string[];
136
+ key?: string;
137
+ tab_id?: string;
124
138
  }>;
125
139
  };
126
140
  };
@@ -168,6 +182,7 @@ export interface RecordInteraction {
168
182
  actions: ActionData[];
169
183
  current_location: string | null;
170
184
  assignment_completed: boolean;
185
+ tabs?: LocalTabInfo[];
171
186
  }
172
187
  export interface AssignmentStatusUpdate {
173
188
  assignment_id: string;
@@ -21,7 +21,75 @@ export declare function isChatModality(modality: string | undefined): boolean;
21
21
  */
22
22
  export declare function iterationHasContent(details: unknown, modality: string | undefined): boolean;
23
23
  /** The flag fragment a user should pass to populate content for a given modality. */
24
- export declare function describeRequiredContentFlag(modality: string): string;
24
+ export declare function describeRequiredContentFlag(modality: string, chatMode?: ChatMode): string;
25
+ /**
26
+ * Chat mode discriminator. Lives inside `iteration.details.mode_details.mode`
27
+ * once the backend migration has run. CLI code that pre-dates the migration
28
+ * may still encounter top-level chat keys (`endpoint`, `chatbot_endpoint_id`);
29
+ * the read helpers below fall back to that legacy shape and treat it as
30
+ * `external_chatbot`.
31
+ */
32
+ declare const CHAT_MODES: readonly ["external_chatbot", "tester_pair"];
33
+ export type ChatMode = typeof CHAT_MODES[number];
34
+ /**
35
+ * Normalise user-supplied `--chat-mode` values. Accepts both `tester_pair`
36
+ * (canonical) and `tester-pair` (hyphenated — matches CLI flag convention
37
+ * elsewhere in `ish`); same for `external-chatbot`. Returns `null` for
38
+ * anything unrecognised so callers can throw a clean ValidationError.
39
+ */
40
+ export declare function normalizeChatMode(raw: string | undefined): ChatMode | null;
41
+ /** Read the chat mode discriminator, defaulting to `external_chatbot` for legacy payloads. */
42
+ export declare function readChatMode(details: unknown): ChatMode;
43
+ /**
44
+ * Extract the external-chatbot endpoint reference from chat details.
45
+ * Reads from `mode_details` first, then falls back to top-level keys for
46
+ * back-compat with legacy iteration payloads.
47
+ */
48
+ export declare function readExternalChatbotEndpoint(details: unknown): {
49
+ endpoint?: Record<string, unknown>;
50
+ chatbot_endpoint_id?: string;
51
+ };
52
+ /**
53
+ * Extract the tester-pair payload from chat details. Returns `undefined` if
54
+ * `mode_details.mode !== "tester_pair"`. Does not validate equal lengths or
55
+ * non-empty scenarios — that's the caller's job (see `iterationHasContent`
56
+ * and `validateIterationDetails`).
57
+ */
58
+ export declare function readTesterPairConfig(details: unknown): {
59
+ audience_a: string[];
60
+ audience_b: string[];
61
+ scenario_a: string;
62
+ scenario_b: string;
63
+ initiator_side: "a" | "b";
64
+ role_criteria_a?: Record<string, unknown>;
65
+ role_criteria_b?: Record<string, unknown>;
66
+ } | undefined;
67
+ /**
68
+ * RoleCriteria — persona-first filter that resolves a tester-profile pool
69
+ * for one side of a tester_pair iteration. Mirrors the backend Pydantic
70
+ * shape in `app/api/models/iterations.py:RoleCriteria` (swift-charm plan).
71
+ * All fields optional; an empty object is treated as "no filter".
72
+ */
73
+ export declare const ROLE_CRITERIA_KEYS: readonly ["occupation", "min_age", "max_age", "gender", "country", "education_level_in", "household_in", "locale_type_in", "income_level_in", "employment_status_in", "requires_captions", "uses_screen_reader", "prefers_reduced_motion", "prefers_high_contrast", "has_any_accessibility_need"];
74
+ export type RoleCriteriaKey = typeof ROLE_CRITERIA_KEYS[number];
75
+ /**
76
+ * Validate a parsed role-criteria object client-side. Whitelists known
77
+ * keys (typo guard — catches `occupations` vs `occupation`), type-checks
78
+ * each field, and returns the cleaned object. Throws a descriptive Error
79
+ * on the first violation. Returns `undefined` for `undefined` input so
80
+ * callers can `validateRoleCriteria(opts.roleCriteriaA)` without a guard.
81
+ *
82
+ * Mirrors the backend Pydantic check shape-for-shape; surfacing locally
83
+ * costs less than a round-trip and gives agents the same error contract
84
+ * they get for other CLI inputs (exit 2, ValidationError).
85
+ */
86
+ export declare function validateRoleCriteria(raw: unknown, flagName: string): Record<string, unknown> | undefined;
87
+ /**
88
+ * Pretty-print a role criteria object as a single-line summary for human
89
+ * output (confirmation block, iteration get renderer). Returns `""` when
90
+ * the object is empty / undefined so callers can compose safely.
91
+ */
92
+ export declare function summarizeRoleCriteria(criteria: Record<string, unknown> | undefined): string;
25
93
  export type DetailsValidation = {
26
94
  ok: true;
27
95
  } | {
@@ -40,3 +108,4 @@ export type DetailsValidation = {
40
108
  * boundary.
41
109
  */
42
110
  export declare function validateIterationDetails(modality: string | undefined, details: unknown): DetailsValidation;
111
+ export {};
@@ -7,6 +7,7 @@
7
7
  * and the call sites (`study run`, `iteration update`, ...) pick up the
8
8
  * change for free.
9
9
  */
10
+ import { normalizeEnumValue } from "./enums.js";
10
11
  import { MEDIA_MODALITIES } from "./types.js";
11
12
  /** True for the media-bucket modalities that share batch + content semantics. */
12
13
  export function isMediaModality(modality) {
@@ -42,18 +43,44 @@ export function iterationHasContent(details, modality) {
42
43
  return false;
43
44
  }
44
45
  if (modality === "chat") {
45
- // A chat iteration carries either an inline endpoint config or a
46
- // pointer to a saved chatbot_endpoints row.
47
- const hasEndpoint = !!d.endpoint && typeof d.endpoint === "object";
48
- const hasEndpointId = typeof d.chatbot_endpoint_id === "string"
49
- && d.chatbot_endpoint_id.length > 0;
50
- return hasEndpoint || hasEndpointId;
46
+ const mode = readChatMode(details);
47
+ if (mode === "tester_pair") {
48
+ const pair = readTesterPairConfig(details);
49
+ if (!pair)
50
+ return false;
51
+ // Each side needs *either* an explicit audience or a role-criteria
52
+ // filter. Scenarios are always required (they're how the persona
53
+ // gets a role to inhabit).
54
+ const sideAHasAudience = pair.audience_a.length > 0 || !!pair.role_criteria_a;
55
+ const sideBHasAudience = pair.audience_b.length > 0 || !!pair.role_criteria_b;
56
+ if (!sideAHasAudience || !sideBHasAudience)
57
+ return false;
58
+ if (pair.scenario_a.length === 0 || pair.scenario_b.length === 0)
59
+ return false;
60
+ // Equal-length pairing is only enforced when *both* sides ship an
61
+ // explicit audience. Criteria-driven sides are resolved server-side
62
+ // and persisted back to audience_*; until then the lengths may
63
+ // legitimately differ (or be zero).
64
+ const bothAudiencesExplicit = pair.audience_a.length > 0
65
+ && pair.audience_b.length > 0
66
+ && !pair.role_criteria_a
67
+ && !pair.role_criteria_b;
68
+ if (bothAudiencesExplicit && pair.audience_a.length !== pair.audience_b.length) {
69
+ return false;
70
+ }
71
+ return true;
72
+ }
73
+ // external_chatbot: an inline endpoint config or a pointer to a saved
74
+ // chatbot_endpoints row. Check `mode_details` first; fall back to the
75
+ // legacy top-level keys for iterations that pre-date the backend migration.
76
+ const ep = readExternalChatbotEndpoint(details);
77
+ return !!ep.endpoint || !!ep.chatbot_endpoint_id;
51
78
  }
52
79
  // interactive (default)
53
80
  return typeof d.url === "string" && d.url.length > 0;
54
81
  }
55
82
  /** The flag fragment a user should pass to populate content for a given modality. */
56
- export function describeRequiredContentFlag(modality) {
83
+ export function describeRequiredContentFlag(modality, chatMode) {
57
84
  if (modality === "text")
58
85
  return "--content-text <text-or-@file>";
59
86
  if (modality === "video" || modality === "audio" || modality === "document") {
@@ -61,10 +88,226 @@ export function describeRequiredContentFlag(modality) {
61
88
  }
62
89
  if (modality === "image")
63
90
  return "--image-urls <comma-separated>";
64
- if (modality === "chat")
91
+ if (modality === "chat") {
92
+ if (chatMode === "tester_pair") {
93
+ return "--chat-mode tester_pair (--audience-a <ids> | --role-criteria-a <json-or-@file>) (--audience-b <ids> | --role-criteria-b <json-or-@file>) --scenario-a <text-or-@file> --scenario-b <text-or-@file>";
94
+ }
65
95
  return "--endpoint <id> | --endpoint-config <file>";
96
+ }
66
97
  return "--url <url>";
67
98
  }
99
+ /**
100
+ * Chat mode discriminator. Lives inside `iteration.details.mode_details.mode`
101
+ * once the backend migration has run. CLI code that pre-dates the migration
102
+ * may still encounter top-level chat keys (`endpoint`, `chatbot_endpoint_id`);
103
+ * the read helpers below fall back to that legacy shape and treat it as
104
+ * `external_chatbot`.
105
+ */
106
+ const CHAT_MODES = ["external_chatbot", "tester_pair"];
107
+ /**
108
+ * Normalise user-supplied `--chat-mode` values. Accepts both `tester_pair`
109
+ * (canonical) and `tester-pair` (hyphenated — matches CLI flag convention
110
+ * elsewhere in `ish`); same for `external-chatbot`. Returns `null` for
111
+ * anything unrecognised so callers can throw a clean ValidationError.
112
+ */
113
+ export function normalizeChatMode(raw) {
114
+ return normalizeEnumValue(raw, CHAT_MODES);
115
+ }
116
+ /** Read the chat mode discriminator, defaulting to `external_chatbot` for legacy payloads. */
117
+ export function readChatMode(details) {
118
+ if (!details || typeof details !== "object")
119
+ return "external_chatbot";
120
+ const md = details.mode_details;
121
+ if (md && typeof md === "object") {
122
+ const mode = md.mode;
123
+ if (mode === "tester_pair")
124
+ return "tester_pair";
125
+ if (mode === "external_chatbot")
126
+ return "external_chatbot";
127
+ }
128
+ return "external_chatbot";
129
+ }
130
+ /**
131
+ * Extract the external-chatbot endpoint reference from chat details.
132
+ * Reads from `mode_details` first, then falls back to top-level keys for
133
+ * back-compat with legacy iteration payloads.
134
+ */
135
+ export function readExternalChatbotEndpoint(details) {
136
+ if (!details || typeof details !== "object")
137
+ return {};
138
+ const d = details;
139
+ const md = d.mode_details && typeof d.mode_details === "object"
140
+ ? d.mode_details
141
+ : undefined;
142
+ const endpoint = (md?.endpoint && typeof md.endpoint === "object")
143
+ ? md.endpoint
144
+ : (d.endpoint && typeof d.endpoint === "object")
145
+ ? d.endpoint
146
+ : undefined;
147
+ const idRaw = (typeof md?.chatbot_endpoint_id === "string" && md.chatbot_endpoint_id.length > 0)
148
+ ? md.chatbot_endpoint_id
149
+ : (typeof d.chatbot_endpoint_id === "string" && d.chatbot_endpoint_id.length > 0)
150
+ ? d.chatbot_endpoint_id
151
+ : undefined;
152
+ return {
153
+ ...(endpoint && { endpoint }),
154
+ ...(idRaw && { chatbot_endpoint_id: idRaw }),
155
+ };
156
+ }
157
+ /**
158
+ * Extract the tester-pair payload from chat details. Returns `undefined` if
159
+ * `mode_details.mode !== "tester_pair"`. Does not validate equal lengths or
160
+ * non-empty scenarios — that's the caller's job (see `iterationHasContent`
161
+ * and `validateIterationDetails`).
162
+ */
163
+ export function readTesterPairConfig(details) {
164
+ if (!details || typeof details !== "object")
165
+ return undefined;
166
+ const md = details.mode_details;
167
+ if (!md || typeof md !== "object")
168
+ return undefined;
169
+ const m = md;
170
+ if (m.mode !== "tester_pair")
171
+ return undefined;
172
+ const audA = Array.isArray(m.audience_a) ? m.audience_a.filter((x) => typeof x === "string") : [];
173
+ const audB = Array.isArray(m.audience_b) ? m.audience_b.filter((x) => typeof x === "string") : [];
174
+ const scenA = typeof m.scenario_a === "string" ? m.scenario_a : "";
175
+ const scenB = typeof m.scenario_b === "string" ? m.scenario_b : "";
176
+ const init = m.initiator_side === "b" ? "b" : "a";
177
+ const critA = (m.role_criteria_a && typeof m.role_criteria_a === "object" && !Array.isArray(m.role_criteria_a))
178
+ ? m.role_criteria_a
179
+ : undefined;
180
+ const critB = (m.role_criteria_b && typeof m.role_criteria_b === "object" && !Array.isArray(m.role_criteria_b))
181
+ ? m.role_criteria_b
182
+ : undefined;
183
+ return {
184
+ audience_a: audA,
185
+ audience_b: audB,
186
+ scenario_a: scenA,
187
+ scenario_b: scenB,
188
+ initiator_side: init,
189
+ ...(critA && { role_criteria_a: critA }),
190
+ ...(critB && { role_criteria_b: critB }),
191
+ };
192
+ }
193
+ /**
194
+ * RoleCriteria — persona-first filter that resolves a tester-profile pool
195
+ * for one side of a tester_pair iteration. Mirrors the backend Pydantic
196
+ * shape in `app/api/models/iterations.py:RoleCriteria` (swift-charm plan).
197
+ * All fields optional; an empty object is treated as "no filter".
198
+ */
199
+ export const ROLE_CRITERIA_KEYS = [
200
+ "occupation",
201
+ "min_age",
202
+ "max_age",
203
+ "gender",
204
+ "country",
205
+ "education_level_in",
206
+ "household_in",
207
+ "locale_type_in",
208
+ "income_level_in",
209
+ "employment_status_in",
210
+ "requires_captions",
211
+ "uses_screen_reader",
212
+ "prefers_reduced_motion",
213
+ "prefers_high_contrast",
214
+ "has_any_accessibility_need",
215
+ ];
216
+ const ROLE_CRITERIA_LIST_KEYS = new Set([
217
+ "occupation",
218
+ "gender",
219
+ "country",
220
+ "education_level_in",
221
+ "household_in",
222
+ "locale_type_in",
223
+ "income_level_in",
224
+ "employment_status_in",
225
+ ]);
226
+ const ROLE_CRITERIA_BOOLEAN_KEYS = new Set([
227
+ "requires_captions",
228
+ "uses_screen_reader",
229
+ "prefers_reduced_motion",
230
+ "prefers_high_contrast",
231
+ "has_any_accessibility_need",
232
+ ]);
233
+ /**
234
+ * Validate a parsed role-criteria object client-side. Whitelists known
235
+ * keys (typo guard — catches `occupations` vs `occupation`), type-checks
236
+ * each field, and returns the cleaned object. Throws a descriptive Error
237
+ * on the first violation. Returns `undefined` for `undefined` input so
238
+ * callers can `validateRoleCriteria(opts.roleCriteriaA)` without a guard.
239
+ *
240
+ * Mirrors the backend Pydantic check shape-for-shape; surfacing locally
241
+ * costs less than a round-trip and gives agents the same error contract
242
+ * they get for other CLI inputs (exit 2, ValidationError).
243
+ */
244
+ export function validateRoleCriteria(raw, flagName) {
245
+ if (raw === undefined || raw === null)
246
+ return undefined;
247
+ if (typeof raw !== "object" || Array.isArray(raw)) {
248
+ throw new Error(`Invalid ${flagName}: expected a JSON object.`);
249
+ }
250
+ const out = {};
251
+ for (const [key, value] of Object.entries(raw)) {
252
+ if (!ROLE_CRITERIA_KEYS.includes(key)) {
253
+ throw new Error(`Invalid ${flagName}: unknown key "${key}". Allowed keys: ${ROLE_CRITERIA_KEYS.join(", ")}.`);
254
+ }
255
+ if (value === null || value === undefined)
256
+ continue;
257
+ if (ROLE_CRITERIA_LIST_KEYS.has(key)) {
258
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string" && v.length > 0)) {
259
+ throw new Error(`Invalid ${flagName}: "${key}" must be an array of non-empty strings.`);
260
+ }
261
+ if (value.length === 0)
262
+ continue;
263
+ out[key] = value;
264
+ continue;
265
+ }
266
+ if (ROLE_CRITERIA_BOOLEAN_KEYS.has(key)) {
267
+ if (typeof value !== "boolean") {
268
+ throw new Error(`Invalid ${flagName}: "${key}" must be a boolean.`);
269
+ }
270
+ out[key] = value;
271
+ continue;
272
+ }
273
+ // numeric fields: min_age, max_age
274
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) {
275
+ throw new Error(`Invalid ${flagName}: "${key}" must be an integer.`);
276
+ }
277
+ if ((key === "min_age" || key === "max_age") && value < 0) {
278
+ throw new Error(`Invalid ${flagName}: "${key}" must be non-negative.`);
279
+ }
280
+ out[key] = value;
281
+ }
282
+ if (typeof out.min_age === "number" && typeof out.max_age === "number" && out.min_age > out.max_age) {
283
+ throw new Error(`Invalid ${flagName}: min_age (${out.min_age}) must be <= max_age (${out.max_age}).`);
284
+ }
285
+ return Object.keys(out).length > 0 ? out : undefined;
286
+ }
287
+ /**
288
+ * Pretty-print a role criteria object as a single-line summary for human
289
+ * output (confirmation block, iteration get renderer). Returns `""` when
290
+ * the object is empty / undefined so callers can compose safely.
291
+ */
292
+ export function summarizeRoleCriteria(criteria) {
293
+ if (!criteria)
294
+ return "";
295
+ const parts = [];
296
+ for (const key of ROLE_CRITERIA_KEYS) {
297
+ const v = criteria[key];
298
+ if (v === undefined || v === null)
299
+ continue;
300
+ if (Array.isArray(v)) {
301
+ if (v.length === 0)
302
+ continue;
303
+ parts.push(`${key}=[${v.join(", ")}]`);
304
+ }
305
+ else {
306
+ parts.push(`${key}=${String(v)}`);
307
+ }
308
+ }
309
+ return parts.join(", ");
310
+ }
68
311
  /**
69
312
  * Detect modality/details mismatch on `iteration update --details-json`.
70
313
  *
@@ -81,9 +324,11 @@ export function validateIterationDetails(modality, details) {
81
324
  }
82
325
  const d = details;
83
326
  const hasUrl = typeof d.url === "string" && d.url.length > 0;
84
- const hasEndpointObj = !!d.endpoint && typeof d.endpoint === "object";
85
- const hasEndpointId = typeof d.chatbot_endpoint_id === "string"
86
- && d.chatbot_endpoint_id.length > 0;
327
+ const ep = readExternalChatbotEndpoint(details);
328
+ const hasEndpointObj = !!ep.endpoint;
329
+ const hasEndpointId = !!ep.chatbot_endpoint_id;
330
+ const hasModeDetails = !!d.mode_details && typeof d.mode_details === "object";
331
+ const isPair = readChatMode(details) === "tester_pair";
87
332
  const hasContentText = typeof d.content_text === "string" && d.content_text.length > 0;
88
333
  const hasContentUrl = typeof d.content_url === "string" && d.content_url.length > 0;
89
334
  const imageUrls = d.image_urls;
@@ -91,20 +336,81 @@ export function validateIterationDetails(modality, details) {
91
336
  ? imageUrls.length > 0
92
337
  : typeof imageUrls === "string" && imageUrls.length > 0;
93
338
  if (modality === "chat") {
339
+ if (isPair) {
340
+ const pair = readTesterPairConfig(details);
341
+ if (!pair) {
342
+ return {
343
+ ok: false,
344
+ reason: "missing or malformed mode_details for tester_pair chat iteration",
345
+ suggestion: "tester_pair iterations require mode_details: { mode: 'tester_pair', audience_a|role_criteria_a, audience_b|role_criteria_b, scenario_a, scenario_b }",
346
+ };
347
+ }
348
+ // Validate criteria shape if present; surfaces client-side errors
349
+ // (typo'd key, wrong type) before the round-trip.
350
+ try {
351
+ validateRoleCriteria(pair.role_criteria_a, "role_criteria_a");
352
+ validateRoleCriteria(pair.role_criteria_b, "role_criteria_b");
353
+ }
354
+ catch (err) {
355
+ return {
356
+ ok: false,
357
+ reason: err instanceof Error ? err.message : "invalid role criteria",
358
+ suggestion: `allowed keys: ${ROLE_CRITERIA_KEYS.join(", ")}`,
359
+ };
360
+ }
361
+ const sideAHasAudience = pair.audience_a.length > 0 || !!pair.role_criteria_a;
362
+ const sideBHasAudience = pair.audience_b.length > 0 || !!pair.role_criteria_b;
363
+ if (!sideAHasAudience || !sideBHasAudience) {
364
+ return {
365
+ ok: false,
366
+ reason: "tester_pair iteration is missing audience input on at least one side",
367
+ suggestion: "each side needs either an explicit audience (audience_a/audience_b) or a role-criteria filter (role_criteria_a/role_criteria_b)",
368
+ };
369
+ }
370
+ const bothAudiencesExplicit = pair.audience_a.length > 0
371
+ && pair.audience_b.length > 0
372
+ && !pair.role_criteria_a
373
+ && !pair.role_criteria_b;
374
+ const lenA = pair.audience_a.length;
375
+ const lenB = pair.audience_b.length;
376
+ const validPairing = lenA === lenB || lenA === 1 || lenB === 1;
377
+ if (bothAudiencesExplicit && !validPairing) {
378
+ return {
379
+ ok: false,
380
+ reason: `tester_pair audiences cannot be paired (audience_a=${lenA}, audience_b=${lenB})`,
381
+ suggestion: "pick the same number on each side (zip 1:1 by index), or exactly 1 on one side to broadcast across the other; or use role_criteria_a/_b to let the backend resolve the pool",
382
+ };
383
+ }
384
+ if (pair.scenario_a.length === 0 || pair.scenario_b.length === 0) {
385
+ return {
386
+ ok: false,
387
+ reason: "tester_pair iteration is missing scenario_a or scenario_b",
388
+ suggestion: "both scenarios are required and asymmetric; pass --scenario-a and --scenario-b on `iteration create`",
389
+ };
390
+ }
391
+ return { ok: true };
392
+ }
94
393
  if (hasEndpointObj || hasEndpointId)
95
394
  return { ok: true };
395
+ if (hasModeDetails) {
396
+ return {
397
+ ok: false,
398
+ reason: "unrecognised mode_details for chat iteration",
399
+ suggestion: "mode_details must be either { mode:'external_chatbot', endpoint|chatbot_endpoint_id } or { mode:'tester_pair', audience_a, audience_b, scenario_a, scenario_b }",
400
+ };
401
+ }
96
402
  if (hasUrl) {
97
403
  return {
98
404
  ok: false,
99
405
  reason: "interactive-shape details on a chat iteration",
100
- suggestion: "use --details-json with .endpoint or .chatbot_endpoint_id, or use `ish iteration update` with --endpoint <id>",
406
+ suggestion: "use --details-json with mode_details.endpoint or mode_details.chatbot_endpoint_id, or use `ish iteration update` with --endpoint <id>",
101
407
  };
102
408
  }
103
409
  if (hasContentText || hasContentUrl || hasImageUrls) {
104
410
  return {
105
411
  ok: false,
106
412
  reason: "media-shape details on a chat iteration",
107
- suggestion: "chat iterations require .endpoint (object) or .chatbot_endpoint_id (string); pass --endpoint <id> or --endpoint-config <file>",
413
+ suggestion: "chat iterations require mode_details with endpoint/chatbot_endpoint_id (external_chatbot mode) or audience_a/_b + scenario_a/_b (tester_pair mode)",
108
414
  };
109
415
  }
110
416
  // No recognised content keys at all. Don't reject — agent might be
@@ -114,7 +420,7 @@ export function validateIterationDetails(modality, details) {
114
420
  if (modality === "interactive") {
115
421
  if (hasUrl)
116
422
  return { ok: true };
117
- if (hasEndpointObj || hasEndpointId) {
423
+ if (hasEndpointObj || hasEndpointId || hasModeDetails) {
118
424
  return {
119
425
  ok: false,
120
426
  reason: "chat-shape details on an interactive iteration",
@@ -140,7 +446,7 @@ export function validateIterationDetails(modality, details) {
140
446
  suggestion: "text iterations require .content_text; pass --content-text <text-or-@file>",
141
447
  };
142
448
  }
143
- if (hasEndpointObj || hasEndpointId) {
449
+ if (hasEndpointObj || hasEndpointId || hasModeDetails) {
144
450
  return {
145
451
  ok: false,
146
452
  reason: "chat-shape details on a text iteration",
@@ -159,7 +465,7 @@ export function validateIterationDetails(modality, details) {
159
465
  suggestion: `${modality} iterations require .content_url; pass --content-url <url-or-file>`,
160
466
  };
161
467
  }
162
- if (hasEndpointObj || hasEndpointId) {
468
+ if (hasEndpointObj || hasEndpointId || hasModeDetails) {
163
469
  return {
164
470
  ok: false,
165
471
  reason: `chat-shape details on a ${modality} iteration`,
@@ -178,7 +484,7 @@ export function validateIterationDetails(modality, details) {
178
484
  suggestion: "image iterations require .image_urls (array or comma-separated string); pass --image-urls <urls>",
179
485
  };
180
486
  }
181
- if (hasEndpointObj || hasEndpointId) {
487
+ if (hasEndpointObj || hasEndpointId || hasModeDetails) {
182
488
  return {
183
489
  ok: false,
184
490
  reason: "chat-shape details on an image iteration",
@@ -127,6 +127,18 @@ const UUID_KEYS_TO_KEEP = new Set([
127
127
  const LEAN_PASSTHROUGH_KEYS = new Set([
128
128
  // Pattern H: variant_id → [tester_id, ...] for drill-in audience discovery.
129
129
  "pick_buckets",
130
+ // Pattern A: workspace cold-start fields. Agents need these without --verbose
131
+ // to spot a safe reuse target (has_headroom) and order workspaces by recency.
132
+ // last_activity_at is a timestamp — would otherwise be stripped.
133
+ // child_counts is a dict — passthrough preserves the inner counts verbatim.
134
+ "study_count",
135
+ "ask_count",
136
+ "tester_profile_count",
137
+ "child_counts",
138
+ "has_headroom",
139
+ "last_activity_at",
140
+ // workspace create --ensure: signals whether an existing workspace was reused.
141
+ "reused",
130
142
  ]);
131
143
  /**
132
144
  * Strip UUID-valued fields, null/undefined values, and timestamps.
@@ -642,8 +654,24 @@ function formatLabel(key) {
642
654
  /**
643
655
  * Default workspace fields exposed in both TTY tables and JSON output.
644
656
  * Anything not in this set (e.g. has_figma_token) is hidden unless --verbose.
657
+ *
658
+ * Pattern A fields (study_count, child_counts, has_headroom, last_activity_at,
659
+ * …) are load-bearing for the cold-start workflow — agents need them to pick
660
+ * a safe reuse target. `reused` ships on `workspace create --ensure` responses.
645
661
  */
646
- const WORKSPACE_DEFAULT_FIELDS = ["alias", "name", "base_url", "created_at"];
662
+ const WORKSPACE_DEFAULT_FIELDS = [
663
+ "alias",
664
+ "name",
665
+ "base_url",
666
+ "created_at",
667
+ "study_count",
668
+ "ask_count",
669
+ "tester_profile_count",
670
+ "child_counts",
671
+ "has_headroom",
672
+ "last_activity_at",
673
+ "reused",
674
+ ];
647
675
  function projectWorkspace(workspace, options = {}) {
648
676
  const result = {};
649
677
  if (options.writePath && workspace.id !== null && workspace.id !== undefined) {
@@ -673,13 +701,35 @@ export function formatWorkspaceList(workspaces, json) {
673
701
  return;
674
702
  }
675
703
  const aliasMap = getAliasMap(ALIAS_PREFIX.workspace);
676
- printTable(["#", "NAME", "BASE URL", "CREATED"], workspaces.map((w) => [
704
+ printTable(["#", "NAME", "ROOM", "STUDIES", "ASKS", "TESTERS", "LAST ACTIVITY"], workspaces.map((w) => [
677
705
  aliasMap.get(String(w.id)) || String(w.id || ""),
678
706
  String(w.name || ""),
679
- String(w.base_url || "-"),
680
- formatDate(w.created_at),
707
+ formatHeadroom(w.has_headroom),
708
+ formatChildCount(w, "studies", "study_count"),
709
+ formatChildCount(w, "asks", "ask_count"),
710
+ formatChildCount(w, "tester_profiles", "tester_profile_count"),
711
+ formatDate(w.last_activity_at),
681
712
  ]));
682
713
  }
714
+ function formatHeadroom(v) {
715
+ if (v === true)
716
+ return "yes";
717
+ if (v === false)
718
+ return "full";
719
+ return "-";
720
+ }
721
+ function formatChildCount(w, childKey, flatKey) {
722
+ const child = w.child_counts;
723
+ if (child !== null && typeof child === "object" && childKey in child) {
724
+ const v = child[childKey];
725
+ if (typeof v === "number")
726
+ return String(v);
727
+ }
728
+ const flat = w[flatKey];
729
+ if (typeof flat === "number")
730
+ return String(flat);
731
+ return "-";
732
+ }
683
733
  export function formatWorkspaceDetail(workspace, json, options = {}) {
684
734
  if (json) {
685
735
  if (_verbose) {
@@ -697,7 +747,14 @@ export function formatWorkspaceDetail(workspace, json, options = {}) {
697
747
  Description: workspace.description || "-",
698
748
  "Base URL": workspace.base_url || "-",
699
749
  Created: formatDate(workspace.created_at),
750
+ "Last activity": formatDate(workspace.last_activity_at),
751
+ Headroom: formatHeadroom(workspace.has_headroom),
752
+ Studies: formatChildCount(workspace, "studies", "study_count"),
753
+ Asks: formatChildCount(workspace, "asks", "ask_count"),
754
+ Testers: formatChildCount(workspace, "tester_profiles", "tester_profile_count"),
700
755
  };
756
+ if (workspace.reused !== undefined)
757
+ display.Reused = workspace.reused ? "yes" : "no";
701
758
  printKeyValue(display);
702
759
  }
703
760
  // --- Site-access formatting ---