@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.
@@ -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
- const [data, participants] = await Promise.all([
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
- if (wantsSummary) {
767
- output(buildStudyResultsSummary(data, participants), globals.json, { preProjected: true });
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
- else {
770
- formatStudyResults(data, participants, globals.json);
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 (!globals.json && data.product_id) {
773
- const url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
774
- console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
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
- .addHelpText("after", "\nExamples:\n $ ish workspace delete <id>")
140
- .action(async (id, _opts, cmd) => {
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
- delete config.study; // study belongs to workspace
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
  }