@ishlabs/cli 0.27.1 → 0.28.1

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.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * ish feedback — report a bug, request a feature, or send any other feedback
3
+ * to the ish team straight from the terminal.
4
+ *
5
+ * Transport: posts to the ish backend (`POST /api/v1/product-feedback` via
6
+ * ApiClient) with the user's bearer token. The backend inserts into the
7
+ * `product_feedback` table — the same table the web app's "Send feedback"
8
+ * dialog writes to — using its privileged connection, so CLI reports show up
9
+ * alongside web feedback. Per ADR-0029 (ish-frontend), clients submit through
10
+ * api.ishlabs.io, NEVER via Supabase PostgREST directly: the `sb_publishable_*`
11
+ * key shipped in this binary is deliberately powerless against the database.
12
+ * (The web flow additionally mirrors to Notion + captures PostHog server-side;
13
+ * those are deferred for the CLI.)
14
+ *
15
+ * Diagnostics: to help whoever triages the report, the user's message is
16
+ * augmented with an environment + active-config block (opt out with
17
+ * --no-diagnostics), and optionally (--health) the `ish check` setup checklist
18
+ * plus the tail of ~/.ish/local-sim.log. The reporter's identity (user_id /
19
+ * user_email) is attached server-side from the JWT, not here.
20
+ */
21
+ import type { Command } from "commander";
22
+ export declare function registerFeedbackCommands(program: Command): void;
@@ -0,0 +1,259 @@
1
+ /**
2
+ * ish feedback — report a bug, request a feature, or send any other feedback
3
+ * to the ish team straight from the terminal.
4
+ *
5
+ * Transport: posts to the ish backend (`POST /api/v1/product-feedback` via
6
+ * ApiClient) with the user's bearer token. The backend inserts into the
7
+ * `product_feedback` table — the same table the web app's "Send feedback"
8
+ * dialog writes to — using its privileged connection, so CLI reports show up
9
+ * alongside web feedback. Per ADR-0029 (ish-frontend), clients submit through
10
+ * api.ishlabs.io, NEVER via Supabase PostgREST directly: the `sb_publishable_*`
11
+ * key shipped in this binary is deliberately powerless against the database.
12
+ * (The web flow additionally mirrors to Notion + captures PostHog server-side;
13
+ * those are deferred for the CLI.)
14
+ *
15
+ * Diagnostics: to help whoever triages the report, the user's message is
16
+ * augmented with an environment + active-config block (opt out with
17
+ * --no-diagnostics), and optionally (--health) the `ish check` setup checklist
18
+ * plus the tail of ~/.ish/local-sim.log. The reporter's identity (user_id /
19
+ * user_email) is attached server-side from the JWT, not here.
20
+ */
21
+ import { existsSync, readFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import pkg from "../../package.json" with { type: "json" };
25
+ import { loadConfig } from "../config.js";
26
+ import { withClient } from "../lib/command-helpers.js";
27
+ import { resolveApiUrl } from "../lib/auth.js";
28
+ import { output } from "../lib/output.js";
29
+ import { c } from "../lib/colors.js";
30
+ const { version: CLI_VERSION } = pkg;
31
+ const FEEDBACK_TYPES = ["bug", "feature", "other"];
32
+ const DESCRIPTION_MIN = 10;
33
+ const DESCRIPTION_MAX = 4000;
34
+ const TITLE_MIN = 3;
35
+ const TITLE_MAX = 120;
36
+ const TYPE_NOUN = {
37
+ bug: "Bug report",
38
+ feature: "Feature request",
39
+ other: "Feedback",
40
+ };
41
+ function validationError(message) {
42
+ // `name === "ValidationError"` maps to exit code 2 in exitCodeFromError.
43
+ const err = new Error(message);
44
+ err.name = "ValidationError";
45
+ return err;
46
+ }
47
+ function readStdin() {
48
+ return new Promise((resolve, reject) => {
49
+ let data = "";
50
+ process.stdin.setEncoding("utf-8");
51
+ process.stdin.on("data", (chunk) => {
52
+ data += chunk;
53
+ });
54
+ process.stdin.on("end", () => resolve(data));
55
+ process.stdin.on("error", reject);
56
+ });
57
+ }
58
+ /**
59
+ * Resolve the feedback body from the positional argument, an `@file`
60
+ * reference, or piped stdin — the same input idioms the rest of the CLI uses
61
+ * for text fields (no interactive prompt; the CLI is for autonomous agents).
62
+ */
63
+ async function resolveDescription(message) {
64
+ if (message && message.startsWith("@")) {
65
+ const path = message.slice(1);
66
+ try {
67
+ return readFileSync(path, "utf-8");
68
+ }
69
+ catch {
70
+ throw new Error(`Cannot read file: ${path}`);
71
+ }
72
+ }
73
+ // Explicit stdin sentinel, or no argument with something piped in.
74
+ if (message === "-" || (message === undefined && !process.stdin.isTTY)) {
75
+ return readStdin();
76
+ }
77
+ if (message === undefined) {
78
+ throw validationError('No feedback message. Pass it as an argument, pipe it on stdin, or use @file:\n' +
79
+ ' ish feedback "the save button on the profile page does nothing"\n' +
80
+ ' ish feedback --type feature "let me export interactions as CSV"\n' +
81
+ " ish feedback @bug-report.md\n" +
82
+ ' echo "..." | ish feedback -');
83
+ }
84
+ return message;
85
+ }
86
+ function deriveTitle(explicit, description, type) {
87
+ if (explicit !== undefined) {
88
+ const t = explicit.trim();
89
+ if (t.length < TITLE_MIN) {
90
+ throw validationError(`--title must be at least ${TITLE_MIN} characters.`);
91
+ }
92
+ return t.slice(0, TITLE_MAX);
93
+ }
94
+ // Mirror the web dialog: first non-empty line of the message becomes the
95
+ // title, capped to TITLE_MAX. Fall back to a generic title so the table's
96
+ // NOT NULL + min-length contract is always satisfied even for terse bodies.
97
+ const firstLine = description
98
+ .split("\n")
99
+ .map((l) => l.trim())
100
+ .find((l) => l.length > 0) ?? "";
101
+ const candidate = firstLine.slice(0, TITLE_MAX).trim();
102
+ return candidate.length >= TITLE_MIN ? candidate : `${TYPE_NOUN[type]} from CLI`;
103
+ }
104
+ // ── Diagnostics ────────────────────────────────────────────────────────────
105
+ //
106
+ // Appended to the message so whoever debugs the report has grounded context.
107
+ // None of this needs the JWT — the backend attaches the reporter's identity
108
+ // from the token. The aim is maximal useful signal without a schema change.
109
+ /** Always-on, instant one-liner: the bare minimum a debugger needs. */
110
+ function platformLine(dev) {
111
+ return `Sent via ish CLI v${CLI_VERSION} · ${process.platform} ${process.arch} · node ${process.versions.node}${dev ? " · dev" : ""}`;
112
+ }
113
+ /** Active-config context (instant; no subprocesses). Default-on. */
114
+ function diagnosticsBlock(apiUrl) {
115
+ const cfg = loadConfig();
116
+ const lines = [
117
+ "Diagnostics (auto-attached; pass --no-diagnostics to omit)",
118
+ `- Active workspace: ${cfg.workspace ?? "none"}`,
119
+ `- Active study: ${cfg.study ?? "none"}`,
120
+ `- Active ask: ${cfg.ask ?? "none"}`,
121
+ `- API: ${apiUrl}`,
122
+ ];
123
+ return lines.join("\n");
124
+ }
125
+ /** Setup/health checklist, reusing `ish check`'s probes. Heavier (subprocess
126
+ * probes for xcrun/adb), so gated behind --health. */
127
+ async function healthBlock() {
128
+ try {
129
+ const { runChecks } = await import("./doctor.js");
130
+ const checks = await runChecks();
131
+ const MARK = {
132
+ pass: "OK",
133
+ warn: "WARN",
134
+ fail: "FAIL",
135
+ skip: "--",
136
+ };
137
+ const lines = checks.map((ch) => {
138
+ const fix = (ch.status === "warn" || ch.status === "fail") && ch.fix
139
+ ? ` (fix: ${ch.fix})`
140
+ : "";
141
+ return `- [${MARK[ch.status] ?? ch.status}] ${ch.name}: ${ch.message ?? ""}${fix}`.trimEnd();
142
+ });
143
+ return ["Setup checks (ish check):", ...lines].join("\n");
144
+ }
145
+ catch (e) {
146
+ return `Setup checks: could not run (${e instanceof Error ? e.message : String(e)}).`;
147
+ }
148
+ }
149
+ /** Tail of the local-simulation debug log, when present. Written to
150
+ * ~/.ish/local-sim.log by `study run --local --debug`. */
151
+ function localSimLogTail(maxLines = 80) {
152
+ // Match the literal path debug.ts writes to (it uses homedir()/.ish, not the
153
+ // ISH_HOME-aware paths helper), so we read exactly where the log lands.
154
+ const logFile = join(homedir(), ".ish", "local-sim.log");
155
+ if (!existsSync(logFile))
156
+ return undefined;
157
+ try {
158
+ const tail = readFileSync(logFile, "utf-8").split("\n").slice(-maxLines).join("\n").trim();
159
+ if (!tail)
160
+ return undefined;
161
+ return [
162
+ `Recent local-sim log (last ${maxLines} lines of ~/.ish/local-sim.log):`,
163
+ "```",
164
+ tail,
165
+ "```",
166
+ ].join("\n");
167
+ }
168
+ catch {
169
+ return undefined;
170
+ }
171
+ }
172
+ /** Assemble everything appended to the user's message. */
173
+ async function buildAppendix(opts) {
174
+ const sections = [platformLine(opts.dev)];
175
+ if (opts.diagnostics)
176
+ sections.push(diagnosticsBlock(opts.apiUrl));
177
+ if (opts.health) {
178
+ sections.push(await healthBlock());
179
+ const log = localSimLogTail();
180
+ if (log)
181
+ sections.push(log);
182
+ }
183
+ return "\n\n—\n" + sections.join("\n\n");
184
+ }
185
+ export function registerFeedbackCommands(program) {
186
+ program
187
+ .command("feedback")
188
+ .description("Report a bug or send feedback to the ish team")
189
+ .argument("[message]", "Feedback text. Use @path to read from a file, or - to read from stdin.")
190
+ .option("--type <type>", "Feedback type: bug | feature | other", "bug")
191
+ .option("--title <title>", "Short title (default: first line of the message)")
192
+ .option("--no-diagnostics", "Don't attach environment/account diagnostics to the report")
193
+ .option("--health", "Also run setup checks (ish check) and attach recent local-sim logs — useful for simulation/setup bugs")
194
+ .option("--dry-run", "Print exactly what would be sent (including diagnostics) without submitting")
195
+ .addHelpText("after", `\nExamples:
196
+ $ ish feedback "the save button on the profile page does nothing"
197
+ $ ish feedback --type feature "let me export interactions as CSV"
198
+ $ ish feedback --type other "the guided setup was great"
199
+ $ ish feedback @bug-report.md
200
+ $ ish study run … --local --debug 2>err.txt; ish feedback @err.txt --health
201
+ $ git log -1 --oneline | ish feedback -
202
+
203
+ Reports go to the ish team and show up alongside web feedback. Sends as the
204
+ logged-in user (run \`ish login\` first). bug is the default type. To give
205
+ whoever debugs it the most signal, attach the failing command's output via
206
+ @file or stdin; environment + active-config diagnostics are attached
207
+ automatically (opt out with --no-diagnostics), and --health adds setup
208
+ checks + local-sim logs.`)
209
+ .action(async (message, opts, cmd) => {
210
+ await withClient(cmd, async (client, globals) => {
211
+ const type = opts.type;
212
+ if (!FEEDBACK_TYPES.includes(type)) {
213
+ throw validationError(`Invalid --type "${opts.type}". Must be one of: ${FEEDBACK_TYPES.join(", ")}.`);
214
+ }
215
+ const rawDescription = await resolveDescription(message);
216
+ const description = rawDescription.trim();
217
+ if (description.length === 0) {
218
+ throw validationError('No feedback message. Pass it as an argument, pipe it on stdin, or use @file:\n' +
219
+ ' ish feedback "the save button on the profile page does nothing"\n' +
220
+ ' ish feedback --type feature "let me export interactions as CSV"\n' +
221
+ " ish feedback @bug-report.md");
222
+ }
223
+ if (description.length < DESCRIPTION_MIN) {
224
+ throw validationError(`Please add a bit more detail (at least ${DESCRIPTION_MIN} characters).`);
225
+ }
226
+ if (description.length > DESCRIPTION_MAX) {
227
+ throw validationError(`Feedback is too long (${description.length} chars; max ${DESCRIPTION_MAX}).`);
228
+ }
229
+ const title = deriveTitle(opts.title, description, type);
230
+ const apiUrl = resolveApiUrl(globals.apiUrl, globals.dev);
231
+ const appendix = await buildAppendix({
232
+ apiUrl,
233
+ dev: globals.dev,
234
+ diagnostics: opts.diagnostics,
235
+ health: opts.health ?? false,
236
+ });
237
+ const body = {
238
+ type,
239
+ title,
240
+ description: description + appendix,
241
+ };
242
+ if (opts.dryRun) {
243
+ if (!globals.quiet) {
244
+ console.error(`${c.dim}Dry run — nothing sent. This is what would be submitted:${c.reset}`);
245
+ }
246
+ output(body, globals.json);
247
+ return;
248
+ }
249
+ if (!globals.quiet) {
250
+ console.error(`Sending ${type} report…`);
251
+ }
252
+ const data = await client.post("/product-feedback", body);
253
+ if (!globals.quiet) {
254
+ console.error(`${c.green}✓${c.reset} Thanks! Your ${type} report was sent to the ish team.`);
255
+ }
256
+ output(data, globals.json, { writePath: true });
257
+ });
258
+ });
259
+ }
@@ -493,9 +493,28 @@ Examples:
493
493
  chatMode = readChatMode(iteration.details);
494
494
  isPair = chatMode === "participant_pair";
495
495
  }
