@ishlabs/cli 0.18.0 → 0.20.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-run.js +57 -30
- package/dist/commands/study.js +36 -6
- package/dist/commands/workspace.js +41 -6
- package/dist/index.js +227 -44
- package/dist/lib/alias-store.js +23 -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 +221 -22
- package/dist/lib/output.d.ts +3 -3
- package/dist/lib/output.js +125 -76
- package/dist/lib/profile-sources.js +18 -0
- package/dist/lib/skill-content.js +11 -2
- package/dist/lib/study-participants.d.ts +32 -0
- package/dist/lib/study-participants.js +12 -0
- package/dist/lib/types.d.ts +0 -1
- package/dist/upgrade.js +9 -2
- package/package.json +1 -1
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 || "-"),
|
|
@@ -807,10 +851,10 @@ export function formatStudyList(studies, json) {
|
|
|
807
851
|
*
|
|
808
852
|
* Returns null when status is consistent; no warning emitted.
|
|
809
853
|
*/
|
|
810
|
-
function detectStudyStatusInconsistency(study) {
|
|
854
|
+
function detectStudyStatusInconsistency(study, participants) {
|
|
811
855
|
if (study.status !== "failed")
|
|
812
856
|
return null;
|
|
813
|
-
const allParticipants = collectParticipants(study);
|
|
857
|
+
const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
|
|
814
858
|
const completedCount = allParticipants.filter((t) => t.status === "completed" || t.status === "complete").length;
|
|
815
859
|
const totalInteractions = allParticipants.reduce((sum, t) => sum + t.interactionCount, 0);
|
|
816
860
|
if (completedCount === 0 && totalInteractions === 0)
|
|
@@ -879,14 +923,16 @@ function renderAssignmentSteps(a) {
|
|
|
879
923
|
}
|
|
880
924
|
}
|
|
881
925
|
}
|
|
882
|
-
export function formatStudyDetail(study, json, options = {}) {
|
|
883
|
-
const inconsistency = detectStudyStatusInconsistency(study);
|
|
926
|
+
export function formatStudyDetail(study, json, options = {}, participants) {
|
|
927
|
+
const inconsistency = detectStudyStatusInconsistency(study, participants);
|
|
884
928
|
if (inconsistency)
|
|
885
929
|
emitStatusInconsistencyWarning(inconsistency);
|
|
886
930
|
if (json) {
|
|
887
|
-
const payload =
|
|
888
|
-
|
|
889
|
-
|
|
931
|
+
const payload = { ...study };
|
|
932
|
+
if (participants !== undefined)
|
|
933
|
+
payload.participants = participants;
|
|
934
|
+
if (inconsistency)
|
|
935
|
+
payload.status_inferred = inconsistency.inferred;
|
|
890
936
|
console.log(jsonOutput(payload, options));
|
|
891
937
|
return;
|
|
892
938
|
}
|
|
@@ -929,10 +975,10 @@ export function formatStudyDetail(study, json, options = {}) {
|
|
|
929
975
|
}
|
|
930
976
|
}
|
|
931
977
|
// Participants summary
|
|
932
|
-
const allParticipants = collectParticipants(study);
|
|
978
|
+
const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
|
|
933
979
|
if (allParticipants.length > 0) {
|
|
934
980
|
console.log(`\nParticipants (${allParticipants.length}):`);
|
|
935
|
-
printTable(["
|
|
981
|
+
printTable(["ALIAS", "NAME", "ITERATION", "STATUS", "INTERACTIONS"], allParticipants.map((t) => [
|
|
936
982
|
t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
|
|
937
983
|
t.name,
|
|
938
984
|
t.iterationLabel,
|
|
@@ -946,8 +992,8 @@ export function formatStudyDetail(study, json, options = {}) {
|
|
|
946
992
|
* study state — fields default to `null`, `0`, or `[]` when nothing has run.
|
|
947
993
|
* Agents can rely on the keys always being present (M4).
|
|
948
994
|
*/
|
|
949
|
-
function buildStudyResultsEnvelope(study) {
|
|
950
|
-
const allParticipants = collectParticipants(study);
|
|
995
|
+
function buildStudyResultsEnvelope(study, participants) {
|
|
996
|
+
const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
|
|
951
997
|
const studyAlias = study.id
|
|
952
998
|
? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
|
|
953
999
|
: null;
|
|
@@ -995,7 +1041,7 @@ function buildStudyResultsEnvelope(study) {
|
|
|
995
1041
|
// CLI-side sanity check (Pattern E / Issue #2). Surface a status_inferred
|
|
996
1042
|
// field when the backend reports failed-with-data; agents can branch on
|
|
997
1043
|
// either the original status or status_inferred.
|
|
998
|
-
const inconsistency = detectStudyStatusInconsistency(study);
|
|
1044
|
+
const inconsistency = detectStudyStatusInconsistency(study, participants);
|
|
999
1045
|
// Pattern B2 (cli half): per-participant rows expose status + error_message so
|
|
1000
1046
|
// agents can act on a failed run without re-fetching every participant.
|
|
1001
1047
|
const failedCount = allParticipants.filter((t) => t.status.toLowerCase() === "failed").length;
|
|
@@ -1025,18 +1071,18 @@ function buildStudyResultsEnvelope(study) {
|
|
|
1025
1071
|
participants: participantRows,
|
|
1026
1072
|
};
|
|
1027
1073
|
}
|
|
1028
|
-
export function formatStudyResults(study, json) {
|
|
1029
|
-
const inconsistency = detectStudyStatusInconsistency(study);
|
|
1074
|
+
export function formatStudyResults(study, participants, json) {
|
|
1075
|
+
const inconsistency = detectStudyStatusInconsistency(study, participants);
|
|
1030
1076
|
if (inconsistency)
|
|
1031
1077
|
emitStatusInconsistencyWarning(inconsistency);
|
|
1032
1078
|
if (json) {
|
|
1033
1079
|
// preProjected: bypass leanJson so the stable envelope keeps documented
|
|
1034
1080
|
// empty defaults (sentiment: null, interview_answers[].answers: []) rather
|
|
1035
1081
|
// than having them stripped by the lean transform.
|
|
1036
|
-
console.log(jsonOutput(buildStudyResultsEnvelope(study), { preProjected: true }));
|
|
1082
|
+
console.log(jsonOutput(buildStudyResultsEnvelope(study, participants), { preProjected: true }));
|
|
1037
1083
|
return;
|
|
1038
1084
|
}
|
|
1039
|
-
const allParticipants = collectParticipants(study);
|
|
1085
|
+
const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
|
|
1040
1086
|
const totalInteractions = allParticipants.reduce((sum, t) => sum + t.interactionCount, 0);
|
|
1041
1087
|
// Header
|
|
1042
1088
|
console.log(`${study.name || "Untitled"} — Results`);
|
|
@@ -1072,7 +1118,7 @@ export function formatStudyResults(study, json) {
|
|
|
1072
1118
|
// Participants table
|
|
1073
1119
|
if (allParticipants.length > 0) {
|
|
1074
1120
|
console.log("\nParticipants:");
|
|
1075
|
-
printTable(["
|
|
1121
|
+
printTable(["ALIAS", "NAME", "ITERATION", "STATUS", "INTERACTIONS", "SENTIMENT"], allParticipants.map((t) => {
|
|
1076
1122
|
const parts = Object.entries(t.sentimentCounts).map(([label, count]) => `${count} ${label.toLowerCase()}`);
|
|
1077
1123
|
return [
|
|
1078
1124
|
t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
|
|
@@ -1102,8 +1148,8 @@ export function formatStudyResults(study, json) {
|
|
|
1102
1148
|
* per-participant {alias, status, sentiment, comment} row. Useful for agents that
|
|
1103
1149
|
* need to branch on outcome without paying for the full envelope.
|
|
1104
1150
|
*/
|
|
1105
|
-
export function buildStudyResultsSummary(study) {
|
|
1106
|
-
const allParticipants = collectParticipants(study);
|
|
1151
|
+
export function buildStudyResultsSummary(study, participants) {
|
|
1152
|
+
const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
|
|
1107
1153
|
const studyAlias = study.id
|
|
1108
1154
|
? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
|
|
1109
1155
|
: null;
|
|
@@ -1118,7 +1164,7 @@ export function buildStudyResultsSummary(study) {
|
|
|
1118
1164
|
}
|
|
1119
1165
|
}
|
|
1120
1166
|
const sentiment = sentimentTotal > 0 ? { counts: sentimentCounts, total: sentimentTotal } : null;
|
|
1121
|
-
const
|
|
1167
|
+
const participantRows = allParticipants.map((t) => ({
|
|
1122
1168
|
alias: t.alias || null,
|
|
1123
1169
|
name: t.name,
|
|
1124
1170
|
status: t.status,
|
|
@@ -1136,7 +1182,7 @@ export function buildStudyResultsSummary(study) {
|
|
|
1136
1182
|
completed_count: completedCount,
|
|
1137
1183
|
failed_count: failedCount,
|
|
1138
1184
|
sentiment,
|
|
1139
|
-
participants,
|
|
1185
|
+
participants: participantRows,
|
|
1140
1186
|
};
|
|
1141
1187
|
}
|
|
1142
1188
|
/**
|
|
@@ -1300,49 +1346,52 @@ export function buildParticipantSummary(participant) {
|
|
|
1300
1346
|
out.error_kind = String(participant.error_kind);
|
|
1301
1347
|
return out;
|
|
1302
1348
|
}
|
|
1303
|
-
function collectParticipants(
|
|
1304
|
-
const
|
|
1305
|
-
const
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1349
|
+
function collectParticipants(participants, iterations) {
|
|
1350
|
+
const iterationLabels = new Map();
|
|
1351
|
+
for (const iter of iterations ?? []) {
|
|
1352
|
+
const id = iter.id ? String(iter.id) : "";
|
|
1353
|
+
if (id) {
|
|
1354
|
+
iterationLabels.set(id, String(iter.label || iter.name || "-"));
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
const rows = [];
|
|
1358
|
+
for (const participant of participants ?? []) {
|
|
1359
|
+
const t = participant;
|
|
1360
|
+
const profile = t.person;
|
|
1361
|
+
const interactions = Array.isArray(t.interactions) ? t.interactions : [];
|
|
1362
|
+
const sentimentCounts = {};
|
|
1363
|
+
for (const interaction of interactions) {
|
|
1364
|
+
const ix = interaction;
|
|
1365
|
+
const sentiment = ix.sentiment;
|
|
1366
|
+
if (sentiment?.label) {
|
|
1367
|
+
const label = String(sentiment.label);
|
|
1368
|
+
sentimentCounts[label] = (sentimentCounts[label] || 0) + 1;
|
|
1322
1369
|
}
|
|
1323
|
-
const answers = Array.isArray(t.interview_answers) ? t.interview_answers : [];
|
|
1324
|
-
const summary = t.participant_summary;
|
|
1325
|
-
const summarySentimentObj = summary?.sentiment;
|
|
1326
|
-
const id = String(t.id || "");
|
|
1327
|
-
participants.push({
|
|
1328
|
-
id,
|
|
1329
|
-
name: String(profile?.name || t.instance_name || "Unknown"),
|
|
1330
|
-
alias: id ? deterministicAlias(ALIAS_PREFIX.participant, id) : "",
|
|
1331
|
-
iterationLabel: iterLabel,
|
|
1332
|
-
status: String(t.status || "-"),
|
|
1333
|
-
errorMessage: t.error_message ? String(t.error_message) : null,
|
|
1334
|
-
interactionCount: interactions.length,
|
|
1335
|
-
sentimentCounts,
|
|
1336
|
-
summarySentiment: summarySentimentObj?.label ? String(summarySentimentObj.label) : null,
|
|
1337
|
-
summaryComment: summary?.comment ? String(summary.comment) : null,
|
|
1338
|
-
interviewAnswers: answers.map((a) => ({
|
|
1339
|
-
questionId: String(a.question_id || ""),
|
|
1340
|
-
answer: a.answer,
|
|
1341
|
-
})),
|
|
1342
|
-
});
|
|
1343
1370
|
}
|
|
1371
|
+
const answers = Array.isArray(t.interview_answers) ? t.interview_answers : [];
|
|
1372
|
+
const summary = t.participant_summary;
|
|
1373
|
+
const summarySentimentObj = summary?.sentiment;
|
|
1374
|
+
const id = String(t.id || "");
|
|
1375
|
+
const iterationId = t.iteration_id ? String(t.iteration_id) : "";
|
|
1376
|
+
const iterationLabel = (iterationId && iterationLabels.get(iterationId)) || "-";
|
|
1377
|
+
rows.push({
|
|
1378
|
+
id,
|
|
1379
|
+
name: String(profile?.name || t.instance_name || "Unknown"),
|
|
1380
|
+
alias: id ? deterministicAlias(ALIAS_PREFIX.participant, id) : "",
|
|
1381
|
+
iterationLabel,
|
|
1382
|
+
status: String(t.status || "-"),
|
|
1383
|
+
errorMessage: t.error_message ? String(t.error_message) : null,
|
|
1384
|
+
interactionCount: interactions.length,
|
|
1385
|
+
sentimentCounts,
|
|
1386
|
+
summarySentiment: summarySentimentObj?.label ? String(summarySentimentObj.label) : null,
|
|
1387
|
+
summaryComment: summary?.comment ? String(summary.comment) : null,
|
|
1388
|
+
interviewAnswers: answers.map((a) => ({
|
|
1389
|
+
questionId: String(a.question_id || ""),
|
|
1390
|
+
answer: a.answer,
|
|
1391
|
+
})),
|
|
1392
|
+
});
|
|
1344
1393
|
}
|
|
1345
|
-
return
|
|
1394
|
+
return rows;
|
|
1346
1395
|
}
|
|
1347
1396
|
function formatQuestionType(q) {
|
|
1348
1397
|
if (!q.type)
|
|
@@ -1370,7 +1419,7 @@ export function formatIterationList(iterations, json) {
|
|
|
1370
1419
|
return;
|
|
1371
1420
|
}
|
|
1372
1421
|
const aliasMap = getAliasMap(ALIAS_PREFIX.iteration);
|
|
1373
|
-
printTable(["
|
|
1422
|
+
printTable(["ALIAS", "LABEL", "NAME", "PARTICIPANTS", "CREATED"], iterations.map((it) => {
|
|
1374
1423
|
const participants = Array.isArray(it.participants) ? it.participants.length : 0;
|
|
1375
1424
|
return [
|
|
1376
1425
|
aliasMap.get(String(it.id)) || String(it.id || ""),
|
|
@@ -1444,7 +1493,7 @@ export function formatPersonList(profiles, json, limit) {
|
|
|
1444
1493
|
console.log("No participant profiles.");
|
|
1445
1494
|
return;
|
|
1446
1495
|
}
|
|
1447
|
-
printTable(["
|
|
1496
|
+
printTable(["ALIAS", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
|
|
1448
1497
|
String(p.alias || p.id || ""),
|
|
1449
1498
|
String(p.name || ""),
|
|
1450
1499
|
String(p.occupation || "-"),
|
|
@@ -1499,7 +1548,7 @@ export function formatGeneratedProfileList(profiles, json) {
|
|
|
1499
1548
|
console.log(jsonOutput(list));
|
|
1500
1549
|
return;
|
|
1501
1550
|
}
|
|
1502
|
-
printTable(["
|
|
1551
|
+
printTable(["ALIAS", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
|
|
1503
1552
|
String(p.alias || p.id || ""),
|
|
1504
1553
|
String(p.name || ""),
|
|
1505
1554
|
String(p.occupation || "-"),
|
|
@@ -1524,7 +1573,7 @@ export function formatSimulationPoll(results, json, isMedia = false) {
|
|
|
1524
1573
|
}
|
|
1525
1574
|
const aliasMap = getAliasMap(ALIAS_PREFIX.participant);
|
|
1526
1575
|
const countHeader = isMedia ? "SEGMENTS" : "INTERACTIONS";
|
|
1527
|
-
printTable(["
|
|
1576
|
+
printTable(["ALIAS", "PARTICIPANT", "STATUS", countHeader], results.map((r) => {
|
|
1528
1577
|
const id = String(r.id || r.participant_id || "");
|
|
1529
1578
|
return [
|
|
1530
1579
|
aliasMap.get(id) || id,
|
|
@@ -1569,7 +1618,7 @@ export function formatAskList(asks, json) {
|
|
|
1569
1618
|
return;
|
|
1570
1619
|
}
|
|
1571
1620
|
const aliasMap = getAliasMap(ALIAS_PREFIX.ask);
|
|
1572
|
-
printTable(["
|
|
1621
|
+
printTable(["ALIAS", "NAME", "STATUS", "PARTICIPANTS", "ROUNDS", "LAST ROUND", "ARCHIVED"], asks.map((a) => [
|
|
1573
1622
|
aliasMap.get(String(a.id)) || String(a.id || ""),
|
|
1574
1623
|
String(a.name || ""),
|
|
1575
1624
|
String(a.status || "-"),
|
|
@@ -1670,7 +1719,7 @@ export function formatAskDetail(ask, json) {
|
|
|
1670
1719
|
String(obj.status || "-"),
|
|
1671
1720
|
];
|
|
1672
1721
|
});
|
|
1673
|
-
printTable(["
|
|
1722
|
+
printTable(["ALIAS", "NAME", "STATUS"], rows);
|
|
1674
1723
|
if (participants.length > 20)
|
|
1675
1724
|
console.log(` … and ${participants.length - 20} more`);
|
|
1676
1725
|
}
|
|
@@ -2140,7 +2189,7 @@ export function formatConfigList(configs, json) {
|
|
|
2140
2189
|
return;
|
|
2141
2190
|
}
|
|
2142
2191
|
const aliasMap = getAliasMap(ALIAS_PREFIX.config);
|
|
2143
|
-
printTable(["
|
|
2192
|
+
printTable(["ALIAS", "NAME", "SOURCE", "CREATED"], configs.map((c) => [
|
|
2144
2193
|
aliasMap.get(String(c.id)) || String(c.id || ""),
|
|
2145
2194
|
String(c.name || ""),
|
|
2146
2195
|
String(c.source_type || "manual"),
|
|
@@ -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))
|
|
@@ -219,6 +219,11 @@ When in doubt: side-by-side comparison usually beats in-place edits. Ids are che
|
|
|
219
219
|
- **Chatbot auth drift**: tokens/sessions baked into \`--from-curl\` expire. If transcripts come back as identical short error strings, re-run \`chat_endpoint_test\` and refresh the curl spec.
|
|
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
|
+
- **\`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).
|
|
222
227
|
|
|
223
228
|
## When in doubt
|
|
224
229
|
|
|
@@ -375,9 +380,13 @@ ish person suggest-scenarios \\
|
|
|
375
380
|
# [{"text":"...","source":"situation","scenario_prompt":"..."}, ...]
|
|
376
381
|
# Valid source values: situation, voice, binary, micro-story
|
|
377
382
|
|
|
378
|
-
# 3. Save the person shell
|
|
383
|
+
# 3. Save the person shell — either from file:
|
|
379
384
|
ish person create --file ./persona.json
|
|
380
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 "..."
|
|
381
390
|
|
|
382
391
|
# 4. Persist the answers as structured evidence
|
|
383
392
|
ish person evidence add p-d4e --traces-file ./answers.json
|
|
@@ -1041,7 +1050,7 @@ ish <command> --help
|
|
|
1041
1050
|
| \`mcp\` | Wire the hosted ish MCP server into local AI | guides/mcp-add |
|
|
1042
1051
|
| | clients (Cursor, VS Code, Claude Code, | |
|
|
1043
1052
|
| | Claude Desktop, Windsurf). Idempotent. | |
|
|
1044
|
-
| \`login\` | Browser-based auth
|
|
1053
|
+
| \`login\` | Browser-based auth. Idempotent: short-circuits on valid saved token. \`--force\` to switch accounts. | — |
|
|
1045
1054
|
| \`logout\` | Clear saved credentials | — |
|
|
1046
1055
|
| \`status\` | Show active session (user, workspace, | concepts/active-context |
|
|
1047
1056
|
| | study, ask, token validity) — alias \`whoami\` | |
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrapper for the post-split `GET /studies/{id}/participants` endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Returns a flat list of participants for one study with an `iteration_id`
|
|
5
|
+
* discriminator on each row. Each row carries the rich per-participant graph
|
|
6
|
+
* (person, interactions[], participant_summary, interview_answers, …) that
|
|
7
|
+
* used to be embedded under `study.iterations[*].participants[*]` on the
|
|
8
|
+
* legacy `GET /studies/{id}` response.
|
|
9
|
+
*/
|
|
10
|
+
import type { ApiClient } from "./api-client.js";
|
|
11
|
+
import type { Participant } from "./types.js";
|
|
12
|
+
export interface StudyParticipant extends Participant {
|
|
13
|
+
person?: {
|
|
14
|
+
id?: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
};
|
|
17
|
+
interactions?: unknown[];
|
|
18
|
+
participant_summary?: Record<string, unknown> | null;
|
|
19
|
+
interview_answers?: Array<{
|
|
20
|
+
question_id?: string;
|
|
21
|
+
answer?: unknown;
|
|
22
|
+
}>;
|
|
23
|
+
participant_files?: unknown[];
|
|
24
|
+
participant_assignments?: unknown[];
|
|
25
|
+
conversation_id?: string | null;
|
|
26
|
+
error_message?: string | null;
|
|
27
|
+
error_kind?: string | null;
|
|
28
|
+
[k: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
export declare function fetchStudyParticipants(client: ApiClient, studyId: string, opts?: {
|
|
31
|
+
timeout?: number;
|
|
32
|
+
}): Promise<StudyParticipant[]>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrapper for the post-split `GET /studies/{id}/participants` endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Returns a flat list of participants for one study with an `iteration_id`
|
|
5
|
+
* discriminator on each row. Each row carries the rich per-participant graph
|
|
6
|
+
* (person, interactions[], participant_summary, interview_answers, …) that
|
|
7
|
+
* used to be embedded under `study.iterations[*].participants[*]` on the
|
|
8
|
+
* legacy `GET /studies/{id}` response.
|
|
9
|
+
*/
|
|
10
|
+
export async function fetchStudyParticipants(client, studyId, opts) {
|
|
11
|
+
return await client.get(`/studies/${studyId}/participants`, undefined, opts);
|
|
12
|
+
}
|
package/dist/lib/types.d.ts
CHANGED
package/dist/upgrade.js
CHANGED
|
@@ -32,11 +32,18 @@ export async function upgrade(currentVersion, targetVersion) {
|
|
|
32
32
|
const scriptUrl = import.meta.url;
|
|
33
33
|
const runningFromNodeModules = scriptUrl.includes("/node_modules/");
|
|
34
34
|
if (looksLikeNode || runningFromNodeModules) {
|
|
35
|
-
|
|
35
|
+
// Pattern C: tag as ValidationError so exitCodeFromError maps to 2
|
|
36
|
+
// (usage_error). Otherwise this would fall through to the generic
|
|
37
|
+
// exit 1 even with the wrapper in place (ISSUE-012).
|
|
38
|
+
const err = new Error("Cannot self-upgrade an npm-installed CLI (would overwrite the node binary). " +
|
|
36
39
|
"Run `npm install -g @ishlabs/cli@latest` instead.");
|
|
40
|
+
err.name = "ValidationError";
|
|
41
|
+
throw err;
|
|
37
42
|
}
|
|
38
43
|
if (targetVersion && !/^\d+\.\d+\.\d+/.test(targetVersion)) {
|
|
39
|
-
|
|
44
|
+
const err = new Error(`Invalid version format: ${targetVersion}`);
|
|
45
|
+
err.name = "ValidationError";
|
|
46
|
+
throw err;
|
|
40
47
|
}
|
|
41
48
|
const latest = targetVersion || (await getLatestVersion());
|
|
42
49
|
if (latest === currentVersion) {
|