@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/commands/study.js
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
5
|
import { Option } from "commander";
|
|
6
|
-
import { withClient, getWebUrl, terminalLink, resolveWorkspace, confirmDestructive, readFileOrStdin } from "../lib/command-helpers.js";
|
|
6
|
+
import { withClient, getWebUrl, terminalLink, resolveWorkspace, confirmDestructive, readFileOrStdin, collectIds } from "../lib/command-helpers.js";
|
|
7
7
|
import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
8
8
|
import { loadConfig, saveConfig } from "../config.js";
|
|
9
|
-
import { formatStudyList, formatStudyDetail, formatStudyResults, buildStudyResultsSummary, buildChatTranscript, output, ValidationError, } from "../lib/output.js";
|
|
9
|
+
import { formatStudyList, formatStudyDetail, formatStudyResults, buildStudyResultsEnvelope, buildStudyResultsSummary, buildChatTranscript, formatStudyResultsGroupBy, output, ValidationError, } from "../lib/output.js";
|
|
10
|
+
import { applyResultsFilters } from "../lib/study-results-filters.js";
|
|
11
|
+
import { buildStudyResultsPerIteration, buildStudyResultsPerFrame, buildStudyResultsPerSegment, buildStudyResultsPerTurn, buildStudyResultsPerAssignment, buildStudyResultsPerStep, } from "../lib/study-results-projections.js";
|
|
10
12
|
import { VALID_CONTENT_TYPES } from "../lib/types.js";
|
|
11
13
|
import { fetchStudyParticipants } from "../lib/study-participants.js";
|
|
12
14
|
import { parseAssignment, loadAssignmentsFile, validateAssignmentsArray, parseQuestion } from "../lib/study-inputs.js";
|
|
@@ -544,8 +546,18 @@ Next: configure a run with \`ish iteration create --study <id>\`,
|
|
|
544
546
|
const data = await client.post(`/products/${resolvedWs}/studies`, body);
|
|
545
547
|
if (data.id) {
|
|
546
548
|
const config = loadConfig();
|
|
549
|
+
const prevStudy = config.study;
|
|
547
550
|
config.study = data.id;
|
|
548
551
|
saveConfig(config);
|
|
552
|
+
// Auto-activating the new study is intentional ergonomics (the
|
|
553
|
+
// common next step is `ish iteration create --study <new>`), but
|
|
554
|
+
// it was previously silent — surprised users who had set a
|
|
555
|
+
// different active study just before (ISSUE-030). Always
|
|
556
|
+
// surface the side-effect on stderr.
|
|
557
|
+
if (!globals.json) {
|
|
558
|
+
const verb = prevStudy && prevStudy !== data.id ? "replaced" : "set";
|
|
559
|
+
console.error(`Active study ${verb} to "${data.name || data.id}".`);
|
|
560
|
+
}
|
|
549
561
|
}
|
|
550
562
|
const result = data;
|
|
551
563
|
if (result.id)
|
|
@@ -659,22 +671,41 @@ list table layout in human mode.`)
|
|
|
659
671
|
});
|
|
660
672
|
study
|
|
661
673
|
.command("results")
|
|
662
|
-
.description("View aggregated results: participant counts, sentiment, interview answers. Returns a stable envelope with empty fields when no runs have completed.")
|
|
674
|
+
.description("View aggregated results: participant counts, sentiment, interview answers. Returns a stable envelope with empty fields when no runs have completed. Slice with filter flags (--frame, --segment, --turn, --side, --assignment, --step, --sentiment, --actor, --iteration, --participant) or project with --group-by (iteration|frame|segment|turn|assignment|step).")
|
|
663
675
|
.argument("<id>", "Study ID")
|
|
664
676
|
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
|
|
665
|
-
.option("--summary", "Lean summary projection: counts + sentiment + per-participant {alias, status, sentiment, comment}. Drops interview_answers + per-interaction breakdowns.")
|
|
677
|
+
.option("--summary", "Lean summary projection: counts + sentiment + per-participant {alias, status, sentiment, comment}. Drops interview_answers + per-interaction breakdowns. Composes with filters: `--summary --frame login` narrows the summary to the login-screen interactions.")
|
|
666
678
|
// PC-N4: agents reach for `--summarize` (verb) by analogy with the MCP
|
|
667
679
|
// `summarize` action; accept it as a hidden alias of --summary so the
|
|
668
680
|
// canonical flag stays the documented one but the muscle-memory variant
|
|
669
681
|
// works without a round-trip.
|
|
670
682
|
.addOption(new Option("--summarize", "Hidden alias for --summary").hideHelp())
|
|
671
|
-
.option("--transcript <participant_id>", "Chat transcript projection for one participant: flat role/text/turn-index array (chat-modality only). Mirrors the MCP `get_chat_transcript` shape.")
|
|
683
|
+
.option("--transcript <participant_id>", "Chat transcript projection for one participant: flat role/text/turn-index array (chat-modality only). Mirrors the MCP `get_chat_transcript` shape. Cannot combine with filters or --group-by (transcript is a single-participant projection).")
|
|
684
|
+
// --- Slice / projection flags (T5) ---
|
|
685
|
+
.option("--frame <ref>", "Filter to interactions whose Frame name contains <ref> (case-insensitive), or whose Frame UUID / `f-…` alias / frame_version_id matches. Interactive only — warned and ignored on other modalities.")
|
|
686
|
+
.option("--segment <ref>", "Filter media studies (video/audio/text/document) by segment index (integer) or segment label (substring). Image and other modalities: warned and ignored.")
|
|
687
|
+
.option("--turn <n>", "Filter chat interactions to a single `actions[0].data.turn_index`. Non-chat modalities: warned and ignored.")
|
|
688
|
+
.option("--side <a|b>", "Filter participant_pair chat interactions by assignment side. Other modalities: warned and ignored.")
|
|
689
|
+
.option("--assignment <ref>", "Filter to a single assignment by UUID or name (substring, case-insensitive).")
|
|
690
|
+
.option("--step <ref>", "Filter `participant_assignments[].step_results[]` to a single step by step-id or name (substring). Pair with --include-evidence to also drop non-evidence interactions.")
|
|
691
|
+
.option("--sentiment <labels>", "Filter to interactions whose sentiment.label is in the comma-separated list (case-insensitive; repeatable). Drops null-sentiment rows.", collectIds, [])
|
|
692
|
+
.option("--actor <actor>", "Filter to interactions whose actor is `ai`, `human`, or `user` (case-insensitive).")
|
|
693
|
+
.option("--iteration <ref>", "Restrict to a single iteration by UUID or label.")
|
|
694
|
+
.option("--participant <ref>", "Restrict to a single participant by UUID or `pt-…` alias.")
|
|
695
|
+
.option("--include-unmatched", "When --frame is set, keep interactions with null frame_version_id under a synthetic `_unmatched` bucket instead of dropping them.")
|
|
696
|
+
.option("--include-evidence", "When --step is set, also drop interactions not listed in any surviving step_results[].evidence_interaction_ids[].")
|
|
697
|
+
.option("--group-by <axis>", "Project results into per-axis slices: iteration | frame | segment | turn | assignment | step. Mutually exclusive with --summary and --transcript.")
|
|
672
698
|
.addHelpText("after", `
|
|
673
699
|
Examples:
|
|
674
700
|
$ ish study results <id>
|
|
675
701
|
$ ish study results <id> --json
|
|
676
702
|
$ ish study results <id> --summary --json
|
|
677
703
|
$ ish study results <id> --transcript pt-d4e --json
|
|
704
|
+
# Slice (filters compose: AND across flags, OR within --sentiment)
|
|
705
|
+
$ ish study results <id> --frame login --group-by iteration
|
|
706
|
+
$ ish study results <id> --segment 3 --sentiment Frustrated
|
|
707
|
+
$ ish study results <id> --assignment "Sign up" --step verify-email --group-by step
|
|
708
|
+
$ ish study results <id> --side a --turn 4
|
|
678
709
|
|
|
679
710
|
Default --json envelope (M10: per-answer sentiment now included):
|
|
680
711
|
{
|
|
@@ -697,6 +728,11 @@ Default --json envelope (M10: per-answer sentiment now included):
|
|
|
697
728
|
]
|
|
698
729
|
}
|
|
699
730
|
|
|
731
|
+
When any filter flag is passed, the envelope gains a \`totals_unfiltered\` field
|
|
732
|
+
({ participant_count, interaction_count }) so callers can sanity-check coverage
|
|
733
|
+
("matched 12 / 80 participants"). A zero-match filter returns the stable
|
|
734
|
+
envelope with participant_count=0 and exit code 0 (not 4).
|
|
735
|
+
|
|
700
736
|
--summary projection (M2-friction-7: drops the interview_answers payload):
|
|
701
737
|
{ study, participant_count, completed_count, failed_count, sentiment, participants: [...] }
|
|
702
738
|
|
|
@@ -713,6 +749,24 @@ Default --json envelope (M10: per-answer sentiment now included):
|
|
|
713
749
|
"participant_summary": { "comment": "...", "sentiment": {...} }
|
|
714
750
|
}
|
|
715
751
|
|
|
752
|
+
--group-by iteration projection:
|
|
753
|
+
{ study, slices: [{ iteration_id, iteration_label, participant_count, interaction_count, sentiment, sample_comments, top_actions }, ...], totals_unfiltered, warnings }
|
|
754
|
+
|
|
755
|
+
--group-by frame projection (interactive only):
|
|
756
|
+
[{ frame_id, frame_label, interaction_count, sentiment_histogram, sample_comments, participant_aliases }, ...]
|
|
757
|
+
|
|
758
|
+
--group-by segment projection (video/audio/text/document):
|
|
759
|
+
[{ segment_index, segment_label, interaction_count, sentiment_histogram, engagement_histogram, sample_comments }, ...]
|
|
760
|
+
|
|
761
|
+
--group-by turn projection (chat only):
|
|
762
|
+
[{ turn_index, interaction_count, sentiment_histogram, sample_replies, failures }, ...]
|
|
763
|
+
|
|
764
|
+
--group-by assignment projection:
|
|
765
|
+
[{ assignment_id, assignment_name, interaction_count, sentiment_histogram, step_completion }, ...]
|
|
766
|
+
|
|
767
|
+
--group-by step projection:
|
|
768
|
+
[{ assignment_id, assignment_name, step_id, step_name, total, passed, inconclusive, failed, rate, participant_verdicts: [{ participant_alias, verdict, reason, evidence_interaction_ids }, ...] }, ...]
|
|
769
|
+
|
|
716
770
|
Tips:
|
|
717
771
|
Use \`--get <path>\` for a single value (e.g. \`--get participant_count\`),
|
|
718
772
|
\`--fields a,b,c\` to project the JSON output further.
|
|
@@ -731,6 +785,7 @@ Common --get paths (default envelope):
|
|
|
731
785
|
--get interview_answers # full per-question payload
|
|
732
786
|
--get interview_answers.0.question # text of the first question
|
|
733
787
|
--get interview_answers.0.answers.0.answer # first answer to the first question
|
|
788
|
+
--get totals_unfiltered.participant_count # pre-filter participant count (when slicing)
|
|
734
789
|
|
|
735
790
|
Common --get paths (--transcript <participant_id> envelope):
|
|
736
791
|
--get transcript # full role/text/turn array
|
|
@@ -739,6 +794,18 @@ Common --get paths (--transcript <participant_id> envelope):
|
|
|
739
794
|
--get participant_summary.sentiment # aggregate sentiment map
|
|
740
795
|
--get unique_bot_replies # bot-side message count
|
|
741
796
|
|
|
797
|
+
Common --get paths (--group-by projections):
|
|
798
|
+
--get slices.iteration_label # per-iteration: one label per line
|
|
799
|
+
--get slices.0.participant_count # per-iteration: first slice's count
|
|
800
|
+
--get 0.frame_label # per-frame: first frame's label
|
|
801
|
+
--get 0.sentiment_histogram # per-frame/segment/turn: first slice's sentiment map
|
|
802
|
+
--get 0.segment_index # per-segment: first segment's index
|
|
803
|
+
--get 0.turn_index # per-turn: first turn's index
|
|
804
|
+
--get 0.assignment_name # per-assignment/step: first slice's assignment
|
|
805
|
+
--get 0.step_name # per-step: first slice's step
|
|
806
|
+
--get 0.rate # per-step: first step's pass-rate
|
|
807
|
+
--get 0.participant_verdicts.verdict # per-step: verdict per participant
|
|
808
|
+
|
|
742
809
|
When no runs have completed, the default envelope is returned with zero counts and empty arrays.`)
|
|
743
810
|
.action(async (id, opts, cmd) => {
|
|
744
811
|
await withClient(cmd, async (client, globals) => {
|
|
@@ -746,10 +813,76 @@ When no runs have completed, the default envelope is returned with zero counts a
|
|
|
746
813
|
// into a single boolean before validation so the rest of the
|
|
747
814
|
// handler reads only `summary`.
|
|
748
815
|
const wantsSummary = !!(opts.summary || opts.summarize);
|
|
816
|
+
// T5: detect whether any filter flag was passed. Interaction-level
|
|
817
|
+
// and participant-level flags both count — they all narrow the
|
|
818
|
+
// result set. `--include-unmatched`/`--include-evidence` are
|
|
819
|
+
// modifiers that only make sense alongside --frame/--step but
|
|
820
|
+
// count as "filter intent" for the transcript/conflict check.
|
|
821
|
+
const hasFilter = opts.frame !== undefined ||
|
|
822
|
+
opts.segment !== undefined ||
|
|
823
|
+
opts.turn !== undefined ||
|
|
824
|
+
opts.side !== undefined ||
|
|
825
|
+
opts.assignment !== undefined ||
|
|
826
|
+
opts.step !== undefined ||
|
|
827
|
+
(opts.sentiment !== undefined && opts.sentiment.length > 0) ||
|
|
828
|
+
opts.actor !== undefined ||
|
|
829
|
+
opts.iteration !== undefined ||
|
|
830
|
+
opts.participant !== undefined ||
|
|
831
|
+
opts.includeUnmatched === true ||
|
|
832
|
+
opts.includeEvidence === true;
|
|
833
|
+
const hasGroupBy = opts.groupBy !== undefined;
|
|
834
|
+
// --- Conflict validation (no IO yet) ---
|
|
749
835
|
if (wantsSummary && opts.transcript) {
|
|
750
836
|
throw new ValidationError("Pass only one of: --summary, --transcript.", ["--summary", "--transcript"]);
|
|
751
837
|
}
|
|
838
|
+
if (opts.transcript && (hasFilter || hasGroupBy)) {
|
|
839
|
+
// --transcript is a single-participant chat projection — slicing
|
|
840
|
+
// doesn't make sense.
|
|
841
|
+
throw new ValidationError("--transcript is a single-participant projection; cannot combine with filter flags or --group-by.", ["--transcript"]);
|
|
842
|
+
}
|
|
843
|
+
if (wantsSummary && hasGroupBy) {
|
|
844
|
+
throw new ValidationError("Pass only one of: --summary, --group-by.", ["--summary", "--group-by"]);
|
|
845
|
+
}
|
|
846
|
+
// --side validation: must be exactly "a" or "b" (case-insensitive).
|
|
847
|
+
const sideNormalised = opts.side ? opts.side.toLowerCase() : undefined;
|
|
848
|
+
if (sideNormalised !== undefined && sideNormalised !== "a" && sideNormalised !== "b") {
|
|
849
|
+
throw new ValidationError(`--side must be "a" or "b", got "${opts.side}".`, ["a", "b"]);
|
|
850
|
+
}
|
|
851
|
+
// --actor validation: must be one of ai|human|user (case-insensitive).
|
|
852
|
+
const actorNormalised = opts.actor ? opts.actor.toLowerCase() : undefined;
|
|
853
|
+
if (actorNormalised !== undefined &&
|
|
854
|
+
actorNormalised !== "ai" &&
|
|
855
|
+
actorNormalised !== "human" &&
|
|
856
|
+
actorNormalised !== "user") {
|
|
857
|
+
throw new ValidationError(`--actor must be "ai", "human", or "user", got "${opts.actor}".`, ["ai", "human", "user"]);
|
|
858
|
+
}
|
|
859
|
+
// --turn validation: must parse as a non-negative integer.
|
|
860
|
+
let turnNum;
|
|
861
|
+
if (opts.turn !== undefined) {
|
|
862
|
+
const n = parseInt(opts.turn, 10);
|
|
863
|
+
if (Number.isNaN(n) || n < 0 || String(n) !== opts.turn.trim()) {
|
|
864
|
+
throw new ValidationError(`--turn must be a non-negative integer, got "${opts.turn}".`, []);
|
|
865
|
+
}
|
|
866
|
+
turnNum = n;
|
|
867
|
+
}
|
|
868
|
+
// --group-by axis whitelist.
|
|
869
|
+
const VALID_GROUP_BY = [
|
|
870
|
+
"iteration",
|
|
871
|
+
"frame",
|
|
872
|
+
"segment",
|
|
873
|
+
"turn",
|
|
874
|
+
"assignment",
|
|
875
|
+
"step",
|
|
876
|
+
];
|
|
877
|
+
let groupByKind;
|
|
878
|
+
if (opts.groupBy !== undefined) {
|
|
879
|
+
if (!VALID_GROUP_BY.includes(opts.groupBy)) {
|
|
880
|
+
throw new ValidationError(`--group-by must be one of: ${VALID_GROUP_BY.join(", ")}. Got "${opts.groupBy}".`, VALID_GROUP_BY);
|
|
881
|
+
}
|
|
882
|
+
groupByKind = opts.groupBy;
|
|
883
|
+
}
|
|
752
884
|
const rid = resolveId(id);
|
|
885
|
+
// --- --transcript fast path (no fetch of study payload) ---
|
|
753
886
|
if (opts.transcript) {
|
|
754
887
|
// --transcript <participant_id>: bypass the study aggregator; fetch
|
|
755
888
|
// the named participant directly. Cheaper (one GET, no nested
|
|
@@ -759,20 +892,134 @@ When no runs have completed, the default envelope is returned with zero counts a
|
|
|
759
892
|
output(buildChatTranscript(participant), globals.json, { preProjected: true });
|
|
760
893
|
return;
|
|
761
894
|
}
|
|
762
|
-
|
|
895
|
+
// --- Default-fast path: no filter, no group-by ---
|
|
896
|
+
if (!hasFilter && !hasGroupBy) {
|
|
897
|
+
const [data, participants] = await Promise.all([
|
|
898
|
+
client.get(`/studies/${rid}`),
|
|
899
|
+
fetchStudyParticipants(client, rid),
|
|
900
|
+
]);
|
|
901
|
+
if (wantsSummary) {
|
|
902
|
+
output(buildStudyResultsSummary(data, participants), globals.json, { preProjected: true });
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
formatStudyResults(data, participants, globals.json);
|
|
906
|
+
}
|
|
907
|
+
if (!globals.json && data.product_id) {
|
|
908
|
+
const url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
|
|
909
|
+
console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
910
|
+
}
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
// --- Slice / projection path: fetch in parallel, then filter+project ---
|
|
914
|
+
//
|
|
915
|
+
// Modality gating for --group-by happens AFTER the study fetch
|
|
916
|
+
// (we need study.modality), but BEFORE any further work — see the
|
|
917
|
+
// post-fetch validation block below. Pre-fetch validation above is
|
|
918
|
+
// limited to checks that don't need wire data.
|
|
919
|
+
const fetchFrames = opts.frame !== undefined;
|
|
920
|
+
const [study, participants, framesPayload] = await Promise.all([
|
|
763
921
|
client.get(`/studies/${rid}`),
|
|
764
922
|
fetchStudyParticipants(client, rid),
|
|
923
|
+
fetchFrames
|
|
924
|
+
? client.get(`/studies/${rid}/frames`)
|
|
925
|
+
: Promise.resolve([]),
|
|
765
926
|
]);
|
|
766
|
-
|
|
767
|
-
|
|
927
|
+
const studyRec = study;
|
|
928
|
+
const modality = typeof studyRec.modality === "string" ? studyRec.modality : "unknown";
|
|
929
|
+
// Modality gating for --group-by — router-level, NOT projection-level
|
|
930
|
+
// (devon's T7 note: projection builders are intentionally
|
|
931
|
+
// modality-agnostic and bucket non-matching rows into `_unmatched`;
|
|
932
|
+
// the surface is responsible for refusing nonsensical axes up front).
|
|
933
|
+
if (groupByKind === "frame" && modality !== "interactive") {
|
|
934
|
+
throw new ValidationError(`--group-by frame requires modality=interactive; this study is "${modality}".`, ["interactive"]);
|
|
768
935
|
}
|
|
769
|
-
|
|
770
|
-
|
|
936
|
+
const SEGMENT_MODALITIES = ["video", "audio", "text", "document"];
|
|
937
|
+
if (groupByKind === "segment" && !SEGMENT_MODALITIES.includes(modality)) {
|
|
938
|
+
throw new ValidationError(`--group-by segment requires modality ∈ {${SEGMENT_MODALITIES.join(", ")}}; this study is "${modality}".`, SEGMENT_MODALITIES);
|
|
771
939
|
}
|
|
772
|
-
if (
|
|
773
|
-
|
|
774
|
-
|
|
940
|
+
if (groupByKind === "turn" && modality !== "chat") {
|
|
941
|
+
throw new ValidationError(`--group-by turn requires modality=chat; this study is "${modality}".`, ["chat"]);
|
|
942
|
+
}
|
|
943
|
+
// Coerce the frames payload to a plain array of records (the API
|
|
944
|
+
// returns a bare array). Tolerate `{items: [...]}` shape in case the
|
|
945
|
+
// endpoint ever normalises.
|
|
946
|
+
const rawFrames = Array.isArray(framesPayload)
|
|
947
|
+
? framesPayload
|
|
948
|
+
: Array.isArray(framesPayload?.items)
|
|
949
|
+
? (framesPayload.items)
|
|
950
|
+
: [];
|
|
951
|
+
const filters = {
|
|
952
|
+
frame: opts.frame,
|
|
953
|
+
segment: opts.segment,
|
|
954
|
+
turn: turnNum,
|
|
955
|
+
side: sideNormalised,
|
|
956
|
+
assignment: opts.assignment,
|
|
957
|
+
step: opts.step,
|
|
958
|
+
sentiment: opts.sentiment && opts.sentiment.length > 0 ? opts.sentiment : undefined,
|
|
959
|
+
actor: actorNormalised,
|
|
960
|
+
iteration: opts.iteration,
|
|
961
|
+
participant: opts.participant,
|
|
962
|
+
includeUnmatched: opts.includeUnmatched === true ? true : undefined,
|
|
963
|
+
includeEvidence: opts.includeEvidence === true ? true : undefined,
|
|
964
|
+
};
|
|
965
|
+
const filtered = applyResultsFilters(studyRec, participants, rawFrames, filters);
|
|
966
|
+
// Surface modality-mismatch warnings (and any other diagnostics from
|
|
967
|
+
// applyResultsFilters) on stderr so JSON output stays clean. The
|
|
968
|
+
// filter pipeline downgrades mismatched flags to no-ops; the warnings
|
|
969
|
+
// tell the agent which flags were ignored and why.
|
|
970
|
+
if (filtered.warnings.length > 0 && !globals.quiet) {
|
|
971
|
+
for (const w of filtered.warnings) {
|
|
972
|
+
console.error(`warning: ${w}`);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
// --- Dispatch: --group-by projection > --summary on filtered > filtered envelope ---
|
|
976
|
+
if (groupByKind !== undefined) {
|
|
977
|
+
let projection;
|
|
978
|
+
switch (groupByKind) {
|
|
979
|
+
case "iteration":
|
|
980
|
+
projection = buildStudyResultsPerIteration(filtered);
|
|
981
|
+
break;
|
|
982
|
+
case "frame":
|
|
983
|
+
projection = buildStudyResultsPerFrame(filtered);
|
|
984
|
+
break;
|
|
985
|
+
case "segment":
|
|
986
|
+
projection = buildStudyResultsPerSegment(filtered);
|
|
987
|
+
break;
|
|
988
|
+
case "turn":
|
|
989
|
+
projection = buildStudyResultsPerTurn(filtered);
|
|
990
|
+
break;
|
|
991
|
+
case "assignment":
|
|
992
|
+
projection = buildStudyResultsPerAssignment(filtered);
|
|
993
|
+
break;
|
|
994
|
+
case "step":
|
|
995
|
+
projection = buildStudyResultsPerStep(filtered);
|
|
996
|
+
break;
|
|
997
|
+
}
|
|
998
|
+
formatStudyResultsGroupBy(projection, groupByKind, globals.json);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (wantsSummary) {
|
|
1002
|
+
// --summary on filtered participants: narrowed summary projection.
|
|
1003
|
+
// Attach totals_unfiltered so callers can still see the pre-filter
|
|
1004
|
+
// denominator (e.g. "12 / 80 participants matched").
|
|
1005
|
+
const summary = buildStudyResultsSummary(filtered.study, filtered.participants);
|
|
1006
|
+
const summaryOut = {
|
|
1007
|
+
...summary,
|
|
1008
|
+
totals_unfiltered: filtered.totals_unfiltered,
|
|
1009
|
+
};
|
|
1010
|
+
output(summaryOut, globals.json, { preProjected: true });
|
|
1011
|
+
return;
|
|
775
1012
|
}
|
|
1013
|
+
// Default (no --group-by, no --summary) but filters set: stable
|
|
1014
|
+
// envelope on the filtered participants + totals_unfiltered. Empty
|
|
1015
|
+
// slice contract: zero matches yields participant_count=0 and exit
|
|
1016
|
+
// 0, never a 4/not-found.
|
|
1017
|
+
const envelope = buildStudyResultsEnvelope(filtered.study, filtered.participants);
|
|
1018
|
+
const envelopeOut = {
|
|
1019
|
+
...envelope,
|
|
1020
|
+
totals_unfiltered: filtered.totals_unfiltered,
|
|
1021
|
+
};
|
|
1022
|
+
output(envelopeOut, globals.json, { preProjected: true });
|
|
776
1023
|
});
|
|
777
1024
|
});
|
|
778
1025
|
study
|
|
@@ -856,6 +1103,15 @@ checklists ("steps") ride along when present in the JSON forms
|
|
|
856
1103
|
json: globals.json,
|
|
857
1104
|
});
|
|
858
1105
|
await client.del(`/studies/${rid}`);
|
|
1106
|
+
// If the deleted study was active, clear it (Pattern A — parallel to
|
|
1107
|
+
// ask delete and chat endpoint delete which already do this).
|
|
1108
|
+
const config = loadConfig();
|
|
1109
|
+
if (config.study === rid) {
|
|
1110
|
+
delete config.study;
|
|
1111
|
+
saveConfig(config);
|
|
1112
|
+
if (!globals.json)
|
|
1113
|
+
console.error("(Cleared active study.)");
|
|
1114
|
+
}
|
|
859
1115
|
output({ id: rid, alias: tagAlias(ALIAS_PREFIX.study, rid), message: "Study deleted" }, globals.json, { writePath: true });
|
|
860
1116
|
});
|
|
861
1117
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ish workspace — Manage workspaces (API: /products).
|
|
3
3
|
*/
|
|
4
|
-
import { withClient, getWebUrl, terminalLink, resolveWorkspace } from "../lib/command-helpers.js";
|
|
4
|
+
import { withClient, getWebUrl, terminalLink, resolveWorkspace, confirmDestructive } from "../lib/command-helpers.js";
|
|
5
5
|
import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
6
6
|
import { loadConfig, saveConfig } from "../config.js";
|
|
7
7
|
import { formatWorkspaceList, formatWorkspaceDetail, formatSiteAccessStatus, output } from "../lib/output.js";
|
|
@@ -134,13 +134,35 @@ existing workspace was returned. On creation, \`reused: false\`.`)
|
|
|
134
134
|
});
|
|
135
135
|
workspace
|
|
136
136
|
.command("delete")
|
|
137
|
-
.description("Delete a workspace")
|
|
137
|
+
.description("Delete a workspace (and ALL nested studies, asks, people, secrets, configs, sources, chat endpoints)")
|
|
138
138
|
.argument("<id>", "Workspace ID")
|
|
139
|
-
.
|
|
140
|
-
.
|
|
139
|
+
.option("-y, --yes", "Skip confirmation prompt (required in --json or non-TTY contexts)")
|
|
140
|
+
.addHelpText("after", `
|
|
141
|
+
Deleting a workspace is the highest-blast-radius destructive op in the CLI:
|
|
142
|
+
it removes ALL nested studies, asks, people, secrets, configs, sources, and
|
|
143
|
+
chat endpoints. This cannot be undone.
|
|
144
|
+
|
|
145
|
+
Examples:
|
|
146
|
+
$ ish workspace delete <id> # interactive — prompts for confirmation
|
|
147
|
+
$ ish workspace delete <id> --yes # non-interactive
|
|
148
|
+
$ ish workspace delete <id> --json --yes`)
|
|
149
|
+
.action(async (id, opts, cmd) => {
|
|
141
150
|
await withClient(cmd, async (client, globals) => {
|
|
142
151
|
const rid = resolveId(id);
|
|
152
|
+
await confirmDestructive(`Delete workspace ${tagAlias(ALIAS_PREFIX.workspace, rid)}? This will delete ALL nested studies, asks, people, secrets, configs, sources, and chat endpoints. This cannot be undone.`, { yes: opts.yes, json: globals.json });
|
|
143
153
|
await client.del(`/products/${rid}`);
|
|
154
|
+
// If the deleted workspace was active, clear it + its scoped children
|
|
155
|
+
// so subsequent commands don't render orphan refs (Pattern A).
|
|
156
|
+
const config = loadConfig();
|
|
157
|
+
if (config.workspace === rid) {
|
|
158
|
+
delete config.workspace;
|
|
159
|
+
delete config.study;
|
|
160
|
+
delete config.ask;
|
|
161
|
+
delete config.chat_endpoint;
|
|
162
|
+
saveConfig(config);
|
|
163
|
+
if (!globals.json)
|
|
164
|
+
console.error("(Cleared active workspace + study / ask / chat endpoint.)");
|
|
165
|
+
}
|
|
144
166
|
output({ id: rid, alias: tagAlias(ALIAS_PREFIX.workspace, rid), message: "Workspace deleted" }, globals.json, { writePath: true });
|
|
145
167
|
});
|
|
146
168
|
});
|
|
@@ -187,9 +209,12 @@ Examples:
|
|
|
187
209
|
if (opts.clear) {
|
|
188
210
|
const config = loadConfig();
|
|
189
211
|
delete config.workspace;
|
|
212
|
+
// workspace-scoped children: clearing the workspace orphans them all.
|
|
190
213
|
delete config.study;
|
|
214
|
+
delete config.ask;
|
|
215
|
+
delete config.chat_endpoint;
|
|
191
216
|
saveConfig(config);
|
|
192
|
-
console.error("Cleared active workspace (and study).");
|
|
217
|
+
console.error("Cleared active workspace (and active study / ask / chat endpoint).");
|
|
193
218
|
return;
|
|
194
219
|
}
|
|
195
220
|
if (!id) {
|
|
@@ -199,10 +224,20 @@ Examples:
|
|
|
199
224
|
const rid = resolveId(id);
|
|
200
225
|
const data = await client.get(`/products/${rid}`);
|
|
201
226
|
const config = loadConfig();
|
|
227
|
+
const switched = config.workspace !== rid;
|
|
202
228
|
config.workspace = rid;
|
|
203
|
-
|
|
229
|
+
if (switched) {
|
|
230
|
+
// Switching workspaces orphans all workspace-scoped active refs;
|
|
231
|
+
// dropping them avoids silent cross-workspace footguns (ISSUE-004).
|
|
232
|
+
delete config.study;
|
|
233
|
+
delete config.ask;
|
|
234
|
+
delete config.chat_endpoint;
|
|
235
|
+
}
|
|
204
236
|
saveConfig(config);
|
|
205
237
|
console.error(`Active workspace set to "${data.name || rid}".`);
|
|
238
|
+
if (switched) {
|
|
239
|
+
console.error("(Cleared active study / ask / chat endpoint — they belonged to the previous workspace.)");
|
|
240
|
+
}
|
|
206
241
|
});
|
|
207
242
|
});
|
|
208
243
|
}
|