-
Notifications
You must be signed in to change notification settings - Fork 0
feat(feedback): add feedback send/dismiss, the survey constants, and the cadence store #343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
thecodedrift
merged 2 commits into
survey/01-ignore-scratch-files
from
survey/02-feedback-command
Sep 18, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| import { readFile } from "node:fs/promises"; | ||
| import { resolve } from "node:path"; | ||
|
|
||
| import { defineCommand } from "citty"; | ||
| import { ZodError } from "zod"; | ||
|
|
||
| import { inputSchema, type FeedbackInput } from "../schemas/feedback"; | ||
| import { writeNextAsk } from "../survey/cadence"; | ||
| import { | ||
| ANSWERED_INTERVAL_MS, | ||
| SURVEY_ID, | ||
| SURVEY_QUESTIONS, | ||
| } from "../survey/constants"; | ||
| import { getTelemetry, isTelemetryEnabled } from "../telemetry"; | ||
| import { type CLIErrorCode, writeJsonError } from "../types/errors"; | ||
| import { CLIError } from "../util/cli-error"; | ||
|
|
||
| /** | ||
| * What both verbs say under the telemetry opt-out. An agent should never | ||
| * reach them in that state, because the invite is not served in it, so this | ||
| * is a defensive line rather than a path the recipe describes. Exit zero: the | ||
| * user asked for nothing to be sent, and nothing was. | ||
| */ | ||
| const NOTHING_SENT = | ||
| "Telemetry is disabled, so no feedback was sent. Nothing else to do."; | ||
|
|
||
| /** | ||
| * The `survey sent` properties for a validated payload. | ||
| * | ||
| * Exactly PostHog's contract: `$survey_id` and one `$survey_response_<id>` | ||
| * per answered question. An optional question left blank is absent rather | ||
| * than sent as an empty string, so the responses view shows a gap and not an | ||
| * empty answer. | ||
| */ | ||
| export function buildSurveyResponse( | ||
| input: FeedbackInput | ||
| ): Record<string, string> { | ||
| const properties: Record<string, string> = { $survey_id: SURVEY_ID }; | ||
| for (const { key, id } of SURVEY_QUESTIONS) { | ||
| const answer = input[key]; | ||
| if (answer !== undefined) properties[`$survey_response_${id}`] = answer; | ||
| } | ||
| return properties; | ||
| } | ||
|
|
||
| const dismissCommand = defineCommand({ | ||
| meta: { | ||
| name: "dismiss", | ||
| description: "Record that the user declined the feedback survey", | ||
| }, | ||
| args: { | ||
| dir: { | ||
| type: "string", | ||
| alias: "d", | ||
| description: "Working directory", | ||
| }, | ||
| }, | ||
| async run({ args }) { | ||
| const cwd = resolve(args.dir ?? process.cwd()); | ||
| if (!isTelemetryEnabled()) { | ||
| console.log(NOTHING_SENT); | ||
| return; | ||
| } | ||
| const telemetry = await getTelemetry(cwd); | ||
| telemetry.capture("survey dismissed", { $survey_id: SURVEY_ID }); | ||
| await writeNextAsk(SURVEY_ID, Date.now() + ANSWERED_INTERVAL_MS); | ||
| console.log("Thanks. Taskless will not ask again for a while."); | ||
| }, | ||
| }); | ||
|
|
||
| const sendCommand = defineCommand({ | ||
| meta: { | ||
| name: "send", | ||
| description: | ||
| "Send a completed feedback survey (use --from to specify the input file)", | ||
| }, | ||
| args: { | ||
| dir: { | ||
| type: "string", | ||
| alias: "d", | ||
| description: "Working directory", | ||
| }, | ||
| from: { | ||
| type: "string", | ||
| description: | ||
| "Path to a JSON file containing the feedback payload (required). Example: --from .taskless/.tmp-feedback.json", | ||
| }, | ||
| json: { | ||
| type: "boolean", | ||
| description: | ||
| "On error, write the standardized { ok:false, code, message } envelope to stdout instead of human text on stderr", | ||
| default: false, | ||
| }, | ||
| }, | ||
| async run({ args }) { | ||
| const cwd = resolve(args.dir ?? process.cwd()); | ||
|
|
||
| /** Emit an error and exit, respecting --json mode */ | ||
| function fail(message: string, code: CLIErrorCode): never { | ||
| if (args.json) { | ||
| writeJsonError(code, message); | ||
| } else { | ||
| console.error(`Error: ${message}`); | ||
| } | ||
| process.exitCode = 1; | ||
| throw new CLIError(message, code, { reported: true }); | ||
| } | ||
|
|
||
| // Validate before checking the opt-out: a malformed payload is wrong | ||
| // whether or not anything would be sent, and the agent should hear so. | ||
| if (!args.from) { | ||
| fail( | ||
| "--from is required. Provide a path to a JSON file.\n Example: taskless feedback send --from .taskless/.tmp-feedback.json", | ||
| "INVALID_INPUT" | ||
| ); | ||
| } | ||
|
|
||
| const filePath = resolve(cwd, args.from); | ||
| let fileContent: string; | ||
| try { | ||
| fileContent = await readFile(filePath, "utf8"); | ||
| } catch { | ||
| fail(`Could not read file "${args.from}".`, "INVALID_INPUT"); | ||
| } | ||
|
|
||
| let rawJson: unknown; | ||
| try { | ||
| rawJson = JSON.parse(fileContent) as unknown; | ||
| } catch { | ||
| fail(`"${args.from}" is not valid JSON.`, "INVALID_INPUT"); | ||
| } | ||
|
|
||
| let input: FeedbackInput; | ||
| try { | ||
| input = inputSchema.parse(rawJson); | ||
| } catch (error) { | ||
| if (error instanceof ZodError) { | ||
| fail( | ||
| `Invalid input: ${error.issues | ||
| .map( | ||
| (issue) => | ||
| `${issue.path.join(".") || "payload"}: ${issue.message}` | ||
| ) | ||
| .join(", ")}`, | ||
| "INVALID_INPUT" | ||
| ); | ||
| } | ||
| fail( | ||
| error instanceof Error ? error.message : String(error), | ||
| "INVALID_INPUT" | ||
| ); | ||
| } | ||
|
|
||
| if (!isTelemetryEnabled()) { | ||
| console.log(NOTHING_SENT); | ||
| return; | ||
| } | ||
|
|
||
| const telemetry = await getTelemetry(cwd); | ||
| telemetry.capture("survey sent", buildSurveyResponse(input)); | ||
| await writeNextAsk(SURVEY_ID, Date.now() + ANSWERED_INTERVAL_MS); | ||
| // The input file is left where it is, like `rule create --from`; the | ||
| // recipe's clean-up step deletes it, and `/.tmp-*` is ignored regardless. | ||
| console.log("Feedback sent. Thank you."); | ||
| }, | ||
| }); | ||
|
|
||
| /** | ||
| * Reached only through the survey invite. Deliberately absent from the | ||
| * `taskless agent` index (see `UNLISTED_COMMANDS` there): listing it would | ||
| * invite an agent to run it unprompted. When a general feedback channel | ||
| * exists, this surface folds into it. | ||
| */ | ||
| export const feedbackCommand = defineCommand({ | ||
| meta: { | ||
| name: "feedback", | ||
| description: "Send or dismiss the Taskless feedback survey", | ||
| }, | ||
| subCommands: { | ||
| dismiss: dismissCommand, | ||
| send: sendCommand, | ||
| }, | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,7 @@ export const SUBCOMMAND_NAMES = [ | |
| "check", | ||
| "demo", | ||
| "detect", | ||
| "feedback", | ||
| "info", | ||
| "init", | ||
| "onboard", | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { z } from "zod"; | ||
|
|
||
| import { COMPLETED_CHOICES } from "../survey/constants"; | ||
|
|
||
| /** | ||
| * Input schema for `taskless feedback send --from` JSON file. | ||
| * | ||
| * Human keys only. The map to the survey's question identifiers lives in | ||
| * `src/survey/constants.ts`, and a payload never carries a `$survey_*` key. | ||
| */ | ||
| export const inputSchema = z.object({ | ||
| verbatim: z | ||
| .string() | ||
| .trim() | ||
| .min(1, "verbatim must be the user's own words, non-empty") | ||
| .describe("The user's reply, in their own words, unedited"), | ||
| goal: z | ||
| .string() | ||
| .trim() | ||
| .min(1, "goal must be a non-empty string") | ||
| .describe("What the user was trying to accomplish, in your words"), | ||
| completed: z | ||
| .enum(COMPLETED_CHOICES, { | ||
| error: `completed must be one of ${COMPLETED_CHOICES.map((choice) => `"${choice}"`).join(", ")}`, | ||
| }) | ||
| .describe( | ||
| "Whether the user completed the task, in your opinion. Success is binary; use Unknown when you cannot tell" | ||
| ), | ||
| workedWell: z | ||
| .string() | ||
| .trim() | ||
| .min(1, "workedWell, when present, must be non-empty") | ||
| .optional() | ||
| .describe("Steps of the interaction with Taskless that worked well"), | ||
| needsImprovement: z | ||
| .string() | ||
| .trim() | ||
| .min(1, "needsImprovement, when present, must be non-empty") | ||
| .optional() | ||
| .describe( | ||
| "Steps of the interaction with Taskless that could use improvement" | ||
| ), | ||
| }); | ||
|
|
||
| export type FeedbackInput = z.infer<typeof inputSchema>; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import { mkdir, readFile, writeFile } from "node:fs/promises"; | ||
| import { join } from "node:path"; | ||
|
|
||
| import { getConfigDirectory } from "../auth/token"; | ||
|
|
||
| const NEXT_ASK_FILE = "next_ask"; | ||
|
|
||
| /** | ||
| * Where a survey's cadence lives: one file per survey under the same XDG | ||
| * config directory that holds the anonymous telemetry id. | ||
| * | ||
| * Keyed by survey rather than by CLI version. A directory per release would | ||
| * grow without bound, and an upgrade should not reset the cadence: a newer | ||
| * CLI reads the same file and may not ask right away, which is fine. A new | ||
| * survey is a new id and therefore a new ask. Per-release segmentation still | ||
| * works because `cliVersion` rides on every capture. | ||
| */ | ||
| export function nextAskPath(surveyId: string): string { | ||
| return join(getConfigDirectory(), "surveys", surveyId, NEXT_ASK_FILE); | ||
| } | ||
|
|
||
| /** | ||
| * The earliest time the next invite may be served, as epoch milliseconds, or | ||
| * `undefined` when the file is absent or not a number. Both read as "ask | ||
| * now"; the write that follows repairs a corrupt file. | ||
| */ | ||
| export async function readNextAsk( | ||
| surveyId: string | ||
| ): Promise<number | undefined> { | ||
| let content: string; | ||
| try { | ||
| content = await readFile(nextAskPath(surveyId), "utf8"); | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| const value = Number(content.trim()); | ||
| return Number.isFinite(value) ? value : undefined; | ||
| } | ||
|
|
||
| /** Record when the next invite may be served. Best-effort, like the anonymous id. */ | ||
| export async function writeNextAsk( | ||
| surveyId: string, | ||
| at: number | ||
| ): Promise<void> { | ||
| const path = nextAskPath(surveyId); | ||
| try { | ||
| await mkdir(join(path, ".."), { recursive: true }); | ||
| await writeFile(path, String(Math.trunc(at)), "utf8"); | ||
| } catch { | ||
| // A cadence that could not be written means the invite may be served | ||
| // again sooner than intended, which is the cheaper failure. | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.