@ishlabs/cli 0.15.0 → 0.17.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.
package/README.md CHANGED
@@ -308,6 +308,16 @@ ish ask run --new --name "newsletter" \
308
308
 
309
309
  **Audience flags** (`--profile`, `--sample`, `--all-simulatable`, `--country`, `--gender`, `--min-age`, `--max-age`, `--search`, `--visibility`, `--name`, `--description`, `--workspace`) only apply with `--new` — the audience is fixed at ask creation.
310
310
 
311
+ **`--visibility` values** (same set everywhere it's accepted):
312
+
313
+ | Value | Selects |
314
+ |---|---|
315
+ | `workspace` | Profiles owned by your workspace (default scope). |
316
+ | `shared` | Community-published profiles visible across workspaces. |
317
+ | `platform` | Admin-curated profiles from the platform pool. |
318
+
319
+ Old values `private` (now `workspace`) and `public` (now `platform`) keep working until the next release; the server logs a deprecation warning and maps them to the new vocabulary.
320
+
311
321
  **Other ask verbs:**
312
322
 
313
323
  ```bash
@@ -19,6 +19,8 @@ function audienceFlags(opts) {
19
19
  sample: opts.sample,
20
20
  all: opts.allSimulatable,
21
21
  search: opts.search,
22
+ bio: opts.bio,
23
+ occupation: opts.occupation,
22
24
  gender: opts.gender,
23
25
  country: opts.country,
24
26
  minAge: opts.minAge,
@@ -30,7 +30,9 @@ Concept pages: ish docs get-page concepts/profile
30
30
  .command("list")
31
31
  .description("List profiles (defaults to simulatable AI profiles)")
32
32
  .option("--workspace <id>", "Filter by workspace ID")
33
- .option("--search <query>", "Free-text search (matches profile name and bio)")
33
+ .option("--search <query>", "Substring match against profile name")
34
+ .option("--bio <text>", "Substring match against profile bio")
35
+ .option("--occupation <text>", "Substring match against profile occupation (repeatable)", collect, [])
34
36
  .option("--type <type>", "Profile type: ai, human, all (default: ai)", "ai")
35
37
  .option("--gender <gender>", "Filter by gender (repeatable)", collect, [])
36
38
  .option("--country <country>", "Filter by country code, e.g. US (repeatable)", collect, [])
@@ -42,10 +44,12 @@ Concept pages: ish docs get-page concepts/profile
42
44
  Examples:
43
45
  $ ish profile list
44
46
  $ ish profile list --search "engineer" --country US
47
+ $ ish profile list --bio "voice-first user"
48
+ $ ish profile list --occupation founder --occupation designer
45
49
  $ ish profile list --gender female --gender male --country US --country GB
46
50
  $ ish profile list --type all --json
47
51
 
48
- # Pagination default --limit is 50; iterate with --offset:
52
+ # Pagination: default --limit is 50, iterate with --offset.
49
53
  $ ish profile list --limit 100
50
54
  $ ish profile list --limit 100 --offset 100 # next page
51
55
  # When more results exist, a stderr hint surfaces the next --offset / --limit.`)
@@ -58,6 +62,10 @@ Examples:
58
62
  };
59
63
  if (opts.search)
60
64
  params.search = opts.search;
65
+ if (opts.bio)
66
+ params.bio = opts.bio;
67
+ if (opts.occupation.length > 0)
68
+ params.occupation = opts.occupation;
61
69
  if (opts.type !== "all")
62
70
  params.type = opts.type;
63
71
  if (opts.gender.length > 0)
@@ -11,6 +11,7 @@ import * as readline from "node:readline/promises";
11
11
  import { withClient, getWebUrl, terminalLink, resolveWorkspace, resolveStudy, parseWaitTimeout, resolveAudienceProfileIds, addAudienceFilterFlags, hasAudienceFlags, readFileOrStdin, } 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 { streamStudyEvents } from "../lib/study-events.js";
14
15
  import { isMediaModality, isChatModality, iterationHasContent, describeRequiredContentFlag, readChatMode, readTesterPairConfig, summarizeRoleCriteria, } from "../lib/modality.js";
