@ishlabs/cli 0.17.7 → 0.18.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 (62) hide show
  1. package/README.md +54 -54
  2. package/dist/commands/ask.d.ts +4 -4
  3. package/dist/commands/ask.js +66 -66
  4. package/dist/commands/chat.js +10 -10
  5. package/dist/commands/config.js +1 -1
  6. package/dist/commands/docs.js +1 -1
  7. package/dist/commands/iteration.js +57 -57
  8. package/dist/commands/mcp.d.ts +23 -0
  9. package/dist/commands/mcp.js +676 -0
  10. package/dist/commands/person.d.ts +5 -0
  11. package/dist/commands/{profile.js → person.js} +197 -162
  12. package/dist/commands/source.d.ts +6 -2
  13. package/dist/commands/source.js +35 -30
  14. package/dist/commands/study-analyze.d.ts +1 -1
  15. package/dist/commands/study-analyze.js +3 -3
  16. package/dist/commands/study-participant.d.ts +8 -0
  17. package/dist/commands/{study-tester.js → study-participant.js} +50 -50
  18. package/dist/commands/study-run.d.ts +6 -6
  19. package/dist/commands/study-run.js +295 -271
  20. package/dist/commands/study.js +89 -66
  21. package/dist/commands/workspace.js +13 -13
  22. package/dist/connect.js +5 -5
  23. package/dist/index.js +6 -4
  24. package/dist/lib/accessibility-profile.d.ts +1 -1
  25. package/dist/lib/accessibility-profile.js +1 -1
  26. package/dist/lib/alias-hydrate.js +4 -4
  27. package/dist/lib/alias-store.d.ts +5 -5
  28. package/dist/lib/alias-store.js +8 -8
  29. package/dist/lib/api-client.d.ts +1 -1
  30. package/dist/lib/api-client.js +1 -1
  31. package/dist/lib/billing.d.ts +11 -11
  32. package/dist/lib/billing.js +16 -16
  33. package/dist/lib/chat-endpoint-templates.js +1 -1
  34. package/dist/lib/command-helpers.d.ts +18 -18
  35. package/dist/lib/command-helpers.js +49 -37
  36. package/dist/lib/docs.js +560 -386
  37. package/dist/lib/enums.d.ts +2 -2
  38. package/dist/lib/enums.js +2 -2
  39. package/dist/lib/local-sim/browser.d.ts +1 -1
  40. package/dist/lib/local-sim/browser.js +1 -1
  41. package/dist/lib/local-sim/debug-report.d.ts +2 -2
  42. package/dist/lib/local-sim/debug-report.js +3 -3
  43. package/dist/lib/local-sim/loop.d.ts +5 -5
  44. package/dist/lib/local-sim/loop.js +38 -38
  45. package/dist/lib/local-sim/types.d.ts +12 -12
  46. package/dist/lib/mcp-clients.d.ts +51 -0
  47. package/dist/lib/mcp-clients.js +175 -0
  48. package/dist/lib/modality.d.ts +10 -10
  49. package/dist/lib/modality.js +46 -46
  50. package/dist/lib/output.d.ts +13 -12
  51. package/dist/lib/output.js +244 -184
  52. package/dist/lib/profile-sources.d.ts +64 -16
  53. package/dist/lib/profile-sources.js +91 -30
  54. package/dist/lib/skill-content.js +215 -168
  55. package/dist/lib/study-events.d.ts +3 -3
  56. package/dist/lib/study-events.js +1 -1
  57. package/dist/lib/study-inputs.d.ts +11 -1
  58. package/dist/lib/study-inputs.js +68 -17
  59. package/dist/lib/types.d.ts +105 -34
  60. package/package.json +1 -1
  61. package/dist/commands/profile.d.ts +0 -5
  62. package/dist/commands/study-tester.d.ts +0 -8
@@ -1,39 +1,38 @@
1
1
  /**
2
- * ish profile — Manage profiles, audience generation, and source uploads.
2
+ * ish person — Manage people, generation, and source uploads.
3
3
  */
4
4
  import fs from "node:fs";
5
5
  import { withClient, readJsonFileOrStdin, resolveWorkspace } from "../lib/command-helpers.js";
6
6
  import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
7
- import { formatTesterProfileList, formatGeneratedProfileList, output, } from "../lib/output.js";
7
+ import { formatPersonList, output, } from "../lib/output.js";
8
8
  import { resolveTextContent } from "../lib/upload.js";
9
- import { isUuid, resolveSourceRef } from "../lib/profile-sources.js";
9
+ import { isUuid, pollGenerationJobUntilDone, resolveSourceRef, } from "../lib/profile-sources.js";
10
10
  import { assertEnumValue, EDUCATION_LEVELS, EVIDENCE_SOURCES, HOUSEHOLDS, LOCALE_TYPES, INCOME_LEVELS, EMPLOYMENT_STATUSES, } from "../lib/enums.js";
11
11
  import { validateAccessibilityProfile } from "../lib/accessibility-profile.js";
12
12
  function collect(value, prev) {
13
13
  return prev.concat(value);
14
14
  }
