@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.
- package/dist/commands/iteration.js +219 -22
- package/dist/commands/profile.js +75 -9
- package/dist/commands/source.js +6 -4
- package/dist/commands/study-run.js +382 -34
- package/dist/commands/study.js +170 -9
- package/dist/commands/workspace.js +35 -2
- package/dist/lib/accessibility-profile.d.ts +12 -0
- package/dist/lib/accessibility-profile.js +136 -0
- package/dist/lib/ask-questions.js +9 -0
- package/dist/lib/billing.d.ts +55 -0
- package/dist/lib/billing.js +77 -0
- package/dist/lib/docs.js +1106 -36
- package/dist/lib/enums.d.ts +54 -0
- package/dist/lib/enums.js +100 -0
- package/dist/lib/local-sim/actions.d.ts +2 -1
- package/dist/lib/local-sim/actions.js +88 -13
- package/dist/lib/local-sim/loop.js +49 -19
- package/dist/lib/local-sim/tabs.d.ts +27 -0
- package/dist/lib/local-sim/tabs.js +157 -0
- package/dist/lib/local-sim/types.d.ts +15 -0
- package/dist/lib/modality.d.ts +70 -1
- package/dist/lib/modality.js +323 -17
- package/dist/lib/output.js +61 -4
- package/dist/lib/skill-content.js +382 -19
- package/dist/lib/types.d.ts +6 -1
- package/package.json +1 -1
|
@@ -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;
|
package/dist/lib/modality.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/lib/modality.js
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
85
|
-
const
|
|
86
|
-
|
|
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
|
|
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",
|
package/dist/lib/output.js
CHANGED
|
@@ -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 = [
|
|
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", "
|
|
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
|
-
|
|
680
|
-
|
|
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 ---
|