From f56accf48b4bde7856bb3bb18c5ba57a83498269 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 23 Aug 2026 17:39:03 -0700 Subject: [PATCH 1/3] fix(cli): report the nightly's own version, not the release it anticipates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing a nightly wrote `install.cliVersion: "0.10.2"` into `.taskless/taskless.json` while the skills written beside it — same command, same run — pinned every invocation to `@taskless/cli-nightly@0.11.0-…`. The manifest attributed the install to a version that never performed it. The cause is an ordering the nightly design chose on purpose: the version is stamped when the publishable artifact is produced, and the committed manifest is deliberately left alone. But `__VERSION__` was baked from that committed manifest, so `package.json` was asked a question it cannot answer and answered confidently with the previous release. `resolveCliVersion` now returns the stamped version for a nightly and the committed one for every other target, and delegates to `resolveNightlyVersion` so the no-stamp refusal is shared rather than restated — a nightly that cannot name itself fails the build instead of falling back, since the fallback is the wrong answer that produced this. Reproduced first: one local nightly build reported v0.10.2 while pinning the nightly invocation, which is both halves of the report from a single artifact. Also confirmed the write path was not at fault — the wizard persists `getCliVersion()` and reads the previous value only to render the diff — so nothing was preserving a stale version. Corrects `--version`, the `%(CLI_VERSION)s` recipe header, the telemetry property, and `install.cliVersion` together; all four read this define. Manifests already written by a nightly are not corrected retroactively; the next install fixes them. Fixes #148 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- .changeset/nightly-self-reported-version.md | 24 +++++++ .../proposal.md | 63 +++++++++++++++++++ .../specs/cli-nightly-builds/spec.md | 38 +++++++++++ .../tasks.md | 26 ++++++++ openspec/specs/cli-nightly-builds/spec.md | 40 +++++++++++- packages/cli/scripts/build-target.ts | 27 ++++++++ packages/cli/test/build-target.test.ts | 38 +++++++++++ packages/cli/vite.config.ts | 3 +- 8 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 .changeset/nightly-self-reported-version.md create mode 100644 openspec/changes/archive/2026-08-24-nightly-self-reported-version/proposal.md create mode 100644 openspec/changes/archive/2026-08-24-nightly-self-reported-version/specs/cli-nightly-builds/spec.md create mode 100644 openspec/changes/archive/2026-08-24-nightly-self-reported-version/tasks.md diff --git a/.changeset/nightly-self-reported-version.md b/.changeset/nightly-self-reported-version.md new file mode 100644 index 00000000..cb3bbd73 --- /dev/null +++ b/.changeset/nightly-self-reported-version.md @@ -0,0 +1,24 @@ +--- +"@taskless/cli": patch +--- + +A nightly now reports the version it is, not the release it anticipates. + +Installing a nightly wrote the previous release into `.taskless/taskless.json` +— `install.cliVersion: "0.10.2"` — while the skills written beside it, by the +same command in the same run, pinned every invocation to +`@taskless/cli-nightly@0.11.0-…`. The manifest attributed the install to a +version that never performed it, which matters because `install.cliVersion` is +what answers "what installed this?", and that question gets asked precisely +when someone is running a nightly to reproduce unreleased behavior. + +A nightly's version is stamped when the publishable artifact is produced, and +the committed `package.json` is deliberately left untouched — so the build was +reading a file that could not know the answer. It now takes the same stamp that +names the published package, so the version a nightly reports and the version +it sends an agent to are the same string by construction. + +This also corrects `taskless --version`, the CLI version in recipe headers, and +the `cliVersion` telemetry property on nightly builds. Released builds are +unaffected. A nightly that cannot determine its own version now fails the build +rather than quietly reporting the released one. diff --git a/openspec/changes/archive/2026-08-24-nightly-self-reported-version/proposal.md b/openspec/changes/archive/2026-08-24-nightly-self-reported-version/proposal.md new file mode 100644 index 00000000..ee98d568 --- /dev/null +++ b/openspec/changes/archive/2026-08-24-nightly-self-reported-version/proposal.md @@ -0,0 +1,63 @@ +## Why + +A nightly reports the version of the release it anticipates, not the version +it is. Installing `@taskless/cli-nightly@0.11.0-20260823220841xd9dee3d` writes +`install.cliVersion: "0.10.2"` into `.taskless/taskless.json`, while the skills +emitted beside it — in the same run, by the same build — pin every command to +the nightly's exact version. + +The cause is an ordering the nightly design chose deliberately. The version is +stamped when the publishable artifact is produced, and the committed manifest +is left alone; but `__VERSION__` is baked from that committed manifest at build +time. So `package.json` is asked a question it cannot answer, and it answers +confidently with the previous release. + +This is quiet rather than loud, which is what makes it worth fixing. Nothing +errors. The manifest simply attributes the install to a version that never +performed it — and `install.cliVersion` exists precisely to answer "what +installed this?", which is the question asked when a nightly is being used to +reproduce unreleased behavior. + +## What Changes + +- **A build reports the version it is published as.** `resolveCliVersion` + returns the stamped nightly version for the `nightly` target and the + committed package version for every other, and the build's `__VERSION__` + define reads it instead of `package.json` directly. +- **The refusal matches the invocation's.** A `nightly` build with no stamp + throws rather than falling back, reusing `resolveNightlyVersion`. The + plausible fallback is the wrong answer that produced this bug. +- **Nothing else moves.** The committed manifest is still untouched by a + nightly build, which the existing requirement demands. + +This corrects `taskless --version`, the `%(CLI_VERSION)s` recipe header, the +`cliVersion` telemetry property, and `install.cliVersion` — all four read the +same define. + +**Delivery is a single PR.** One pure function, its call site, tests, and a +spec delta. + +## Capabilities + +### Modified Capabilities + +- `cli-nightly-builds`: a nightly reports its own published version as its CLI + version, rather than the released version its committed manifest declares. + +## Impact + +- **Modified**: `packages/cli/scripts/build-target.ts` (new `resolveCliVersion`), + `packages/cli/vite.config.ts` (the `__VERSION__` define), + `packages/cli/test/build-target.test.ts`. +- **Unchanged**: `packages/cli/package.json`, and `.github/scripts/nightly-pack.cjs` + — the stamp is already computed once and already passed to the build as + `TASKLESS_NIGHTLY_VERSION`. This change consumes a value that was already + there rather than plumbing a new one. +- **Unchanged**: the `assert-skill-versions` build check, which compares skill + frontmatter against the committed `package.json`. That is a claim about + source files in the repository, not about what a build reports, and a nightly + must not rewrite it. +- **Not fixed retroactively**: manifests already written by a nightly still + record the anticipated release. The next install corrects them. + +**Tracking:** taskless/cli#148 diff --git a/openspec/changes/archive/2026-08-24-nightly-self-reported-version/specs/cli-nightly-builds/spec.md b/openspec/changes/archive/2026-08-24-nightly-self-reported-version/specs/cli-nightly-builds/spec.md new file mode 100644 index 00000000..8e9c94a6 --- /dev/null +++ b/openspec/changes/archive/2026-08-24-nightly-self-reported-version/specs/cli-nightly-builds/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: A nightly reports the version it is published as + +A nightly build SHALL report, as its own CLI version, the version it is published under — not the released version declared by the package manifest in version control. This applies wherever the build states its version, including the version command, the CLI version embedded in recipe headers, telemetry, and the CLI version recorded when it installs into a project. + +The version SHALL come from the same stamp used to name the published package, so that the version a nightly reports and the version an agent is sent to are the same string by construction rather than by coincidence. + +A nightly build with no stamp available SHALL fail rather than report the committed version. Reporting the released version is the failure this requirement exists to prevent, and a build that cannot name itself has no correct value to substitute. + +Builds other than nightly SHALL report the committed package version, which for those targets is the version they are. + +#### Scenario: A nightly records its own version when it installs + +- **WHEN** a nightly installs into a project +- **THEN** the CLI version recorded in the project manifest SHALL be the nightly's published version +- **AND** SHALL match the version pinned in the skills and commands emitted by that same install + +#### Scenario: A nightly states its own version when asked + +- **WHEN** a nightly is asked for its version +- **THEN** it SHALL report its published version rather than the released version its committed manifest declares + +#### Scenario: A nightly build without a version stamp fails + +- **WHEN** a nightly build is attempted with no version stamp available +- **THEN** the build SHALL fail +- **AND** SHALL NOT fall back to the committed package version + +#### Scenario: A released build reports the committed version + +- **WHEN** a non-nightly build is asked for its version +- **THEN** it SHALL report the version declared by the committed package manifest + +#### Scenario: Reporting its own version does not modify the committed manifest + +- **WHEN** a nightly build has run +- **THEN** the CLI package manifest in version control SHALL still declare the released version diff --git a/openspec/changes/archive/2026-08-24-nightly-self-reported-version/tasks.md b/openspec/changes/archive/2026-08-24-nightly-self-reported-version/tasks.md new file mode 100644 index 00000000..746b82e8 --- /dev/null +++ b/openspec/changes/archive/2026-08-24-nightly-self-reported-version/tasks.md @@ -0,0 +1,26 @@ +Delivery shape: **single PR**. A pure function, one call site, tests, and a spec delta — nothing here can land independently of the rest. + +## 1. Reproduce before fixing + +- [x] 1.1 Build a nightly locally (`TASKLESS_NIGHTLY_VERSION=… pnpm --filter @taskless/cli build:nightly`) and confirm it reports the committed version while pinning the nightly invocation — the two halves of the report, from one artifact +- [x] 1.2 Confirm the write path is not itself at fault: `wizard/index.ts` persists `getCliVersion()`, and reads `previousState.cliVersion` only to render the diff, so nothing is preserving a stale value + +## 2. Fix + +- [x] 2.1 Add `resolveCliVersion(environment, packageVersion)` to `build-target.ts`, delegating to `resolveNightlyVersion` for the nightly target so the no-stamp refusal is shared rather than restated +- [x] 2.2 Read it from the `__VERSION__` define in `vite.config.ts` +- [x] 2.3 Leave `assert-skill-versions` reading `pkg.version` — it asserts about committed source, not about the build + +## 3. Tests + +- [x] 3.1 Nightly reports the stamped version, not the committed one +- [x] 3.2 A nightly with no stamp throws, naming the env var +- [x] 3.3 prod/dev/self and an empty environment all keep the committed version + +## 4. Verification + +- [x] 4.1 Nightly build reports the stamped version in `--version` and in the recipe header +- [x] 4.2 Prod build still reports the committed version +- [x] 4.3 End-to-end: `init` under a nightly build writes the stamped version to `install.cliVersion` +- [x] 4.4 `pnpm --filter @taskless/cli build` then `test`, `pnpm typecheck`, `pnpm lint`, `pnpm openspec validate --all --strict` +- [x] 4.5 Changeset diff --git a/openspec/specs/cli-nightly-builds/spec.md b/openspec/specs/cli-nightly-builds/spec.md index 34990f6d..40c8473e 100644 --- a/openspec/specs/cli-nightly-builds/spec.md +++ b/openspec/specs/cli-nightly-builds/spec.md @@ -3,9 +3,7 @@ ## Purpose TBD - created by archiving change nightly-cli-builds. Update Purpose after archive. - ## Requirements - ### Requirement: Unreleased work on the default branch is installable as a nightly package The CLI SHALL be published as `@taskless/cli-nightly` from commits on the default branch that carry unreleased work, so that merged-but-unreleased behavior can be installed and exercised. Nightlies SHALL NOT be published from pull requests: only code that has already merged, and therefore already passed review, is eligible. @@ -237,3 +235,41 @@ The annotation SHALL depend on the publish having succeeded, and SHALL be perfor - **WHEN** a push publishes no nightly - **THEN** no job holding permission to write pull requests SHALL be instantiated for it + +### Requirement: A nightly reports the version it is published as + +A nightly build SHALL report, as its own CLI version, the version it is published under — not the released version declared by the package manifest in version control. This applies wherever the build states its version, including the version command, the CLI version embedded in recipe headers, telemetry, and the CLI version recorded when it installs into a project. + +The version SHALL come from the same stamp used to name the published package, so that the version a nightly reports and the version an agent is sent to are the same string by construction rather than by coincidence. + +A nightly build with no stamp available SHALL fail rather than report the committed version. Reporting the released version is the failure this requirement exists to prevent, and a build that cannot name itself has no correct value to substitute. + +Builds other than nightly SHALL report the committed package version, which for those targets is the version they are. + +#### Scenario: A nightly records its own version when it installs + +- **WHEN** a nightly installs into a project +- **THEN** the CLI version recorded in the project manifest SHALL be the nightly's published version +- **AND** SHALL match the version pinned in the skills and commands emitted by that same install + +#### Scenario: A nightly states its own version when asked + +- **WHEN** a nightly is asked for its version +- **THEN** it SHALL report its published version rather than the released version its committed manifest declares + +#### Scenario: A nightly build without a version stamp fails + +- **WHEN** a nightly build is attempted with no version stamp available +- **THEN** the build SHALL fail +- **AND** SHALL NOT fall back to the committed package version + +#### Scenario: A released build reports the committed version + +- **WHEN** a non-nightly build is asked for its version +- **THEN** it SHALL report the version declared by the committed package manifest + +#### Scenario: Reporting its own version does not modify the committed manifest + +- **WHEN** a nightly build has run +- **THEN** the CLI package manifest in version control SHALL still declare the released version + diff --git a/packages/cli/scripts/build-target.ts b/packages/cli/scripts/build-target.ts index dc56dced..6bbec2a8 100644 --- a/packages/cli/scripts/build-target.ts +++ b/packages/cli/scripts/build-target.ts @@ -90,6 +90,33 @@ export function resolveNightlyVersion(environment: BuildEnvironment): string { return version; } +/** + * The version this build should report as its own — `__VERSION__`, which backs + * `taskless --version`, the `%(CLI_VERSION)s` recipe header, the `cliVersion` + * telemetry property, and the `install.cliVersion` written into + * `.taskless/taskless.json`. + * + * For every target but `nightly` this is the committed package version. A + * nightly is the exception because its version is stamped at pack time and the + * manifest in version control is deliberately left alone — so reading + * `package.json` at build time yields the version of the release the nightly + * ANTICIPATES, not the version it is published as. + * + * Getting this wrong is quiet rather than loud. A nightly installed to exercise + * unreleased behavior records `install.cliVersion` as the last release, so the + * manifest disagrees with the skills emitted beside it — which pin the exact + * nightly — and anyone reading the manifest to answer "what installed this?" + * gets a version that never wrote it. + */ +export function resolveCliVersion( + environment: BuildEnvironment, + packageVersion: string +): string { + return resolveBuildTarget(environment) === "nightly" + ? resolveNightlyVersion(environment) + : packageVersion; +} + /** * The CLI invocation baked into emitted skill/command/recipe content, chosen by * the TASKLESS_BUILD_TARGET env var (see package.json build:dev/build:self/ diff --git a/packages/cli/test/build-target.test.ts b/packages/cli/test/build-target.test.ts index 5886eeef..12cb785a 100644 --- a/packages/cli/test/build-target.test.ts +++ b/packages/cli/test/build-target.test.ts @@ -6,6 +6,7 @@ import { resolveBuildTarget, resolveCliInvocation, resolveCliNotice, + resolveCliVersion, resolveOutputDirectory, } from "../scripts/build-target"; @@ -46,6 +47,23 @@ describe("build target resolution", () => { }); }); +describe("the version a build reports as its own", () => { + // Only nightly diverges. dev/self/prod all run from a checkout whose + // package.json IS the version they are, so reading it is correct there. + it.each(["prod", "dev", "self"])( + "uses the committed package version for the %s target", + (target) => { + expect( + resolveCliVersion({ TASKLESS_BUILD_TARGET: target }, "0.10.2") + ).toBe("0.10.2"); + } + ); + + it("uses the committed package version when no target is set", () => { + expect(resolveCliVersion({}, "0.10.2")).toBe("0.10.2"); + }); +}); + describe("the nightly target", () => { // The whole point of the target: a nightly's skills, commands, and recipes // must send an agent to the package the reader installed. Without the @@ -57,6 +75,26 @@ describe("the nightly target", () => { ); }); + // #148: the reported symptom. A nightly installed to exercise unreleased + // behavior wrote `install.cliVersion: 0.10.2` — the release it anticipates — + // while the skills emitted beside it pinned the nightly it actually was. + // The version is stamped at pack time and the committed manifest is + // deliberately left alone, so `package.json` cannot answer this. + it("reports the stamped version as its own, not the committed one", () => { + expect(resolveCliVersion(nightlyEnvironment, "0.10.2")).toBe( + NIGHTLY_VERSION + ); + }); + + // Same refusal as the invocation: a nightly that cannot name its own version + // has nothing correct to report, and the plausible fallback is the wrong + // answer that started #148. + it("refuses to report a version when the stamp is missing", () => { + expect(() => + resolveCliVersion({ TASKLESS_BUILD_TARGET: "nightly" }, "0.10.2") + ).toThrow(NIGHTLY_VERSION_ENV); + }); + // `dist`, unlike dev/self — the tarball is packed with `files: ["dist"]` and // `bin: ./dist/index.js`, so a nightly build overwrites a prod build. it("emits to dist, the same directory as prod", () => { diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index 06b5af1b..1fb1833e 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -12,6 +12,7 @@ import { resolveBuildTarget, resolveCliInvocation, resolveCliNotice, + resolveCliVersion, resolveOutputDirectory, } from "./scripts/build-target"; @@ -221,7 +222,7 @@ function assertPromptsGraph(): Plugin { export default defineConfig({ define: { - __VERSION__: JSON.stringify(pkg.version), + __VERSION__: JSON.stringify(resolveCliVersion(process.env, pkg.version)), __TASKLESS_CLI__: JSON.stringify(cliInvocation), __TASKLESS_CLI_NOTICE__: JSON.stringify(cliNotice), }, From d983e4b52ee62726e044896dc6e5403bc9c3da70 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 23 Aug 2026 17:45:59 -0700 Subject: [PATCH 2/3] fix(cli): refuse to emit a nightly that disagrees with itself about its version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-mortem guard for the bug in the parent commit. #148 shipped not because the two version-bearing defines were derived from different sources by design, but because nothing ever compared them: the invocation read TASKLESS_NIGHTLY_VERSION while the version read package.json, so one artifact announced v0.10.2 and sent every agent to v0.11.0-… . Nothing failed. The disagreement was visible only by comparing two outputs of the same build against each other, which is exactly what nobody does. The fix couples them by construction, so the check is tautological today. That is the point — it exists for the refactor that decouples them again: a second env var, a cached value, a default reintroduced "for local builds". Deriving from one source is not the same as being checked against it, which is precisely what this bug demonstrated. Asserts on the resolved defines rather than scanning the emitted bundle. The build already holds the structured values, and re-deriving them from generated text would be the weaker tool — the styleguide's "verify build output in the build" rule. Verified by simulating the decoupling: reverting the version to `pkg.version` reproduces #148 exactly and the build now refuses it, naming the issue and the env var both values derive from. Had this guard existed, the bug could not have been emitted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- .changeset/nightly-self-reported-version.md | 6 ++ .../specs/cli-nightly-builds/spec.md | 8 +++ openspec/specs/cli-nightly-builds/spec.md | 11 +++- packages/cli/scripts/build-target.ts | 40 +++++++++++++ packages/cli/test/build-target.test.ts | 56 +++++++++++++++++++ packages/cli/vite.config.ts | 9 ++- 6 files changed, 128 insertions(+), 2 deletions(-) diff --git a/.changeset/nightly-self-reported-version.md b/.changeset/nightly-self-reported-version.md index cb3bbd73..b6e72969 100644 --- a/.changeset/nightly-self-reported-version.md +++ b/.changeset/nightly-self-reported-version.md @@ -22,3 +22,9 @@ This also corrects `taskless --version`, the CLI version in recipe headers, and the `cliVersion` telemetry property on nightly builds. Released builds are unaffected. A nightly that cannot determine its own version now fails the build rather than quietly reporting the released one. + +The build now also refuses to emit a nightly whose reported version and +embedded invocation disagree. Both derive from the same stamp, so they cannot +diverge today — but that was true of the two values in this bug as well, right +up until one of them started reading `package.json` instead. Deriving from one +source is not the same as being checked against it. diff --git a/openspec/changes/archive/2026-08-24-nightly-self-reported-version/specs/cli-nightly-builds/spec.md b/openspec/changes/archive/2026-08-24-nightly-self-reported-version/specs/cli-nightly-builds/spec.md index 8e9c94a6..d3c83ac1 100644 --- a/openspec/changes/archive/2026-08-24-nightly-self-reported-version/specs/cli-nightly-builds/spec.md +++ b/openspec/changes/archive/2026-08-24-nightly-self-reported-version/specs/cli-nightly-builds/spec.md @@ -10,6 +10,8 @@ A nightly build with no stamp available SHALL fail rather than report the commit Builds other than nightly SHALL report the committed package version, which for those targets is the version they are. +A nightly build SHALL verify, before emitting, that the version it reports and the version its embedded invocation names are the same. Deriving both from one stamp is not sufficient on its own: the two were derived from a single source and still diverged, because nothing compared them. + #### Scenario: A nightly records its own version when it installs - **WHEN** a nightly installs into a project @@ -27,6 +29,12 @@ Builds other than nightly SHALL report the committed package version, which for - **THEN** the build SHALL fail - **AND** SHALL NOT fall back to the committed package version +#### Scenario: A nightly that disagrees with itself is not emitted + +- **WHEN** a nightly build's reported version and the invocation it embeds name different versions +- **THEN** the build SHALL fail +- **AND** SHALL NOT emit the artifact + #### Scenario: A released build reports the committed version - **WHEN** a non-nightly build is asked for its version diff --git a/openspec/specs/cli-nightly-builds/spec.md b/openspec/specs/cli-nightly-builds/spec.md index 40c8473e..77603d0b 100644 --- a/openspec/specs/cli-nightly-builds/spec.md +++ b/openspec/specs/cli-nightly-builds/spec.md @@ -3,7 +3,9 @@ ## Purpose TBD - created by archiving change nightly-cli-builds. Update Purpose after archive. + ## Requirements + ### Requirement: Unreleased work on the default branch is installable as a nightly package The CLI SHALL be published as `@taskless/cli-nightly` from commits on the default branch that carry unreleased work, so that merged-but-unreleased behavior can be installed and exercised. Nightlies SHALL NOT be published from pull requests: only code that has already merged, and therefore already passed review, is eligible. @@ -246,6 +248,8 @@ A nightly build with no stamp available SHALL fail rather than report the commit Builds other than nightly SHALL report the committed package version, which for those targets is the version they are. +A nightly build SHALL verify, before emitting, that the version it reports and the version its embedded invocation names are the same. Deriving both from one stamp is not sufficient on its own: the two were derived from a single source and still diverged, because nothing compared them. + #### Scenario: A nightly records its own version when it installs - **WHEN** a nightly installs into a project @@ -263,6 +267,12 @@ Builds other than nightly SHALL report the committed package version, which for - **THEN** the build SHALL fail - **AND** SHALL NOT fall back to the committed package version +#### Scenario: A nightly that disagrees with itself is not emitted + +- **WHEN** a nightly build's reported version and the invocation it embeds name different versions +- **THEN** the build SHALL fail +- **AND** SHALL NOT emit the artifact + #### Scenario: A released build reports the committed version - **WHEN** a non-nightly build is asked for its version @@ -272,4 +282,3 @@ Builds other than nightly SHALL report the committed package version, which for - **WHEN** a nightly build has run - **THEN** the CLI package manifest in version control SHALL still declare the released version - diff --git a/packages/cli/scripts/build-target.ts b/packages/cli/scripts/build-target.ts index 6bbec2a8..6edd632d 100644 --- a/packages/cli/scripts/build-target.ts +++ b/packages/cli/scripts/build-target.ts @@ -117,6 +117,46 @@ export function resolveCliVersion( : packageVersion; } +/** + * Refuse to emit a nightly whose two version-bearing defines disagree. + * + * A post-mortem guard, not a hypothetical one. #148 shipped because + * `__VERSION__` and `__TASKLESS_CLI__` are *derived* from the same stamp but + * were not *checked* against each other: the invocation read + * `TASKLESS_NIGHTLY_VERSION` and the version read `package.json`, so one + * artifact simultaneously announced v0.10.2 and sent every agent to + * v0.11.0-…. Nothing failed. The disagreement was only visible by comparing + * two outputs of the same build against each other, which is exactly what + * nobody does. + * + * They are coupled by construction today. This exists so that if a later + * refactor decouples them — a second env var, a cached value, a default + * reintroduced "for local builds" — the build stops instead of shipping the + * same silent mismatch again. The check is cheap and it fires at the only + * moment the two values are both in hand. + * + * Deliberately asks the resolved defines rather than scanning the emitted + * bundle: the build already holds the structured values, and re-deriving them + * from generated text would be the weaker tool (see the styleguide's + * "verify build output in the build"). + */ +export function assertVersionConsistency( + environment: BuildEnvironment, + version: string, + invocation: string +): void { + if (resolveBuildTarget(environment) !== "nightly") return; + if (invocation.endsWith(`@${version}`)) return; + throw new Error( + `nightly build is inconsistent with itself: it reports version ` + + `${JSON.stringify(version)} but its invocation is ` + + `${JSON.stringify(invocation)}. These must name the same version — a ` + + `build that announces one version and sends agents to another is ` + + `taskless/cli#148. Both derive from ${NIGHTLY_VERSION_ENV}; if they no ` + + `longer do, that is the bug.` + ); +} + /** * The CLI invocation baked into emitted skill/command/recipe content, chosen by * the TASKLESS_BUILD_TARGET env var (see package.json build:dev/build:self/ diff --git a/packages/cli/test/build-target.test.ts b/packages/cli/test/build-target.test.ts index 12cb785a..175c1406 100644 --- a/packages/cli/test/build-target.test.ts +++ b/packages/cli/test/build-target.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + assertVersionConsistency, NIGHTLY_VERSION_ENV, OUT_DIRS, resolveBuildTarget, @@ -64,6 +65,61 @@ describe("the version a build reports as its own", () => { }); }); +// #148 post-mortem. The two defines were DERIVED from one stamp but never +// CHECKED against each other, so a single artifact announced one version and +// sent every agent to another, silently. These pin the guard that makes that +// state unbuildable rather than merely unlikely. +describe("a build must agree with itself about its version", () => { + it("refuses a nightly whose invocation names a different version", () => { + expect(() => + assertVersionConsistency( + nightlyEnvironment, + "0.10.2", + `npx @taskless/cli-nightly@${NIGHTLY_VERSION}` + ) + ).toThrow(/inconsistent with itself/); + }); + + // The exact shape of the shipped bug, named in the message so whoever hits + // this next has the history rather than just the assertion. + it("names the issue and the env var both values derive from", () => { + expect(() => + assertVersionConsistency( + nightlyEnvironment, + "0.10.2", + `npx @taskless/cli-nightly@${NIGHTLY_VERSION}` + ) + ).toThrow(new RegExp(String.raw`148[\s\S]*` + NIGHTLY_VERSION_ENV)); + }); + + it("accepts a nightly whose two values agree", () => { + expect(() => + assertVersionConsistency( + nightlyEnvironment, + NIGHTLY_VERSION, + `npx @taskless/cli-nightly@${NIGHTLY_VERSION}` + ) + ).not.toThrow(); + }); + + // A version pin is meaningless for the other targets: prod names a floating + // package deliberately, and dev/self name a filesystem path. Asserting there + // would fail every ordinary build. + it.each([ + ["prod", "npx @taskless/cli"], + ["dev", "node /repo/packages/cli/dist-dev/index.js"], + ["self", "node packages/cli/dist-self/index.js"], + ])("does not constrain the %s target", (target, invocation) => { + expect(() => + assertVersionConsistency( + { TASKLESS_BUILD_TARGET: target }, + "0.10.2", + invocation + ) + ).not.toThrow(); + }); +}); + describe("the nightly target", () => { // The whole point of the target: a nightly's skills, commands, and recipes // must send an agent to the package the reader installed. Without the diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index 1fb1833e..2cda547a 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -8,6 +8,7 @@ import tsconfigPaths from "vite-tsconfig-paths"; import { SKILL_CATALOG } from "./src/install/catalog"; import { + assertVersionConsistency, OUT_DIRS, resolveBuildTarget, resolveCliInvocation, @@ -220,9 +221,15 @@ function assertPromptsGraph(): Plugin { }; } +const cliVersion = resolveCliVersion(process.env, pkg.version); + +// Fails the build rather than emitting an artifact whose version and +// invocation disagree — the shape of taskless/cli#148. +assertVersionConsistency(process.env, cliVersion, cliInvocation); + export default defineConfig({ define: { - __VERSION__: JSON.stringify(resolveCliVersion(process.env, pkg.version)), + __VERSION__: JSON.stringify(cliVersion), __TASKLESS_CLI__: JSON.stringify(cliInvocation), __TASKLESS_CLI_NOTICE__: JSON.stringify(cliNotice), }, From b061eb60dcc13b7e10f620661875728c340b63e0 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 23 Aug 2026 18:31:26 -0700 Subject: [PATCH 3/3] docs(openspec): correct the two specs that said version always comes from package.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught a real gap: making a nightly report its stamped version falsified two requirements this change never touched. - `cli`: "CLI version is injected at build time" said `__VERSION__` is the version string read from `packages/cli/package.json`, full stop. Now scoped — the committed version for every target but nightly, which uses its stamp — with a scenario for the nightly case beside the existing one. - `analytics`: "All capture calls include standard properties" described `cliVersion` as the package version read from `package.json`, and its scenario said the same. That one is pointed: this change's own claim to fix the `cliVersion` telemetry property is exactly what the scenario described, so the spec asserted the behavior being removed. Both are recorded as MODIFIED deltas on the archived change, so the record matches what shipped rather than only the permanent specs agreeing. `openspec validate --strict` cannot catch a contradiction between two specs — it validates each in isolation — so nothing would have failed here. Swept the rest of `openspec/specs/` for the same claim; the remaining hits are skill-version sync and the ast-grep schema pin, which correctly mean the committed manifest and are untouched by this change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- .../specs/analytics/spec.md | 30 +++++++++++++++++++ .../specs/cli/spec.md | 15 ++++++++++ openspec/specs/analytics/spec.md | 8 +++-- openspec/specs/cli/spec.md | 7 ++++- 4 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 openspec/changes/archive/2026-08-24-nightly-self-reported-version/specs/analytics/spec.md create mode 100644 openspec/changes/archive/2026-08-24-nightly-self-reported-version/specs/cli/spec.md diff --git a/openspec/changes/archive/2026-08-24-nightly-self-reported-version/specs/analytics/spec.md b/openspec/changes/archive/2026-08-24-nightly-self-reported-version/specs/analytics/spec.md new file mode 100644 index 00000000..cf7adba0 --- /dev/null +++ b/openspec/changes/archive/2026-08-24-nightly-self-reported-version/specs/analytics/spec.md @@ -0,0 +1,30 @@ +## MODIFIED Requirements + +### Requirement: All capture calls include standard properties + +Every `capture()` call SHALL include the `cli` property (anonymous UUID), the `cliVersion` property (the version the build reports as its own, baked in at build time), and the `scaffoldVersion` property (the `version` field from `.taskless/taskless.json`, or `0` if the manifest is absent or unreadable). When authenticated, the `groups` parameter SHALL include `{ organization: String(orgId) }`. The `cliVersion` and `scaffoldVersion` values SHALL be resolved once at telemetry initialization and attached to every subsequent `capture()` call without re-reading the source files. + +#### Scenario: Anonymous capture includes standard properties + +- **WHEN** `capture("cli_run")` is called without authentication +- **THEN** the event SHALL include `{ cli: anonymousUuid, cliVersion: , scaffoldVersion: }` +- **AND** the event SHALL NOT include a `groups` parameter + +#### Scenario: Authenticated capture includes standard properties and group + +- **WHEN** `capture("cli_rule_created")` is called with authentication +- **THEN** the event SHALL include `{ cli: anonymousUuid, cliVersion: , scaffoldVersion: }` +- **AND** the `groups` parameter SHALL include `{ organization: String(orgId) }` + +#### Scenario: Scaffold version falls back to 0 when manifest missing + +- **WHEN** `getTelemetry(cwd)` is initialized in a directory with no `.taskless/taskless.json` +- **THEN** every `capture()` call from the returned client SHALL include `scaffoldVersion: 0` + +#### Scenario: CLI version is the version the build reports as its own + +- **WHEN** `getTelemetry()` is initialized +- **THEN** `cliVersion` SHALL be the version the build reports as its own, bundled at build time +- **AND** for every build target but `nightly` that SHALL be the version in `packages/cli/package.json` +- **AND** for a `nightly` it SHALL be the version the nightly is published under, so events are attributed to the build that emitted them rather than to the release it anticipates +- **AND** SHALL be attached to every event emitted through the returned client diff --git a/openspec/changes/archive/2026-08-24-nightly-self-reported-version/specs/cli/spec.md b/openspec/changes/archive/2026-08-24-nightly-self-reported-version/specs/cli/spec.md new file mode 100644 index 00000000..1fc3be28 --- /dev/null +++ b/openspec/changes/archive/2026-08-24-nightly-self-reported-version/specs/cli/spec.md @@ -0,0 +1,15 @@ +## MODIFIED Requirements + +### Requirement: CLI version is injected at build time + +The CLI's `vite.config.ts` SHALL use the Vite `define` option to replace a `__VERSION__` sentinel with the version the build reports as its own, resolved at build time. For every build target but `nightly` that is the version string in `packages/cli/package.json`. A `nightly` is the exception, because its version is stamped when the publishable artifact is produced and the committed manifest is deliberately left unchanged, so `package.json` names the release the nightly anticipates rather than the nightly itself (see the `cli-nightly-builds` capability). No runtime file reads or import assertions SHALL be used for version resolution. + +#### Scenario: Build replaces version sentinel + +- **WHEN** `pnpm build` is run in `packages/cli/` +- **THEN** all occurrences of `__VERSION__` in source code SHALL be replaced with the literal version string from package.json + +#### Scenario: A nightly build replaces the sentinel with its own stamped version + +- **WHEN** a `nightly` build is run +- **THEN** `__VERSION__` SHALL be replaced with the version the nightly is published under, not the version in package.json diff --git a/openspec/specs/analytics/spec.md b/openspec/specs/analytics/spec.md index 65b378df..8d8e2989 100644 --- a/openspec/specs/analytics/spec.md +++ b/openspec/specs/analytics/spec.md @@ -107,7 +107,7 @@ The PostHog client SHALL be created with `flushAt: 1` and `flushInterval: 0` bec ### Requirement: All capture calls include standard properties -Every `capture()` call SHALL include the `cli` property (anonymous UUID), the `cliVersion` property (the `@taskless/cli` package version read from `package.json`), and the `scaffoldVersion` property (the `version` field from `.taskless/taskless.json`, or `0` if the manifest is absent or unreadable). When authenticated, the `groups` parameter SHALL include `{ organization: String(orgId) }`. The `cliVersion` and `scaffoldVersion` values SHALL be resolved once at telemetry initialization and attached to every subsequent `capture()` call without re-reading the source files. +Every `capture()` call SHALL include the `cli` property (anonymous UUID), the `cliVersion` property (the version the build reports as its own, baked in at build time), and the `scaffoldVersion` property (the `version` field from `.taskless/taskless.json`, or `0` if the manifest is absent or unreadable). When authenticated, the `groups` parameter SHALL include `{ organization: String(orgId) }`. The `cliVersion` and `scaffoldVersion` values SHALL be resolved once at telemetry initialization and attached to every subsequent `capture()` call without re-reading the source files. #### Scenario: Anonymous capture includes standard properties @@ -126,10 +126,12 @@ Every `capture()` call SHALL include the `cli` property (anonymous UUID), the `c - **WHEN** `getTelemetry(cwd)` is initialized in a directory with no `.taskless/taskless.json` - **THEN** every `capture()` call from the returned client SHALL include `scaffoldVersion: 0` -#### Scenario: CLI version is resolved from package.json +#### Scenario: CLI version is the version the build reports as its own - **WHEN** `getTelemetry()` is initialized -- **THEN** `cliVersion` SHALL be read from `packages/cli/package.json` (bundled at build time or read at runtime) +- **THEN** `cliVersion` SHALL be the version the build reports as its own, bundled at build time +- **AND** for every build target but `nightly` that SHALL be the version in `packages/cli/package.json` +- **AND** for a `nightly` it SHALL be the version the nightly is published under, so events are attributed to the build that emitted them rather than to the release it anticipates - **AND** SHALL be attached to every event emitted through the returned client ### Requirement: CLI events use cli\_ prefix diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index dd567826..185579e0 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -268,13 +268,18 @@ The CLI SHALL support a `taskless info` subcommand that outputs a JSON object to ### Requirement: CLI version is injected at build time -The CLI's `vite.config.ts` SHALL use the Vite `define` option to replace a `__VERSION__` sentinel with the version string read from `packages/cli/package.json` at build time. No runtime file reads or import assertions SHALL be used for version resolution. +The CLI's `vite.config.ts` SHALL use the Vite `define` option to replace a `__VERSION__` sentinel with the version the build reports as its own, resolved at build time. For every build target but `nightly` that is the version string in `packages/cli/package.json`. A `nightly` is the exception, because its version is stamped when the publishable artifact is produced and the committed manifest is deliberately left unchanged, so `package.json` names the release the nightly anticipates rather than the nightly itself (see the `cli-nightly-builds` capability). No runtime file reads or import assertions SHALL be used for version resolution. #### Scenario: Build replaces version sentinel - **WHEN** `pnpm build` is run in `packages/cli/` - **THEN** all occurrences of `__VERSION__` in source code SHALL be replaced with the literal version string from package.json +#### Scenario: A nightly build replaces the sentinel with its own stamped version + +- **WHEN** a `nightly` build is run +- **THEN** `__VERSION__` SHALL be replaced with the version the nightly is published under, not the version in package.json + ### Requirement: citty is the argument parser The CLI SHALL use `citty` as its sole argument parsing dependency. Each subcommand SHALL be defined as an isolated command object and registered with the main CLI via `defineCommand` and `runMain`.