diff --git a/.changeset/qualitative-survey-feedback.md b/.changeset/qualitative-survey-feedback.md index b6bbb8d4..e882a27c 100644 --- a/.changeset/qualitative-survey-feedback.md +++ b/.changeset/qualitative-survey-feedback.md @@ -7,3 +7,5 @@ A `feedback` subcommand joins the CLI, reached only through the survey invite a served recipe carries and so absent from the `taskless agent` index: `feedback send --from ` validates a human-keyed payload (`verbatim`, `goal`, `completed` as `Yes`/`No`/`Unknown`, optional `workedWell` and `needsImprovement`), maps it to the PostHog survey's question ids, and captures `survey sent`; `feedback dismiss` captures `survey dismissed`. Both hold the next invite off for 20 days and, under the telemetry opt-out, say nothing was sent and exit 0. Two recipes back it: `taskless agent feedback` tells the agent it is the survey's respondent (the user's reply verbatim, the rest from its own account of the session) and embeds the payload schema; `feedback-invite` is the fragment a served recipe carries, rendered header-less. + +The four recipes where an agent-driven session most often goes wrong (`onboard`, `create-sg-rule`, `create-vale-rule`, `create-remote-rule`) now end with a short feedback invite when telemetry is on, the run is not in CI, and the survey's `next_ask` has passed. Serving it captures `survey shown` and holds the next invite off for 10 days; `taskless onboard` and `taskless agent onboard` both carry it. The `@taskless/cli/prompts` export never does. `DO_NOT_TRACK=1`, `TASKLESS_TELEMETRY_DISABLED=1`, or `CI=true` means no invite is served. diff --git a/openspec/changes/qualitative-survey-feedback/.openspec.yaml b/openspec/changes/archive/2026-09-17-qualitative-survey-feedback/.openspec.yaml similarity index 100% rename from openspec/changes/qualitative-survey-feedback/.openspec.yaml rename to openspec/changes/archive/2026-09-17-qualitative-survey-feedback/.openspec.yaml diff --git a/openspec/changes/qualitative-survey-feedback/design.md b/openspec/changes/archive/2026-09-17-qualitative-survey-feedback/design.md similarity index 100% rename from openspec/changes/qualitative-survey-feedback/design.md rename to openspec/changes/archive/2026-09-17-qualitative-survey-feedback/design.md diff --git a/openspec/changes/qualitative-survey-feedback/proposal.md b/openspec/changes/archive/2026-09-17-qualitative-survey-feedback/proposal.md similarity index 100% rename from openspec/changes/qualitative-survey-feedback/proposal.md rename to openspec/changes/archive/2026-09-17-qualitative-survey-feedback/proposal.md diff --git a/openspec/changes/qualitative-survey-feedback/specs/analytics/spec.md b/openspec/changes/archive/2026-09-17-qualitative-survey-feedback/specs/analytics/spec.md similarity index 100% rename from openspec/changes/qualitative-survey-feedback/specs/analytics/spec.md rename to openspec/changes/archive/2026-09-17-qualitative-survey-feedback/specs/analytics/spec.md diff --git a/openspec/changes/qualitative-survey-feedback/specs/cli-feedback-survey/spec.md b/openspec/changes/archive/2026-09-17-qualitative-survey-feedback/specs/cli-feedback-survey/spec.md similarity index 100% rename from openspec/changes/qualitative-survey-feedback/specs/cli-feedback-survey/spec.md rename to openspec/changes/archive/2026-09-17-qualitative-survey-feedback/specs/cli-feedback-survey/spec.md diff --git a/openspec/changes/qualitative-survey-feedback/specs/cli-taskless-bootstrap/spec.md b/openspec/changes/archive/2026-09-17-qualitative-survey-feedback/specs/cli-taskless-bootstrap/spec.md similarity index 100% rename from openspec/changes/qualitative-survey-feedback/specs/cli-taskless-bootstrap/spec.md rename to openspec/changes/archive/2026-09-17-qualitative-survey-feedback/specs/cli-taskless-bootstrap/spec.md diff --git a/openspec/changes/qualitative-survey-feedback/specs/cli/spec.md b/openspec/changes/archive/2026-09-17-qualitative-survey-feedback/specs/cli/spec.md similarity index 100% rename from openspec/changes/qualitative-survey-feedback/specs/cli/spec.md rename to openspec/changes/archive/2026-09-17-qualitative-survey-feedback/specs/cli/spec.md diff --git a/openspec/changes/qualitative-survey-feedback/tasks.md b/openspec/changes/archive/2026-09-17-qualitative-survey-feedback/tasks.md similarity index 76% rename from openspec/changes/qualitative-survey-feedback/tasks.md rename to openspec/changes/archive/2026-09-17-qualitative-survey-feedback/tasks.md index df1f9a39..35280190 100644 --- a/openspec/changes/qualitative-survey-feedback/tasks.md +++ b/openspec/changes/archive/2026-09-17-qualitative-survey-feedback/tasks.md @@ -1,33 +1,33 @@ ## 1. Slice 1: migration 0007 ignores scratch files (bottom of the stack) -- [ ] 1.1 Add `packages/cli/src/filesystem/migrations/0007-ignore-scratch-files.ts` calling `addToGitignore(cwd, ["/.tmp-*"])`, with a header comment saying why `0001` is not edited; register it as `"7"` in `migrate.ts`. Verify `git diff` touches no file under `migrations/` other than the new one. -- [ ] 1.2 Add tests: a version-6 scaffold gains `/.tmp-*` and records 7; a fresh scaffold ends with `.env.local.json`, `/sgconfig.yml`, `/.tmp-*`; running 7 twice leaves one line. Verify with `pnpm --filter @taskless/cli test -- migrate`. -- [ ] 1.3 Fix any test that pins the max scaffold version or the exact `.gitignore` contents (`check-gitignore.test.ts`, `migrate-*.test.ts`, `install-state.test.ts`). Verify `pnpm --filter @taskless/cli test` passes. -- [ ] 1.4 Add the single changeset (`patch`, `@taskless/cli`) describing the ignore line, with a body that says the scaffold's own `version` field carries the compatibility signal. Verify `.changeset/*.md` exists and `pnpm changeset status` lists it. -- [ ] 1.5 Run `pnpm typecheck` and `pnpm lint`; open PR 1 against `main`. +- [x] 1.1 Add `packages/cli/src/filesystem/migrations/0007-ignore-scratch-files.ts` calling `addToGitignore(cwd, ["/.tmp-*"])`, with a header comment saying why `0001` is not edited; register it as `"7"` in `migrate.ts`. Verify `git diff` touches no file under `migrations/` other than the new one. +- [x] 1.2 Add tests: a version-6 scaffold gains `/.tmp-*` and records 7; a fresh scaffold ends with `.env.local.json`, `/sgconfig.yml`, `/.tmp-*`; running 7 twice leaves one line. Verify with `pnpm --filter @taskless/cli test -- migrate`. +- [x] 1.3 Fix any test that pins the max scaffold version or the exact `.gitignore` contents (`check-gitignore.test.ts`, `migrate-*.test.ts`, `install-state.test.ts`). Verify `pnpm --filter @taskless/cli test` passes. +- [x] 1.4 Add the single changeset (`patch`, `@taskless/cli`) describing the ignore line, with a body that says the scaffold's own `version` field carries the compatibility signal. Verify `.changeset/*.md` exists and `pnpm changeset status` lists it. +- [x] 1.5 Run `pnpm typecheck` and `pnpm lint`; open PR 1 against `main`. ## 2. Slice 2: survey constants, cadence store, and the `feedback` subcommand -- [ ] 2.1 Add `packages/cli/src/survey/constants.ts` with the survey id, the five `{ key, id, question }` entries (Q3 id `6ebdfabb-3575-49aa-857c-47b6bbfdebc8`), the surveyed-topic list (used only by the gate), and the 10-day / 20-day intervals. Verify a test asserts the five question ids against the values in design.md. -- [ ] 2.2 Add `packages/cli/src/survey/cadence.ts`: read `next_ask` (missing or unparseable → `undefined`), write `next_ask` under `getConfigDirectory()/surveys//`. Verify tests with `XDG_CONFIG_HOME` pointed at a temp directory cover absent, valid, corrupt, and that a different survey id reads its own file. -- [ ] 2.3 Add `packages/cli/src/schemas/feedback.ts` (Zod: `verbatim`, `goal`, `completed` as `"Yes" | "No" | "Unknown"`, optional `workedWell` / `needsImprovement`). Verify tests reject `completed: "partially"` and a missing `verbatim`, each naming the field. -- [ ] 2.4 Export the telemetry "enabled" predicate from `telemetry.ts` rather than duplicating it, and a `TelemetryClient`-typed way for a command to know it holds the no-op client. Verify `telemetry.test.ts` still passes. -- [ ] 2.5 Add `packages/cli/src/commands/feedback.ts` with `dismiss` and `send --from`; `send` maps keys to `$survey_response_`, adds `$survey_id`, and omits blank optionals; both advance `next_ask` by 20 days and print a one-line "nothing sent" under disabled telemetry with exit 0. Verify tests spawn the built CLI with telemetry stubbed and assert the captured payload shape, the `INVALID_INPUT` path, the input file surviving, and no `.taskless/` bootstrap. -- [ ] 2.6 Add `feedback` to `SUBCOMMAND_NAMES`, register it in `index.ts`, add it to `UNLISTED_COMMANDS` in `agent.ts`. Verify `pnpm typecheck` passes and `taskless agent` output does not list `feedback`. -- [ ] 2.7 Extend the changeset on the bottom branch with the new verbs. Run `pnpm typecheck` and `pnpm lint`; open PR 2 against PR 1's branch. +- [x] 2.1 Add `packages/cli/src/survey/constants.ts` with the survey id, the five `{ key, id, question }` entries (Q3 id `6ebdfabb-3575-49aa-857c-47b6bbfdebc8`), the surveyed-topic list (used only by the gate), and the 10-day / 20-day intervals. Verify a test asserts the five question ids against the values in design.md. +- [x] 2.2 Add `packages/cli/src/survey/cadence.ts`: read `next_ask` (missing or unparseable → `undefined`), write `next_ask` under `getConfigDirectory()/surveys//`. Verify tests with `XDG_CONFIG_HOME` pointed at a temp directory cover absent, valid, corrupt, and that a different survey id reads its own file. +- [x] 2.3 Add `packages/cli/src/schemas/feedback.ts` (Zod: `verbatim`, `goal`, `completed` as `"Yes" | "No" | "Unknown"`, optional `workedWell` / `needsImprovement`). Verify tests reject `completed: "partially"` and a missing `verbatim`, each naming the field. +- [x] 2.4 Export the telemetry "enabled" predicate from `telemetry.ts` rather than duplicating it, and a `TelemetryClient`-typed way for a command to know it holds the no-op client. Verify `telemetry.test.ts` still passes. +- [x] 2.5 Add `packages/cli/src/commands/feedback.ts` with `dismiss` and `send --from`; `send` maps keys to `$survey_response_`, adds `$survey_id`, and omits blank optionals; both advance `next_ask` by 20 days and print a one-line "nothing sent" under disabled telemetry with exit 0. Verify tests spawn the built CLI with telemetry stubbed and assert the captured payload shape, the `INVALID_INPUT` path, the input file surviving, and no `.taskless/` bootstrap. +- [x] 2.6 Add `feedback` to `SUBCOMMAND_NAMES`, register it in `index.ts`, add it to `UNLISTED_COMMANDS` in `agent.ts`. Verify `pnpm typecheck` passes and `taskless agent` output does not list `feedback`. +- [x] 2.7 Extend the changeset on the bottom branch with the new verbs. Run `pnpm typecheck` and `pnpm lint`; open PR 2 against PR 1's branch. ## 3. Slice 3: the `feedback` and `feedback-invite` recipes -- [ ] 3.1 Write `packages/cli/src/agent/feedback.md` (`# Topic: feedback … topic v1`): the agent is the respondent, one verbatim answer from the user, the rest from observation, `%(INPUT_SCHEMA)s`, write `.taskless/.tmp-feedback.json`, run `%(TASKLESS_CLI)s feedback send --from …`, clean up. Register `feedback` in `TOPIC_INPUT_SCHEMAS`. Verify `pnpm build && pnpm cli agent feedback` prints the header and the JSON Schema. -- [ ] 3.2 Write `packages/cli/src/agent/feedback-invite.md` (`# Topic: feedback-invite … topic v1`) with the exact ask sentence from design.md, the dismissal rule (`skip`, silence, unrelated → `%(TASKLESS_CLI)s feedback dismiss`), the feedback path (`%(TASKLESS_CLI)s agent feedback`), and ask-once. Verify `pnpm cli agent feedback-invite` renders with no unsubstituted marker. -- [ ] 3.3 Add both topics to `INTERNAL_TOPICS`. Verify `recipe-cross-references.test.ts`, the topic-membership test, and `pnpm cli check` (Vale over the new prose) all pass. -- [ ] 3.4 Extend the changeset. Run `pnpm typecheck` and `pnpm lint`; open PR 3 against PR 2's branch. +- [x] 3.1 Write `packages/cli/src/agent/feedback.md` (`# Topic: feedback … topic v1`): the agent is the respondent, one verbatim answer from the user, the rest from observation, `%(INPUT_SCHEMA)s`, write `.taskless/.tmp-feedback.json`, run `%(TASKLESS_CLI)s feedback send --from …`, clean up. Register `feedback` in `TOPIC_INPUT_SCHEMAS`. Verify `pnpm build && pnpm cli agent feedback` prints the header and the JSON Schema. +- [x] 3.2 Write `packages/cli/src/agent/feedback-invite.md` (`# Topic: feedback-invite … topic v1`) with the exact ask sentence from design.md, the dismissal rule (`skip`, silence, unrelated → `%(TASKLESS_CLI)s feedback dismiss`), the feedback path (`%(TASKLESS_CLI)s agent feedback`), and ask-once. Verify `pnpm cli agent feedback-invite` renders with no unsubstituted marker. +- [x] 3.3 Add both topics to `INTERNAL_TOPICS`. Verify `recipe-cross-references.test.ts`, the topic-membership test, and `pnpm cli check` (Vale over the new prose) all pass. +- [x] 3.4 Extend the changeset. Run `pnpm typecheck` and `pnpm lint`; open PR 3 against PR 2's branch. ## 4. Slice 4: the gate and injection (tip of the stack) -- [ ] 4.1 Add `packages/cli/src/survey/invite.ts`: `withSurveyInvite({ recipe, topic, invocation, cwd })` evaluates the gate (telemetry enabled, `CI` not `"true"`/`"1"`, topic surveyed, `next_ask` passed), and when open captures `survey shown` with `$survey_id`, writes `next_ask` + 10 days, and returns recipe + rendered `feedback-invite` (`header: false`). Verify unit tests cover every gate branch in the spec, including the corrupt-file repair. -- [ ] 4.2 Call it from `agent.ts` (after a successful `getRecipe`) and from `onboard.ts` (recipe-serving path only; not `--mark-complete`, not the already-onboarded early return). Verify a test with telemetry stubbed on and `XDG_CONFIG_HOME` in a temp directory shows the invite on `agent create-sg-rule`, `agent onboard`, and `onboard`, and not on `agent check`. -- [ ] 4.3 Confirm the untouched paths: `prompts.test.ts` byte-parity passes as-is, and `getPrompt()` output contains no invite under any option. Verify `pnpm --filter @taskless/cli test` is green. -- [ ] 4.4 Add a comment in `survey/constants.ts` recording that `survey shown` means "served", not "surfaced", and that the funnel reads shown ≫ sent by design. -- [ ] 4.5 Extend the changeset with the invite. Run `pnpm typecheck` and `pnpm lint`; open PR 4 against PR 3's branch. -- [ ] 4.6 After the invite is verified end to end against the real survey (one `survey sent` visible in PostHog under the survey's responses with labelled columns), archive the change on this tip: `pnpm openspec archive qualitative-survey-feedback -y`, checking every prior scenario in the three modified specs survives per the pre-archive procedure in CLAUDE.md. +- [x] 4.1 Add `packages/cli/src/survey/invite.ts`: `withSurveyInvite({ recipe, topic, invocation, cwd })` evaluates the gate (telemetry enabled, `CI` not `"true"`/`"1"`, topic surveyed, `next_ask` passed), and when open captures `survey shown` with `$survey_id`, writes `next_ask` + 10 days, and returns recipe + rendered `feedback-invite` (`header: false`). Verify unit tests cover every gate branch in the spec, including the corrupt-file repair. +- [x] 4.2 Call it from `agent.ts` (after a successful `getRecipe`) and from `onboard.ts` (recipe-serving path only; not `--mark-complete`, not the already-onboarded early return). Verify a test with telemetry stubbed on and `XDG_CONFIG_HOME` in a temp directory shows the invite on `agent create-sg-rule`, `agent onboard`, and `onboard`, and not on `agent check`. +- [x] 4.3 Confirm the untouched paths: `prompts.test.ts` byte-parity passes as-is, and `getPrompt()` output contains no invite under any option. Verify `pnpm --filter @taskless/cli test` is green. +- [x] 4.4 Add a comment in `survey/constants.ts` recording that `survey shown` means "served", not "surfaced", and that the funnel reads shown ≫ sent by design. +- [x] 4.5 Extend the changeset with the invite. Run `pnpm typecheck` and `pnpm lint`; open PR 4 against PR 3's branch. +- [x] 4.6 After the invite is verified end to end against the real survey (one `survey sent` visible in PostHog under the survey's responses with labelled columns), archive the change on this tip: `pnpm openspec archive qualitative-survey-feedback -y`, checking every prior scenario in the three modified specs survives per the pre-archive procedure in CLAUDE.md. diff --git a/openspec/specs/analytics/spec.md b/openspec/specs/analytics/spec.md index 07ec1004..d1234c87 100644 --- a/openspec/specs/analytics/spec.md +++ b/openspec/specs/analytics/spec.md @@ -177,6 +177,16 @@ Commands that carry no concrete state beyond the invocation (e.g. `info`, (`cli_`, `cli__completed`, `help_index`, `help_`, `help_unknown`) SHALL be removed in this release; there is no dual-emit window. +The survey events are the one exception to the prefix. `survey shown`, +`survey dismissed`, and `survey sent` are PostHog's own event literals for a +custom survey, and their `$survey_id` and `$survey_response_` +keys are PostHog's property contract; the CLI SHALL emit them under those +exact names and SHALL add no survey-specific property of its own. They go +through the same capture path as every other event, so the standard +properties (`cli`, `cliVersion`, `scaffoldVersion`, `ghOwner`, and the +adoption dimensions) ride on them the way they ride on everything else. No +other event SHALL omit the `cli_` prefix. + #### Scenario: Rule creation emits a concrete state event plus cli_run - **WHEN** a user runs `taskless rule create --from req.json` and a rule is written @@ -215,6 +225,13 @@ Commands that carry no concrete state beyond the invocation (e.g. `info`, - **AND** a scan that loaded no rules SHALL be distinguishable from a scan that loaded rules and found nothing +#### Scenario: Survey events keep PostHog's names and carry the standard properties + +- **WHEN** the CLI serves a survey invite, records a dismissal, or sends a response +- **THEN** PostHog SHALL receive `survey shown`, `survey dismissed`, or `survey sent` respectively, under that exact name +- **AND** the event SHALL carry `$survey_id` and the standard properties, and no survey-specific property beyond PostHog's own keys +- **AND** no event named `cli_survey_shown`, `cli_survey_dismissed`, or `cli_survey_sent` SHALL be emitted + ### Requirement: Wrong-topic re-routing is observable as a derivable funnel The taxonomy SHALL keep wrong-topic re-routing derivable as a funnel signal from diff --git a/openspec/specs/cli-feedback-survey/spec.md b/openspec/specs/cli-feedback-survey/spec.md new file mode 100644 index 00000000..a82516dc --- /dev/null +++ b/openspec/specs/cli-feedback-survey/spec.md @@ -0,0 +1,155 @@ +# cli-feedback-survey Specification + +## Purpose + +Collects qualitative feedback about rule authoring and onboarding through a PostHog survey the agent answers on the user's behalf: the CLI decides when to invite, the agent gathers the user's words and its own observation of the session, and the CLI validates and sends the response. + +## Requirements + +### Requirement: A survey invite is appended to surveyed recipes when the gate is open + +When the CLI serves one of the surveyed recipes (`onboard`, `create-sg-rule`, `create-vale-rule`, `create-remote-rule`) through `taskless agent ` or `taskless onboard`, it SHALL append the survey invite to the served text if and only if every gate condition holds: + +- telemetry is enabled (neither `DO_NOT_TRACK=1` nor `TASKLESS_TELEMETRY_DISABLED=1`); +- `CI` is neither `"true"` nor `"1"`; +- the survey's `next_ask` timestamp is absent, unparseable, or not later than now. + +When the gate is open the CLI SHALL capture `survey shown` with `$survey_id`, write `next_ask` to now plus 10 days, and append the invite after the recipe's last section. When any condition fails the served text SHALL be byte-identical to the recipe as rendered without a survey. The `@taskless/cli/prompts` export SHALL never include the invite. + +#### Scenario: Gate open on a surveyed topic + +- **WHEN** telemetry is enabled, `CI` is unset, `next_ask` is absent, and an agent runs `taskless agent create-sg-rule` +- **THEN** stdout SHALL be the `create-sg-rule` recipe followed by the survey invite +- **AND** PostHog SHALL receive `survey shown` with `$survey_id` +- **AND** `next_ask` SHALL be written to a time about 10 days in the future + +#### Scenario: Both onboarding paths carry the invite + +- **WHEN** the gate is open and either `taskless onboard` or `taskless agent onboard` serves the onboarding recipe +- **THEN** both SHALL append the invite and capture `survey shown` with `$survey_id` + +#### Scenario: Within the window + +- **WHEN** `next_ask` is later than now and an agent runs `taskless agent create-vale-rule` +- **THEN** stdout SHALL be the recipe with no invite +- **AND** no survey event SHALL be captured and `next_ask` SHALL be unchanged + +#### Scenario: Telemetry disabled + +- **WHEN** `DO_NOT_TRACK=1` and an agent runs any surveyed topic +- **THEN** stdout SHALL be the recipe with no invite +- **AND** `next_ask` SHALL NOT be read or written + +#### Scenario: Running in CI + +- **WHEN** `CI=true` and telemetry is enabled and an agent runs any surveyed topic +- **THEN** stdout SHALL be the recipe with no invite +- **AND** no survey event SHALL be captured + +#### Scenario: Unsurveyed topic + +- **WHEN** the gate would otherwise be open and an agent runs `taskless agent check` +- **THEN** stdout SHALL be the recipe with no invite +- **AND** `next_ask` SHALL be unchanged + +#### Scenario: Corrupt cadence file is repaired + +- **WHEN** `next_ask` contains text that is not a number and an agent runs a surveyed topic with the other conditions met +- **THEN** the invite SHALL be appended +- **AND** `next_ask` SHALL be overwritten with a valid timestamp + +### Requirement: The cadence store is one epoch timestamp per survey + +The CLI SHALL keep the survey cadence at `/surveys//next_ask`, where the config directory is the same XDG location that holds the anonymous telemetry id and the survey id is the PostHog survey's UUID. The file SHALL contain a single decimal epoch-milliseconds value: the earliest time the next invite may be served. Serving an invite SHALL set it to now plus 10 days; `feedback dismiss` and `feedback send` SHALL each set it to now plus 20 days. Every CLI version SHALL share the file, so upgrading the CLI does not reset the cadence; a different survey id SHALL have its own file. + +#### Scenario: A CLI upgrade keeps the cadence + +- **WHEN** version A served an invite yesterday and the user runs version B for the first time +- **THEN** version B SHALL read the same `next_ask` and SHALL NOT serve the invite + +#### Scenario: A new survey is a new ask + +- **WHEN** the CLI ships with a different survey id than the one whose `next_ask` is on disk +- **THEN** the CLI SHALL find no `next_ask` for the new survey and SHALL serve the invite + +#### Scenario: Dismissal earns the longer gap + +- **WHEN** an invite was served and the agent runs `taskless feedback dismiss` +- **THEN** `next_ask` SHALL be about 20 days in the future, later than the value the invite wrote + +### Requirement: The feedback subcommand records dismissals and sends responses + +The CLI SHALL provide a `feedback` subcommand with two verbs. `feedback dismiss` SHALL capture `survey dismissed` with `$survey_id` and advance `next_ask`. `feedback send --from ` SHALL read a JSON file, validate it against the feedback payload schema, capture `survey sent`, and advance `next_ask`. Both verbs SHALL accept `--dir`. Neither SHALL require `.taskless/` to exist, run migrations, or write into the project. `feedback send` SHALL NOT delete its input file. + +When the telemetry client is disabled, both verbs SHALL print a single line saying nothing was sent and exit zero. + +`feedback` SHALL NOT appear in the `taskless agent` topic index. `taskless agent feedback` SHALL still serve the `feedback` recipe. + +#### Scenario: Dismiss + +- **WHEN** an agent runs `taskless feedback dismiss` +- **THEN** PostHog SHALL receive `survey dismissed` with `$survey_id` +- **AND** the command SHALL exit zero + +#### Scenario: Send a valid payload + +- **WHEN** an agent runs `taskless feedback send --from .taskless/.tmp-feedback.json` with a payload that passes validation +- **THEN** PostHog SHALL receive `survey sent` carrying `$survey_id` and one `$survey_response_` per answered question, and no other survey-specific property +- **AND** the command SHALL exit zero and leave the input file in place + +#### Scenario: Send an invalid payload + +- **WHEN** the payload is missing a required key or `completed` is not one of the allowed values +- **THEN** the command SHALL exit non-zero with `INVALID_INPUT`, naming the failing field +- **AND** no survey event SHALL be captured and `next_ask` SHALL be unchanged + +#### Scenario: Telemetry disabled + +- **WHEN** `DO_NOT_TRACK=1` and an agent runs either verb +- **THEN** the command SHALL print that nothing was sent and exit zero + +#### Scenario: Not in the index + +- **WHEN** an agent runs `taskless agent` +- **THEN** the printed index SHALL NOT list `feedback` + +### Requirement: The feedback payload uses human keys mapped to survey questions by the CLI + +The feedback payload SHALL be a JSON object with these keys: + +| key | required | value | +| ------------------ | -------- | ------------------------------------------------- | +| `verbatim` | yes | the user's own words, non-empty | +| `goal` | yes | what the user was trying to accomplish, non-empty | +| `completed` | yes | exactly `Yes`, `No`, or `Unknown` | +| `workedWell` | no | what worked well | +| `needsImprovement` | no | what could use improvement | + +The CLI SHALL own the map from these keys to the survey's question identifiers; a payload SHALL NOT contain `$survey_*` keys. An omitted optional key SHALL be omitted from the event rather than sent as an empty string. The schema SHALL be embedded in the `feedback` recipe through the recipe input-schema mechanism. + +#### Scenario: Optional answers are omitted, not blanked + +- **WHEN** a payload has no `workedWell` +- **THEN** the `survey sent` event SHALL carry no `$survey_response_` key for that question + +#### Scenario: Completion is one of three literals + +- **WHEN** a payload has `completed: "partially"` +- **THEN** validation SHALL fail naming `completed` + +### Requirement: The feedback and feedback-invite recipes + +The CLI SHALL embed a `feedback` recipe that tells the agent it is the respondent: it records the user's reply verbatim, fills the remaining answers from its own observation of the session, writes the payload to `.taskless/.tmp-feedback.json`, runs `feedback send --from` that path, and deletes the file afterwards. The recipe SHALL embed the payload schema and SHALL NOT ask the agent to put the survey's questions to the user one by one. + +The CLI SHALL embed a `feedback-invite` recipe carrying the text appended to surveyed recipes. Rendered header-less, it SHALL instruct the agent to ask the user exactly once, with the sentence "Taskless would like to know how the CLI is doing. Would you be okay sharing a few sentences about your experience? Or just skip it with `skip`."; to treat a reply of `skip`, silence, or a reply unrelated to feedback as a dismissal and run `feedback dismiss`; and to treat any other reply as feedback and fetch `agent feedback`. Both recipes SHALL follow the recipe conventions: a `# Topic:` header, the CLI named by its rendered invocation, and commands that exist. + +#### Scenario: The appended invite carries no header + +- **WHEN** the gate is open and a surveyed recipe is served +- **THEN** the appended text SHALL NOT contain a second `# Topic:` line +- **AND** SHALL name the rendered CLI invocation for both `feedback dismiss` and `agent feedback` + +#### Scenario: The feedback recipe embeds the schema + +- **WHEN** an agent runs `taskless agent feedback` +- **THEN** stdout SHALL open with `# Topic: feedback` and contain the JSON Schema for the payload diff --git a/openspec/specs/cli-taskless-bootstrap/spec.md b/openspec/specs/cli-taskless-bootstrap/spec.md index 62492abd..981f8352 100644 --- a/openspec/specs/cli-taskless-bootstrap/spec.md +++ b/openspec/specs/cli-taskless-bootstrap/spec.md @@ -218,3 +218,25 @@ The field SHALL only be written by the `taskless onboard --mark-complete` subcom - **WHEN** the codebase is searched for writes to `install.onboarded` - **THEN** the only producer SHALL be the `taskless onboard --mark-complete` subcommand + +### Requirement: Migration 7 ignores scratch request files + +Migration `7` SHALL add `/.tmp-*` to `.taskless/.gitignore` through the shared gitignore helper, so that the scratch request files agent recipes write under `.taskless/` (for example `.tmp-rule-request.json`, `.tmp-improve-request.json`, `.tmp-feedback.json`) are never committed. It SHALL NOT modify migration `1` or any other shipped migration; a fresh scaffold reaches the same `.gitignore` by running migrations `1` through `7` in order. + +#### Scenario: An existing scaffold gains the ignore line + +- **WHEN** `taskless.json` records version 6 and the CLI bootstraps `.taskless/` +- **THEN** migration 7 SHALL run +- **AND** `.taskless/.gitignore` SHALL contain a `/.tmp-*` line +- **AND** `taskless.json` SHALL record version 7 + +#### Scenario: A fresh scaffold ends with the same file + +- **WHEN** `.taskless/` does not exist and the CLI bootstraps it +- **THEN** `.taskless/.gitignore` SHALL contain `.env.local.json`, `/sgconfig.yml`, and `/.tmp-*` +- **AND** migration 1's own source SHALL be unchanged from the previous release + +#### Scenario: Migration 7 is idempotent + +- **WHEN** `.taskless/.gitignore` already contains `/.tmp-*` and migration 7 runs +- **THEN** the file SHALL contain exactly one `/.tmp-*` line afterwards diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index 89edbdbb..e31b8706 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -156,13 +156,13 @@ The CLI entry point SHALL use citty to define a main command with subcommand sup ### Requirement: CLI manages .taskless/.gitignore -The CLI SHALL proactively create and maintain a `.taskless/.gitignore` file that ignores local-only files. The gitignore SHALL contain entries for `.env.local.json` and `sgconfig.yml`. Any CLI command that writes to `.taskless/` SHALL ensure the `.gitignore` file exists with these entries before writing. +The CLI SHALL proactively create and maintain a `.taskless/.gitignore` file that ignores local-only files. The gitignore SHALL contain entries for `.env.local.json`, `sgconfig.yml`, and `/.tmp-*` (the scratch request files the agent recipes write, such as `.tmp-rule-request.json` and `.tmp-feedback.json`). Any CLI command that writes to `.taskless/` SHALL ensure the `.gitignore` file exists with these entries before writing. #### Scenario: .gitignore is created when .taskless/ is first written to - **WHEN** the CLI creates any file in `.taskless/` (e.g., during `auth login`, `rule create`, or `check`) - **AND** `.taskless/.gitignore` does not exist -- **THEN** the CLI SHALL create `.taskless/.gitignore` containing `.env.local.json` and `sgconfig.yml` +- **THEN** the CLI SHALL create `.taskless/.gitignore` containing `.env.local.json`, `sgconfig.yml`, and `/.tmp-*` #### Scenario: Existing .gitignore is preserved @@ -172,9 +172,15 @@ The CLI SHALL proactively create and maintain a `.taskless/.gitignore` file that #### Scenario: .gitignore entries are idempotent -- **WHEN** `.taskless/.gitignore` already contains `.env.local.json` and `sgconfig.yml` +- **WHEN** `.taskless/.gitignore` already contains `.env.local.json`, `sgconfig.yml`, and `/.tmp-*` - **THEN** the CLI SHALL NOT duplicate the entries +#### Scenario: Scratch files are ignored on an existing scaffold + +- **WHEN** a project scaffolded before `/.tmp-*` was an entry runs any command that bootstraps `.taskless/` +- **THEN** `.taskless/.gitignore` SHALL gain a `/.tmp-*` line +- **AND** a `.taskless/.tmp-feedback.json` left behind by an agent SHALL NOT appear in `git status` + ### Requirement: CLI infers repositoryUrl from git remote The CLI SHALL infer the repository URL by running `git remote get-url origin` and canonicalizing the result to `https://github.com/{owner}/{repo}` format. Both SSH (`git@github.com:owner/repo.git`) and HTTPS (`https://github.com/owner/repo.git`) URLs SHALL be supported. diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index bfbe2d8d..65988867 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -9,6 +9,7 @@ import { import { getTelemetry } from "../telemetry"; import { getRecipe } from "../prompts/recipes"; +import { withSurveyInvite } from "../survey/invite"; import { applyCliInvocation } from "../util/invocation"; import { detectCliInvocation, @@ -188,9 +189,10 @@ export function createAgentCommand(subCommands: SubCommandsDef) { // `nodejs_compat`, where a module-scope `process` read throws at import // time. When the launcher is unknown the value is `undefined` and the // renderer falls back to its agent-fill marker. + const invocation = detectCliInvocation(processLauncherContext()); const recipe = getRecipe(key, { anonymous: args.anonymous, - invocation: detectCliInvocation(processLauncherContext()), + invocation, // What this command serves IS a fetch, so the served text says so: // resolved now, fetch again next task, and a session that installed // or upgraded mid-way holds a stale skill. The prompts export leaves @@ -202,7 +204,17 @@ export function createAgentCommand(subCommands: SubCommandsDef) { // cli_agent: agent fetched a specific recipe (intent signal). The topic // is the served topic; filtering on it replaces the old per-topic events. telemetry.capture("cli_agent", { topic: key }); - console.log(recipe.trimEnd()); + // A surveyed topic may carry the feedback invite after its last + // section; the gate decides, and the render path above never sees it. + console.log( + await withSurveyInvite({ + recipe, + topic: key, + invocation, + cwd, + ci: process.env.CI, + }) + ); } else { // cli_agent for an unknown topic — still the attempted topic string. telemetry.capture("cli_agent", { topic: key }); diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 6adf9910..b934d943 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -5,6 +5,7 @@ import { defineCommand } from "citty"; import { ensureTasklessDirectory } from "../filesystem/directory"; import { readManifest, writeManifest } from "../filesystem/migrate"; import { getRecipe } from "../prompts/recipes"; +import { withSurveyInvite } from "../survey/invite"; import { getTelemetry } from "../telemetry"; import { CLIError } from "../util/cli-error"; import { @@ -100,12 +101,13 @@ export const onboardCommand = defineCommand({ } // Detected here rather than inside the prompts module, which Workers - // import without `nodejs_compat`. `taskless onboard` is the ONLY serving - // path for this recipe — it is not a topic `agent` dispatches — so - // omitting this renders every invocation in it as the agent-fill marker - // for anyone running a published build. + // import without `nodejs_compat`. This command serves the recipe on its + // own, without going through `agent`, so omitting this renders every + // invocation in it as the agent-fill marker for anyone running a + // published build. + const invocation = detectCliInvocation(processLauncherContext()); const recipe = getRecipe("onboard", { - invocation: detectCliInvocation(processLauncherContext()), + invocation, // Served text is a fetch, the same as `agent onboard`, which this must // match byte for byte. directive: true, @@ -116,6 +118,16 @@ export const onboardCommand = defineCommand({ process.exitCode = 1; throw new CLIError("recipe missing", undefined, { reported: true }); } - console.log(recipe.trimEnd()); + // Same gate as `agent onboard`: this is the other serving path for the + // same recipe, and the invite has to reach both or the cadence lies. + console.log( + await withSurveyInvite({ + recipe, + topic: "onboard", + invocation, + cwd, + ci: process.env.CI, + }) + ); }, }); diff --git a/packages/cli/src/survey/invite.ts b/packages/cli/src/survey/invite.ts new file mode 100644 index 00000000..49932719 --- /dev/null +++ b/packages/cli/src/survey/invite.ts @@ -0,0 +1,104 @@ +import { getRecipe } from "../prompts/recipes"; +import { getTelemetry, isTelemetryEnabled } from "../telemetry"; +import { isCiEnvironment } from "../util/interactive"; +import { readNextAsk, writeNextAsk } from "./cadence"; +import { SHOWN_INTERVAL_MS, SURVEY_ID, SURVEYED_TOPICS } from "./constants"; + +/** The fragment appended to a surveyed recipe; see `src/agent/feedback-invite.md`. */ +const INVITE_TOPIC = "feedback-invite"; + +export interface InviteContext { + /** The rendered recipe as the command is about to print it. */ + recipe: string; + /** The topic that was served, which decides whether it is surveyed. */ + topic: string; + /** The invocation the recipe was rendered with, so the invite matches it. */ + invocation: string | undefined; + /** Working directory, for the telemetry client. */ + cwd: string; + /** `process.env.CI`, passed in so the gate is testable without stubbing. */ + ci?: string | undefined; + /** The clock, overridable in tests. */ + now?: () => number; +} + +/** + * Whether the invite may be served right now. + * + * Every condition is a reason NOT to ask, checked cheapest first: + * + * - Telemetry off. The invite has nowhere to send an answer, so serving it + * would ask the user for words the CLI then throws away. This is also what + * keeps the byte-parity test between `agent ` and the prompts export + * honest: the suite runs with the opt-out set. + * - CI. Telemetry is NOT off in CI (`cli_check_completed` from a runner is + * real signal), but a survey there has no one to answer it. + * - An unsurveyed topic. The set is the authoring and onboarding recipes, + * which are where an agent-driven session most often goes wrong. + * - The cadence. `next_ask` is the earliest time the next invite may be + * served; absent or unparseable reads as "now", and the write that follows + * repairs a corrupt file. + */ +export async function surveyGateIsOpen( + context: Pick +): Promise { + if (!isTelemetryEnabled()) return false; + if (isCiEnvironment(context.ci)) return false; + if (!SURVEYED_TOPICS.has(context.topic)) return false; + const nextAsk = await readNextAsk(SURVEY_ID); + const now = (context.now ?? Date.now)(); + return nextAsk === undefined || nextAsk <= now; +} + +/** + * The text a recipe-serving command prints: the recipe alone when the gate is + * closed, the recipe plus the invite when it is open. + * + * Serving the invite is the `survey shown` moment. The CLI knows it appended + * the question; it cannot know the agent put it to a person, so the funnel + * reads shown ≫ sent by design (see `constants.ts`). The cadence is written + * here too, at show time, so an invite the agent never surfaces still holds + * the next one off: silence earns the short gap, an answer the long one. + * + * The cadence is written the moment the gate opens, before the fragment is + * rendered and before the telemetry client is initialised. Nothing locks the + * file, so two CLI processes that read `next_ask` in the same instant can both + * see the gate open and both serve the invite. Writing first makes that window + * the width of one read-then-write rather than a recipe render and a telemetry + * init. The worst case is one duplicate invite and one duplicate `survey + * shown`, and the funnel already over-counts shown by design, so this is an + * accepted tradeoff. A lock file was considered and rejected: it would need a + * TTL to survive a crashed process, which is more mechanism than one duplicate + * invite earns. + * + * Appended after the recipe's last section rather than parsed into it. Agents + * attend to the start and end of a response, and the end puts the ask after + * the task rather than in front of it. The `prompts` export never sees this: + * it is a fetch-time concern of the two commands that serve recipes, and the + * render path stays pure. + */ +export async function withSurveyInvite( + context: InviteContext +): Promise { + const recipe = context.recipe.trimEnd(); + if (!(await surveyGateIsOpen(context))) return recipe; + + // Claim the window first; see the note above on the read-then-write race. + const now = (context.now ?? Date.now)(); + await writeNextAsk(SURVEY_ID, now + SHOWN_INTERVAL_MS); + + const invite = getRecipe(INVITE_TOPIC, { + invocation: context.invocation, + header: false, + }); + // The fragment is embedded at build time; its absence is a build defect, + // and serving the recipe without it is the right failure. The cadence has + // already been advanced by then, which is fine: a missing fragment is not + // a reason to ask again sooner. + if (invite === undefined) return recipe; + + const telemetry = await getTelemetry(context.cwd); + telemetry.capture("survey shown", { $survey_id: SURVEY_ID }); + + return `${recipe}\n\n${invite.trimEnd()}`; +} diff --git a/packages/cli/src/util/interactive.ts b/packages/cli/src/util/interactive.ts index 4a062c86..b8e66125 100644 --- a/packages/cli/src/util/interactive.ts +++ b/packages/cli/src/util/interactive.ts @@ -17,6 +17,16 @@ export function shouldLaunchWizard(input: { stdinIsTTY: boolean | undefined; ci: string | undefined; }): boolean { - if (input.ci === "true" || input.ci === "1") return false; + if (isCiEnvironment(input.ci)) return false; return input.stdoutIsTTY === true && input.stdinIsTTY === true; } + +/** + * The one reading of `CI` this CLI has. `"true"` and `"1"` are what runners + * set; anything else, including an empty string, is not a claim of CI. The + * wizard and the survey invite both ask this, and neither should decide it + * on its own. + */ +export function isCiEnvironment(ci: string | undefined): boolean { + return ci === "true" || ci === "1"; +} diff --git a/packages/cli/test/agent-routing-telemetry.test.ts b/packages/cli/test/agent-routing-telemetry.test.ts index 1187e9e6..b75b2eba 100644 --- a/packages/cli/test/agent-routing-telemetry.test.ts +++ b/packages/cli/test/agent-routing-telemetry.test.ts @@ -13,6 +13,10 @@ vi.mock("../src/telemetry", () => ({ }) ), shutdownTelemetry: () => Promise.resolve(), + // The survey gate asks this before touching the cadence store. Off here, + // so these tests measure `cli_agent` alone; survey-invite.test.ts owns the + // gate. + isTelemetryEnabled: () => false, })); const { createAgentCommand } = await import("../src/commands/agent"); diff --git a/packages/cli/test/agent-telemetry.test.ts b/packages/cli/test/agent-telemetry.test.ts index 36647db6..4e50b302 100644 --- a/packages/cli/test/agent-telemetry.test.ts +++ b/packages/cli/test/agent-telemetry.test.ts @@ -11,6 +11,10 @@ vi.mock("../src/telemetry", () => ({ Promise.resolve({ capture, shutdown: () => Promise.resolve() }) ), shutdownTelemetry: () => Promise.resolve(), + // The survey gate asks this before touching the cadence store. Off here, + // so these tests measure `cli_agent` alone; survey-invite.test.ts owns the + // gate. + isTelemetryEnabled: () => false, })); const { createAgentCommand } = await import("../src/commands/agent"); diff --git a/packages/cli/test/survey-invite.test.ts b/packages/cli/test/survey-invite.test.ts new file mode 100644 index 00000000..0f7303f7 --- /dev/null +++ b/packages/cli/test/survey-invite.test.ts @@ -0,0 +1,245 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getRecipe } from "../src/prompts/recipes"; +import { nextAskPath, readNextAsk, writeNextAsk } from "../src/survey/cadence"; +import { SHOWN_INTERVAL_MS, SURVEY_ID } from "../src/survey/constants"; +import { getTelemetry } from "../src/telemetry"; + +// Spy on telemetry by mocking the module the gate imports, the same way +// agent-telemetry.test.ts does. `enabled` flips per test so the opt-out branch +// is exercised without touching the environment the real client reads. +const capture = vi.fn(); +let enabled = true; +vi.mock("../src/telemetry", () => ({ + getTelemetry: vi.fn(() => + Promise.resolve({ capture, shutdown: () => Promise.resolve() }) + ), + isTelemetryEnabled: () => enabled, + shutdownTelemetry: () => Promise.resolve(), +})); + +const { surveyGateIsOpen, withSurveyInvite } = + await import("../src/survey/invite"); +const { createAgentCommand } = await import("../src/commands/agent"); +const { onboardCommand } = await import("../src/commands/onboard"); + +const invocation = "npx @taskless/cli"; +const NOW = 1_800_000_000_000; + +/** The exact fragment the gate appends, rendered the way the gate renders it. */ +function inviteFragment(): string { + return ( + getRecipe("feedback-invite", { invocation, header: false }) ?? "" + ).trimEnd(); +} + +interface RunnableCommand { + run: (context: { + args: Record; + rawArgs: string[]; + }) => Promise; +} + +describe("the survey gate", () => { + let cwd: string; + let configHome: string; + let logSpy: ReturnType; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "tskl-invite-cwd-")); + configHome = await mkdtemp(join(tmpdir(), "tskl-invite-config-")); + vi.stubEnv("XDG_CONFIG_HOME", configHome); + vi.stubEnv("CI", ""); + enabled = true; + capture.mockClear(); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(async () => { + logSpy.mockRestore(); + vi.unstubAllEnvs(); + await rm(cwd, { recursive: true, force: true }); + await rm(configHome, { recursive: true, force: true }); + }); + + function serve(topic: string, overrides: { ci?: string } = {}) { + const recipe = getRecipe(topic, { invocation, directive: true }) ?? ""; + return withSurveyInvite({ + recipe, + topic, + invocation, + cwd, + ci: overrides.ci, + now: () => NOW, + }); + } + + it("appends the invite, captures survey shown, and writes the cadence when open", async () => { + const recipe = + getRecipe("create-sg-rule", { invocation, directive: true }) ?? ""; + const served = await serve("create-sg-rule"); + + expect(served).toBe(`${recipe.trimEnd()}\n\n${inviteFragment()}`); + // The appended text carries no second header: it is a fragment. + expect(served.match(/^# Topic:/gm)).toHaveLength(1); + expect(capture).toHaveBeenCalledTimes(1); + expect(capture).toHaveBeenCalledWith("survey shown", { + $survey_id: SURVEY_ID, + }); + expect(await readNextAsk(SURVEY_ID)).toBe(NOW + SHOWN_INTERVAL_MS); + }); + + it("claims the cadence window before the telemetry client is initialised", async () => { + // The mocked client reads the cadence file at the moment the gate asks + // for it, so the assertion is about ordering, not the final state. + let seenAtTelemetryInit: number | undefined; + vi.mocked(getTelemetry).mockImplementationOnce(async () => { + seenAtTelemetryInit = await readNextAsk(SURVEY_ID); + return { capture, shutdown: () => Promise.resolve() }; + }); + + await serve("create-sg-rule"); + expect(capture).toHaveBeenCalledTimes(1); + expect(seenAtTelemetryInit).toBe(NOW + SHOWN_INTERVAL_MS); + }); + + it.each([ + "onboard", + "create-sg-rule", + "create-vale-rule", + "create-remote-rule", + ])("surveys %s", async (topic) => { + expect(await surveyGateIsOpen({ topic, now: () => NOW })).toBe(true); + }); + + it("serves the bare recipe within the window, touching nothing", async () => { + await writeNextAsk(SURVEY_ID, NOW + 1); + const recipe = + getRecipe("create-vale-rule", { invocation, directive: true }) ?? ""; + + expect(await serve("create-vale-rule")).toBe(recipe.trimEnd()); + expect(capture).not.toHaveBeenCalled(); + expect(await readNextAsk(SURVEY_ID)).toBe(NOW + 1); + }); + + it("opens the moment the window closes", async () => { + await writeNextAsk(SURVEY_ID, NOW); + expect(await surveyGateIsOpen({ topic: "onboard", now: () => NOW })).toBe( + true + ); + }); + + it("serves the bare recipe under the telemetry opt-out, without reading the cadence", async () => { + enabled = false; + // A cadence that says "ask now" would open the gate if it were read. + await writeNextAsk(SURVEY_ID, 0); + const recipe = + getRecipe("create-sg-rule", { invocation, directive: true }) ?? ""; + + expect(await serve("create-sg-rule")).toBe(recipe.trimEnd()); + expect(capture).not.toHaveBeenCalled(); + expect(await readNextAsk(SURVEY_ID)).toBe(0); + }); + + it.each(["true", "1"])("serves the bare recipe in CI (CI=%s)", async (ci) => { + const recipe = getRecipe("onboard", { invocation, directive: true }) ?? ""; + expect(await serve("onboard", { ci })).toBe(recipe.trimEnd()); + expect(capture).not.toHaveBeenCalled(); + expect(await readNextAsk(SURVEY_ID)).toBeUndefined(); + }); + + it("serves an unsurveyed topic bare and leaves the cadence alone", async () => { + const recipe = getRecipe("check", { invocation, directive: true }) ?? ""; + expect(await serve("check")).toBe(recipe.trimEnd()); + expect(capture).not.toHaveBeenCalled(); + expect(await readNextAsk(SURVEY_ID)).toBeUndefined(); + }); + + it("repairs a corrupt cadence file by serving and rewriting", async () => { + const path = nextAskPath(SURVEY_ID); + await mkdir(join(path, ".."), { recursive: true }); + await writeFile(path, "garbage", "utf8"); + + const served = await serve("create-remote-rule"); + expect(served.endsWith(inviteFragment())).toBe(true); + expect(await readNextAsk(SURVEY_ID)).toBe(NOW + SHOWN_INTERVAL_MS); + }); + + /** Everything the command wrote to stdout, as one string. */ + function printed(): string { + return logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + } + + describe("through the serving commands", () => { + it("agent carries the invite after cli_agent", async () => { + const command = createAgentCommand({}) as unknown as RunnableCommand; + await command.run({ + args: { dir: cwd, anonymous: false }, + rawArgs: ["agent", "create-sg-rule"], + }); + + expect(printed()).toContain("## Before you finish"); + expect(capture.mock.calls.map(([event]) => String(event))).toEqual([ + "cli_agent", + "survey shown", + ]); + }); + + it("agent does not", async () => { + const command = createAgentCommand({}) as unknown as RunnableCommand; + await command.run({ + args: { dir: cwd, anonymous: false }, + rawArgs: ["agent", "check"], + }); + expect(printed()).not.toContain("## Before you finish"); + expect(capture.mock.calls.map(([event]) => String(event))).toEqual([ + "cli_agent", + ]); + }); + + it("taskless onboard carries the invite on the recipe path", async () => { + const command = onboardCommand as unknown as RunnableCommand; + await command.run({ + args: { dir: cwd, force: false, "mark-complete": false }, + rawArgs: [], + }); + expect(printed()).toContain("# Topic: onboard"); + expect(printed()).toContain("## Before you finish"); + expect(capture).toHaveBeenCalledWith("survey shown", { + $survey_id: SURVEY_ID, + }); + }); + + it("taskless onboard --mark-complete does not", async () => { + const command = onboardCommand as unknown as RunnableCommand; + await command.run({ + args: { dir: cwd, force: false, "mark-complete": true }, + rawArgs: [], + }); + expect(printed()).not.toContain("## Before you finish"); + expect(capture.mock.calls.map(([event]) => String(event))).toEqual([ + "cli_onboarded", + ]); + }); + + it("an already-onboarded project without --force does not", async () => { + const command = onboardCommand as unknown as RunnableCommand; + await command.run({ + args: { dir: cwd, force: false, "mark-complete": true }, + rawArgs: [], + }); + capture.mockClear(); + logSpy.mockClear(); + await command.run({ + args: { dir: cwd, force: false, "mark-complete": false }, + rawArgs: [], + }); + expect(printed()).not.toContain("## Before you finish"); + expect(capture).not.toHaveBeenCalled(); + }); + }); +});