496
- if (!iterationHasContent(iteration.details, modality)) {
497
- const flagHint = describeRequiredContentFlag(modality, isPair ? "participant_pair" : undefined);
496
+ // Native (ios/android) interactive iterations name their target via
497
+ // `app_artifact`, which is OPTIONAL at create time: `ish iteration create
498
+ // --platform ios` with no --app stores it as "chosen at run time" (no
499
+ // app_artifact) and the app is supplied here via --app. The web app's
500
+ // "Run on your device" panel generates exactly that command
501
+ // (`ish study run --local --platform ios --app ./Build.app …`), so a
502
+ // run-time --app must satisfy the content requirement even though the
503
+ // iteration itself carries no stored target. `iterationHasContent` only
504
+ // sees the persisted details, so layer the run-time flag on top here.
505
+ const iterationPlatform = normalizePlatform(readIterationDetails(iteration.details).platform);
506
+ const isNativeIteration = iterationPlatform === "ios" || iterationPlatform === "android";
507
+ const runtimeAppProvided = isNativeIteration && typeof opts.app === "string" && opts.app.trim().length > 0;
508
+ if (!iterationHasContent(iteration.details, modality) && !runtimeAppProvided) {
498
509
  const iterAlias = tagAlias(ALIAS_PREFIX.iteration, iterationId);
510
+ if (isNativeIteration) {
511
+ // The target is an app, not a URL — point at --app, not --url.
512
+ const ext = iterationPlatform === "ios" ? ".app" : ".apk";
513
+ throw new Error(`Iteration "${iterationLabel}" (${iterAlias}) is a native ${iterationPlatform} iteration with no app set. ` +
514
+ `Pass \`--app <path-to-${ext}-or-bundle-id>\` on this run, ` +
515
+ `or store one on the iteration via \`ish iteration update ${iterAlias} --details-json '{"app_artifact":"<bundle-id-or-path>"}'\`, then retry.`);
516
+ }
517
+ const flagHint = describeRequiredContentFlag(modality, isPair ? "participant_pair" : undefined);
499
518
  throw new Error(`Iteration "${iterationLabel}" (${iterAlias}) has no ${isMedia ? "content" : isPair ? "people/scenarios" : isChat ? "endpoint" : "URL"} configured yet. ` +
500
519
  `Add ${isMedia ? "content" : isPair ? "the pair-mode payload" : isChat ? "an endpoint" : "a URL"} with ` +
501
520
  `\`ish iteration create --study ${resolvedStudy} ${flagHint}\` ` +
@@ -296,7 +296,7 @@ async function collectWorkspaceUsage(client, workspaceId) {
296
296
  people_used: typeof participants.total === "number" ? participants.total : 0,
297
297
  people_max: lookupLimit("maxCustomPersons"),
298
298
  concurrent_participants_max: lookupLimit("maxConcurrentParticipants"),
299
- workspace_members_max: lookupLimit("maxWorkspaceMembers"),
299
+ workspace_members_max: lookupLimit("maxSeats"),
300
300
  };
301
301
  }
302
302
  // ---------------------------------------------------------------------------
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { registerInitCommands } from "./commands/init.js";
20
20
  import { registerMcpCommands } from "./commands/mcp.js";
21
21
  import { registerSecretCommands } from "./commands/secret.js";
22
22
  import { registerDoctorCommands } from "./commands/doctor.js";
23
+ import { registerFeedbackCommands } from "./commands/feedback.js";
23
24
  import { AGENT_HELP_FOOTER } from "./lib/docs.js";
24
25
  import { runInline, EXIT_USAGE, injectGlobalWorkspaceOption } from "./lib/command-helpers.js";
25
26
  import { resolveApiUrl, resolveToken, verifyToken } from "./lib/auth.js";
@@ -512,6 +513,7 @@ registerInitCommands(program);
512
513
  registerMcpCommands(program);
513
514
  registerSecretCommands(program);
514
515
  registerDoctorCommands(program);
516
+ registerFeedbackCommands(program);
515
517
  program
516
518
  .command("upgrade")
517
519
  .description("Update ish to the latest version")
@@ -6,7 +6,7 @@ import * as fs from "node:fs";
6
6
  import { resolveApiUrl, resolveToken } from "./auth.js";
7
7
  import { getAppUrl } from "../auth.js";
8
8
  import { ApiClient, ApiError } from "./api-client.js";
9
- import { outputError, setVerbose, setFields, setGetField } from "./output.js";
9
+ import { outputError, shouldSuggestReport, setVerbose, setFields, setGetField } from "./output.js";
10
10
  import { setColorsEnabled, colorsEnabled } from "./colors.js";
11
11
  import { loadConfig } from "../config.js";
12
12
  import { resolveId } from "./alias-store.js";
@@ -513,7 +513,10 @@ export async function withClient(cmd, fn) {
513
513
  await fn(client, globals);
514
514
  }
515
515
  catch (err) {
516
- outputError(err, globals.json);
516
+ // Nudge to report genuine faults — but never when the failing command
517
+ // IS `ish feedback` (don't suggest reporting via the thing that broke).
518
+ const suggestReport = shouldSuggestReport(err) && commandPath(cmd).split(".")[0] !== "feedback";
519
+ outputError(err, globals.json, { suggestReport });
517
520
  await exitWithFlush(exitCodeFromError(err));
518
521
  }
519
522
  });