15
- export function registerProfileCommands(program) {
16
- const profile = program
17
- .command("profile")
18
- .alias("tester-profile")
19
- .description("Manage profiles, audience generation, and source uploads")
15
+ export function registerPersonCommands(program) {
16
+ const person = program
17
+ .command("person")
18
+ .description("Manage people, generation, and source uploads")
20
19
  .addHelpText("after", `
21
- A tester profile is a reusable audience persona scoped to a workspace.
22
- \`ish profile generate\` produces profiles from a written brief and/or sources
23
- (transcripts, audio, images, PDFs). Distinct from a "tester" (\`t-\`), which is one
24
- instance of a profile inside one iteration.
20
+ A person is a reusable persona scoped to a workspace.
21
+ \`ish person generate\` produces people from a written brief and/or sources
22
+ (transcripts, audio, images, PDFs). Distinct from a "participant" (\`pt-\`), which is one
23
+ instance of a person inside one iteration.
25
24
 
26
- Concept pages: ish docs get-page concepts/profile
25
+ Concept pages: ish docs get-page concepts/person
27
26
  ish docs get-page concepts/source
28
- ish docs get-page concepts/audience`);
29
- profile
27
+ ish docs get-page concepts/people`);
28
+ person
30
29
  .command("list")
31
- .description("List profiles (defaults to simulatable AI profiles)")
30
+ .description("List people (defaults to simulatable AI people)")
32
31
  .option("--workspace <id>", "Filter by workspace ID")
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, [])
36
- .option("--type <type>", "Profile type: ai, human, all (default: ai)", "ai")
32
+ .option("--search <query>", "Substring match against person name")
33
+ .option("--bio <text>", "Substring match against person bio")
34
+ .option("--occupation <text>", "Substring match against person occupation (repeatable)", collect, [])
35
+ .option("--type <type>", "Person type: ai, human, all (default: ai)", "ai")
37
36
  .option("--gender <gender>", "Filter by gender (repeatable)", collect, [])
38
37
  .option("--country <country>", "Filter by country code, e.g. US (repeatable)", collect, [])
39
38
  .option("--min-age <n>", "Minimum age")
@@ -42,16 +41,16 @@ Concept pages: ish docs get-page concepts/profile
42
41
  .option("--offset <n>", "Offset for pagination", "0")
43
42
  .addHelpText("after", `
44
43
  Examples:
45
- $ ish profile list
46
- $ ish profile list --search "engineer" --country US
47
- $ ish profile list --bio "voice-first user"
48
- $ ish profile list --occupation founder --occupation designer
49
- $ ish profile list --gender female --gender male --country US --country GB
50
- $ ish profile list --type all --json
44
+ $ ish person list
45
+ $ ish person list --search "engineer" --country US
46
+ $ ish person list --bio "voice-first user"
47
+ $ ish person list --occupation founder --occupation designer
48
+ $ ish person list --gender female --gender male --country US --country GB
49
+ $ ish person list --type all --json
51
50
 
52
51
  # Pagination: default --limit is 50, iterate with --offset.
53
- $ ish profile list --limit 100
54
- $ ish profile list --limit 100 --offset 100 # next page
52
+ $ ish person list --limit 100
53
+ $ ish person list --limit 100 --offset 100 # next page
55
54
  # When more results exist, a stderr hint surfaces the next --offset / --limit.`)
56
55
  .action(async (opts, cmd) => {
57
56
  await withClient(cmd, async (client, globals) => {
@@ -76,8 +75,8 @@ Examples:
76
75
  params.min_age = opts.minAge;
77
76
  if (opts.maxAge)
78
77
  params.max_age = opts.maxAge;
79
- const data = await client.get("/tester-profiles", params);
80
- formatTesterProfileList(data, globals.json, parseInt(opts.limit, 10));
78
+ const data = await client.get("/people", params);
79
+ formatPersonList(data, globals.json, parseInt(opts.limit, 10));
81
80
  // Pattern H1: when there's more data, surface a stderr hint so agents
82
81
  // know `--limit / --offset` exist without re-reading help. Stderr only,
83
82
  // so it doesn't pollute machine-readable stdout — that means we DON'T
@@ -85,7 +84,7 @@ Examples:
85
84
  // exactly when the agent needs it most). --quiet is the explicit
86
85
  // opt-out for progress chatter.
87
86
  //
88
- // The raw `/tester-profiles` envelope is `{items, total, limit, offset}`
87
+ // The raw `/people` envelope is `{items, total, limit, offset}`
89
88
  // — `has_more` and `returned` are synthesized client-side by `wrapList`
90
89
  // only when the response is rendered. Compute them locally here so the
91
90
  // hint actually fires; reading `data.has_more` directly would always
@@ -103,55 +102,61 @@ Examples:
103
102
  }
104
103
  });
105
104
  });
106
- profile
105
+ person
107
106
  .command("create")
108
- .description("Create a profile from an exact JSON spec (no LLM)")
109
- .requiredOption("--file <path>", "JSON file with profile data")
107
+ .description("Create a person from an exact JSON spec (no LLM)")
108
+ .requiredOption("--file <path>", "JSON file with person data")
110
109
  .option("--workspace <id>", "Workspace (product) ID; falls back to active workspace")
111
- .addHelpText("after", "\nExamples:\n $ ish profile create --file profile.json\n\n Expected JSON: { \"name\": \"...\", \"type\": \"ai\", \"gender\": \"female\", \"country\": \"US\", \"occupation\": \"...\", \"bio\": \"...\" }")
110
+ .addHelpText("after", "\nExamples:\n $ ish person create --file profile.json\n\n Expected JSON: { \"name\": \"...\", \"type\": \"ai\", \"gender\": \"female\", \"country\": \"US\", \"occupation\": \"...\", \"bio\": \"...\" }")
112
111
  .action(async (opts, cmd) => {
113
112
  await withClient(cmd, async (client, globals) => {
114
113
  const body = await readJsonFileOrStdin(opts.file);
115
114
  if (opts.workspace || body.product_id == null) {
116
115
  body.product_id = resolveWorkspace(opts.workspace);
117
116
  }
118
- const data = await client.post("/tester-profiles", body);
117
+ const data = await client.post("/people", body);
119
118
  const result = data;
120
119
  if (result.id)
121
- result.alias = tagAlias(ALIAS_PREFIX.testerProfile, String(result.id));
120
+ result.alias = tagAlias(ALIAS_PREFIX.person, String(result.id));
122
121
  output(result, globals.json, { writePath: true });
123
122
  });
124
123
  });
125
- profile
124
+ person
126
125
  .command("generate")
127
- .description("Generate one or many profiles via LLM (audience generation)")
128
- .option("--description <text>", "Audience description (use @path to read from file)")
126
+ .description("Generate people plus evidence-grounded scenarios from a brief and/or sources")
127
+ .option("--description <text>", "Description / researcher brief (use @path to read from file)")
129
128
  .option("--description-file <path>", "Read description from a file")
130
129
  .option("--source <id-or-path>", "Source UUID or local file path; auto-uploads paths (repeatable)", collect, [])
131
- .option("--source-description <text>", "Per-source context note (paired with --source by index, repeatable). Only applies to local-path sources for already-uploaded aliases the description is whatever was set at upload time.", collect, [])
130
+ .option("--source-description <text>", "Per-source researcher note how the person reacted to THAT file. Paired with --source by index (repeatable). Only applies to local-path sources; for already-uploaded aliases the note is whatever was set at upload time.", collect, [])
132
131
  .option("--diarize", "Apply speaker diarization to audio sources (silently ignored for text/image)")
133
- .option("--count <n>", "Number of profiles to generate (1-10). Omit to let the model propose")
134
- .option("--propose-count", "Print the LLM's suggested audience size for a single processed source and exit (no generation)")
132
+ .option("--count <n>", "Number of people to generate (1-10). Omit to let the model propose a count")
133
+ .option("--propose-count", "Print the LLM's suggested count for a single processed source and exit (no generation)")
135
134
  .option("--workspace <id>", "Workspace (product) ID; falls back to active workspace")
135
+ .option("--no-scenarios", "Skip fetching the evidence-grounded scenarios for each generated person")
136
136
  .option("--no-wait", "Don't poll source-processing status. Only relevant when --source is a local path (paths get auto-uploaded and processed).")
137
- .option("--timeout <seconds>", "Source-processing poll timeout in seconds. Only relevant when --source is a local path.", "300")
138
- .option("--include-simulation-config", "Include the full simulation_config (system prompt + model settings) on each generated profile in JSON output")
137
+ .option("--source-timeout <seconds>", "Source-processing poll timeout in seconds. Only relevant when --source is a local path.", "300")
138
+ .option("--timeout <seconds>", "Generation-job poll timeout in seconds (default 600). The job keeps running server-side past this; re-poll, don't re-enqueue.", "600")
139
139
  .addHelpText("after", `
140
- Examples:
141
- # Generate 3 profiles from a description
142
- $ ish profile generate --description "Tech-savvy millennials in the US who use mobile banking" --count 3
140
+ Generation is an async job: ish enqueues it, polls until the people plus
141
+ their evidence-grounded scenarios are ready (~30-60s), then prints them. The
142
+ job reads your brief and any uploaded sources (transcripts, emails, PDFs,
143
+ audio, images) describing how real people reacted, and grounds each generated
144
+ scenario in those reactions. Provide --description (>=10 chars) and/or --source.
143
145
 
144
- # Generate one profile from a transcript (auto-uploads the file)
145
- $ ish profile generate --source ./interviews/sarah.txt --count 1
146
+ Examples:
147
+ # Generate 3 people from a description
148
+ $ ish person generate --description "Tech-savvy millennials in the US who use mobile banking" --count 3
146
149
 
147
- # Generate 5 profiles from an audio call + a written brief
148
- $ ish profile generate --description "Voices behind support tickets" --source ./call.mp3 --diarize --count 5
150
+ # Ground a person in how someone reacted to a real artifact
151
+ $ ish source upload ./proposal.eml --description "called this proposal lazy and vague"
152
+ # → ps-3a4
153
+ $ ish person generate --description "Skeptical enterprise buyer" --source ps-3a4 --count 1 --json
149
154
 
150
- # Use a previously-uploaded source by alias
151
- $ ish profile generate --source tps-3a4 --count 2
155
+ # Inline source upload with a per-file researcher note
156
+ $ ish person generate --description "Voices behind support tickets" --source ./call.mp3 --source-description "frustrated about repeated transfers" --diarize --count 5
152
157
 
153
- # Ask the LLM how many profiles a processed source warrants (no generation)
154
- $ ish profile generate --source tps-3a4 --propose-count`)
158
+ # Ask the LLM how many people a processed source warrants (no generation)
159
+ $ ish person generate --source ps-3a4 --propose-count`)
155
160
  .action(async (opts, cmd) => {
156
161
  await withClient(cmd, async (client, globals) => {
157
162
  const productId = resolveWorkspace(opts.workspace);
@@ -167,9 +172,9 @@ Examples:
167
172
  const raw = opts.source[0];
168
173
  const id = isUuid(raw) ? raw : tryResolveSourceAlias(raw);
169
174
  if (!id) {
170
- throw new Error("--propose-count expects an uploaded source ID or alias (e.g. tps-3a4). Upload first with `ish source upload`.");
175
+ throw new Error("--propose-count expects an uploaded source ID or alias (e.g. ps-3a4). Upload first with `ish source upload`.");
171
176
  }
172
- const data = await client.post(`/tester-profiles/sources/${id}/propose-count`, undefined, { timeout: 60_000 });
177
+ const data = await client.post(`/people/attachments/${id}/propose-count`, undefined, { timeout: 60_000 });
173
178
  output(data, globals.json);
174
179
  return;
175
180
  }
@@ -183,7 +188,24 @@ Examples:
183
188
  if (!description && opts.source.length === 0) {
184
189
  throw new Error("Provide --description, --description-file, or at least one --source.");
185
190
  }
186
- const timeoutMs = Math.max(1, parseInt(opts.timeout, 10)) * 1000;
191
+ // The backend requires a description of >=10 chars when one is given.
192
+ // Catch it before uploading sources / enqueueing so the agent gets a
193
+ // fast, local validation error rather than a 422 mid-flight.
194
+ if (description !== undefined && description.trim().length > 0 && description.trim().length < 10) {
195
+ throw new Error("--description must be at least 10 characters when provided.");
196
+ }
197
+ let count;
198
+ if (opts.count) {
199
+ const n = parseInt(opts.count, 10);
200
+ if (Number.isNaN(n) || n < 1 || n > 10) {
201
+ throw new Error("--count must be an integer between 1 and 10.");
202
+ }
203
+ count = n;
204
+ }
205
+ // --timeout bounds the generation-job poll; --source-timeout bounds the
206
+ // (separate) source-processing poll that only runs for local-path sources.
207
+ const jobTimeoutMs = Math.max(1, parseInt(opts.timeout, 10)) * 1000;
208
+ const sourceTimeoutMs = Math.max(1, parseInt(opts.sourceTimeout, 10)) * 1000;
187
209
  const wait = opts.wait !== false;
188
210
  if (opts.sourceDescription.length > opts.source.length) {
189
211
  throw new Error(`Got ${opts.sourceDescription.length} --source-description value(s) but only ${opts.source.length} --source value(s). Each --source-description is paired with --source by position; provide one --source per --source-description.`);
@@ -196,13 +218,13 @@ Examples:
196
218
  // Treat as alias if it matches our prefix pattern.
197
219
  const candidate = isUuid(raw) ? raw : tryResolveSourceAlias(raw);
198
220
  if (candidate) {
199
- // Already-uploaded source: the backend's GenerateAudienceRequest accepts
200
- // only source_upload_ids, no per-source description override. The
201
- // description set at upload time (ConfirmSourceUploadRequest.description)
202
- // is what the LLM sees. Silently dropping a CLI-supplied description here
203
- // is the M3 bug — error out instead so the user knows.
221
+ // Already-uploaded source: the job request carries only
222
+ // source_upload_ids, no per-source description override. The
223
+ // researcher note set at upload time (the confirm `description`) is
224
+ // what the pipeline reads. Silently dropping a CLI-supplied note
225
+ // here is the M3 bug — error out instead so the user knows.
204
226
  if (desc !== undefined) {
205
- throw new Error(`--source-description for "${raw}" is ignored because that source is already uploaded — the description set at upload time is what generate uses.\n` +
227
+ throw new Error(`--source-description for "${raw}" is ignored because that source is already uploaded — the note set at upload time is what generate uses.\n` +
206
228
  "Either:\n" +
207
229
  " - Drop --source-description for this source, OR\n" +
208
230
  " - Re-upload via path: --source <local-path> --source-description \"...\"");
@@ -215,7 +237,7 @@ Examples:
215
237
  description: desc,
216
238
  diarize: opts.diarize,
217
239
  wait,
218
- timeoutMs,
240
+ timeoutMs: sourceTimeoutMs,
219
241
  quiet: globals.quiet,
220
242
  });
221
243
  sourceIds.push(id);
@@ -227,48 +249,61 @@ Examples:
227
249
  body.description = description;
228
250
  if (sourceIds.length > 0)
229
251
  body.source_upload_ids = sourceIds;
230
- if (opts.count) {
231
- const n = parseInt(opts.count, 10);
232
- if (Number.isNaN(n) || n < 1 || n > 10) {
233
- throw new Error("--count must be an integer between 1 and 10.");
234
- }
235
- body.count = n;
236
- }
237
- // Pattern D1: emit stderr progress before the LLM call so agents (and
238
- // humans) see something is happening during the ~10–20s wait. Mirrors
239
- // the `--wait` ergonomics on `ask create` / `study run`.
252
+ if (count !== undefined)
253
+ body.count = count;
254
+ // Enqueue the agentic generation job. It turns the evidence bundle
255
+ // (brief + sources describing real reactions) into people plus
256
+ // scenarios grounded in those reactions — runs ~30-60s.
240
257
  if (!globals.quiet) {
241
- const target = body.count ? `${body.count} profile${body.count === 1 ? "" : "s"}` : "profiles";
242
- console.error(` generating ${target}...`);
243
- }
244
- // /generate is LLM-backed and slow.
245
- const profiles = await client.post("/tester-profiles/generate", body, { timeout: 180_000 });
258
+ const target = count ? `${count} person${count === 1 ? "" : "s"}` : "people";
259
+ console.error(` enqueuing generation job for ${target}...`);
260
+ }
261
+ const job = await client.post("/people/generation-jobs", body, { timeout: 60_000 });
262
+ const final = await pollGenerationJobUntilDone(client, job.id, {
263
+ timeoutMs: jobTimeoutMs,
264
+ quiet: globals.quiet,
265
+ });
266
+ if (final.status === "failed") {
267
+ throw new Error(`Generation job failed: ${final.error ?? "unknown error"}`);
268
+ }
269
+ const personIds = final.person_ids ?? [];
246
270
  if (!globals.quiet) {
247
- console.error(` generated ${profiles.length} profile${profiles.length === 1 ? "" : "s"}`);
248
- }
249
- // simulation_config is the inlined system prompt + model settings — ~3.5KB
250
- // of mostly-identical boilerplate per profile. Strip it from the default
251
- // JSON output; users who need it can pass --include-simulation-config or
252
- // fetch it later via `profile get --json`.
253
- const trimmed = opts.includeSimulationConfig
254
- ? profiles
255
- : profiles.map((p) => {
256
- const { simulation_config: _drop, ...rest } = p;
257
- return rest;
258
- });
259
- formatGeneratedProfileList(trimmed, globals.json);
271
+ console.error(` generated ${personIds.length} person${personIds.length === 1 ? "" : "s"}`);
272
+ }
273
+ // Fetch each generated person (and, unless --no-scenarios, its
274
+ // evidence-grounded scenarios) so the output is the people
275
+ // themselves rather than bare ids.
276
+ const fetchScenarios = opts.scenarios !== false;
277
+ const people = await Promise.all(personIds.map(async (pid) => {
278
+ const person = await client.get(`/people/${pid}`);
279
+ const record = person;
280
+ record.alias = tagAlias(ALIAS_PREFIX.person, pid);
281
+ if (fetchScenarios) {
282
+ const scenarios = await client.get(`/people/${pid}/scenarios`);
283
+ record.scenarios = scenarios;
284
+ }
285
+ return record;
286
+ }));
287
+ if (globals.json) {
288
+ output({
289
+ job: { id: job.id, status: final.status, person_ids: personIds },
290
+ people,
291
+ }, true);
292
+ return;
293
+ }
294
+ formatPersonList(people, false);
260
295
  });
261
296
  });
262
- profile
297
+ person
263
298
  .command("get")
264
- .description("Get profile details (accepts multiple IDs for batched lookup)")
265
- .argument("<ids...>", "Profile ID(s) — one or more aliases/UUIDs (space- or comma-separated)")
299
+ .description("Get person details (accepts multiple IDs for batched lookup)")
300
+ .argument("<ids...>", "Person ID(s) — one or more aliases/UUIDs (space- or comma-separated)")
266
301
  .addHelpText("after", `
267
302
  Examples:
268
- $ ish profile get tp-1b9
269
- $ ish profile get tp-1b9 --json
270
- $ ish profile get tp-1b9 tp-fc1 tp-2fc
271
- $ ish profile get tp-1b9,tp-fc1,tp-2fc --fields alias,name,country,occupation
303
+ $ ish person get p-1b9
304
+ $ ish person get p-1b9 --json
305
+ $ ish person get p-1b9 p-fc1 p-2fc
306
+ $ ish person get p-1b9,p-fc1,p-2fc --fields alias,name,country,occupation
272
307
 
273
308
  With multiple IDs, returns a {items:[...], total:N} envelope and uses the
274
309
  list table layout in human mode. Use --fields to project per-item.`)
@@ -276,38 +311,38 @@ list table layout in human mode. Use --fields to project per-item.`)
276
311
  await withClient(cmd, async (client, globals) => {
277
312
  const flat = ids.flatMap((s) => s.split(",").map((x) => x.trim()).filter(Boolean));
278
313
  if (flat.length === 0)
279
- throw new Error("Provide at least one profile id.");
314
+ throw new Error("Provide at least one person id.");
280
315
  if (flat.length === 1) {
281
- const data = await client.get(`/tester-profiles/${resolveId(flat[0])}`);
316
+ const data = await client.get(`/people/${resolveId(flat[0])}`);
282
317
  const result = data;
283
318
  if (result.id)
284
- result.alias = tagAlias(ALIAS_PREFIX.testerProfile, String(result.id));
319
+ result.alias = tagAlias(ALIAS_PREFIX.person, String(result.id));
285
320
  output(result, globals.json);
286
321
  return;
287
322
  }
288
323
  const results = await Promise.all(flat.map(async (raw) => {
289
- const data = await client.get(`/tester-profiles/${resolveId(raw)}`);
324
+ const data = await client.get(`/people/${resolveId(raw)}`);
290
325
  const r = data;
291
326
  if (r.id)
292
- r.alias = tagAlias(ALIAS_PREFIX.testerProfile, String(r.id));
327
+ r.alias = tagAlias(ALIAS_PREFIX.person, String(r.id));
293
328
  return r;
294
329
  }));
295
330
  if (globals.json) {
296
331
  output({ items: results, total: results.length }, true);
297
332
  }
298
333
  else {
299
- formatTesterProfileList(results, false);
334
+ formatPersonList(results, false);
300
335
  }
301
336
  });
