Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions openspec/changes/feedback-channels/tasks.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
## 1. Survey registry and payload schema

- [ ] 1.1 Restructure `packages/cli/src/survey/constants.ts` into a `SURVEYS` registry keyed by `rule` / `general` / `bug` (ids and question ids per design.md's table), keep a `RULE_SURVEY_ID` export, and point `survey/invite.ts` and the cadence calls at it; verify `pnpm --filter @taskless/cli typecheck` passes and `survey-invite.test.ts` / `survey-cadence.test.ts` still pass unchanged
- [ ] 1.2 Rewrite `packages/cli/src/schemas/feedback.ts` as a `z.discriminatedUnion("kind", …)` of three `z.strictObject` branches, each declaring `kind` as a required `z.literal(…)` with no default, exporting each branch schema; verify with new cases in `feedback-schema.test.ts`: missing `kind` names `kind`, a `general` payload with `ruleKind` names `ruleKind`, a `bug` payload without `expected` names `expected`, each branch's rendered JSON Schema lists `kind` as required with a single `const`, and every existing rule-schema case passes with `kind: "rule"` added
- [ ] 1.3 Make `buildSurveyResponse` map through `SURVEYS[input.kind]`; verify a `feedback-command.test.ts` case per kind asserts the `$survey_id` and the exact set of `$survey_response_<id>` keys
- [x] 1.1 Restructure `packages/cli/src/survey/constants.ts` into a `SURVEYS` registry keyed by `rule` / `general` / `bug` (ids and question ids per design.md's table), keep a `RULE_SURVEY_ID` export, and point `survey/invite.ts` and the cadence calls at it; verify `pnpm --filter @taskless/cli typecheck` passes and `survey-invite.test.ts` / `survey-cadence.test.ts` still pass unchanged
- [x] 1.2 Rewrite `packages/cli/src/schemas/feedback.ts` as a `z.discriminatedUnion("kind", …)` of three `z.strictObject` branches, each declaring `kind` as a required `z.literal(…)` with no default, exporting each branch schema; verify with new cases in `feedback-schema.test.ts`: missing `kind` names `kind`, a `general` payload with `ruleKind` names `ruleKind`, a `bug` payload without `expected` names `expected`, each branch's rendered JSON Schema lists `kind` as required with a single `const`, and every existing rule-schema case passes with `kind: "rule"` added
- [x] 1.3 Make `buildSurveyResponse` map through `SURVEYS[input.kind]`; verify a `feedback-command.test.ts` case per kind asserts the `$survey_id` and the exact set of `$survey_response_<id>` keys

## 2. `feedback send` behavior

Expand Down
30 changes: 18 additions & 12 deletions packages/cli/src/commands/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { inputSchema, type FeedbackInput } from "../schemas/feedback";
import { writeNextAsk } from "../survey/cadence";
import {
ANSWERED_INTERVAL_MS,
SURVEY_ID,
SURVEY_QUESTIONS,
RULE_SURVEY_ID,
SURVEYS,
} from "../survey/constants";
import { getTelemetry, isTelemetryEnabled } from "../telemetry";
import { type CLIErrorCode, writeJsonError } from "../types/errors";
Expand All @@ -28,17 +28,23 @@ const NOTHING_SENT =
/**
* 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.
* Exactly PostHog's contract: `$survey_id` of the survey the payload's `kind`
* selects, 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. A question with no
* payload key is the CLI's to answer, and is skipped here.
*/
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];
const survey = SURVEYS[input.kind];
// The branches share no key type, so the payload is read as a plain record;
// the schema has already decided which keys it may hold.
const answers = input as Readonly<Record<string, string | undefined>>;
const properties: Record<string, string> = { $survey_id: survey.id };
for (const { key, id } of survey.questions) {
if (key === undefined) continue;
const answer = answers[key];
if (answer !== undefined) properties[`$survey_response_${id}`] = answer;
}
return properties;
Expand All @@ -63,8 +69,8 @@ const dismissCommand = defineCommand({
return;
}
const telemetry = await getTelemetry(cwd);
telemetry.capture("survey dismissed", { $survey_id: SURVEY_ID });
await writeNextAsk(SURVEY_ID, Date.now() + ANSWERED_INTERVAL_MS);
telemetry.capture("survey dismissed", { $survey_id: RULE_SURVEY_ID });
await writeNextAsk(RULE_SURVEY_ID, Date.now() + ANSWERED_INTERVAL_MS);
console.log("Thanks. Taskless will not ask again for a while.");
},
});
Expand Down Expand Up @@ -133,7 +139,7 @@ const sendCommand = defineCommand({

const telemetry = await getTelemetry(cwd);
telemetry.capture("survey sent", buildSurveyResponse(input));
await writeNextAsk(SURVEY_ID, Date.now() + ANSWERED_INTERVAL_MS);
await writeNextAsk(RULE_SURVEY_ID, Date.now() + ANSWERED_INTERVAL_MS);
Comment thread
theCodeDrift marked this conversation as resolved.
// 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.");
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/prompts/recipes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from "../util/invocation";
import { inputSchema as ruleCreateInputSchema } from "../schemas/rules-create";
import { inputSchema as ruleImproveInputSchema } from "../schemas/rules-improve";
import { inputSchema as feedbackInputSchema } from "../schemas/feedback";
import { ruleInputSchema } from "../schemas/feedback";
import {
AST_GREP_VERSION,
VALE_VERSION,
Expand Down Expand Up @@ -80,7 +80,7 @@ export function canonicalRecipeTopics(): string[] {
const TOPIC_INPUT_SCHEMAS: Record<string, z.ZodType> = {
"create-remote-rule": ruleCreateInputSchema,
"improve-rule": ruleImproveInputSchema,
feedback: feedbackInputSchema,
feedback: ruleInputSchema,
};

/** Agent-fill marker used when the caller does not supply a real value. */
Expand Down
131 changes: 93 additions & 38 deletions packages/cli/src/schemas/feedback.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,50 @@
import { z } from "zod";

import { COMPLETED_CHOICES } from "../survey/constants";
import { COMPLETED_CHOICES, FEEDBACK_KINDS } 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
* Human keys only. The map to each survey's question identifiers lives in
* `src/survey/constants.ts`, and a payload never carries a `$survey_*` key.
*
* `kind` is the discriminator: a required literal on every branch, never
* defaulted. Each branch is strict, so a key belonging to another kind fails
* naming that key instead of being dropped on the way to the event. Each
* recipe embeds only its own branch, so the agent reading `bug-report` never
* sees the rule survey's keys.
*/
export const inputSchema = z.object({
ruleKind: z

/** An answer the payload must carry. */
function requiredAnswer(key: string, description: string) {
return z
.string()
.trim()
.min(1, "ruleKind must be a non-empty string")
.describe(
"The kind of rule the user was trying to create: the engine and what the rule was for. `none (onboarding)` on the onboarding path"
),
.min(1, `${key} must be a non-empty string`)
.describe(description);
}

/**
* An answer the payload may leave out. Blank is not "unanswered": an agent
* that wrote the key meant to answer, and omitting the key is how a question
* is left unanswered.
*/
function optionalAnswer(key: string, description: string) {
return z
.string()
.trim()
.min(1, `${key}, when present, must be non-empty`)
.optional()
.describe(description);
}

/** The invited survey about rule authoring and onboarding. */
export const ruleInputSchema = z.strictObject({
kind: z.literal("rule").describe("Always `rule` for this survey"),
ruleKind: requiredAnswer(
"ruleKind",
"The kind of rule the user was trying to create: the engine and what the rule was for. `none (onboarding)` on the onboarding path"
),
verbatim: z
.string()
.trim()
Expand All @@ -32,36 +61,62 @@ export const inputSchema = z.object({
.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"
),
agents: z
.string()
.trim()
.min(1, "agents, when present, must be non-empty")
.optional()
.describe(
"The open-source, publicly available agent(s) or framework(s) the user is working through, including the one running this recipe"
),
mostValuableRule: z
.string()
.trim()
.min(1, "mostValuableRule, when present, must be non-empty")
.optional()
.describe(
"Of the rules created so far, the one creating the most value for the team and why. Omit when there are no rules or you cannot tell"
),
workedWell: optionalAnswer(
"workedWell",
"Steps of the interaction with Taskless that worked well"
),
needsImprovement: optionalAnswer(
"needsImprovement",
"Steps of the interaction with Taskless that could use improvement"
),
agents: optionalAnswer(
"agents",
"The open-source, publicly available agent(s) or framework(s) the user is working through, including the one running this recipe"
),
mostValuableRule: optionalAnswer(
"mostValuableRule",
"Of the rules created so far, the one creating the most value for the team and why. Omit when there are no rules or you cannot tell"
),
});

/** Feedback the user chose to give about Taskless. */
export const generalInputSchema = z.strictObject({
kind: z.literal("general").describe("Always `general` for this survey"),
verbatim: requiredAnswer(
"verbatim",
"The user's feedback, in their own words, unedited"
),
context: optionalAnswer(
"context",
"What led to the feedback, from what you observed in the session, as the user approved it. Omit when there is nothing to add"
),
});

/**
* A bug report. The survey's version-information question has no key here:
* the CLI answers it itself, so the agent can neither get it wrong nor leave
* it out.
*/
export const bugInputSchema = z.strictObject({
kind: z.literal("bug").describe("Always `bug` for this survey"),
summary: requiredAnswer("summary", "A one-line summary of the bug"),
trying: requiredAnswer("trying", "What the user was trying to do"),
expected: requiredAnswer("expected", "The result the user expected"),
actual: requiredAnswer("actual", "The result the user actually got"),
context: optionalAnswer(
"context",
"Anything else that would help fix it: the command run, the error text, the steps to reproduce. Omit when there is nothing to add"
),
});

export const inputSchema = z.discriminatedUnion(
"kind",
[ruleInputSchema, generalInputSchema, bugInputSchema],
{
// Zod's own message for a missing or unknown discriminator is "Invalid
// input", which tells the agent nothing about what to write.
error: `kind must be one of ${FEEDBACK_KINDS.map((kind) => `"${kind}"`).join(", ")}`,
}
);

export type FeedbackInput = z.infer<typeof inputSchema>;
Loading
Loading