@@ -530,7 +533,8 @@ export async function runInline(cmd, fn) {
530
533
  await fn(globals);
531
534
  }
532
535
  catch (err) {
533
- outputError(err, globals.json);
536
+ const suggestReport = shouldSuggestReport(err) && commandPath(cmd).split(".")[0] !== "feedback";
537
+ outputError(err, globals.json, { suggestReport });
534
538
  await exitWithFlush(exitCodeFromError(err));
535
539
  }
536
540
  });
package/dist/lib/docs.js CHANGED
@@ -2549,6 +2549,15 @@ from envelopes that don't originate from an HTTP response (Commander
2549
2549
  parse errors, local validation failures, alias-resolution errors). Do
2550
2550
  not branch on \`status: 0\` — that value is never emitted as of 0.20.
2551
2551
 
2552
+ A **\`report\`** field appears on *genuine faults* — uncategorized
2553
+ client errors, \`server\`/5xx, network failures, and unknown throws —
2554
+ carrying the suggested \`ish feedback "…"\` invocation to report the
2555
+ bug. It is deliberately **absent** on user-actionable failures (usage /
2556
+ validation exit 2, auth exit 3, not-found exit 4, \`usage_limit_reached\`)
2557
+ so agents don't report their own input mistakes. In human mode the same
2558
+ nudge prints as a \`→ Looks like a bug? Report it: …\` line on stderr.
2559
+ See \`guides/feedback\`.
2560
+
2552
2561
  ## Conventions
2553
2562
 
2554
2563
  - Successful commands exit 0 and print one JSON object/array on stdout.
@@ -3224,23 +3233,26 @@ request time, for any client, is the backend's \`TIER_LIMITS\` dict in
3224
3233
 
3225
3234
  ## Limits enforced
3226
3235
 
3227
- | Limit | Free | Media | Starter | Pro | Enterprise |
3228
- |-----------------------------|------|-------|---------|-----|------------|
3229
- | \`maxProducts\` | 1 | 1 | ∞ | ∞ | ∞ |
3230
- | \`maxStudiesPerProduct\` | 3 | | ∞ | ∞ | ∞ |
3231
- | \`maxIterationsPerStudy\` | 2 | | ∞ | ∞ | ∞ |
3232
- | \`maxCustomPersons\` | 3 | 10 | 10 | ∞ | ∞ |
3233
- | \`maxConcurrentParticipants\` | 3 | 3 | 10 | 50 | ∞ |
3234
- | \`maxWorkspaceMembers\` | 1 | 1 | 1 | 10 | ∞ |
3235
- | \`maxSeats\` | 1 | 1 | 1 | 10 | ∞ |
3236
+ | Limit | Free | Starter | Pro | Enterprise |
3237
+ |-----------------------------|------|---------|-----|------------|
3238
+ | \`maxProducts\` | 1 | 3 | ∞ | ∞ |
3239
+ | \`maxStudiesPerProduct\` | 3 | 15 | ∞ | ∞ |
3240
+ | \`maxAsksPerProduct\` | 3 | 15 | ∞ | ∞ |
3241
+ | \`maxIterationsPerStudy\` | 2 | 5 | ∞ | ∞ |
3242
+ | \`maxCustomPersons\` | 3 | 10 | | ∞ |
3243
+ | \`maxConcurrentParticipants\` | 3 | 10 | 50 | ∞ |
3244
+ | \`maxSeats\` | 1 | 1 | 10 | ∞ |
3236
3245
 
3237
3246
  Commands that may hit a limit: \`ish workspace create\`,
3238
- \`ish study create\`, \`ish study generate\`, \`ish iteration create\`,
3247
+ \`ish study create\`, \`ish study generate\`, \`ish ask create\`
3248
+ (and \`ish ask run --new\`), \`ish iteration create\`,
3239
3249
  \`ish person create\`, \`ish person generate\`.
3240
3250
 
3241
- \`maxConcurrentParticipants\` gates how many participants can be in-flight
3242
- at once per dispatch. \`maxWorkspaceMembers\` gates workspace membership
3243
- (seats). Both are enforced server-side.
3251
+ \`maxConcurrentParticipants\` caps how many participants a single run can
3252
+ include cumulative per iteration for studies, per ask for asks — and is
3253
+ enforced when participants are created/added, not at dispatch. \`maxSeats\`
3254
+ gates workspace membership (active members + pending invitations). Both are
3255
+ enforced server-side.
3244
3256
 
3245
3257
  ## What you see when a limit is hit
3246
3258
 
@@ -3292,6 +3304,7 @@ upgrade or delete an existing resource to free up headroom.
3292
3304
  that page is about *how many credits each run draws*).
3293
3305
  - \`concepts/workspace\` — \`maxProducts\` is per-account.
3294
3306
  - \`concepts/study\` — \`maxStudiesPerProduct\` gates study creation.
3307
+ - \`concepts/ask\` — \`maxAsksPerProduct\` gates ask creation.
3295
3308
  - \`concepts/iteration\` — \`maxIterationsPerStudy\` gates iteration creation.
3296
3309
  - \`concepts/person\` — \`maxCustomPersons\` gates person creation.
3297
3310
  - \`reference/json-mode\` — full error envelope shape and exit codes.
@@ -4410,6 +4423,14 @@ The platform defaults to the iteration's; \`--app\` on \`study run\` overrides t
4410
4423
  stored target with a fresh local build. The WebDriverAgent runner cold-starts
4411
4424
  slowly the first time (~30-60s) and is then reused across participants.
4412
4425
 
4426
+ If the iteration was created **without** \`--app\` (its target is "chosen at run
4427
+ time"), supply the app on the run instead — there's nothing stored to default
4428
+ from:
4429
+
4430
+ \`\`\`
4431
+ ish study run --local --platform ios --app ./Build.app --all --wait
4432
+ \`\`\`
4433
+
4413
4434
  ## 3b. Parallel runs — \`--parallel N\` (iOS + Android)
4414
4435
 
4415
4436
  \`\`\`
@@ -4466,6 +4487,74 @@ reproducible runs.
4466
4487
  - \`reference/screenshots\` — grouped index vs per-interaction frames.
4467
4488
  - \`guides/first-study\` — the browser-URL version of this flow.
4468
4489
  `;