302
337
  });
303
- profile
338
+ person
304
339
  .command("update")
305
- .description("Update a profile")
306
- .argument("<id>", "Profile ID")
340
+ .description("Update a person")
341
+ .argument("<id>", "Person ID")
307
342
  .option("--file <path>", "JSON file with update data (escape hatch for fields not covered by inline flags)")
308
- .option("--name <text>", "Profile name")
309
- .option("--description <text>", "Profile description")
310
- .option("--bio <text>", "Profile bio")
343
+ .option("--name <text>", "Person name")
344
+ .option("--description <text>", "Person description")
345
+ .option("--bio <text>", "Person bio")
311
346
  .option("--occupation <text>", "Occupation")
312
347
  .option("--country <code>", "Country code, e.g. US")
313
348
  .option("--gender <g>", "Gender, e.g. female")
@@ -320,13 +355,13 @@ list table layout in human mode. Use --fields to project per-item.`)
320
355
  .option("--accessibility-profile <json-or-path>", "AccessibilityProfile v1.0 as an inline JSON string OR a path to a JSON file. Empty object {} is the canonical default. Validated client-side against the spec before submit.")
321
356
  .addHelpText("after", `
322
357
  Examples:
323
- $ ish profile update <id> --bio "Edited bio"
324
- $ ish profile update <id> --name "Alice" --country US --education-level bachelor
325
- $ ish profile update <id> --household couple_with_kids --locale-type suburban
326
- $ ish profile update <id> --income-level middle --employment-status employed_full_time
327
- $ ish profile update <id> --accessibility-profile '{"version":"1.0","visual":{"uses_screen_reader":true,"text_size":"large"},"cognitive":{"reduce_motion":true},"assistive_tech":["VoiceOver"]}'
328
- $ ish profile update <id> --accessibility-profile ./a11y.json
329
- $ ish profile update <id> --file updates.json
358
+ $ ish person update <id> --bio "Edited bio"
359
+ $ ish person update <id> --name "Alice" --country US --education-level bachelor
360
+ $ ish person update <id> --household couple_with_kids --locale-type suburban
361
+ $ ish person update <id> --income-level middle --employment-status employed_full_time
362
+ $ ish person update <id> --accessibility-profile '{"version":"1.0","visual":{"uses_screen_reader":true,"text_size":"large"},"cognitive":{"reduce_motion":true},"assistive_tech":["VoiceOver"]}'
363
+ $ ish person update <id> --accessibility-profile ./a11y.json
364
+ $ ish person update <id> --file updates.json
330
365
 
