@ishlabs/cli 0.19.0 → 0.21.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/dist/commands/ask.js +26 -2
- package/dist/commands/config.js +9 -1
- package/dist/commands/docs.js +6 -7
- package/dist/commands/person.js +123 -9
- package/dist/commands/secret.js +25 -2
- package/dist/commands/source.d.ts +1 -1
- package/dist/commands/source.js +10 -6
- package/dist/commands/study.js +269 -13
- package/dist/commands/workspace.js +41 -6
- package/dist/index.js +227 -44
- package/dist/lib/alias-store.d.ts +1 -0
- package/dist/lib/alias-store.js +25 -4
- package/dist/lib/auth.js +22 -4
- package/dist/lib/baggage.d.ts +15 -6
- package/dist/lib/baggage.js +14 -8
- package/dist/lib/command-helpers.d.ts +1 -0
- package/dist/lib/command-helpers.js +79 -7
- package/dist/lib/docs.js +400 -21
- package/dist/lib/output.d.ts +18 -0
- package/dist/lib/output.js +278 -18
- package/dist/lib/profile-sources.js +18 -0
- package/dist/lib/skill-content.js +78 -2
- package/dist/lib/study-participants.d.ts +13 -0
- package/dist/lib/study-participants.js +13 -0
- package/dist/lib/study-results-filters.d.ts +91 -0
- package/dist/lib/study-results-filters.js +538 -0
- package/dist/lib/study-results-projections.d.ts +122 -0
- package/dist/lib/study-results-projections.js +577 -0
- package/dist/upgrade.js +9 -2
- package/package.json +1 -1
package/dist/lib/output.d.ts
CHANGED
|
@@ -48,6 +48,12 @@ export declare function formatWorkspaceDetail(workspace: Record<string, unknown>
|
|
|
48
48
|
export declare function formatSiteAccessStatus(summary: import("./site-access.js").SiteAccessSummary, json: boolean): void;
|
|
49
49
|
export declare function formatStudyList(studies: Record<string, unknown>[], json: boolean): void;
|
|
50
50
|
export declare function formatStudyDetail(study: Record<string, unknown>, json: boolean, options?: OutputOptions, participants?: ReadonlyArray<Record<string, unknown>>): void;
|
|
51
|
+
/**
|
|
52
|
+
* Stable JSON envelope for `study results`. Schema is fixed regardless of
|
|
53
|
+
* study state — fields default to `null`, `0`, or `[]` when nothing has run.
|
|
54
|
+
* Agents can rely on the keys always being present (M4).
|
|
55
|
+
*/
|
|
56
|
+
export declare function buildStudyResultsEnvelope(study: Record<string, unknown>, participants: ReadonlyArray<Record<string, unknown>>): Record<string, unknown>;
|
|
51
57
|
export declare function formatStudyResults(study: Record<string, unknown>, participants: ReadonlyArray<Record<string, unknown>>, json: boolean): void;
|
|
52
58
|
/**
|
|
53
59
|
* `study results --summary` projection. Drops interview_answers + per-participant
|
|
@@ -102,3 +108,15 @@ export declare function deriveWinnerConfidence(args: {
|
|
|
102
108
|
}): "low" | "medium" | "high";
|
|
103
109
|
export declare function formatAskResults(ask: Record<string, unknown>, json: boolean, roundFilter?: number): void;
|
|
104
110
|
export declare function formatConfigList(configs: Record<string, unknown>[], json: boolean): void;
|
|
111
|
+
export type StudyResultsGroupByKind = "iteration" | "frame" | "segment" | "turn" | "assignment" | "step";
|
|
112
|
+
/**
|
|
113
|
+
* Render a `--group-by <kind>` projection. JSON mode is a thin pass-through
|
|
114
|
+
* to jsonOutput with `preProjected: true` so the lean transform doesn't
|
|
115
|
+
* strip our stable empties. Human mode renders one section per slice plus
|
|
116
|
+
* a small ASCII sentiment histogram.
|
|
117
|
+
*
|
|
118
|
+
* The renderer accepts both the wrapped `{study, slices, ...}` shape (per-
|
|
119
|
+
* iteration) and the bare-array shape (every other --group-by); the
|
|
120
|
+
* surface (T5) doesn't need to know the difference.
|
|
121
|
+
*/
|
|
122
|
+
export declare function formatStudyResultsGroupBy(projection: unknown, kind: StudyResultsGroupByKind, json: boolean): void;
|
package/dist/lib/output.js
CHANGED
|
@@ -224,7 +224,11 @@ function wrapList(items, existing, opts = {}) {
|
|
|
224
224
|
const limit = typeof existing?.limit === "number" ? existing.limit : returned;
|
|
225
225
|
const offset = typeof existing?.offset === "number" ? existing.offset : 0;
|
|
226
226
|
const has_more = total > offset + returned;
|
|
227
|
-
|
|
227
|
+
// ISSUE-031: if the user explicitly named fields via --fields or --get,
|
|
228
|
+
// skip the per-item lean-strip so the requested fields actually survive
|
|
229
|
+
// to the output. Otherwise `--fields id,alias` silently drops `id`.
|
|
230
|
+
const userSpecifiedFields = (_fields && _fields.length > 0) || (typeof _getField === "string" && _getField.length > 0);
|
|
231
|
+
const leanItems = _verbose || opts.preProjectedItems || userSpecifiedFields
|
|
228
232
|
? items
|
|
229
233
|
: leanJson(items) ?? [];
|
|
230
234
|
return { items: leanItems, total, returned, limit, offset, has_more };
|
|
@@ -277,9 +281,16 @@ function pickFields(data, fields) {
|
|
|
277
281
|
/** Serialize data as JSON, applying lean transform and field selection. */
|
|
278
282
|
function jsonOutput(data, options = {}) {
|
|
279
283
|
let out;
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
284
|
+
// ISSUE-031: when the user explicitly names fields via --fields or
|
|
285
|
+
// --get, their request takes precedence over the default lean-strip.
|
|
286
|
+
// Previously --fields id,alias would silently drop `id` because
|
|
287
|
+
// leanJson ran first and stripped UUID-valued fields. The user
|
|
288
|
+
// asking for a field is unambiguous intent — bypass the strip.
|
|
289
|
+
const userSpecifiedFields = (_fields && _fields.length > 0) || (typeof _getField === "string" && _getField.length > 0);
|
|
290
|
+
if (_verbose || options.preProjected || userSpecifiedFields) {
|
|
291
|
+
// Verbose: full payload. preProjected: caller already chose the fields.
|
|
292
|
+
// userSpecifiedFields: caller said exactly what they want — don't
|
|
293
|
+
// second-guess by stripping UUIDs they asked for.
|
|
283
294
|
out = data;
|
|
284
295
|
}
|
|
285
296
|
else {
|
|
@@ -461,6 +472,29 @@ function suggestionsForError(err) {
|
|
|
461
472
|
}
|
|
462
473
|
return [];
|
|
463
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Pattern F (ISSUE-023): rewrite server-side internal entity names to the
|
|
477
|
+
* user-facing CLI surface names so messages like "Product not found" don't
|
|
478
|
+
* leak the backend's internal vocabulary to a first-time CLI user.
|
|
479
|
+
*
|
|
480
|
+
* Mapping reflects the rename audit: server still calls workspaces
|
|
481
|
+
* "products" and people "profiles" / "tester profiles" in some response
|
|
482
|
+
* messages; the CLI surface is `workspace` / `person`. Source is
|
|
483
|
+
* `attachment` server-side.
|
|
484
|
+
*
|
|
485
|
+
* Word-boundary anchored to avoid touching e.g. "productivity" or
|
|
486
|
+
* "Attachments must be ..." (the latter is genuinely about attachments).
|
|
487
|
+
*/
|
|
488
|
+
function remapEntityName(message) {
|
|
489
|
+
return message
|
|
490
|
+
.replace(/\bProduct not found\b/g, "Workspace not found")
|
|
491
|
+
.replace(/\bproduct not found\b/g, "workspace not found")
|
|
492
|
+
.replace(/\bTester [Pp]rofile not found\b/g, (m) => m.includes("P") ? "Person not found" : "person not found")
|
|
493
|
+
.replace(/\bProfile not found\b/g, "Person not found")
|
|
494
|
+
.replace(/\bprofile not found\b/g, "person not found")
|
|
495
|
+
.replace(/\bAttachment not found\b/g, "Source not found")
|
|
496
|
+
.replace(/\battachment not found\b/g, "source not found");
|
|
497
|
+
}
|
|
464
498
|
export function outputError(err, json) {
|
|
465
499
|
const suggestions = suggestionsForError(err);
|
|
466
500
|
if (err instanceof ApiError) {
|
|
@@ -505,7 +539,7 @@ export function outputError(err, json) {
|
|
|
505
539
|
: undefined;
|
|
506
540
|
if (json) {
|
|
507
541
|
console.error(JSON.stringify({
|
|
508
|
-
error: err.message,
|
|
542
|
+
error: remapEntityName(err.message),
|
|
509
543
|
error_code: err.error_code,
|
|
510
544
|
status: err.status,
|
|
511
545
|
retryable: err.retryable,
|
|
@@ -527,7 +561,7 @@ export function outputError(err, json) {
|
|
|
527
561
|
console.error("Error: Insufficient credits. Purchase more at https://app.ishlabs.io");
|
|
528
562
|
}
|
|
529
563
|
else {
|
|
530
|
-
console.error(`Error: ${err.message}`);
|
|
564
|
+
console.error(`Error: ${remapEntityName(err.message)}`);
|
|
531
565
|
}
|
|
532
566
|
if (Array.isArray(bodyErrors)) {
|
|
533
567
|
for (const entry of bodyErrors) {
|
|
@@ -549,6 +583,12 @@ export function outputError(err, json) {
|
|
|
549
583
|
else if (err instanceof ValidationError) {
|
|
550
584
|
if (json) {
|
|
551
585
|
console.error(JSON.stringify({
|
|
586
|
+
// ValidationError is CLI-thrown (we control its message), so we
|
|
587
|
+
// don't apply remapEntityName — that helper exists to translate
|
|
588
|
+
// server-side internal vocabulary ("Product"/"Profile"/"Attachment")
|
|
589
|
+
// back to user-facing names. Applying it here risks mangling
|
|
590
|
+
// user-supplied content (e.g. a workspace name containing "Profile
|
|
591
|
+
// not found"). Restrict the remap to the ApiError branch.
|
|
552
592
|
error: err.message,
|
|
553
593
|
error_code: "validation_error",
|
|
554
594
|
retryable: false,
|
|
@@ -597,6 +637,10 @@ export function outputError(err, json) {
|
|
|
597
637
|
const mergedSuggestions = [...new Set([...suggestions, ...taggedSuggestions])];
|
|
598
638
|
if (json) {
|
|
599
639
|
console.error(JSON.stringify({
|
|
640
|
+
// Generic Error: CLI-thrown (we control the message), so we don't
|
|
641
|
+
// apply remapEntityName — see ValidationError branch above for the
|
|
642
|
+
// same reasoning. Server-side names only leak through the ApiError
|
|
643
|
+
// branch where remapEntityName IS applied.
|
|
600
644
|
error: err.message,
|
|
601
645
|
error_code: errorCode,
|
|
602
646
|
retryable,
|
|
@@ -702,7 +746,7 @@ export function formatWorkspaceList(workspaces, json) {
|
|
|
702
746
|
return;
|
|
703
747
|
}
|
|
704
748
|
const aliasMap = getAliasMap(ALIAS_PREFIX.workspace);
|
|
705
|
-
printTable(["
|
|
749
|
+
printTable(["ALIAS", "NAME", "ROOM", "STUDIES", "ASKS", "PARTICIPANTS", "LAST ACTIVITY"], workspaces.map((w) => [
|
|
706
750
|
aliasMap.get(String(w.id)) || String(w.id || ""),
|
|
707
751
|
String(w.name || ""),
|
|
708
752
|
formatHeadroom(w.has_headroom),
|
|
@@ -784,7 +828,7 @@ export function formatStudyList(studies, json) {
|
|
|
784
828
|
return;
|
|
785
829
|
}
|
|
786
830
|
const aliasMap = getAliasMap(ALIAS_PREFIX.study);
|
|
787
|
-
printTable(["
|
|
831
|
+
printTable(["ALIAS", "NAME", "MODALITY", "TYPE", "STATUS", "PARTICIPANTS"], studies.map((s) => [
|
|
788
832
|
aliasMap.get(String(s.id)) || String(s.id || ""),
|
|
789
833
|
String(s.name || ""),
|
|
790
834
|
String(s.modality || "-"),
|
|
@@ -934,7 +978,7 @@ export function formatStudyDetail(study, json, options = {}, participants) {
|
|
|
934
978
|
const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
|
|
935
979
|
if (allParticipants.length > 0) {
|
|
936
980
|
console.log(`\nParticipants (${allParticipants.length}):`);
|
|
937
|
-
printTable(["
|
|
981
|
+
printTable(["ALIAS", "NAME", "ITERATION", "STATUS", "INTERACTIONS"], allParticipants.map((t) => [
|
|
938
982
|
t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
|
|
939
983
|
t.name,
|
|
940
984
|
t.iterationLabel,
|
|
@@ -948,7 +992,7 @@ export function formatStudyDetail(study, json, options = {}, participants) {
|
|
|
948
992
|
* study state — fields default to `null`, `0`, or `[]` when nothing has run.
|
|
949
993
|
* Agents can rely on the keys always being present (M4).
|
|
950
994
|
*/
|
|
951
|
-
function buildStudyResultsEnvelope(study, participants) {
|
|
995
|
+
export function buildStudyResultsEnvelope(study, participants) {
|
|
952
996
|
const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
|
|
953
997
|
const studyAlias = study.id
|
|
954
998
|
? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
|
|
@@ -1074,7 +1118,7 @@ export function formatStudyResults(study, participants, json) {
|
|
|
1074
1118
|
// Participants table
|
|
1075
1119
|
if (allParticipants.length > 0) {
|
|
1076
1120
|
console.log("\nParticipants:");
|
|
1077
|
-
printTable(["
|
|
1121
|
+
printTable(["ALIAS", "NAME", "ITERATION", "STATUS", "INTERACTIONS", "SENTIMENT"], allParticipants.map((t) => {
|
|
1078
1122
|
const parts = Object.entries(t.sentimentCounts).map(([label, count]) => `${count} ${label.toLowerCase()}`);
|
|
1079
1123
|
return [
|
|
1080
1124
|
t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
|
|
@@ -1375,7 +1419,7 @@ export function formatIterationList(iterations, json) {
|
|
|
1375
1419
|
return;
|
|
1376
1420
|
}
|
|
1377
1421
|
const aliasMap = getAliasMap(ALIAS_PREFIX.iteration);
|
|
1378
|
-
printTable(["
|
|
1422
|
+
printTable(["ALIAS", "LABEL", "NAME", "PARTICIPANTS", "CREATED"], iterations.map((it) => {
|
|
1379
1423
|
const participants = Array.isArray(it.participants) ? it.participants.length : 0;
|
|
1380
1424
|
return [
|
|
1381
1425
|
aliasMap.get(String(it.id)) || String(it.id || ""),
|
|
@@ -1449,7 +1493,7 @@ export function formatPersonList(profiles, json, limit) {
|
|
|
1449
1493
|
console.log("No participant profiles.");
|
|
1450
1494
|
return;
|
|
1451
1495
|
}
|
|
1452
|
-
printTable(["
|
|
1496
|
+
printTable(["ALIAS", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
|
|
1453
1497
|
String(p.alias || p.id || ""),
|
|
1454
1498
|
String(p.name || ""),
|
|
1455
1499
|
String(p.occupation || "-"),
|
|
@@ -1504,7 +1548,7 @@ export function formatGeneratedProfileList(profiles, json) {
|
|
|
1504
1548
|
console.log(jsonOutput(list));
|
|
1505
1549
|
return;
|
|
1506
1550
|
}
|
|
1507
|
-
printTable(["
|
|
1551
|
+
printTable(["ALIAS", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
|
|
1508
1552
|
String(p.alias || p.id || ""),
|
|
1509
1553
|
String(p.name || ""),
|
|
1510
1554
|
String(p.occupation || "-"),
|
|
@@ -1529,7 +1573,7 @@ export function formatSimulationPoll(results, json, isMedia = false) {
|
|
|
1529
1573
|
}
|
|
1530
1574
|
const aliasMap = getAliasMap(ALIAS_PREFIX.participant);
|
|
1531
1575
|
const countHeader = isMedia ? "SEGMENTS" : "INTERACTIONS";
|
|
1532
|
-
printTable(["
|
|
1576
|
+
printTable(["ALIAS", "PARTICIPANT", "STATUS", countHeader], results.map((r) => {
|
|
1533
1577
|
const id = String(r.id || r.participant_id || "");
|
|
1534
1578
|
return [
|
|
1535
1579
|
aliasMap.get(id) || id,
|
|
@@ -1574,7 +1618,7 @@ export function formatAskList(asks, json) {
|
|
|
1574
1618
|
return;
|
|
1575
1619
|
}
|
|
1576
1620
|
const aliasMap = getAliasMap(ALIAS_PREFIX.ask);
|
|
1577
|
-
printTable(["
|
|
1621
|
+
printTable(["ALIAS", "NAME", "STATUS", "PARTICIPANTS", "ROUNDS", "LAST ROUND", "ARCHIVED"], asks.map((a) => [
|
|
1578
1622
|
aliasMap.get(String(a.id)) || String(a.id || ""),
|
|
1579
1623
|
String(a.name || ""),
|
|
1580
1624
|
String(a.status || "-"),
|
|
@@ -1675,7 +1719,7 @@ export function formatAskDetail(ask, json) {
|
|
|
1675
1719
|
String(obj.status || "-"),
|
|
1676
1720
|
];
|
|
1677
1721
|
});
|
|
1678
|
-
printTable(["
|
|
1722
|
+
printTable(["ALIAS", "NAME", "STATUS"], rows);
|
|
1679
1723
|
if (participants.length > 20)
|
|
1680
1724
|
console.log(` … and ${participants.length - 20} more`);
|
|
1681
1725
|
}
|
|
@@ -2145,7 +2189,7 @@ export function formatConfigList(configs, json) {
|
|
|
2145
2189
|
return;
|
|
2146
2190
|
}
|
|
2147
2191
|
const aliasMap = getAliasMap(ALIAS_PREFIX.config);
|
|
2148
|
-
printTable(["
|
|
2192
|
+
printTable(["ALIAS", "NAME", "SOURCE", "CREATED"], configs.map((c) => [
|
|
2149
2193
|
aliasMap.get(String(c.id)) || String(c.id || ""),
|
|
2150
2194
|
String(c.name || ""),
|
|
2151
2195
|
String(c.source_type || "manual"),
|
|
@@ -2182,3 +2226,219 @@ function formatDate(value) {
|
|
|
2182
2226
|
return str.slice(0, 10);
|
|
2183
2227
|
}
|
|
2184
2228
|
}
|
|
2229
|
+
const POSITIVE_SENTIMENT = new Set(["satisfied", "curious", "engaged", "confident", "delighted"]);
|
|
2230
|
+
const NEGATIVE_SENTIMENT = new Set(["frustrated", "confused", "blocked", "anxious", "disappointed"]);
|
|
2231
|
+
function sentimentColor(label) {
|
|
2232
|
+
const l = label.toLowerCase();
|
|
2233
|
+
if (POSITIVE_SENTIMENT.has(l))
|
|
2234
|
+
return c.green;
|
|
2235
|
+
if (NEGATIVE_SENTIMENT.has(l))
|
|
2236
|
+
return c.red;
|
|
2237
|
+
return c.dim;
|
|
2238
|
+
}
|
|
2239
|
+
function asciiHistogram(hist, options = {}) {
|
|
2240
|
+
const width = options.width ?? 20;
|
|
2241
|
+
const indent = options.indent ?? " ";
|
|
2242
|
+
const entries = Object.entries(hist).filter(([, v]) => v > 0);
|
|
2243
|
+
if (entries.length === 0)
|
|
2244
|
+
return [];
|
|
2245
|
+
const max = entries.reduce((acc, [, v]) => (v > acc ? v : acc), 0);
|
|
2246
|
+
const labelWidth = entries.reduce((acc, [k]) => (k.length > acc ? k.length : acc), 0);
|
|
2247
|
+
return entries
|
|
2248
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
2249
|
+
.map(([label, count]) => {
|
|
2250
|
+
const bars = max > 0 ? Math.max(1, Math.round((count / max) * width)) : 0;
|
|
2251
|
+
const color = sentimentColor(label);
|
|
2252
|
+
return `${indent}${label.padEnd(labelWidth)} ${color}${"█".repeat(bars)}${c.reset} ${count}`;
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
function slicesFromProjection(projection) {
|
|
2256
|
+
// Iteration projection wraps `{ study, slices, totals_unfiltered, warnings }`;
|
|
2257
|
+
// all others are bare arrays. Both come through here.
|
|
2258
|
+
if (Array.isArray(projection)) {
|
|
2259
|
+
return projection.filter((s) => Boolean(s) && typeof s === "object" && !Array.isArray(s));
|
|
2260
|
+
}
|
|
2261
|
+
if (projection && typeof projection === "object") {
|
|
2262
|
+
const wrapped = projection;
|
|
2263
|
+
const slices = wrapped.slices;
|
|
2264
|
+
if (Array.isArray(slices)) {
|
|
2265
|
+
return slices.filter((s) => Boolean(s) && typeof s === "object" && !Array.isArray(s));
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
return [];
|
|
2269
|
+
}
|
|
2270
|
+
function totalInteractionsFromSlices(slices) {
|
|
2271
|
+
let total = 0;
|
|
2272
|
+
for (const s of slices) {
|
|
2273
|
+
const n = typeof s.interaction_count === "number" ? s.interaction_count : 0;
|
|
2274
|
+
total += n;
|
|
2275
|
+
}
|
|
2276
|
+
return total;
|
|
2277
|
+
}
|
|
2278
|
+
function totalsUnfilteredFromProjection(projection) {
|
|
2279
|
+
if (projection && typeof projection === "object" && !Array.isArray(projection)) {
|
|
2280
|
+
const t = projection.totals_unfiltered;
|
|
2281
|
+
if (t && typeof t === "object" && !Array.isArray(t)) {
|
|
2282
|
+
return t;
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
return null;
|
|
2286
|
+
}
|
|
2287
|
+
function renderIterationSlice(slice) {
|
|
2288
|
+
const label = String(slice.iteration_label ?? slice.iteration_id ?? "?");
|
|
2289
|
+
const pCount = Number(slice.participant_count ?? 0);
|
|
2290
|
+
const iCount = Number(slice.interaction_count ?? 0);
|
|
2291
|
+
console.log(`\n ${c.bold}Iteration ${label}${c.reset} ${c.dim}${pCount} participant${pCount !== 1 ? "s" : ""} · ${iCount} interaction${iCount !== 1 ? "s" : ""}${c.reset}`);
|
|
2292
|
+
const hist = slice.sentiment ?? {};
|
|
2293
|
+
for (const line of asciiHistogram(hist, { indent: " " }))
|
|
2294
|
+
console.log(line);
|
|
2295
|
+
const top = Array.isArray(slice.top_actions) ? slice.top_actions : [];
|
|
2296
|
+
if (top.length > 0) {
|
|
2297
|
+
const parts = top.map((a) => `${a.action_type} ×${a.count}`);
|
|
2298
|
+
console.log(` ${c.dim}Top actions:${c.reset} ${parts.join(", ")}`);
|
|
2299
|
+
}
|
|
2300
|
+
const comments = Array.isArray(slice.sample_comments) ? slice.sample_comments : [];
|
|
2301
|
+
for (const ccomment of comments) {
|
|
2302
|
+
console.log(` ${c.dim}"${ccomment}"${c.reset}`);
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
function renderFrameSlice(slice) {
|
|
2306
|
+
const label = slice.frame_label ? String(slice.frame_label) : String(slice.frame_id);
|
|
2307
|
+
const iCount = Number(slice.interaction_count ?? 0);
|
|
2308
|
+
const aliases = Array.isArray(slice.participant_aliases) ? slice.participant_aliases : [];
|
|
2309
|
+
console.log(`\n ${c.bold}${label}${c.reset} ${c.dim}${iCount} interaction${iCount !== 1 ? "s" : ""} · ${aliases.length} participant${aliases.length !== 1 ? "s" : ""}${c.reset}`);
|
|
2310
|
+
const hist = slice.sentiment_histogram ?? {};
|
|
2311
|
+
for (const line of asciiHistogram(hist, { indent: " " }))
|
|
2312
|
+
console.log(line);
|
|
2313
|
+
const comments = Array.isArray(slice.sample_comments) ? slice.sample_comments : [];
|
|
2314
|
+
for (const ccomment of comments) {
|
|
2315
|
+
console.log(` ${c.dim}"${ccomment}"${c.reset}`);
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
function renderSegmentSlice(slice) {
|
|
2319
|
+
const idx = slice.segment_index;
|
|
2320
|
+
const label = slice.segment_label ? String(slice.segment_label) : null;
|
|
2321
|
+
const header = idx !== null && idx !== undefined
|
|
2322
|
+
? `Segment ${idx}${label ? ` — ${label}` : ""}`
|
|
2323
|
+
: (label ?? "Segment ?");
|
|
2324
|
+
const iCount = Number(slice.interaction_count ?? 0);
|
|
2325
|
+
console.log(`\n ${c.bold}${header}${c.reset} ${c.dim}${iCount} interaction${iCount !== 1 ? "s" : ""}${c.reset}`);
|
|
2326
|
+
const hist = slice.sentiment_histogram ?? {};
|
|
2327
|
+
for (const line of asciiHistogram(hist, { indent: " " }))
|
|
2328
|
+
console.log(line);
|
|
2329
|
+
const engagement = slice.engagement_histogram ?? {};
|
|
2330
|
+
if (Object.keys(engagement).length > 0) {
|
|
2331
|
+
const parts = Object.entries(engagement).map(([k, v]) => `${v} ${k}`);
|
|
2332
|
+
console.log(` ${c.dim}Engagement:${c.reset} ${parts.join(", ")}`);
|
|
2333
|
+
}
|
|
2334
|
+
const comments = Array.isArray(slice.sample_comments) ? slice.sample_comments : [];
|
|
2335
|
+
for (const ccomment of comments) {
|
|
2336
|
+
console.log(` ${c.dim}"${ccomment}"${c.reset}`);
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
function renderTurnSlice(slice) {
|
|
2340
|
+
const turn = Number(slice.turn_index ?? 0);
|
|
2341
|
+
const iCount = Number(slice.interaction_count ?? 0);
|
|
2342
|
+
const failures = Number(slice.failures ?? 0);
|
|
2343
|
+
const failPart = failures > 0 ? ` ${c.red}${failures} failure${failures !== 1 ? "s" : ""}${c.reset}` : "";
|
|
2344
|
+
console.log(`\n ${c.bold}Turn ${turn}${c.reset} ${c.dim}${iCount} interaction${iCount !== 1 ? "s" : ""}${c.reset}${failPart}`);
|
|
2345
|
+
const hist = slice.sentiment_histogram ?? {};
|
|
2346
|
+
for (const line of asciiHistogram(hist, { indent: " " }))
|
|
2347
|
+
console.log(line);
|
|
2348
|
+
const replies = Array.isArray(slice.sample_replies) ? slice.sample_replies : [];
|
|
2349
|
+
for (const r of replies) {
|
|
2350
|
+
console.log(` ${c.dim}"${r}"${c.reset}`);
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
function renderAssignmentSlice(slice) {
|
|
2354
|
+
const name = String(slice.assignment_name ?? slice.assignment_id ?? "?");
|
|
2355
|
+
const iCount = Number(slice.interaction_count ?? 0);
|
|
2356
|
+
console.log(`\n ${c.bold}${name}${c.reset} ${c.dim}${iCount} interaction${iCount !== 1 ? "s" : ""}${c.reset}`);
|
|
2357
|
+
const hist = slice.sentiment_histogram ?? {};
|
|
2358
|
+
for (const line of asciiHistogram(hist, { indent: " " }))
|
|
2359
|
+
console.log(line);
|
|
2360
|
+
const sc = Array.isArray(slice.step_completion) ? slice.step_completion : [];
|
|
2361
|
+
if (sc.length > 0) {
|
|
2362
|
+
const rows = sc.map((s) => [
|
|
2363
|
+
String(s.name ?? s.step_id ?? "?"),
|
|
2364
|
+
String(s.passed ?? 0),
|
|
2365
|
+
String(s.inconclusive ?? 0),
|
|
2366
|
+
String(s.failed ?? 0),
|
|
2367
|
+
typeof s.rate === "number" ? s.rate.toFixed(2) : "-",
|
|
2368
|
+
]);
|
|
2369
|
+
console.log(` ${c.dim}Steps:${c.reset}`);
|
|
2370
|
+
printTable(["STEP", "PASSED", "INCONCLUSIVE", "FAILED", "RATE"], rows);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
function renderStepSlice(slice) {
|
|
2374
|
+
const name = String(slice.step_name ?? slice.step_id ?? "?");
|
|
2375
|
+
const assignment = String(slice.assignment_name ?? "?");
|
|
2376
|
+
const total = Number(slice.total ?? 0);
|
|
2377
|
+
const passed = Number(slice.passed ?? 0);
|
|
2378
|
+
const inconclusive = Number(slice.inconclusive ?? 0);
|
|
2379
|
+
const failed = Number(slice.failed ?? 0);
|
|
2380
|
+
const rate = typeof slice.rate === "number" ? slice.rate.toFixed(2) : "-";
|
|
2381
|
+
const rateColor = failed > passed ? c.red : (passed > failed ? c.green : c.dim);
|
|
2382
|
+
console.log(`\n ${c.bold}${assignment} › ${name}${c.reset} ${rateColor}${passed}/${total} passed${c.reset} ${c.dim}(${inconclusive} inconclusive, ${failed} failed, rate ${rate})${c.reset}`);
|
|
2383
|
+
const verdicts = Array.isArray(slice.participant_verdicts)
|
|
2384
|
+
? slice.participant_verdicts
|
|
2385
|
+
: [];
|
|
2386
|
+
if (verdicts.length > 0) {
|
|
2387
|
+
const rows = verdicts.map((v) => [
|
|
2388
|
+
String(v.participant_alias ?? "-"),
|
|
2389
|
+
String(v.verdict ?? "-"),
|
|
2390
|
+
v.reason ? truncate(String(v.reason), 60) : "-",
|
|
2391
|
+
]);
|
|
2392
|
+
printTable(["PARTICIPANT", "VERDICT", "REASON"], rows);
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
/**
|
|
2396
|
+
* Render a `--group-by <kind>` projection. JSON mode is a thin pass-through
|
|
2397
|
+
* to jsonOutput with `preProjected: true` so the lean transform doesn't
|
|
2398
|
+
* strip our stable empties. Human mode renders one section per slice plus
|
|
2399
|
+
* a small ASCII sentiment histogram.
|
|
2400
|
+
*
|
|
2401
|
+
* The renderer accepts both the wrapped `{study, slices, ...}` shape (per-
|
|
2402
|
+
* iteration) and the bare-array shape (every other --group-by); the
|
|
2403
|
+
* surface (T5) doesn't need to know the difference.
|
|
2404
|
+
*/
|
|
2405
|
+
export function formatStudyResultsGroupBy(projection, kind, json) {
|
|
2406
|
+
if (json) {
|
|
2407
|
+
console.log(jsonOutput(projection, { preProjected: true }));
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
const slices = slicesFromProjection(projection);
|
|
2411
|
+
const totalInteractions = totalInteractionsFromSlices(slices);
|
|
2412
|
+
const unfiltered = totalsUnfilteredFromProjection(projection);
|
|
2413
|
+
const totalUnfiltered = unfiltered && typeof unfiltered.interaction_count === "number"
|
|
2414
|
+
? unfiltered.interaction_count
|
|
2415
|
+
: null;
|
|
2416
|
+
const headline = `Sliced by ${kind}: ${slices.length} group${slices.length !== 1 ? "s" : ""} (${totalInteractions}${totalUnfiltered !== null ? `/${totalUnfiltered}` : ""} interaction${totalInteractions !== 1 ? "s" : ""})`;
|
|
2417
|
+
console.log(`${c.bold}${headline}${c.reset}`);
|
|
2418
|
+
if (slices.length === 0) {
|
|
2419
|
+
console.log(` ${c.dim}(no groups matched)${c.reset}`);
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
for (const slice of slices) {
|
|
2423
|
+
switch (kind) {
|
|
2424
|
+
case "iteration":
|
|
2425
|
+
renderIterationSlice(slice);
|
|
2426
|
+
break;
|
|
2427
|
+
case "frame":
|
|
2428
|
+
renderFrameSlice(slice);
|
|
2429
|
+
break;
|
|
2430
|
+
case "segment":
|
|
2431
|
+
renderSegmentSlice(slice);
|
|
2432
|
+
break;
|
|
2433
|
+
case "turn":
|
|
2434
|
+
renderTurnSlice(slice);
|
|
2435
|
+
break;
|
|
2436
|
+
case "assignment":
|
|
2437
|
+
renderAssignmentSlice(slice);
|
|
2438
|
+
break;
|
|
2439
|
+
case "step":
|
|
2440
|
+
renderStepSlice(slice);
|
|
2441
|
+
break;
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
@@ -164,6 +164,11 @@ export async function resolveSourceRef(client, value, opts) {
|
|
|
164
164
|
}
|
|
165
165
|
// --- Agentic generation jobs ---
|
|
166
166
|
const GENERATION_POLL_INTERVAL_MS = 2_500;
|
|
167
|
+
/** NEW-CP-3: emit a "still running" heartbeat at least this often even if
|
|
168
|
+
* status/progress_message hasn't changed. Backend often stays at
|
|
169
|
+
* `running` with no message for tens of seconds; without a breadcrumb
|
|
170
|
+
* the CLI looks frozen. */
|
|
171
|
+
const GENERATION_HEARTBEAT_MS = 15_000;
|
|
167
172
|
const GENERATION_TERMINAL_STATUSES = new Set(["completed", "failed"]);
|
|
168
173
|
/**
|
|
169
174
|
* Thrown when a generation job doesn't reach a terminal status before the
|
|
@@ -190,7 +195,9 @@ export class GenerationTimeoutError extends Error {
|
|
|
190
195
|
export async function pollGenerationJobUntilDone(client, jobId, opts = {}) {
|
|
191
196
|
const timeoutMs = opts.timeoutMs ?? 600_000;
|
|
192
197
|
const deadline = Date.now() + timeoutMs;
|
|
198
|
+
const startedAt = Date.now();
|
|
193
199
|
let lastReported = "";
|
|
200
|
+
let lastHeartbeatAt = Date.now();
|
|
194
201
|
while (true) {
|
|
195
202
|
const job = await client.get(`/people/generation-jobs/${jobId}`, undefined, { timeout: 60_000 });
|
|
196
203
|
if (!opts.quiet) {
|
|
@@ -198,8 +205,19 @@ export async function pollGenerationJobUntilDone(client, jobId, opts = {}) {
|
|
|
198
205
|
? `${job.status}: ${job.progress_message}`
|
|
199
206
|
: job.status;
|
|
200
207
|
if (line !== lastReported) {
|
|
208
|
+
// Status / message changed — emit it as a fresh breadcrumb.
|
|
201
209
|
process.stderr.write(` ${line}\n`);
|
|
202
210
|
lastReported = line;
|
|
211
|
+
lastHeartbeatAt = Date.now();
|
|
212
|
+
}
|
|
213
|
+
else if (Date.now() - lastHeartbeatAt >= GENERATION_HEARTBEAT_MS) {
|
|
214
|
+
// NEW-CP-3: the backend often holds at `status=running` with no
|
|
215
|
+
// `progress_message` for tens of seconds. Without a heartbeat the
|
|
216
|
+
// CLI looks frozen. Emit a "still running" line every 15s so the
|
|
217
|
+
// operator knows we're polling and not hung.
|
|
218
|
+
const elapsedSec = Math.round((Date.now() - startedAt) / 1000);
|
|
219
|
+
process.stderr.write(` still ${job.status} (${elapsedSec}s elapsed)…\n`);
|
|
220
|
+
lastHeartbeatAt = Date.now();
|
|
203
221
|
}
|
|
204
222
|
}
|
|
205
223
|
if (GENERATION_TERMINAL_STATUSES.has(job.status))
|
|
@@ -220,6 +220,10 @@ When in doubt: side-by-side comparison usually beats in-place edits. Ids are che
|
|
|
220
220
|
- **401 surfaces as fake blocker**: an unauthenticated endpoint produces "participant got stuck on auth screen" — looks like a UX blocker but is config. Always confirm endpoint auth before reading transcripts as user-research data.
|
|
221
221
|
- **No per-page/per-timestamp scoping for media**: there's no "evaluate just slide 14" or "react to seconds 0-30" API. State the focus explicitly in the \`assignment\` text, or pre-stitch the artifact (e.g. replace one slide locally, upload as a new iteration).
|
|
222
222
|
- **\`study get --json\` participants live at the top level**, not nested under \`iterations[*].participants\`. The backend split made \`/studies/{id}\` lite (metadata + iteration shells, no participant graph) and added \`/studies/{id}/participants\`; the CLI joins them so \`study get --json\` carries a flat \`participants[]\` with \`iteration_id\` on each row. Read \`.participants[]\`, not \`.iterations[].participants[]\`.
|
|
223
|
+
- **All destructive deletes require \`--yes\` in non-TTY mode**: \`ish workspace delete\`, \`study delete\`, \`ask delete\`, \`person delete\`, \`source delete\`, \`chat endpoint delete\`. In \`--json\` mode (or any piped/non-TTY invocation), omitting \`--yes\` refuses with \`error_kind: "ConfirmationRequired"\` + an \`example\` field showing the same command with \`--yes\` appended. \`workspace delete\` is the highest-blast-radius: it removes ALL nested studies, asks, people, secrets, configs, sources, and chat endpoints — the prompt names them explicitly.
|
|
224
|
+
- **\`ish login\` is idempotent**: with a valid saved token, \`ish login\` short-circuits with "Already logged in" and **does not open a new browser tab**. Use \`--force\` (or \`-f\`) only when actually switching accounts.
|
|
225
|
+
- **\`ish person create\` accepts inline flags** (mirrors \`person update\`): the file-only API (\`--file <path>\`) is preserved as an escape hatch but the common path is \`ish person create --name "X" --type ai --country US ...\` — \`--type\` defaults to \`ai\` when \`--file\` is omitted. See \`ish person create --help\` for the full inline-flag set including \`--household\` (MECE rule applies) and \`--accessibility-profile\`.
|
|
226
|
+
- **\`ish status\` now surfaces \`chat_endpoint\`** alongside \`workspace\`/\`study\`/\`ask\`. Stale or orphan active refs get a \`warning\` + \`hint\` field on the affected ref (instead of silently dropping the \`name\`). On \`workspace use <other>\`, the CLI cascade-clears \`study\`/\`ask\`/\`chat_endpoint\` (they belong to the previous workspace).
|
|
223
227
|
|
|
224
228
|
## When in doubt
|
|
225
229
|
|
|
@@ -376,9 +380,13 @@ ish person suggest-scenarios \\
|
|
|
376
380
|
# [{"text":"...","source":"situation","scenario_prompt":"..."}, ...]
|
|
377
381
|
# Valid source values: situation, voice, binary, micro-story
|
|
378
382
|
|
|
379
|
-
# 3. Save the person shell
|
|
383
|
+
# 3. Save the person shell — either from file:
|
|
380
384
|
ish person create --file ./persona.json
|
|
381
385
|
# → p-d4e
|
|
386
|
+
#
|
|
387
|
+
# …or inline (mirror of person update):
|
|
388
|
+
# ish person create --name "Alice" --type ai --country US \\
|
|
389
|
+
# --occupation founder --household single --bio "..."
|
|
382
390
|
|
|
383
391
|
# 4. Persist the answers as structured evidence
|
|
384
392
|
ish person evidence add p-d4e --traces-file ./answers.json
|
|
@@ -909,6 +917,70 @@ Rules to remember:
|
|
|
909
917
|
See \`ish docs get-page concepts/extending-a-simulation\` for the full
|
|
910
918
|
mental model (cancel + extend as a pair, error envelopes, cost model).
|
|
911
919
|
|
|
920
|
+
## 12. Slice study results by frame / segment / turn / sentiment
|
|
921
|
+
|
|
922
|
+
Goal: ask narrower questions of a finished run than the kitchen-sink
|
|
923
|
+
\`ish study results\` envelope answers. The canonical use case:
|
|
924
|
+
**"what differed on the login screen across these five iterations?"**.
|
|
925
|
+
|
|
926
|
+
\`\`\`bash
|
|
927
|
+
# 12a. Across-iterations comparison on one frame (the canonical question).
|
|
928
|
+
# --frame matches frame names by case-insensitive substring; pass
|
|
929
|
+
# a full Frame UUID or an f-… alias when the name is ambiguous.
|
|
930
|
+
ish study results s-b2c --frame login --group-by iteration --json
|
|
931
|
+
|
|
932
|
+
# 12b. Frustrated reactions to one segment of a video study:
|
|
933
|
+
ish study results s-b2c --segment 3 --sentiment Frustrated
|
|
934
|
+
|
|
935
|
+
# 12c. Who failed the "verify email" step, and why?
|
|
936
|
+
# --group-by step exposes per-participant verdicts inline so you
|
|
937
|
+
# don't fan out across participants.
|
|
938
|
+
ish study results s-b2c --assignment "Sign up" --step verify-email \\
|
|
939
|
+
--group-by step --json
|
|
940
|
+
|
|
941
|
+
# 12d. Pair-mode chat: only side A turn 4.
|
|
942
|
+
ish study results s-b2c --side a --turn 4
|
|
943
|
+
|
|
944
|
+
# 12e. Sanity-check coverage when a filter narrows the slice:
|
|
945
|
+
ish study results s-b2c --frame checkout --json \\
|
|
946
|
+
| jq '{matched: .participant_count, total: .totals_unfiltered.participant_count}'
|
|
947
|
+
|
|
948
|
+
# 12f. A filter that matches zero interactions still returns the stable
|
|
949
|
+
# envelope shape — participant_count: 0, totals_unfiltered populated,
|
|
950
|
+
# exit code 0 (not 4). Never error on no-match.
|
|
951
|
+
ish study results s-b2c --frame doesnotexist --json
|
|
952
|
+
# → ValidationError because "doesnotexist" matches no frame names; pass
|
|
953
|
+
# --include-unmatched only when --frame DID resolve and you want the
|
|
954
|
+
# degraded captures (frame_version_id: null) back.
|
|
955
|
+
\`\`\`
|
|
956
|
+
|
|
957
|
+
Rules to remember:
|
|
958
|
+
- **Filters compose with AND across flags; OR within \`--sentiment\`.**
|
|
959
|
+
\`--frame login --sentiment Frustrated,Confused\` keeps only login-frame
|
|
960
|
+
interactions whose sentiment is Frustrated OR Confused.
|
|
961
|
+
- **Modality mismatch is not an error.** \`--segment 0\` on an
|
|
962
|
+
interactive study emits a stderr warning and is ignored. The
|
|
963
|
+
exception is **\`--group-by\`** — \`--group-by frame\` on a chat study,
|
|
964
|
+
\`--group-by turn\` on a video study, etc. error at the router (exit 2).
|
|
965
|
+
- **Empty-slice contract: exit 0, not 4.** Zero matches return a
|
|
966
|
+
stable envelope with \`participant_count: 0\` and
|
|
967
|
+
\`totals_unfiltered\` populated. Agents key on
|
|
968
|
+
\`totals_unfiltered.participant_count\` to ask "is the filter too
|
|
969
|
+
tight, or did the run not produce data?".
|
|
970
|
+
- \`--frame\` accepts a name substring, a Frame UUID, an \`f-…\` alias,
|
|
971
|
+
or a \`frame_version_id\` UUID. Ambiguous substring (matches >1
|
|
972
|
+
frame) errors with the candidate list.
|
|
973
|
+
- \`--summary\` is orthogonal to filters and narrows the summary over
|
|
974
|
+
the filtered set. \`--transcript\` is single-participant and errors
|
|
975
|
+
(exit 2) when **any** filter or \`--group-by\` is set.
|
|
976
|
+
- Per-step output exposes \`participant_verdicts: [{participant_alias,
|
|
977
|
+
verdict, reason, evidence_interaction_ids}]\` — not
|
|
978
|
+
\`per_participant_verdicts\`. The verdict enum is \`passed\` /
|
|
979
|
+
\`inconclusive\` / \`failed\`.
|
|
980
|
+
|
|
981
|
+
See \`ish docs get-page guides/slicing-results\` for the full filter
|
|
982
|
+
table, projection shapes, and the defensive null-handling rules.
|
|
983
|
+
|
|
912
984
|
## Tips for chaining commands as an agent
|
|
913
985
|
|
|
914
986
|
- Capture aliases from JSON: \`ITER=$(ish iteration create --url … --json | jq -r .alias)\`
|
|
@@ -1002,6 +1074,10 @@ mental model (cancel + extend as a pair, error envelopes, cost model).
|
|
|
1002
1074
|
| List of participants from \`study run\` | \`--json \\| jq '.participants[].id'\` | \`--get participant_aliases\` (or \`participant_ids\` for UUIDs) |
|
|
1003
1075
|
| Per-answer sentiment | \`--json \\| jq '...'\` per participant | \`ish study results <id> --json\` (sentiment is on every answer row) |
|
|
1004
1076
|
| "Did this run land?" headline | \`study results --json\` + jq filtering | \`ish study results <id> --summary --json\` |
|
|
1077
|
+
| Across-iterations comparison on one frame | \`study results --json\` + jq per iteration | \`ish study results <id> --frame login --group-by iteration --json\` |
|
|
1078
|
+
| Per-step pass/fail with reasons inline | \`study participant --json\` per participant + jq | \`ish study results <id> --step verify-email --group-by step --json\` |
|
|
1079
|
+
| Frustrated reactions to one media segment | \`study results --json\` + jq | \`ish study results <id> --segment 3 --sentiment Frustrated --json\` |
|
|
1080
|
+
| Sanity-check filter coverage | hand-count \`.participants\` vs total | \`--get totals_unfiltered.participant_count\` (set on every sliced envelope) |
|
|
1005
1081
|
| Chat transcript for one participant (external_chatbot) | \`study participant --json\` + jq | \`ish study results <id> --transcript <participant_id> --json\` |
|
|
1006
1082
|
| Pair-mode conversation transcripts | \`study participant --json\` per participant | \`ish iteration get <iter-id> --json \\| jq '.conversations[]'\` |
|
|
1007
1083
|
| Participant headline only (no action timeline) | \`study participant --json\` + jq | \`ish study participant <id> --summary --json\` |
|
|
@@ -1042,7 +1118,7 @@ ish <command> --help
|
|
|
1042
1118
|
| \`mcp\` | Wire the hosted ish MCP server into local AI | guides/mcp-add |
|
|
1043
1119
|
| | clients (Cursor, VS Code, Claude Code, | |
|
|
1044
1120
|
| | Claude Desktop, Windsurf). Idempotent. | |
|
|
1045
|
-
| \`login\` | Browser-based auth
|
|
1121
|
+
| \`login\` | Browser-based auth. Idempotent: short-circuits on valid saved token. \`--force\` to switch accounts. | — |
|
|
1046
1122
|
| \`logout\` | Clear saved credentials | — |
|
|
1047
1123
|
| \`status\` | Show active session (user, workspace, | concepts/active-context |
|
|
1048
1124
|
| | study, ask, token validity) — alias \`whoami\` | |
|
|
@@ -6,6 +6,19 @@
|
|
|
6
6
|
* (person, interactions[], participant_summary, interview_answers, …) that
|
|
7
7
|
* used to be embedded under `study.iterations[*].participants[*]` on the
|
|
8
8
|
* legacy `GET /studies/{id}` response.
|
|
9
|
+
*
|
|
10
|
+
* Audit (study-results-slice plan, T4): the flat endpoint already returns
|
|
11
|
+
* everything the new `ish study results --frame/--segment/--step/...` filter
|
|
12
|
+
* pipeline needs in a single round-trip — no per-participant fan-out:
|
|
13
|
+
* - `interactions[]` (modality-discriminated via `ParticipantWithAttributesPublicResponse`)
|
|
14
|
+
* - `participant_assignments[].step_results[]` with `{step_id, name,
|
|
15
|
+
* description, verdict, reason, evidence_interaction_ids[]}`, hydrated
|
|
16
|
+
* by `attach_participant_step_results_flat` in the study repository
|
|
17
|
+
* before serialisation (`ish-backend/app/api/study/repository.py:315`)
|
|
18
|
+
* - `participant_summary`, `interview_answers`
|
|
19
|
+
* If a future filter ever needs `conversation_id` on each interaction (for
|
|
20
|
+
* `--group-by conversation`), that's a backend-side addition on
|
|
21
|
+
* `_InteractionResponseBase`, not a CLI change.
|
|
9
22
|
*/
|
|
10
23
|
import type { ApiClient } from "./api-client.js";
|
|
11
24
|
import type { Participant } from "./types.js";
|