4490
+ const GUIDE_FEEDBACK = `# guide: report a bug or send feedback
4491
+
4492
+ \`ish feedback\` files a bug report, feature request, or general note
4493
+ straight from the terminal — to the same place the web app's "Send
4494
+ feedback" dialog goes. As the agent operating the CLI, you usually see
4495
+ a failure first; this is how you hand it to the team without leaving
4496
+ the shell.
4497
+
4498
+ \`\`\`bash
4499
+ ish feedback "the save button on the profile page does nothing"
4500
+ ish feedback --type feature "let me export interactions as CSV"
4501
+ ish feedback --type other "the guided setup was great"
4502
+ \`\`\`
4503
+
4504
+ ## The message
4505
+
4506
+ Pass the body as the positional argument, or read it from elsewhere —
4507
+ the same idioms as other text fields:
4508
+
4509
+ - \`ish feedback @bug-report.md\` — read the body from a file.
4510
+ - \`… 2>err.txt; ish feedback @err.txt\` — attach a failing command's output.
4511
+ - \`some-cmd 2>&1 | ish feedback -\` — read the body from stdin.
4512
+
4513
+ The body must be at least 10 characters. \`--title\` is optional; by
4514
+ default the first line of the message becomes the title.
4515
+
4516
+ ## Type
4517
+
4518
+ \`--type\` is one of \`bug\` (default), \`feature\`, or \`other\` — the same
4519
+ three the web dialog offers.
4520
+
4521
+ ## Diagnostics (help whoever debugs it)
4522
+
4523
+ To give the debugger as much grounded signal as possible, the report
4524
+ auto-attaches an environment + account block: ish CLI version, OS,
4525
+ node, the logged-in account, and your active workspace/study/ask. Opt
4526
+ out with \`--no-diagnostics\`.
4527
+
4528
+ \`--health\` adds more for simulation/setup bugs: it runs the \`ish check\`
4529
+ setup checklist (Xcode, simulators, adb, Chromium, account) and
4530
+ attaches the tail of \`~/.ish/local-sim.log\` when present. Use it
4531
+ whenever the bug involves \`study run --local\` or native device setup.
4532
+
4533
+ \`--dry-run\` prints exactly what would be sent (message + diagnostics)
4534
+ without submitting — preview it first if you're unsure.
4535
+
4536
+ ## When the CLI nudges you to report
4537
+
4538
+ On a *genuine fault* (uncategorized client error, 5xx/\`server\`, network
4539
+ failure, unknown throw) the CLI appends a hint: a \`report\` field in the
4540
+ \`--json\` error envelope, and a \`→ Looks like a bug? Report it: …\` line
4541
+ on stderr in human mode. It is intentionally **silent** on
4542
+ user-actionable failures (usage, validation, auth, not-found,
4543
+ usage-limit) so the nudge stays high-signal. When you see it, that
4544
+ error is worth an \`ish feedback\` — paste what you were doing and add
4545
+ \`--health\` if it was a local/native run.
4546
+
4547
+ ## Where it lands
4548
+
4549
+ Sends as the logged-in user (run \`ish login\` first) through the ish
4550
+ backend, so reports show up alongside web feedback in the team's review
4551
+ surface. Output is the created row \`{id, type, title, status}\`.
4552
+
4553
+ ## Related
4554
+
4555
+ - \`reference/json-mode\` — the \`report\` field + error envelope.
4556
+ - \`guides/native-app\` — when to reach for \`--health\`.
4557
+ `;
4469
4558
  const PAGES = [
4470
4559
  {
4471
4560
  slug: "overview",
@@ -4641,6 +4730,12 @@ const PAGES = [
4641
4730
  description: "One command wires the hosted ish MCP server into Cursor, VS Code, Claude Code, Claude Desktop, and Windsurf. Idempotent, atomic, preserves unrelated keys, no tokens written.",
4642
4731
  body: GUIDE_MCP_ADD,
4643
4732
  },
4733
+ {
4734
+ slug: "guides/feedback",
4735
+ title: "guide: report a bug or send feedback (`ish feedback`)",
4736
+ description: "File a bug/feature/other report from the terminal: message via arg/@file/stdin, --type, auto-attached environment+account diagnostics (--no-diagnostics to opt out), --health for setup checks + local-sim logs, --dry-run preview. Also: the `report` hint the CLI appends to genuine-fault errors.",
4737
+ body: GUIDE_FEEDBACK,
4738
+ },
4644
4739
  ];
4645
4740
  const PAGES_BY_SLUG = new Map(PAGES.map((p) => [p.slug, p]));
4646
4741
  export function listPages() {
@@ -10,15 +10,31 @@
10
10
  * lives there, not here.
11
11
  */
12
12
  import type { Page } from "playwright-core";
13
- import type { LocalStepAction, ActionResult, ContextValue, TreeData } from "./types.js";
13
+ import type { LocalStepAction, ActionResult, ContextValue, WireContextValue, TreeData } from "./types.js";
14
14
  import type { TabManager } from "./tabs.js";
15
15
  /**
16
16
  * Execute a single action on the page.
17
17
  */
18
18
  export declare function executeAction(page: Page, action: LocalStepAction, treeData: TreeData, contextValues: ContextValue[], tabs?: TabManager): Promise<ActionResult>;
19
+ /**
20
+ * Normalize the backend's wire context-value shape ({@link WireContextValue}:
21
+ * `{key, requires_resolution, …}`) into the internal {@link ContextValue}
22
+ * ({name, type, …}) the executors and {@link resolveTextValue} consume.
23
+ *
24
+ * This mapping is the fix for a long-standing silent break: the backend has
25
+ * always sent `key`/`requires_resolution`, the CLI has always read `name`/`type`,
26
+ * and nothing mapped between them — so var/secret lookups never matched and the
27
+ * agent typed the literal key (e.g. `LOGIN_USERNAME`) into login fields on web,
28
+ * iOS and Android alike. `requires_resolution` is true for secrets (resolved at
29
+ * runtime) and false for preloaded variables.
30
+ */
31
+ export declare function normalizeContextValues(wire: WireContextValue[]): ContextValue[];
19
32
  /**
20
33
  * Resolve the actual text to type from an action, handling var/secret value types.
21
- * Exported so the native (Android) executor can resolve values the same way.
34
+ * Exported so the native (Android/iOS) executors can resolve values the same way.
35
+ *
36
+ * `contextValues` must already be normalized via {@link normalizeContextValues}
37
+ * — the lookup matches on `name`, which is the backend's `key` post-mapping.
22
38
  */
23
39
  export declare function resolveTextValue(action: LocalStepAction, contextValues: ContextValue[]): string;
24
40
  /**
@@ -376,9 +376,32 @@ async function executeKeyboardShortcut(page, action) {
376
376
  await page.keyboard.press(combo);
377
377
  }
378
378
  // --- Helpers ---
379
+ /**
380
+ * Normalize the backend's wire context-value shape ({@link WireContextValue}:
381
+ * `{key, requires_resolution, …}`) into the internal {@link ContextValue}
382
+ * ({name, type, …}) the executors and {@link resolveTextValue} consume.
383
+ *
384
+ * This mapping is the fix for a long-standing silent break: the backend has
385
+ * always sent `key`/`requires_resolution`, the CLI has always read `name`/`type`,
386
+ * and nothing mapped between them — so var/secret lookups never matched and the
387
+ * agent typed the literal key (e.g. `LOGIN_USERNAME`) into login fields on web,
388
+ * iOS and Android alike. `requires_resolution` is true for secrets (resolved at
389
+ * runtime) and false for preloaded variables.
390
+ */
391
+ export function normalizeContextValues(wire) {
392
+ return wire.map(cv => ({
393
+ name: cv.key,
394
+ type: cv.requires_resolution ? "secret" : "var",
395
+ value: cv.value,
396
+ ...(cv.description != null && { description: cv.description }),
397
+ }));
398
+ }
379
399
  /**
380
400
  * Resolve the actual text to type from an action, handling var/secret value types.
381
- * Exported so the native (Android) executor can resolve values the same way.
401
+ * Exported so the native (Android/iOS) executors can resolve values the same way.
402
+ *
403
+ * `contextValues` must already be normalized via {@link normalizeContextValues}
404
+ * — the lookup matches on `name`, which is the backend's `key` post-mapping.
382
405
  */
383
406
  export function resolveTextValue(action, contextValues) {
384
407
  if (action.value_type === "var" || action.value_type === "secret") {
@@ -403,10 +403,15 @@ export class AndroidDevice {
403
403
  await settle(250);
404
404
  }
405
405
  const text = resolveTextValue(action, this.contextValues);
406
- if (action.mode === "click_type") {
406
+ // Clear before typing by default — ADBKeyboard's type broadcast APPENDS, so
407
+ // a retried or app-pre-filled field would otherwise accumulate text. This is
408
+ // the replace-on-type semantic the browser (select-all+type) and iOS paths
409
+ // also use; previously only click_type cleared. Skip the clear+type entirely
410
+ // when there's nothing to type so an empty no-op can't wipe the field.
411
+ if (text) {
407
412
  await adbKeyboardClear();
413
+ await adbKeyboardType(text);
408
414
  }
409
- await adbKeyboardType(text);
410
415
  if (action.submit) {
411
416
  await settle(150);
412
417
  await pressKeyEvent("KEYCODE_ENTER");
@@ -33,7 +33,7 @@
33
33
  import { resolveTextValue } from "./actions.js";
34
34
  import { requireOneBootedSimulator, screenshotPng, terminateApp, launchApp, installApp, uninstallApp, isAppInstalled, bundleIdFromApp, appBuildFromSimulator, } from "./simctl.js";
35
35
  // iOS UI interaction + a11y run through WebDriverAgent (XCUITest), not idb.
36
- import { ensureWda, closeWda, describeScreen, describeAll, activeBundleId, uiTap, uiLongPress, uiSwipe, uiText, uiKey, HID_KEY_RETURN, } from "./xcuitest.js";
36
+ import { ensureWda, closeWda, describeScreen, describeAll, activeBundleId, uiTap, uiLongPress, uiSwipe, uiText, uiClearActiveField, uiKey, HID_KEY_RETURN, } from "./xcuitest.js";
37
37
  import { isLocalPath } from "../upload.js";
38
38
  import { deNormalizePoint, deNormalizeDrag, pointToPixel } from "./coordinates.js";
39
39
  import { parseXcuiHierarchy, serializeNativeTree, boundsCenter } from "./native-a11y.js";
@@ -456,12 +456,16 @@ export class IOSDevice {
456
456
  await settle(250);
457
457
  }
458
458
  const text = resolveTextValue(action, this.contextValues);
459
- // WDA text input appends to the focused field; for click_type (replace) there
460
- // is no idb "clear", so we rely on the field being empty after focus. The
461
- // vision agent typically taps an empty field, so this matches Android's
462
- // common path; a true select-all clear isn't exposed by idb.
463
- if (text)
459
+ // WDA's /wda/keys APPENDS to the focused field, so clear it first — typing
460
+ // replaces by default (matching the browser select-all+type and Android's
461
+ // ADB_CLEAR_TEXT). uiClearActiveField uses WDA's native element clear on the
462
+ // focused element; it's best-effort, so a non-editable focus simply no-ops
463
+ // and we fall back to appending. Only clear when we actually have text to
464
+ // type, so an empty/no-op type never wipes the field.
465
+ if (text) {
466
+ await uiClearActiveField(this.udid);
464
467
  await uiText(this.udid, text);
468
+ }
465
469
  if (action.submit) {
466
470
  await settle(150);
467
471
  await uiKey(this.udid, HID_KEY_RETURN);
@@ -8,7 +8,7 @@
8
8
  import { appendFileSync } from "node:fs";
9
9
  import { launchSharedBrowser, FULL_PAGE_HEIGHT_CAP_PX_MOBILE, FULL_PAGE_HEIGHT_CAP_PX_DESKTOP, } from "./browser.js";
10
10
  import { uploadScreenshot } from "./upload.js";
11
- import { detectNoVisibleChange, describeAction, classifyStepKind } from "./actions.js";
11
+ import { detectNoVisibleChange, describeAction, classifyStepKind, normalizeContextValues } from "./actions.js";
12
12
  import { createDevice } from "./device.js";
13
13
  import { nativeStateResetWarning } from "./ios.js";
14
14
  import { provisionDevicePool, maxConcurrentDevices, totalMemBytes, PER_DEVICE_MB, } from "./device-pool.js";
@@ -427,7 +427,10 @@ async function runSingleSimulation(client, participantId, participantName, opts,
427
427
  assignments: initResponse.assignments,
428
428
  participant_background: initResponse.participant_background,
429
429
  participant_language: initResponse.participant_language,
430
- context_values: initResponse.context_values,
430
+ // Map the backend wire shape ({key, requires_resolution}) into the internal
431
+ // {name, type} ContextValue the devices/resolveTextValue expect. Without
432
+ // this every var/secret lookup misses and the literal key gets typed.
433
+ context_values: normalizeContextValues(initResponse.context_values),
431
434
  max_interactions: initResponse.max_interactions,
432
435
  agent_model: initResponse.agent_model,
433
436
  dom_model: initResponse.dom_model,
@@ -27,7 +27,7 @@ export interface LocalSimInitResponse {
27
27
  participant_background: Record<string, unknown> | null;
28
28
  participant_language: string | null;
29
29
  config: Record<string, unknown>;
30
- context_values: ContextValue[];
30
+ context_values: WireContextValue[];
31
31
  max_interactions: number;
32
32
  agent_model: string;
33
33
  dom_model: string;
@@ -40,6 +40,32 @@ export interface LocalSimAssignment {
40
40
  instructions: string;
41
41
  sequence: number;
42
42
  }
43
+ /**
44
+ * Wire shape of a context value exactly as the backend serializes it
45
+ * (ish-backend `app/runs/values.py` `ContextValue` → `/simulation/local/init`):
46
+ * the identifier field is `key` (NOT `name`), and `requires_resolution` is the
47
+ * secret-vs-variable discriminator (NO `type` field). The CLI normalizes this
48
+ * into the internal {@link ContextValue} shape at ingestion via
49
+ * {@link normalizeContextValues}.
50
+ *
51
+ * History: the internal shape below has used `name`/`type` since v0.7.0 while the
52
+ * backend has always sent `key`/`requires_resolution`. Because the two were
53
+ * assumed identical and consumed without mapping, every `resolveTextValue`
54
+ * lookup (`find(v => v.name === action.value)`) silently missed and the agent
55
+ * typed the literal key (e.g. `LOGIN_USERNAME`) into the field on every
56
+ * platform. Keep this interface faithful to the wire so the mismatch can't
57
+ * recur unnoticed.
58
+ */
59
+ export interface WireContextValue {
60
+ key: string;
61
+ description?: string | null;
62
+ value: string | null;
63
+ requires_resolution: boolean;
64
+ }
65
+ /**
66
+ * Internal context-value shape used by the executors / {@link resolveTextValue}.
67
+ * Produced from {@link WireContextValue} by {@link normalizeContextValues}.
68
+ */
43
69
  export interface ContextValue {
44
70
  name: string;
45
71
  type: "var" | "secret";
@@ -76,6 +76,30 @@ export declare function uiText(udid: string, text: string): Promise<void>;
76
76
  * map it to a newline (see WDA_RETURN). Unknown codes are a no-op-safe error.
77
77
  */
78
78
  export declare function uiKey(udid: string, keycode: number): Promise<void>;
79
+ /**
80
+ * Extract the element id from a WDA active-element payload, accepting both the
81
+ * W3C key and the legacy `ELEMENT` alias. Pure + exported for unit testing the
82
+ * (fiddly) key handling without a live runner. Returns null when the shape has
83
+ * no usable id (nothing focused / non-element response).
84
+ */
85
+ export declare function parseActiveElementId(value: unknown): string | null;
86
+ /**
87
+ * Clear the currently-focused text field via WDA's native element clear — the
88
+ * same select-all+delete Appium's `clear()` performs, so it handles multiline
89
+ * and arbitrary cursor positions correctly.
90
+ *
91
+ * Why this exists: `uiText` posts to `/wda/keys`, which APPENDS to the focused
92
+ * field. Without a clear, re-typing a field (a retry, or a field the app
93
+ * pre-filled) accumulates text (e.g. `LOGIN_USERNAMELOGIN_USERNAME…`). The
94
+ * coordinate/vision typing path has no element uuid up front, so we resolve the
95
+ * focused element with `GET …/element/active`, then `POST …/element/:uuid/clear`.
96
+ *
97
+ * Best-effort by design: a non-editable focus (or any WDA hiccup) just returns
98
+ * false and the caller falls back to the prior append behavior — clearing is an
99
+ * improvement, never a hard precondition for typing. Returns true when a clear
100
+ * was actually issued.
101
+ */
102
+ export declare function uiClearActiveField(udid: string): Promise<boolean>;
79
103
  /** Re-export so a future ios.ts can drop the simctl HID constant. */
80
104
  export declare const HID_KEY_RETURN = 40;
81
105
  export {};
@@ -331,5 +331,53 @@ export async function uiKey(udid, keycode) {
331
331
  const s = await getSession(udid);
332
332
  await wdaCall(s.port, "POST", `/session/${s.sessionId}/wda/keys`, { value: [WDA_RETURN] });
333
333
  }
334
+ /**
335
+ * The W3C WebDriver element-reference key. `GET …/element/active` returns the
336
+ * focused element under this key (WDA also historically mirrors it as `ELEMENT`).
337
+ */
338
+ const W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
339
+ /**
340
+ * Extract the element id from a WDA active-element payload, accepting both the
341
+ * W3C key and the legacy `ELEMENT` alias. Pure + exported for unit testing the
342
+ * (fiddly) key handling without a live runner. Returns null when the shape has
343
+ * no usable id (nothing focused / non-element response).
344
+ */
345
+ export function parseActiveElementId(value) {
346
+ if (!value || typeof value !== "object")
347
+ return null;
348
+ const obj = value;
349
+ const id = obj[W3C_ELEMENT_KEY] ?? obj.ELEMENT;
350
+ return typeof id === "string" && id.length > 0 ? id : null;
351
+ }
352
+ /**
353
+ * Clear the currently-focused text field via WDA's native element clear — the
354
+ * same select-all+delete Appium's `clear()` performs, so it handles multiline
355
+ * and arbitrary cursor positions correctly.
356
+ *
357
+ * Why this exists: `uiText` posts to `/wda/keys`, which APPENDS to the focused
358
+ * field. Without a clear, re-typing a field (a retry, or a field the app
359
+ * pre-filled) accumulates text (e.g. `LOGIN_USERNAMELOGIN_USERNAME…`). The
360
+ * coordinate/vision typing path has no element uuid up front, so we resolve the
361
+ * focused element with `GET …/element/active`, then `POST …/element/:uuid/clear`.
362
+ *
363
+ * Best-effort by design: a non-editable focus (or any WDA hiccup) just returns
364
+ * false and the caller falls back to the prior append behavior — clearing is an
365
+ * improvement, never a hard precondition for typing. Returns true when a clear
366
+ * was actually issued.
367
+ */
368
+ export async function uiClearActiveField(udid) {
369
+ try {
370
+ const s = await getSession(udid);
371
+ const active = unwrap(await wdaCall(s.port, "GET", `/session/${s.sessionId}/element/active`));
372
+ const uuid = parseActiveElementId(active);
373
+ if (!uuid)
374
+ return false;
375
+ await wdaCall(s.port, "POST", `/session/${s.sessionId}/element/${uuid}/clear`, {});
376
+ return true;
377
+ }
378
+ catch {
379
+ return false;
380
+ }
381
+ }
334
382
  /** Re-export so a future ios.ts can drop the simctl HID constant. */
335
383
  export const HID_KEY_RETURN = 40;
@@ -46,7 +46,17 @@ export declare class ValidationError extends Error {
46
46
  hint?: string | undefined;
47
47
  constructor(message: string, valid_options: string[], hint?: string | undefined);
48
48
  }
49
- export declare function outputError(err: unknown, json: boolean): void;
49
+ /**
50
+ * Whether an error is worth nudging the user to report via `ish feedback`.
51
+ * Excludes user-actionable failures — usage/validation, auth (login),
52
+ * not-found, and usage-limit (upgrade) — so the hint stays high-signal and
53
+ * agents don't reflexively report their own input mistakes. Genuine faults
54
+ * (5xx, unexpected client errors, unknown throws) get the nudge.
55
+ */
56
+ export declare function shouldSuggestReport(err: unknown): boolean;
57
+ export declare function outputError(err: unknown, json: boolean, opts?: {
58
+ suggestReport?: boolean;
59
+ }): void;
50
60
  export declare function printTable(headers: string[], rows: string[][]): void;
51
61
  export declare function printKeyValue(obj: Record<string, unknown>, indent?: string): void;
52
62
  export declare function formatWorkspaceList(workspaces: Record<string, unknown>[], json: boolean): void;
@@ -562,8 +562,48 @@ function remapEntityName(message) {
562
562
  .replace(/\bAttachment not found\b/g, "Source not found")
563
563
  .replace(/\battachment not found\b/g, "source not found");
564
564
  }
565
- export function outputError(err, json) {
565
+ // Shown after a genuine fault so the operating agent (the CLI's primary user)
566
+ // knows it can hand the failure straight to the ish team. Kept short.
567
+ const REPORT_HINT_HUMAN = 'Looks like a bug? Report it: ish feedback "what you were trying to do" (add --health for setup/sim issues)';
568
+ const REPORT_HINT_JSON = 'ish feedback "<what you were trying to do>" — add --health for setup/sim issues';
569
+ /**
570
+ * Whether an error is worth nudging the user to report via `ish feedback`.
571
+ * Excludes user-actionable failures — usage/validation, auth (login),
572
+ * not-found, and usage-limit (upgrade) — so the hint stays high-signal and
573
+ * agents don't reflexively report their own input mistakes. Genuine faults
574
+ * (5xx, unexpected client errors, unknown throws) get the nudge.
575
+ */
576
+ export function shouldSuggestReport(err) {
577
+ if (err instanceof ValidationError)
578
+ return false;
579
+ if (err instanceof ApiError) {
580
+ if (err.status === 400 ||
581
+ err.status === 401 ||
582
+ err.status === 402 || // insufficient credits — buy credits, not a bug
583
+ err.status === 403 ||
584
+ err.status === 404 ||
585
+ err.status === 422) {
586
+ return false;
587
+ }
588
+ if (err.error_code === "usage_limit_reached")
589
+ return false;
590
+ return true;
591
+ }
592
+ if (err instanceof Error) {
593
+ const tagged = err;
594
+ if (err.name === "ValidationError")
595
+ return false;
596
+ if (tagged.error_code === "auth_failed")
597
+ return false;
598
+ if (tagged.error_kind === "ConfirmationRequired")
599
+ return false;
600
+ return true;
601
+ }
602
+ return true;
603
+ }
604
+ export function outputError(err, json, opts = {}) {
566
605
  const suggestions = suggestionsForError(err);
606
+ const report = opts.suggestReport === true;
567
607
  if (err instanceof ApiError) {
568
608
  // Surface backend-structured fields when present in the response body
569
609
  // (e.g. 422 errors return `errors: [{loc, msg, type, input, allowed_values}]`,
@@ -621,6 +661,7 @@ export function outputError(err, json) {
621
661
  ...(seededAliases && { seeded_but_not_dispatched_aliases: seededAliases }),
622
662
  ...(bodyErrors !== undefined && { errors: bodyErrors }),
623
663
  ...(mergedSuggestions.length > 0 && { suggestions: mergedSuggestions }),
664
+ ...(report && { report: REPORT_HINT_JSON }),
624
665
  }));
625
666
  }
626
667
  else {
@@ -724,6 +765,7 @@ export function outputError(err, json) {
724
765
  ...(seededIds && { seeded_but_not_dispatched_ids: seededIds }),
725
766
  ...(seededAliases && { seeded_but_not_dispatched_aliases: seededAliases }),
726
767
  ...(mergedSuggestions.length > 0 && { suggestions: mergedSuggestions }),
768
+ ...(report && { report: REPORT_HINT_JSON }),
727
769
  }));
728
770
  }
729
771
  else {
@@ -736,12 +778,24 @@ export function outputError(err, json) {
736
778
  }
737
779
  else {
738
780
  if (json) {
739
- console.error(JSON.stringify({ error: String(err), error_code: "unknown_error", retryable: false }));
781
+ console.error(JSON.stringify({
782
+ error: String(err),
783
+ error_code: "unknown_error",
784
+ retryable: false,
785
+ ...(report && { report: REPORT_HINT_JSON }),
786
+ }));
740
787
  }
741
788
  else {
742
789
  console.error(`Error: ${err}`);
743
790
  }
744
791
  }
792
+ // A single human-mode nudge for genuine faults (gated by the caller via
793
+ // shouldSuggestReport). The JSON branches above carry the same hint as a
794
+ // `report` field. ValidationError's own JSON branch intentionally omits it
795
+ // (usage errors aren't bugs); the caller never sets suggestReport there.
796
+ if (!json && report) {
797
+ console.error(` ${c.dim}→ ${REPORT_HINT_HUMAN}${c.reset}`);
798
+ }
745
799
  }
746
800
  // --- Entity-specific formatters (human mode) ---
747
801
  export function printTable(headers, rows) {
@@ -1143,6 +1143,14 @@ table, projection shapes, and the defensive null-handling rules.
1143
1143
  confirmed. The orphan-tunnel-on-startup-404 bug is fixed.
1144
1144
  - The \`Warning: Could not verify token (network error). Proceeding
1145
1145
  anyway.\` stderr line is gone on green runs.
1146
+ - On a **genuine fault** (uncategorized client error, 5xx/\`server\`,
1147
+ network, unknown throw) the error envelope adds a \`report\` field and
1148
+ human mode prints a \`→ Looks like a bug? Report it: …\` line. File it
1149
+ with \`ish feedback "what you were doing"\` (add \`--health\` for
1150
+ local/native-run bugs). The nudge is deliberately silent on
1151
+ user-actionable errors (usage, validation, auth, not-found,
1152
+ usage-limit), so when you see it, it's worth reporting. See
1153
+ guides/feedback.
1146
1154
 
1147
1155
  ## Common reshaping → use the CLI, not jq/python
1148
1156
 
@@ -1211,6 +1219,7 @@ ish <command> --help
1211
1219
  | | study, ask, token validity) — alias \`whoami\` | |
1212
1220
  | \`connect\` | Cloudflare tunnel exposing localhost | — |
1213
1221
  | \`upgrade\` | Self-update | — |
1222
+ | \`feedback\` | Report a bug / feature request / note to the ish team. \`--health\` attaches setup checks + local-sim logs. | guides/feedback |
1214
1223
 
1215
1224
  ## Discovering flags safely
1216
1225
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ishlabs/cli",
3
- "version": "0.27.1",
3
+ "version": "0.28.1",
4
4
  "description": "The command-line interface for ish",
5
5
  "type": "module",
6
6
  "bin": {