331
366
  Inline flags compose into the patch body. --file is an escape hatch when you
332
367
  need fields not covered by the inline flags. When both are provided, inline
@@ -381,31 +416,31 @@ Schema: https://ishlabs.io/spec/accessibility-profile-schema.v1.json`)
381
416
  if (Object.keys(body).length === 0) {
382
417
  throw new Error("Nothing to update. Provide --file or at least one inline flag (e.g. --bio).");
383
418
  }
384
- const data = await client.put(`/tester-profiles/${resolveId(id)}`, body);
419
+ const data = await client.put(`/people/${resolveId(id)}`, body);
385
420
  const result = data;
386
421
  if (result.id)
387
- result.alias = tagAlias(ALIAS_PREFIX.testerProfile, String(result.id));
422
+ result.alias = tagAlias(ALIAS_PREFIX.person, String(result.id));
388
423
  output(result, globals.json, { writePath: true });
389
424
  });
390
425
  });
391
- profile
426
+ person
392
427
  .command("delete")
393
- .description("Delete a profile")
394
- .argument("<id>", "Profile ID")
395
- .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the profile)")
396
- .addHelpText("after", "\nExamples:\n $ ish profile delete <id>")
428
+ .description("Delete a person")
429
+ .argument("<id>", "Person ID")
430
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the person)")
431
+ .addHelpText("after", "\nExamples:\n $ ish person delete <id>")
397
432
  .action(async (id, _opts, cmd) => {
398
433
  await withClient(cmd, async (client, globals) => {
399
434
  const rid = resolveId(id);
400
- await client.del(`/tester-profiles/${rid}`);
401
- output({ id: rid, alias: tagAlias(ALIAS_PREFIX.testerProfile, rid), message: "Profile deleted" }, globals.json, { writePath: true });
435
+ await client.del(`/people/${rid}`);
436
+ output({ id: rid, alias: tagAlias(ALIAS_PREFIX.person, rid), message: "Profile deleted" }, globals.json, { writePath: true });
402
437
  });
403
438
  });