15
16
  import { runLocalSimulations } from "../lib/local-sim/loop.js";
16
17
  import { ensureBrowser } from "../lib/local-sim/install.js";
@@ -93,6 +94,11 @@ function dedupeSimulations(simResults) {
93
94
  ];
94
95
  }
95
96
  const POLL_INTERVAL_MS = 5_000;
97
+ // When the backend SSE stream is connected we drop to a slow backstop —
98
+ // events wake us up promptly, so re-fetching every 5s is wasteful. If the
99
+ // stream dies (older backend, broker offline, token mint failure) the loop
100
+ // transparently reverts to POLL_INTERVAL_MS.
101
+ const SSE_BACKSTOP_INTERVAL_MS = 30_000;
96
102
  const TERMINAL_STATUSES = new Set(["completed", "errored", "failed", "cancelled", "canceled"]);
97
103
  function flattenTesterStatuses(iterations, only) {
98
104
  const rows = [];
@@ -118,38 +124,90 @@ function flattenTesterStatuses(iterations, only) {
118
124
  async function pollStudyUntilDone(client, opts) {
119
125
  const start = Date.now();
120
126
  let lastReported = "";
121
- while (true) {
122
- const study = await client.get(`/studies/${opts.studyId}`, undefined, { timeout: 60_000 });
123
- const isMedia = isMediaModality(study.modality);
124
- let iterations = study.iterations;
125
- if (opts.iterationId) {
126
- iterations = (iterations ?? []).filter((it) => it.id === opts.iterationId);
127
- }
128
- const rows = flattenTesterStatuses(iterations, opts.testerIds);
129
- const total = rows.length;
130
- const done = rows.filter((r) => TERMINAL_STATUSES.has(r.status)).length;
131
- const errored = rows.filter((r) => r.status === "errored" || r.status === "failed").length;
132
- const summary = `${done}/${total} done${errored > 0 ? `, ${errored} errored/failed` : ""}`;
133
- if (!opts.quiet && summary !== lastReported) {
134
- process.stderr.write(` ${summary}\n`);
135
- lastReported = summary;
136
- }
137
- if (total > 0 && done === total) {
138
- return { rows, isMedia };
139
- }
140
- if (Date.now() - start > opts.timeoutMs) {
141
- throw new WaitTimeoutError(`Timed out after ${Math.round(opts.timeoutMs / 1000)}s waiting for simulations. ` +
142
- `${done}/${total} done. Run \`ish study poll --study ${opts.studyId}\` to check status.`, {
143
- study_id: opts.studyId,
144
- ...(opts.iterationId && { iteration_id: opts.iterationId }),
145
- timeout_seconds: Math.round(opts.timeoutMs / 1000),
146
- done,
147
- total,
148
- pending: total - done,
149
- rows,
150
- });
127
+ // Open the SSE event stream as a wake source. When the backend supports
128
+ // it (enable_realtime=true + broker live), tester status changes wake
129
+ // the loop the moment they happen — no need to wait for the next poll
130
+ // tick. When it doesn't, the iterator exits silently on the first read
131
+ // and we fall through to pure polling at POLL_INTERVAL_MS.
132
+ const ac = new AbortController();
133
+ const eventIter = streamStudyEvents(client, opts.studyId, ac.signal);
134
+ // Optimistic: we assume the stream will deliver until the first read
135
+ // confirms otherwise. The first `await pendingEvent` settles within
136
+ // milliseconds for an unavailable stream (token mint 503 / endpoint 503)
137
+ // and is essentially a no-op for the latency budget.
138
+ let sseAlive = true;
139
+ let pendingEvent = eventIter.next();
140
+ try {
141
+ while (true) {
142
+ const study = await client.get(`/studies/${opts.studyId}`, undefined, { timeout: 60_000 });
143
+ const isMedia = isMediaModality(study.modality);
144
+ let iterations = study.iterations;
145
+ if (opts.iterationId) {
146
+ iterations = (iterations ?? []).filter((it) => it.id === opts.iterationId);
147
+ }
148
+ const rows = flattenTesterStatuses(iterations, opts.testerIds);
149
+ const total = rows.length;
150
+ const done = rows.filter((r) => TERMINAL_STATUSES.has(r.status)).length;
151
+ const errored = rows.filter((r) => r.status === "errored" || r.status === "failed").length;
152
+ const summary = `${done}/${total} done${errored > 0 ? `, ${errored} errored/failed` : ""}`;
153
+ if (!opts.quiet && summary !== lastReported) {
154
+ process.stderr.write(` ${summary}\n`);
155
+ lastReported = summary;
156
+ }
157
+ if (total > 0 && done === total) {
158
+ return { rows, isMedia };
159
+ }
160
+ if (Date.now() - start > opts.timeoutMs) {
161
+ throw new WaitTimeoutError(`Timed out after ${Math.round(opts.timeoutMs / 1000)}s waiting for simulations. ` +
162
+ `${done}/${total} done. Run \`ish study poll --study ${opts.studyId}\` to check status.`, {
163
+ study_id: opts.studyId,
164
+ ...(opts.iterationId && { iteration_id: opts.iterationId }),
165
+ timeout_seconds: Math.round(opts.timeoutMs / 1000),
166
+ done,
167
+ total,
168
+ pending: total - done,
169
+ rows,
170
+ });
171
+ }
172
+ // Wait for either the next backend event or the next tick. Use the
173
+ // backstop interval while the stream is delivering (events are the
174
+ // primary wake source); fall back to POLL_INTERVAL_MS once the
175
+ // stream is dead (older backend or broker dropped mid-run).
176
+ const interval = sseAlive ? SSE_BACKSTOP_INTERVAL_MS : POLL_INTERVAL_MS;
177
+ if (sseAlive && pendingEvent !== null) {
178
+ let timerHandle;
179
+ const timer = new Promise((resolve) => {
180
+ timerHandle = setTimeout(() => resolve({ kind: "timer" }), interval);
181
+ });
182
+ const eventOrEnd = pendingEvent.then((r) => ({ kind: "event", result: r }), () => ({ kind: "event", result: { value: undefined, done: true } }));
183
+ const winner = await Promise.race([eventOrEnd, timer]);
184
+ if (timerHandle !== undefined)
185
+ clearTimeout(timerHandle);
186
+ if (winner.kind === "event") {
187
+ if (winner.result.done) {
188
+ // Stream ended; revert to pure polling for the rest of the run.
189
+ sseAlive = false;
190
+ pendingEvent = null;
191
+ }
192
+ else {
193
+ // Re-arm for the next event. We deliberately don't act on
194
+ // the event payload here — the next loop iteration re-fetches
195
+ // status and that's the truth source.
196
+ pendingEvent = eventIter.next();
197
+ }
198
+ }
199
+ // Timer winning doesn't replace pendingEvent — it stays parked
200
+ // and resolves on whichever event arrives next.
201
+ }
202
+ else {
203
+ await new Promise((resolve) => setTimeout(resolve, interval));
204
+ }
151
205
  }
152
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
206
+ }
207
+ finally {
208
+ // Stop the SSE fetch + reader so we don't leave a dangling HTTP
209
+ // connection to the backend after returning / throwing.
210
+ ac.abort();
153
211
  }
154
212
  }
155
213
  function readIterationDetails(details) {
@@ -202,9 +260,10 @@ Note: --workspace and --study are optional if you have set active context
202
260
  first with \`ish iteration create\`.
203
261
 
204
262
  Audience: pass nothing to reuse the iteration's existing testers. Pass
205
- --profile to use specific profiles, or filter flags (--country, --gender,
206
- --min-age, --max-age, --search, --visibility) with --sample <N> or --all
207
- to seed a fresh audience from the workspace pool.
263
+ --profile to use specific profiles, or filter flags (--bio, --country,
264
+ --gender, --min-age, --max-age, --occupation, --search, --visibility)
265
+ with --sample <N> or --all to seed a fresh audience from the workspace
266
+ pool.
208
267
 
209
268
  Examples:
210
269
  # Run the latest iteration, reusing its testers:
@@ -395,15 +454,22 @@ Examples:
395
454
  throw new Error(`Iteration "${iterationLabel}" has no testers and no audience flags were given. ` +
396
455
  "Pass --profile <ids>, or filter flags (--country, --gender, --min-age, --max-age, --search, --visibility) with --sample <N> or --all.");
397
456
  }
398
- // Step 3: Resolve simulation config (per-profile fallback for
399
- // media + chat external_chatbot, both of which require a config_id
400
- // per batch item). Pair-mode chat dispatch is per-conversation,
401
- // not per-tester; the backend resolves configs via the tester rows
402
- // it creates on /testers/pair-batch, so the CLI doesn't pre-fetch.
457
+ // Step 3: Resolve simulation config. Always pre-flight every profile
458
+ // when no --config override is given: missing simulation_config_id
459
+ // is fatal across all modalities (media + chat batch dispatch use it
460
+ // per-item; interactive + pair dispatch fail server-side on the
461
+ // first sim start) and creating tester rows before discovering it
462
+ // leaves phantom DRAFT rows in the iteration. Pair mode reads
463
+ // pairConfig.audience_a; non-pair uses profileIds. profileConfigMap
464
+ // is consumed by the media branch; other branches just need the
465
+ // validation side effect.
403
466
  const resolvedConfigOverride = opts.config ? resolveId(opts.config) : undefined;
404
467
  const profileConfigMap = new Map();
405
- if ((isMedia || (isChat && !isPair)) && !resolvedConfigOverride) {
406
- for (const pid of profileIds) {
468
+ if (!resolvedConfigOverride) {
469
+ const idsToCheck = isPair && pairConfig
470
+ ? [...new Set([...pairConfig.audience_a, ...pairConfig.audience_b])]
471
+ : profileIds;
472
+ for (const pid of idsToCheck) {
407
473
  const profile = await client.get(`/tester-profiles/${pid}`);
408
474
  if (profile.simulation_config_id) {
409
475
  profileConfigMap.set(pid, profile.simulation_config_id);
@@ -732,18 +798,21 @@ Examples:
732
798
  // Fall back to the first audience_a profile's
733
799
  // simulation_config_id. Pair dispatch takes a single config
734
800
  // for the whole batch, so we don't need the per-profile map
735
- // the external_chatbot path builds.
801
+ // the external_chatbot path builds. Step 3 already populated
802
+ // profileConfigMap with every audience profile's config when
803
+ // --config was not passed, so reuse that.
736
804
  const fallbackProfileId = pairConfig.audience_a[0];
737
805
  if (!fallbackProfileId) {
738
806
  throw new Error("Pair-mode dispatch requires --config <id>: the iteration has no audience profile to draw a default config_id from.");
739
807
  }
740
- const fallbackProfile = await client.get(`/tester-profiles/${fallbackProfileId}`);
741
- if (!fallbackProfile.simulation_config_id) {
808
+ pairConfigId = profileConfigMap.get(fallbackProfileId);
809
+ if (!pairConfigId) {
810
+ // Defensive: Step 3 should have either populated the map or
811
+ // thrown. If we land here something upstream changed.
742
812
  throw new Error(`Pair-mode dispatch requires a config_id. Profile ${fallbackProfileId} has no simulation config assigned and --config was not passed.\n` +
743
813
  "Use --config <id> to specify one, or assign a config to the profile.\n" +
744
814
  "List configs with: ish config list");
745
815
  }
746
- pairConfigId = fallbackProfile.simulation_config_id;
747
816
  }
748
817
  const simResult = await dispatchAttempt(() => client.post("/simulation/chat/pair/start/batch", {
749
818
  product_id: resolvedWorkspace,
@@ -214,11 +214,12 @@ async function collectWorkspaceUsage(client, workspaceId) {
214
214
  .get(`/products/${workspaceId}/studies`)
215
215
  .catch(() => []);
216
216
  // testers_used: paginated list returns { total, items, ... }. Backend
217
- // gates `maxCustomTesterProfiles` on visibility=private.
217
+ // gates `maxCustomTesterProfiles` on visibility=workspace (rows owned by
218
+ // this workspace; was `private` before the visibility rename).
218
219
  const testersPromise = client
219
220
  .get("/tester-profiles", {
220
221
  product_id: workspaceId,
221
- visibility: "private",
222
+ visibility: "workspace",
222
223
  type: "ai",
223
224
  limit: "1",
224
225
  offset: "0",
package/dist/connect.js CHANGED
@@ -284,12 +284,14 @@ async function resolveToken(tokenArg, apiUrl, tokenFileArg) {
284
284
  // --- Branding ---
285
285
  function printBanner() {
286
286
  console.log(`
287
- ${c.orange}${c.bold} ██╗███████╗██╗ ██╗
288
- ██║██╔════╝██║ ██║
289
- ██║███████╗███████║
290
- ██║╚════██║██╔══██║
291
- ██║███████║██║ ██║
292
- ╚═╝╚══════╝╚═╝ ╚═╝${c.reset}
287
+ ${c.orange}${c.bold} /██ /██
288
+ |__/ | ██
289
+ /██ /███████| ███████
290
+ | ██ /██_____/| ██__ ██
291
+ | ██| ██████ | ██ \\ ██
292
+ | ██ \\____ ██| ██ | ██
293
+ | ██ /███████/| ██ | ██
294
+ |__/|_______/ |__/ |__/${c.reset}
293
295
 
294
296
  Connected
295
297
  `);
package/dist/index.js CHANGED
@@ -30,7 +30,7 @@ import pkg from "../package.json" with { type: "json" };
30
30
  const { version } = pkg;
31
31
  program
32
32
  .name("ish")
33
- .description("Ish CLI — run studies and asks against AI tester audiences")
33
+ .description("ish CLI — run studies and asks against AI tester audiences")
34
34
  .version(version)
35
35
  .addHelpText("after", AGENT_HELP_FOOTER);
36
36
  // Unified error envelope for Commander-level failures (unknown command,
@@ -20,6 +20,13 @@ export declare class ApiClient {
20
20
  token: string;
21
21
  });
22
22
  get accessToken(): string;
23
+ /** Resolved base URL including the ``/api/v1`` prefix.
24
+ *
25
+ * Exposed so helpers that bypass the JSON-decoding ``fetch`` wrappers
26
+ * (e.g. the SSE stream in ``lib/study-events.ts``) can build their own
27
+ * requests against the same backend.
28
+ */
29
+ get apiBase(): string;
23
30
  private headers;
24
31
  get<T = unknown>(path: string, params?: Record<string, string | string[]>, opts?: RequestOpts): Promise<T>;
25
32
  post<T = unknown>(path: string, body?: unknown, opts?: RequestOpts): Promise<T>;
@@ -83,6 +83,15 @@ export class ApiClient {
83
83
  get accessToken() {
84
84
  return this.token;
85
85
  }
86
+ /** Resolved base URL including the ``/api/v1`` prefix.
87
+ *
88
+ * Exposed so helpers that bypass the JSON-decoding ``fetch`` wrappers
89
+ * (e.g. the SSE stream in ``lib/study-events.ts``) can build their own
90
+ * requests against the same backend.
91
+ */
92
+ get apiBase() {
93
+ return this.baseUrl;
94
+ }
86
95
  headers() {
87
96
  return {
88
97
  Authorization: `Bearer ${this.token}`,
@@ -9,6 +9,8 @@ export interface AudienceFilterOpts {
9
9
  sample?: string;
10
10
  all?: boolean;
11
11
  search?: string;
12
+ bio?: string;
13
+ occupation?: string[];
12
14
  gender?: string[];
13
15
  country?: string[];
14
16
  minAge?: string;
@@ -33,6 +33,10 @@ function describeFilters(flags) {
33
33
  const parts = [];
34
34
  if (flags.search)
35
35
  parts.push(`--search "${flags.search}"`);
36
+ if (flags.bio)
37
+ parts.push(`--bio "${flags.bio}"`);
38
+ if (flags.occupation?.length)
39
+ parts.push(...flags.occupation.map((o) => `--occupation ${o}`));
36
40
  if (flags.gender?.length)
37
41
  parts.push(...flags.gender.map((g) => `--gender ${g}`));
38
42
  if (flags.country?.length)
@@ -75,6 +79,10 @@ async function suggestCountries(client, workspace, flags, opts) {
75
79
  if (keepOtherFilters) {
76
80
  if (flags.search)
77
81
  broader.search = flags.search;
82
+ if (flags.bio)
83
+ broader.bio = flags.bio;
84
+ if (flags.occupation && flags.occupation.length > 0)
85
+ broader.occupation = flags.occupation;
78
86
  if (flags.gender && flags.gender.length > 0)
79
87
  broader.gender = flags.gender;
80
88
  if (flags.minAge)
@@ -118,6 +126,8 @@ async function suggestCountries(client, workspace, flags, opts) {
118
126
  }
119
127
  function hasFilterFlag(flags) {
120
128
  return Boolean(flags.search
129
+ || flags.bio
130
+ || (flags.occupation && flags.occupation.length > 0)
121
131
  || (flags.gender && flags.gender.length > 0)
122
132
  || (flags.country && flags.country.length > 0)
123
133
  || flags.minAge
@@ -153,7 +163,7 @@ export async function resolveAudienceProfileIds(client, workspace, flags, opts =
153
163
  const filtersUsed = hasFilterFlag(flags);
154
164
  if (explicit.length > 0) {
155
165
  if (sampleN !== undefined || flags.all || filtersUsed) {
156
- throw new Error(`Use either explicit --profile flags or --sample/${allFlagName}/filter flags (--country, --gender, --min-age, --max-age, --search, --visibility), not both.`);
166
+ throw new Error(`Use either explicit --profile flags or --sample/${allFlagName}/filter flags (--bio, --country, --gender, --min-age, --max-age, --occupation, --search, --visibility), not both.`);
157
167
  }
158
168
  return explicit;
159
169
  }
@@ -161,7 +171,7 @@ export async function resolveAudienceProfileIds(client, workspace, flags, opts =
161
171
  throw new Error(`Use either --sample <N> or ${allFlagName}, not both. --sample picks a random subset; ${allFlagName} returns every match.`);
162
172
  }
163
173
  if (sampleN === undefined && !flags.all && !filtersUsed) {
164
- throw new Error(`Pick an audience: pass --profile <id> (repeatable), --sample <N>, ${allFlagName}, or filter flags (--country, --gender, --min-age, --max-age, --search, --visibility).`);
174
+ throw new Error(`Pick an audience: pass --profile <id> (repeatable), --sample <N>, ${allFlagName}, or filter flags (--bio, --country, --gender, --min-age, --max-age, --occupation, --search, --visibility).`);
165
175
  }
166
176
  const params = {
167
177
  product_id: workspace,
@@ -171,6 +181,10 @@ export async function resolveAudienceProfileIds(client, workspace, flags, opts =
171
181
  };
172
182
  if (flags.search)
173
183
  params.search = flags.search;
184
+ if (flags.bio)
185
+ params.bio = flags.bio;
186
+ if (flags.occupation && flags.occupation.length > 0)
187
+ params.occupation = flags.occupation;
174
188
  if (flags.gender && flags.gender.length > 0)
175
189
  params.gender = flags.gender;
176
190
  if (flags.country && flags.country.length > 0)
@@ -240,12 +254,14 @@ export function addAudienceFilterFlags(cmd, opts = {}) {
240
254
  .option("--profile <ids>", "Tester profile IDs/aliases (comma-separated or repeatable)", collectIds, [])
241
255
  .option("--sample <N>", "Randomly sample N profiles from the matching pool")
242
256
  .option(allFlag, allDesc)
243
- .option("--search <text>", "Free-text search (matches profile name and bio)")
257
+ .option("--search <text>", "Substring match against profile name")
258
+ .option("--bio <text>", "Substring match against profile bio")
259
+ .option("--occupation <text>", "Substring match against profile occupation (repeatable)", collectRepeatable, [])
244
260
  .option("--gender <gender>", "Filter by gender (repeatable)", collectRepeatable, [])
245
261
  .option("--country <code>", "Filter by 2-letter country code (repeatable)", collectRepeatable, [])
246
262
  .option("--min-age <n>", "Minimum age (inclusive)")
247
263
  .option("--max-age <n>", "Maximum age (inclusive)")
248
- .option("--visibility <v>", "Filter by visibility (private|public)");
264
+ .option("--visibility <v>", "Filter by visibility: workspace (your workspace), shared (community-published), platform (admin-curated). Old values `private` / `public` still accepted as aliases for `workspace` / `platform` until the next release; server logs a deprecation warning.");
249
265
  }
250
266
  export function getGlobals(cmd) {
251
267
  let globals;
package/dist/lib/docs.js CHANGED
@@ -1267,8 +1267,15 @@ flags. Two ways to select:
1267
1267
  - \`--gender female\` (repeatable)
1268
1268
  - \`--min-age 25\`
1269
1269
  - \`--max-age 50\`
1270
- - \`--search "early adopter"\`
1271
- - \`--visibility shared|private\`
1270
+ - \`--search "Anna"\` (substring match against profile name)
1271
+ - \`--bio "voice-first user"\` (substring match against profile bio)
1272
+ - \`--occupation founder\` (substring match against profile occupation; repeatable, OR semantics)
1273
+ - \`--visibility workspace|shared|platform\` (filter by where the
1274
+ profile lives: your workspace, the community-published pool, or
1275
+ the admin-curated platform pool; old values \`private\` /
1276
+ \`public\` still accepted as aliases for \`workspace\` /
1277
+ \`platform\` until the next release with a server-side
1278
+ deprecation warning)
1272
1279
 
1273
1280
  The two modes are **mutually exclusive** — pass either \`--profile\` or
1274
1281
  the filter set, not both.
@@ -1297,20 +1304,22 @@ Two adjacent footguns surface most often on first-time audience
1297
1304
  construction. Both are documented here because they cost a round-trip
1298
1305
  to discover by experiment.
1299
1306
 
1300
- ### \`occupation\` is a loose substring match
1301
-
1302
- \`audience_build\` (and the \`--search\` flag) treats \`occupation\` as
1303
- a **loose, case-insensitive substring filter**, not a whole-token /
1304
- taxonomy match. \`occupation=["manager"]\` will match hotel managers,
1305
- retail store managers, bank branch managers anything containing
1306
- the literal string "manager". Three patterns that recover the
1307
- specificity you usually want:
1308
-
1309
- - **Whole-token alternation**: \`occupation=["engineering manager",
1310
- "software engineering manager", "vp engineering", "tech lead"]\` —
1311
- exhaustive enumeration of the role surface beats one short token.
1312
- - **Pair with other filters**: \`occupation=["manager"]\` +
1313
- \`min_age=28\` + \`country=["US","SE"]\` narrows even a loose substring
1307
+ ### \`--occupation\` is a loose substring match
1308
+
1309
+ \`audience_build\` and the \`--occupation\` flag treat the value as a
1310
+ **loose, case-insensitive substring filter**, not a whole-token or
1311
+ taxonomy match. \`--occupation manager\` will match hotel managers,
1312
+ retail store managers, bank branch managers: anything containing the
1313
+ literal string "manager". Three patterns that recover the specificity
1314
+ you usually want:
1315
+
1316
+ - **Whole-token alternation**: \`--occupation "engineering manager"
1317
+ --occupation "software engineering manager" --occupation "vp
1318
+ engineering" --occupation "tech lead"\`: exhaustive enumeration of
1319
+ the role surface beats one short token. Multiple \`--occupation\`
1320
+ flags OR together server-side.
1321
+ - **Pair with other filters**: \`--occupation manager --min-age 28
1322
+ --country US --country SE\` narrows even a loose substring
1314
1323
  meaningfully.
1315
1324
  - **Preview before dispatch**: \`audience_build\` returns a
1316
1325
  \`match_preview\` summary on the response — a 1-line histogram of
@@ -1353,6 +1362,12 @@ ish study run --profile tp-795,tp-af2
1353
1362
  # Sample 3 Swedish profiles aged 35-50:
1354
1363
  ish study run --country SE --min-age 35 --max-age 50 --sample 3
1355
1364
 
1365
+ # Every female founder or designer:
1366
+ ish study run --gender female --occupation founder --occupation designer --all
1367
+
1368
+ # Bio substring (e.g. accessibility cohort):
1369
+ ish study run --bio "screen reader" --all
1370
+
1356
1371
  # Every female profile in the workspace:
1357
1372
  ish study run --gender female --all
1358
1373