404
- profile
439
+ person
405
440
  .command("suggest-scenarios")
406
- .description("Ask the LLM for scenario probes to craft a specific simulated tester")
441
+ .description("Ask the LLM for scenario probes to craft a specific simulated person")
407
442
  .option("--workspace <id>", "Workspace (product) ID; falls back to active workspace")
408
- .option("--context <text>", "What you already know about this tester. Use @path to read from file.")
443
+ .option("--context <text>", "What you already know about this participant. Use @path to read from file.")
409
444
  .option("--context-file <path>", "Read --context from a file")
410
445
  .option("--count <n>", "Number of scenarios to return (1-10, default 5)")
411
446
  .option("--previous-answers <json-or-@path>", "Answers already collected this session. Inline JSON, @/path/to.json, or - for stdin. Array of {type, prompt, answer}; max 40.")
@@ -413,23 +448,23 @@ Schema: https://ishlabs.io/spec/accessibility-profile-schema.v1.json`)
413
448
  .addHelpText("after", `
414
449
  Examples:
415
450
  # Bare invocation: 5 scenarios from a free-form context blob
416
- $ ish profile suggest-scenarios --context "Mid-career engineer who handles oncall for a Stripe-using fintech"
451
+ $ ish person suggest-scenarios --context "Mid-career engineer who handles oncall for a Stripe-using fintech"
417
452
 
418
453
  # Load context from a file, ask for 3 scenarios
419
- $ ish profile suggest-scenarios --context-file ./persona-notes.md --count 3
454
+ $ ish person suggest-scenarios --context-file ./persona-notes.md --count 3
420
455
 
421
456
  # Follow-up probe: skip prompts already shown, build on prior answers
422
- $ ish profile suggest-scenarios \\
457
+ $ ish person suggest-scenarios \\
423
458
  --context "$(cat notes.md)" \\
424
459
  --count 3 \\
425
460
  --already-surfaced '["How do you triage 02:00 pages?"]' \\
426
461
  --previous-answers @./answers.json
427
462
 
428
463
  # Capture just the first scenario's type
429
- $ ish profile suggest-scenarios --context "..." --count 1 --get scenarios[0].type
464
+ $ ish person suggest-scenarios --context "..." --count 1 --get scenarios[0].type
430
465
 
431
- The loop: suggest → answer locally → persist via \`ish profile evidence add <id>\` (read back with \`evidence list\`).
432
- See \`ish docs get-page guides/build-specific-tester\` for the full workflow.`)
466
+ The loop: suggest → answer locally → persist via \`ish person evidence add <id>\` (read back with \`evidence list\`).
467
+ See \`ish docs get-page guides/build-specific-participant\` for the full workflow.`)
433
468
  .action(async (opts, cmd) => {
434
469
  await withClient(cmd, async (client, globals) => {
435
470
  const productId = resolveWorkspace(opts.workspace);
@@ -493,44 +528,44 @@ See \`ish docs get-page guides/build-specific-tester\` for the full workflow.`)
493
528
  const target = body.count ?? 5;
494
529
  console.error(` suggesting ${target} scenario${target === 1 ? "" : "s"}...`);
495
530
  }
496
- const data = await client.post("/tester-profiles/suggest-scenarios", body, { timeout: 180_000 });
531
+ const data = await client.post("/people/suggest-scenarios", body, { timeout: 180_000 });
497
532
  output(data, globals.json);
498
533
  });
499
534
  });
500
- const evidence = profile
535
+ const evidence = person
501
536
  .command("evidence")
502
- .description("Manage scenario-answer evidence on a tester profile")
537
+ .description("Manage scenario-answer evidence on a person")
503
538
  .addHelpText("after", `
504
539
  Evidence rows persist answers to \`suggest-scenarios\` probes onto a
505
- specific profile. The \`source\` field on every trace is the same enum
540
+ specific person. The \`source\` field on every trace is the same enum
506
541
  as the \`type\` field on a suggested scenario — copy verbatim when
507
542
  building a traces.json.
508
543
 
509
- Guide: ish docs get-page guides/build-specific-tester`);
544
+ Guide: ish docs get-page guides/build-specific-participant`);
510
545
  evidence
511
546
  .command("add")
512
- .description("Persist scenario answers as structured evidence on a profile")
513
- .argument("<id>", "Profile ID (alias or UUID)")
547
+ .description("Persist scenario answers as structured evidence on a person")
548
+ .argument("<id>", "Person ID (alias or UUID)")
514
549
  .option("--traces <json-or-@path>", `Array of {text, source, scenario_prompt?, raw_response?} where source ∈ ${EVIDENCE_SOURCES.join("|")}. Inline JSON, @/path/to.json, or - for stdin.`)
515
550
  .option("--traces-file <path>", "Read --traces from a JSON file")
516
551
  .addHelpText("after", `
517
552
  Examples:
518
553
  # Inline JSON for a single trace
519
- $ ish profile evidence add tp-d4e --traces '[{"text":"I would page my staff engineer first.","source":"situation","scenario_prompt":"PagerDuty fires at 02:00."}]'
554
+ $ ish person evidence add p-d4e --traces '[{"text":"I would page my staff engineer first.","source":"situation","scenario_prompt":"PagerDuty fires at 02:00."}]'
520
555
 
521
556
  # From a file
522
- $ ish profile evidence add tp-d4e --traces-file ./answers.json
557
+ $ ish person evidence add p-d4e --traces-file ./answers.json
523
558
 
524
559
  # From stdin (pipe-friendly)
525
- $ jq -c '.traces' session.json | ish profile evidence add tp-d4e --traces -
560
+ $ jq -c '.traces' session.json | ish person evidence add p-d4e --traces -
526
561
 
527
562
  # Project the response
528
- $ ish profile evidence add tp-d4e --traces-file ./answers.json --fields id,source,created_at
563
+ $ ish person evidence add p-d4e --traces-file ./answers.json --fields id,source,created_at
529
564
 
530
565
  Valid source values: ${EVIDENCE_SOURCES.join(", ")}.
531
566
  \`source\` on a trace = \`type\` on a suggested scenario — same enum.
532
- Pair with \`ish profile suggest-scenarios\` to drive the iterative probe → answer loop.
533
- Verify with \`ish profile evidence list <id>\`.`)
567
+ Pair with \`ish person suggest-scenarios\` to drive the iterative probe → answer loop.
568
+ Verify with \`ish person evidence list <id>\`.`)
534
569
  .action(async (id, opts, cmd) => {
535
570
  await withClient(cmd, async (client, globals) => {
536
571
  if (opts.traces && opts.tracesFile) {
@@ -571,9 +606,9 @@ Verify with \`ish profile evidence list <id>\`.`)
571
606
  const rid = resolveId(id);
572
607
  const body = { traces };
573
608
  if (!globals.quiet) {
574
- console.error(` persisting ${traces.length} evidence trace${traces.length === 1 ? "" : "s"} on ${tagAlias(ALIAS_PREFIX.testerProfile, rid)}...`);
609
+ console.error(` persisting ${traces.length} evidence trace${traces.length === 1 ? "" : "s"} on ${tagAlias(ALIAS_PREFIX.person, rid)}...`);
575
610
  }
576
- const data = await client.post(`/tester-profiles/${rid}/scenarios`, body);
611
+ const data = await client.post(`/people/${rid}/scenarios`, body);
577
612
  if (globals.json) {
578
613
  output({ items: data, total: data.length }, true);
579
614
  }
@@ -584,24 +619,24 @@ Verify with \`ish profile evidence list <id>\`.`)
584
619
  });
585
620
  evidence
586
621
  .command("list")
587
- .description("List evidence traces persisted on a tester profile (newest first)")
588
- .argument("<id>", "Profile ID (alias or UUID)")
622
+ .description("List evidence traces persisted on a person (newest first)")
623
+ .argument("<id>", "Person ID (alias or UUID)")
589
624
  .addHelpText("after", `
590
625
  Examples:
591
- # Read back every trace on a profile
592
- $ ish profile evidence list tp-d4e
626
+ # Read back every trace on a person
627
+ $ ish person evidence list p-d4e
593
628
 
594
629
  # Project per-row fields
595
- $ ish profile evidence list tp-d4e --fields id,source,scenario_prompt
630
+ $ ish person evidence list p-d4e --fields id,source,scenario_prompt
596
631
 
597
632
  # Capture just the sources, one per line (auto-descends into items)
598
- $ ish profile evidence list tp-d4e --get source
633
+ $ ish person evidence list p-d4e --get source
599
634
 
600
635
  Returns a {items, total} envelope. Use the same id you passed to \`evidence add\`.`)
601
636
  .action(async (id, _opts, cmd) => {
602
637
  await withClient(cmd, async (client, globals) => {
603
638
  const rid = resolveId(id);
604
- const data = await client.get(`/tester-profiles/${rid}/scenarios`);
639
+ const data = await client.get(`/people/${rid}/scenarios`);
605
640
  if (globals.json) {
606
641
  output({ items: data, total: data.length }, true);
607
642
  }
@@ -639,12 +674,12 @@ async function parseJsonFlag(value, flagName) {
639
674
  throw new Error(`${flagName} expects inline JSON (starting with { or [), an @path reference (@/path/to/file.json), or '-' for stdin.`);
640
675
  }
641
676
  /**
642
- * If the value matches the testerProfileSource alias pattern (e.g. "tps-3a4"),
677
+ * If the value matches the personSource alias pattern (e.g. "ps-3a4"),
643
678
  * resolve it to the underlying UUID. Otherwise return undefined so the caller
644
679
  * falls back to treating the value as a path.
645
680
  */
646
681
  function tryResolveSourceAlias(value) {
647
- if (!/^tps-[0-9a-f]{3,}$/.test(value))
682
+ if (!/^ps-[0-9a-f]{3,}$/.test(value))
648
683
  return undefined;
649
684
  try {
650
685
  return resolveId(value);