From 00a9e26c37ed170ae835b1e1ebb68c38325facc4 Mon Sep 17 00:00:00 2001 From: Vivek Yanamadula Date: Wed, 29 Jul 2026 17:01:48 -0400 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Add=20Rudder=20update=20workflo?= =?UTF-8?q?w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex Agent --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- package-lock.json | 4 +- package.json | 2 +- skills/rudder/SKILL.md | 119 +++++++++-- skills/rudder/scripts/update.mjs | 332 +++++++++++++++++++++++++++++++ test/plugin-package.test.ts | 77 +++++++ test/skill-runtime.test.ts | 235 ++++++++++++++++++++++ 9 files changed, 751 insertions(+), 26 deletions(-) create mode 100644 skills/rudder/scripts/update.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index df21bb3..b486d25 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -13,11 +13,11 @@ "source": { "source": "npm", "package": "@ruddercode/rudder-plugin", - "version": "0.1.2", + "version": "0.1.3", "registry": "https://registry.npmjs.org" }, "description": "Generate focused unit tests from coding-session intent and worktree changes using your existing coding agent.", - "version": "0.1.2", + "version": "0.1.3", "author": { "name": "RudderCode", "email": "vivek@archermoney.com" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 7e6b13d..e9d22a3 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "rudder", - "version": "0.1.2", + "version": "0.1.3", "description": "Generate focused unit tests from coding-session intent and worktree changes using your existing coding agent.", "author": { "name": "Vivek Yanamadula", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 57d2ef3..085f0bf 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "rudder", - "version": "0.1.2", + "version": "0.1.3", "description": "Generate focused unit tests from coding-session intent and worktree changes using your existing coding agent.", "author": { "name": "Vivek Yanamadula", diff --git a/package-lock.json b/package-lock.json index 68aa7aa..877a0ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ruddercode/rudder-plugin", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ruddercode/rudder-plugin", - "version": "0.1.2", + "version": "0.1.3", "license": "Apache-2.0", "devDependencies": { "@types/node": "^24", diff --git a/package.json b/package.json index bdcfab1..09dc7ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ruddercode/rudder-plugin", - "version": "0.1.2", + "version": "0.1.3", "description": "Generate focused unit tests from coding-session intent and worktree changes.", "type": "module", "engines": { diff --git a/skills/rudder/SKILL.md b/skills/rudder/SKILL.md index 0c1216c..2ba6eac 100644 --- a/skills/rudder/SKILL.md +++ b/skills/rudder/SKILL.md @@ -1,13 +1,42 @@ --- name: rudder -description: Generate and verify focused unit tests for branch changes using locally captured coding-session intent and the repository's native tooling. Use when the user asks to run Rudder, create or regenerate tests for current work, reach a coverage target, inspect captured Rudder context, or delete Rudder prompt data. Do not use for unrelated test maintenance or requests to change production code. +description: Use locally captured coding-session intent to generate focused tests, implement the smallest production changes through red-green TDD, and verify coverage with the repository's native tooling. Use when the user asks to run Rudder, create or regenerate tests for current work, reach a coverage target, inspect captured Rudder context, delete Rudder prompt data, or responds to a Rudder update notice. Do not use for unrelated test maintenance or standalone production changes. --- # Rudder -Use the current coding agent to derive tests from intent and branch changes. +Use the current coding agent to derive tests and production changes from intent. Keep prompt data and generation local; do not call a separate model or service. +## Check for updates at invocation start + +Start each Rudder test-generation invocation with `scripts/update.mjs check`. +Run it relative to this file exactly once before any test-generation work. +Do not interrupt the run when the check is unavailable. + +If `shouldNotify` is true, immediately show this notice: + +```text +Rudder is available—you are using . +Would you like to update to the latest version? +``` + +Use that text exactly. +Continue the current Rudder run without waiting for an answer. +If the user does not agree to update, show the notice on the next invocation. + +## Handle update-notice responses + +When the user agrees to update Rudder, handle the response: + +- Identify Codex or Claude Code. + Run `scripts/update.mjs apply --host ` relative to this file. + Use `codex` or `claude-code` for ``. + The user's affirmative response is the required authorization to update. +- If the helper reports `updated`, report its `nextStep`. +- On `failed`, report the failure and continue the active Rudder flow. +- For any other response, take no update action. + ## Handle data-control requests For requests to inspect or delete Rudder data, use @@ -22,7 +51,45 @@ Explain that `delete --confirm` irreversibly removes all prompt records. Do not run deletion without the user explicitly requesting it. Stop after completing a data-control request. -## Generate tests +## Enforce question-driven coverage + +Coverage is loop control, never a source of test intent. + +- Generate or expand a test only when a captured user prompt or answer + explicitly requires its expectation. +- Never add a test merely to execute an uncovered line or branch, match the + implementation, improve coverage, or exercise a defensive case. +- After the first test pass, if coverage is below the target, stop editing tests. + Select one uncovered behavior. + Ask one concrete question about the expected behavior. +- Do not write the next test until the user answers. + Repository code may help frame the question, but it cannot supply the answer. +- After each answer, rerun `scripts/context.mjs`, require a captured prompt + record for the answer, and add only the expectation that answer authorizes. +- Complete the red-green cycle before measuring coverage or asking another + question. +- If the answer is missing, declined, or not captured, stop below the target. + Report the uncovered behavior. + Never fill the gap by inference. + +## Enforce red-green TDD + +For every new or changed expectation: + +1. Write and tag the focused test before changing production code. +2. Run the narrowest test and observe the expected failure. + Confirm it fails because the required behavior is missing. + If the test is wrong, correct it and rerun it. + Do not weaken the prompt-backed expectation. + If it already passes, do not change production code for that expectation. +3. Make the smallest production change required to satisfy the expectation. + Do not change unrelated behavior or weaken the test. + Do not alter coverage configuration or thresholds. +4. Rerun the narrow test until it passes. + Then run the applicable related and full test commands. +5. Measure coverage only after the suite is green. + +## Run the workflow 1. Determine the repository root and target branch from the request. Determine the requested coverage target. @@ -42,11 +109,19 @@ Stop after completing a data-control request. Inspect the native test and coverage configuration. Treat helper classifications as candidates. Correct them using repository conventions. -4. Turn only directly expressed user intent into behavioral requirements. - A captured prompt or the current conversation must require the expectation. - Only then treat an existing test change as intended. - Otherwise flag it as an ambiguity. +4. Turn only directly expressed, captured user intent into behavioral requirements. + Every generated expectation must be traceable to a prompt record. + Otherwise, follow the question-driven coverage rules above. 5. Show the exact tracked and untracked test paths that would be affected. + Before clearing them, inspect each confirmed path for test cases with an + immediately preceding Rudder source-intent tag in the required format: + + ```text + // + ``` + + Record the exact tagged test cases and their tags. Do not treat a tag on a + helper, fixture, or a non-adjacent comment as a generated test. Request explicit confirmation before clearing any test change. Do not proceed on silence or an ambiguous reply. 6. After confirmation, create a recoverable backup for the exact approved paths: @@ -61,10 +136,15 @@ Stop after completing a data-control request. Verify the reported patch and copied untracked files exist. Then restore only the confirmed test paths to the merge-base state. + After that reset, attempt to restore each recorded, tagged test case with + its intent tag. Restore only the smallest imports, fixtures, or helpers + required for those test cases to compile; do not restore untagged tests or + the entire test file. If a tagged test cannot be isolated and restored + reliably, leave it in the backup and report the unsuccessful restoration. Never use `git reset --hard`, broad `git clean`, or change production files. -7. Generate focused unit tests for changed production behavior. +7. Generate the first pass of focused unit tests from already captured intent. Follow existing organization, fixtures, naming, and framework conventions. - Do not change production code, coverage configuration, or repository thresholds. + Do not change coverage configuration or repository thresholds. 8. Tag every generated or rewritten test case with its source intent. Add a single language-appropriate line comment immediately above the test case. Use identifiers from the captured prompt record that requires the expectation: @@ -78,15 +158,16 @@ Stop after completing a data-control request. When multiple prompts directly contribute, tag with the most relevant prompt. Rerun the context helper after a follow-up answer to get its prompt record. Do not tag unchanged preexisting tests, shared helpers, or fixtures. -9. Run the narrowest relevant native tests first. - Then run the applicable repository test and coverage commands. - Measure changed production code when the tooling supports it. -10. Identify failures or uncovered branches that depend on missing intent. - Ask one concrete question whose answer changes a test expectation. - Do not ask for facts inferable from code, prompts, or the repository. -11. Incorporate the answer, rerun tests and coverage, and continue until the - configured target passes or a concrete blocker remains. - -Report the requirements derived from intent and test files changed. +9. Run the red-green TDD cycle for every prompt-backed expectation. +10. When the suite is green, run the applicable coverage command. + Measure changed production code when the tooling supports it. + If coverage is below the target, follow the question-driven coverage loop. + Ask exactly one question to the user. +11. After the answer is captured, add only its authorized expectation. + Run the red-green cycle, measure coverage, then ask the next question. + Continue until the target passes or the user tells you to stop the flow. + Also stop if the user leaves a question unanswered. + +Report the requirements derived from intent and all files changed. Report commands run, coverage, unanswered ambiguities, and the backup location. Never claim the target passed without command output that demonstrates it. diff --git a/skills/rudder/scripts/update.mjs b/skills/rudder/scripts/update.mjs new file mode 100644 index 0000000..f91062e --- /dev/null +++ b/skills/rudder/scripts/update.mjs @@ -0,0 +1,332 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; +const CHECK_TIMEOUT_MS = 1500; +const UPDATE_COMMAND_ATTEMPTS = 3; +const REGISTRY_URL = + 'https://registry.npmjs.org/@ruddercode%2Frudder-plugin/latest'; + +const updatePlans = { + codex: { + commands: [ + ['codex', 'plugin', 'marketplace', 'upgrade', 'rudder', '--json'], + ['codex', 'plugin', 'add', 'rudder@rudder', '--json'], + ], + nextStep: 'Start a new Codex session to use the updated Rudder plugin.', + }, + 'claude-code': { + commands: [ + ['claude', 'plugin', 'marketplace', 'update', 'rudder'], + ['claude', 'plugin', 'update', 'rudder@rudder'], + ], + nextStep: 'Run /reload-plugins in this session to use the updated Rudder plugin.', + }, +}; + +function rudderHome() { + return process.env.RUDDER_HOME || join(homedir(), '.rudder'); +} + +function statePath() { + return join(rudderHome(), 'update-state.json'); +} + +function packageVersion() { + const packagePath = fileURLToPath( + new URL('../../../package.json', import.meta.url) + ); + const manifest = JSON.parse(readFileSync(packagePath, 'utf8')); + if (typeof manifest.version !== 'string' || !manifest.version) { + throw new TypeError('Rudder package version is missing'); + } + return manifest.version; +} + +function parseVersion(version) { + const match = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec( + version + ); + if (!match) return null; + return { + core: match.slice(1, 4).map(Number), + prerelease: match[4]?.split('.') ?? [], + }; +} + +function comparePrerelease(left, right) { + if (left.length === 0 || right.length === 0) { + if (left.length === right.length) return 0; + return left.length === 0 ? 1 : -1; + } + + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const leftPart = left[index]; + const rightPart = right[index]; + if (leftPart === undefined || rightPart === undefined) { + if (leftPart === rightPart) return 0; + return leftPart === undefined ? -1 : 1; + } + if (leftPart === rightPart) continue; + + const leftNumber = /^\d+$/u.test(leftPart) ? Number(leftPart) : null; + const rightNumber = /^\d+$/u.test(rightPart) ? Number(rightPart) : null; + if (leftNumber !== null && rightNumber !== null) { + return Math.sign(leftNumber - rightNumber); + } + if (leftNumber !== null || rightNumber !== null) { + return leftNumber !== null ? -1 : 1; + } + return leftPart < rightPart ? -1 : 1; + } + return 0; +} + +export function compareVersions(leftVersion, rightVersion) { + const left = parseVersion(leftVersion); + const right = parseVersion(rightVersion); + if (!left || !right) return null; + + for (let index = 0; index < left.core.length; index += 1) { + if (left.core[index] !== right.core[index]) { + return Math.sign(left.core[index] - right.core[index]); + } + } + return comparePrerelease(left.prerelease, right.prerelease); +} + +function readState() { + try { + if (!existsSync(statePath())) return {}; + const state = JSON.parse(readFileSync(statePath(), 'utf8')); + if (typeof state !== 'object' || state === null || Array.isArray(state)) { + return {}; + } + return state; + } catch { + return {}; + } +} + +function writeState(state) { + const home = rudderHome(); + mkdirSync(home, { recursive: true, mode: 0o700 }); + const temporaryPath = join(home, `.update-state-${process.pid}.json`); + try { + writeFileSync(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, { + mode: 0o600, + }); + renameSync(temporaryPath, statePath()); + } finally { + rmSync(temporaryPath, { force: true }); + } +} + +function tryWriteState(state) { + try { + writeState(state); + } catch { + // Update checks are best-effort and must never block a Rudder invocation. + } +} + +function cachedVersion(state) { + return typeof state.latestVersion === 'string' + ? state.latestVersion + : null; +} + +function cacheIsFresh(state, now) { + if (typeof state.lastCheckedAt !== 'string') return false; + const checkedAt = Date.parse(state.lastCheckedAt); + const age = now - checkedAt; + return Number.isFinite(checkedAt) && age >= 0 && age < CHECK_INTERVAL_MS; +} + +async function fetchLatestVersion() { + const response = await fetch(REGISTRY_URL, { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(CHECK_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`npm registry returned HTTP ${response.status}`); + } + const manifest = await response.json(); + if ( + typeof manifest !== 'object' || + manifest === null || + typeof manifest.version !== 'string' || + compareVersions(manifest.version, manifest.version) === null + ) { + throw new TypeError('npm registry returned an invalid Rudder version'); + } + return manifest.version; +} + +function result(currentVersion, latestVersion, source) { + const comparison = latestVersion + ? compareVersions(latestVersion, currentVersion) + : null; + const updateAvailable = comparison !== null && comparison > 0; + return { + currentVersion, + latestVersion, + updateAvailable, + shouldNotify: updateAvailable, + source, + }; +} + +export async function checkForUpdate({ force = false } = {}) { + const currentVersion = packageVersion(); + if (process.env.RUDDER_DISABLE_UPDATE_CHECK === '1') { + return result(currentVersion, null, 'disabled'); + } + + const now = Date.now(); + const state = readState(); + if (!force && cacheIsFresh(state, now)) { + return result(currentVersion, cachedVersion(state), 'cache'); + } + + try { + const latestVersion = await fetchLatestVersion(); + const nextState = { + schemaVersion: 1, + lastCheckedAt: new Date(now).toISOString(), + latestVersion, + }; + tryWriteState(nextState); + return result(currentVersion, latestVersion, 'registry'); + } catch { + const nextState = { + schemaVersion: 1, + lastCheckedAt: new Date(now).toISOString(), + latestVersion: cachedVersion(state), + }; + tryWriteState(nextState); + return result( + currentVersion, + cachedVersion(nextState), + cachedVersion(nextState) ? 'stale-cache' : 'unavailable' + ); + } +} + +export function updatePlan(host) { + const plan = updatePlans[host]; + if (!plan) { + throw new TypeError('host must be codex or claude-code'); + } + return plan; +} + +function commandError(command, args, run) { + return ( + run.error?.message || + run.stderr?.trim() || + run.stdout?.trim() || + `${command} ${args.join(' ')} exited with ${run.status}` + ); +} + +function runCommand([command, ...args]) { + let failure; + for (let attempt = 0; attempt < UPDATE_COMMAND_ATTEMPTS; attempt += 1) { + const run = spawnSync(command, args, { + encoding: 'utf8', + timeout: 60_000, + }); + if (!run.error && run.status === 0) return null; + failure = commandError(command, args, run); + } + return failure; +} + +export async function applyUpdate(host) { + const update = await checkForUpdate({ force: true }); + if (!update.updateAvailable || !update.latestVersion) { + return { status: 'current', ...update }; + } + + const plan = updatePlan(host); + for (const command of plan.commands) { + const error = runCommand(command); + if (error) { + return { + status: 'failed', + previousVersion: update.currentVersion, + version: update.latestVersion, + error, + }; + } + } + return { + status: 'updated', + previousVersion: update.currentVersion, + version: update.latestVersion, + nextStep: plan.nextStep, + }; +} + +function argumentValue(args, name) { + const index = args.indexOf(name); + if (index === -1) return null; + const value = args[index + 1]; + if (!value || value.startsWith('--')) { + throw new TypeError(`${name} requires a value`); + } + return value; +} + +async function main() { + const args = process.argv.slice(2); + const command = args[0]; + let output; + + if (command === 'check') { + output = await checkForUpdate({ force: args.includes('--force') }); + } else if (command === 'plan') { + const host = argumentValue(args, '--host'); + if (!host) throw new TypeError('plan requires --host'); + output = updatePlan(host); + } else if (command === 'apply') { + const host = argumentValue(args, '--host'); + if (!host) throw new TypeError('apply requires --host'); + output = await applyUpdate(host); + } else { + throw new TypeError( + 'usage: update.mjs |apply --host >' + ); + } + + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); +} + +const entrypoint = process.argv[1] + ? pathToFileURL(process.argv[1]).href + : null; +if (entrypoint === import.meta.url) { + try { + await main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +} diff --git a/test/plugin-package.test.ts b/test/plugin-package.test.ts index 4d6fb07..a77249c 100644 --- a/test/plugin-package.test.ts +++ b/test/plugin-package.test.ts @@ -149,6 +149,83 @@ test('ships a public marketplace catalog and complete Rudder skill', () => { } }); +// codex/019faf66-7413-7a31-a0ea-b5fe1c9b66d6/019faf6c-61f1-7512-808a-f870371b1baa +test('runs the update helper before Rudder generates tests', () => { + const skill = readFileSync( + join(pluginRoot, 'skills', 'rudder', 'SKILL.md'), + 'utf8' + ); + const updateScript = readFileSync( + join(pluginRoot, 'skills', 'rudder', 'scripts', 'update.mjs'), + 'utf8' + ); + + assert.ok(updateScript.length > 0); + assert.equal(skill.match(/scripts\/update\.mjs check/gu)?.length, 1); + assert.ok( + skill.indexOf('scripts/update.mjs check') < + skill.indexOf('## Run the workflow') + ); +}); + +// codex/019faf66-7413-7a31-a0ea-b5fe1c9b66d6/019faf7c-40c9-7153-b2b9-f1a62201d44e +test('keeps coverage generation inside the user question flow', () => { + const skill = readFileSync( + join(pluginRoot, 'skills', 'rudder', 'SKILL.md'), + 'utf8' + ); + + assert.match(skill, /Ask exactly one question to the user\./); + assert.match(skill, /or the user tells you to stop the flow\./); + assert.match(skill, /Do not write the next test until the user answers\./); +}); + +// codex/019faf66-7413-7a31-a0ea-b5fe1c9b66d6/019faf93-22e4-7093-92da-391b03125caa +test('requires a prompt-backed red-green production cycle', () => { + const skill = readFileSync( + join(pluginRoot, 'skills', 'rudder', 'SKILL.md'), + 'utf8' + ); + + assert.match( + skill, + /Write and tag the focused test before changing production code\./ + ); + assert.match(skill, /observe the expected failure\./); + assert.match(skill, /smallest production change required/); + assert.match(skill, /Rerun the narrow test until it passes\./); + assert.match(skill, /Measure coverage only after the suite is green\./); + assert.doesNotMatch( + skill, + /Do not change production code, coverage configuration/ + ); +}); + +// codex/019faf9a-f8c0-7d33-8f77-a17b27aa7a14/019fafa2-116b-7440-8f47-3c40777c5b55 +test('restores only Rudder-tagged generated tests after the merge-base reset', () => { + const skill = readFileSync( + join(pluginRoot, 'skills', 'rudder', 'SKILL.md'), + 'utf8' + ); + + assert.match(skill, /immediately preceding Rudder source-intent tag/); + assert.match(skill, /Record the exact tagged test cases and their tags\./); + assert.match(skill, /attempt to restore each recorded, tagged test case/); + assert.match(skill, /do not restore untagged tests or\n the entire test file/); + assert.match(skill, /report the unsuccessful restoration/); +}); + +// codex/019faf66-7413-7a31-a0ea-b5fe1c9b66d6/019faf90-8df6-7223-aa9f-33012b8f26cb +test('keeps an unsuccessful plugin update non-blocking', () => { + const skill = readFileSync( + join(pluginRoot, 'skills', 'rudder', 'SKILL.md'), + 'utf8' + ); + + assert.match(skill, /report the failure and continue the active Rudder flow/); + assert.doesNotMatch(skill, /handle the response and stop/); +}); + test('releases the root plugin package with plugin-specific artifacts', () => { const publishWorkflow = readFileSync( join(pluginRoot, '.github', 'workflows', 'publish.yml'), diff --git a/test/skill-runtime.test.ts b/test/skill-runtime.test.ts index 329b2ff..a23607a 100644 --- a/test/skill-runtime.test.ts +++ b/test/skill-runtime.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; import { + chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -41,6 +42,32 @@ const dataScript = join( 'scripts', 'manage-data.mjs' ); +const updateScriptUrl = new URL( + '../skills/rudder/scripts/update.mjs', + import.meta.url +); + +interface UpdateResult { + currentVersion: string; + latestVersion: string | null; + updateAvailable: boolean; + shouldNotify: boolean; + source: string; +} + +interface UpdateModule { + applyUpdate(host: 'codex' | 'claude-code'): Promise<{ + status: string; + error?: string; + previousVersion?: string; + version?: string; + }>; + checkForUpdate(options?: { force?: boolean }): Promise; + updatePlan(host: 'codex' | 'claude-code'): { + commands: string[][]; + nextStep: string; + }; +} let root: string; let repo: string; @@ -62,6 +89,10 @@ function runData(...args: string[]): Record { ) as Record; } +async function loadUpdateModule(): Promise { + return import(updateScriptUrl.href) as Promise; +} + before(() => { root = mkdtempSync(join(tmpdir(), 'rudder-skill-runtime-')); repo = join(root, 'repo'); @@ -169,6 +200,210 @@ test('the skill helper returns branch changes and locally captured intent', () = ); }); +// codex/019faf66-7413-7a31-a0ea-b5fe1c9b66d6/019faf8b-5c9c-7962-b60a-19e540b127d5 +test('caches the latest registry version while reminders continue', async () => { + const updateStatePath = join(stateRoot, 'update-state.json'); + const originalFetch = globalThis.fetch; + let fetchCount = 0; + globalThis.fetch = async () => { + fetchCount += 1; + return new Response(JSON.stringify({ version: '0.1.4' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + + try { + rmSync(updateStatePath, { force: true }); + const { checkForUpdate } = await loadUpdateModule(); + + const discovered = await checkForUpdate({ force: true }); + assert.equal(discovered.currentVersion, '0.1.3'); + assert.equal(discovered.latestVersion, '0.1.4'); + assert.equal(discovered.shouldNotify, true); + assert.equal(discovered.source, 'registry'); + + const cached = await checkForUpdate(); + assert.equal(cached.shouldNotify, true); + assert.equal(cached.source, 'cache'); + assert.equal(fetchCount, 1); + + const state = JSON.parse(readFileSync(updateStatePath, 'utf8')) as { + schemaVersion: number; + lastCheckedAt: string; + latestVersion: string; + }; + assert.equal(state.schemaVersion, 1); + assert.ok(Number.isFinite(Date.parse(state.lastCheckedAt))); + assert.equal(state.latestVersion, '0.1.4'); + } finally { + globalThis.fetch = originalFetch; + rmSync(updateStatePath, { force: true }); + } +}); + +// codex/019faf66-7413-7a31-a0ea-b5fe1c9b66d6/019faf95-d1bc-7911-a067-9e6580871b15 +test('uses stale cache or skips the prompt when registry data is unavailable', async () => { + const updateStatePath = join(stateRoot, 'update-state.json'); + const originalFetch = globalThis.fetch; + + try { + mkdirSync(stateRoot, { recursive: true }); + writeFileSync( + updateStatePath, + JSON.stringify({ + schemaVersion: 1, + lastCheckedAt: '2000-01-01T00:00:00.000Z', + latestVersion: '0.1.4', + }) + ); + globalThis.fetch = async () => { + throw new Error('offline'); + }; + + const { checkForUpdate } = await loadUpdateModule(); + const staleCache = await checkForUpdate({ force: true }); + + assert.equal(staleCache.latestVersion, '0.1.4'); + assert.equal(staleCache.shouldNotify, true); + assert.equal(staleCache.source, 'stale-cache'); + + rmSync(updateStatePath, { force: true }); + globalThis.fetch = async () => + new Response(JSON.stringify({ version: 'invalid' }), { status: 200 }); + + const unavailable = await checkForUpdate({ force: true }); + + assert.equal(unavailable.latestVersion, null); + assert.equal(unavailable.shouldNotify, false); + assert.equal(unavailable.source, 'unavailable'); + } finally { + globalThis.fetch = originalFetch; + rmSync(updateStatePath, { force: true }); + } +}); + +// codex/019faf9a-f8c0-7d33-8f77-a17b27aa7a14/019fafac-79fa-7e03-9de5-78fcfeb51076 +test('uses the default Rudder marketplace for an accepted update', async () => { + const { updatePlan } = await loadUpdateModule(); + + assert.deepEqual(updatePlan('codex').commands, [ + ['codex', 'plugin', 'marketplace', 'upgrade', 'rudder', '--json'], + ['codex', 'plugin', 'add', 'rudder@rudder', '--json'], + ]); + assert.deepEqual(updatePlan('claude-code').commands, [ + ['claude', 'plugin', 'marketplace', 'update', 'rudder'], + ['claude', 'plugin', 'update', 'rudder@rudder'], + ]); +}); + +// codex/019faf66-7413-7a31-a0ea-b5fe1c9b66d6/019faf99-21d4-7f62-ba6f-48cd6cb249c8 +test('exposes a JSON CLI contract with clear argument errors', () => { + const runUpdateCli = (...args: string[]) => + spawnSync(process.execPath, [fileURLToPath(updateScriptUrl), ...args], { + encoding: 'utf8', + env: { + ...process.env, + RUDDER_DISABLE_UPDATE_CHECK: '1', + RUDDER_HOME: stateRoot, + }, + }); + + const checked = runUpdateCli('check'); + assert.equal(checked.status, 0); + assert.equal(JSON.parse(checked.stdout).source, 'disabled'); + + const planned = runUpdateCli('plan', '--host', 'codex'); + assert.equal(planned.status, 0); + assert.deepEqual(JSON.parse(planned.stdout).commands, [ + ['codex', 'plugin', 'marketplace', 'upgrade', 'rudder', '--json'], + ['codex', 'plugin', 'add', 'rudder@rudder', '--json'], + ]); + + const applied = runUpdateCli('apply', '--host', 'codex'); + assert.equal(applied.status, 0); + assert.equal(JSON.parse(applied.stdout).status, 'current'); + + const incomplete = runUpdateCli('plan', '--host'); + assert.equal(incomplete.status, 1); + assert.match(incomplete.stderr, /--host requires a value/); + + const unsupportedHost = runUpdateCli('plan', '--host', 'other'); + assert.equal(unsupportedHost.status, 1); + assert.match(unsupportedHost.stderr, /host must be codex or claude-code/); + + const unsupported = runUpdateCli('unsupported'); + assert.equal(unsupported.status, 1); + assert.match(unsupported.stderr, /usage: update\.mjs/); +}); + +// codex/019faf66-7413-7a31-a0ea-b5fe1c9b66d6/019faf90-8df6-7223-aa9f-33012b8f26cb +test('retries a failed update twice without blocking the flow', async () => { + const updateStatePath = join(stateRoot, 'update-state.json'); + const commandLog = join(root, 'update-commands.log'); + const fakeBin = join(root, 'update-bin'); + const executable = join(fakeBin, 'codex'); + const originalFetch = globalThis.fetch; + const originalPath = process.env.PATH; + const originalLog = process.env.RUDDER_TEST_COMMAND_LOG; + const originalFailure = process.env.RUDDER_TEST_UPDATE_FAILURE; + + mkdirSync(fakeBin, { recursive: true }); + writeFileSync( + executable, + [ + '#!/bin/sh', + 'printf \'%s\\n\' "$*" >> "$RUDDER_TEST_COMMAND_LOG"', + 'if [ "$RUDDER_TEST_UPDATE_FAILURE" = "1" ]; then', + ' echo "simulated plugin failure" >&2', + ' exit 7', + 'fi', + '', + ].join('\n') + ); + chmodSync(executable, 0o755); + globalThis.fetch = async () => + new Response(JSON.stringify({ version: '0.1.4' }), { status: 200 }); + process.env.PATH = `${fakeBin}:${originalPath ?? ''}`; + process.env.RUDDER_TEST_COMMAND_LOG = commandLog; + + try { + rmSync(updateStatePath, { force: true }); + const { applyUpdate } = await loadUpdateModule(); + + const updated = await applyUpdate('codex'); + assert.equal(updated.status, 'updated'); + assert.deepEqual(readFileSync(commandLog, 'utf8').trim().split('\n'), [ + 'plugin marketplace upgrade rudder --json', + 'plugin add rudder@rudder --json', + ]); + + writeFileSync(commandLog, ''); + process.env.RUDDER_TEST_UPDATE_FAILURE = '1'; + const failed = await applyUpdate('codex'); + + assert.equal(failed.status, 'failed'); + assert.match(failed.error ?? '', /simulated plugin failure/); + assert.deepEqual(readFileSync(commandLog, 'utf8').trim().split('\n'), [ + 'plugin marketplace upgrade rudder --json', + 'plugin marketplace upgrade rudder --json', + 'plugin marketplace upgrade rudder --json', + ]); + } finally { + globalThis.fetch = originalFetch; + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + if (originalLog === undefined) delete process.env.RUDDER_TEST_COMMAND_LOG; + else process.env.RUDDER_TEST_COMMAND_LOG = originalLog; + if (originalFailure === undefined) { + delete process.env.RUDDER_TEST_UPDATE_FAILURE; + } else { + process.env.RUDDER_TEST_UPDATE_FAILURE = originalFailure; + } + rmSync(updateStatePath, { force: true }); + } +}); + test('the skill helper backs up only explicit test paths', () => { const backup = JSON.parse( execFileSync( From f1966331c84993aac1f6da4519019615b6b41b23 Mon Sep 17 00:00:00 2001 From: Vivek Yanamadula Date: Wed, 29 Jul 2026 17:03:31 -0400 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20Fix=20Rudder=20workflow=20fo?= =?UTF-8?q?rmatting=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex Agent --- skills/rudder/SKILL.md | 15 ++++++--------- test/plugin-package.test.ts | 2 +- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/skills/rudder/SKILL.md b/skills/rudder/SKILL.md index 2ba6eac..d667eeb 100644 --- a/skills/rudder/SKILL.md +++ b/skills/rudder/SKILL.md @@ -113,15 +113,14 @@ For every new or changed expectation: Every generated expectation must be traceable to a prompt record. Otherwise, follow the question-driven coverage rules above. 5. Show the exact tracked and untracked test paths that would be affected. - Before clearing them, inspect each confirmed path for test cases with an - immediately preceding Rudder source-intent tag in the required format: + Before clearing them, inspect each confirmed path for test cases with an immediately preceding Rudder source-intent tag in the required format: ```text // ``` - Record the exact tagged test cases and their tags. Do not treat a tag on a - helper, fixture, or a non-adjacent comment as a generated test. + Record the exact tagged test cases and their tags. + Do not treat a tag on a helper, fixture, or a non-adjacent comment as a generated test. Request explicit confirmation before clearing any test change. Do not proceed on silence or an ambiguous reply. 6. After confirmation, create a recoverable backup for the exact approved paths: @@ -136,11 +135,9 @@ For every new or changed expectation: Verify the reported patch and copied untracked files exist. Then restore only the confirmed test paths to the merge-base state. - After that reset, attempt to restore each recorded, tagged test case with - its intent tag. Restore only the smallest imports, fixtures, or helpers - required for those test cases to compile; do not restore untagged tests or - the entire test file. If a tagged test cannot be isolated and restored - reliably, leave it in the backup and report the unsuccessful restoration. + After that reset, attempt to restore each recorded, tagged test case with its intent tag. + Restore only the smallest imports, fixtures, or helpers required for those test cases to compile; do not restore untagged tests or the entire test file. + If a tagged test cannot be isolated and restored reliably, leave it in the backup and report the unsuccessful restoration. Never use `git reset --hard`, broad `git clean`, or change production files. 7. Generate the first pass of focused unit tests from already captured intent. Follow existing organization, fixtures, naming, and framework conventions. diff --git a/test/plugin-package.test.ts b/test/plugin-package.test.ts index a77249c..6b2773f 100644 --- a/test/plugin-package.test.ts +++ b/test/plugin-package.test.ts @@ -211,7 +211,7 @@ test('restores only Rudder-tagged generated tests after the merge-base reset', ( assert.match(skill, /immediately preceding Rudder source-intent tag/); assert.match(skill, /Record the exact tagged test cases and their tags\./); assert.match(skill, /attempt to restore each recorded, tagged test case/); - assert.match(skill, /do not restore untagged tests or\n the entire test file/); + assert.match(skill, /do not restore untagged tests or the entire test file/); assert.match(skill, /report the unsuccessful restoration/); }); From 3941faa92fe77780df867a48006d79db3199d16e Mon Sep 17 00:00:00 2001 From: Vivek Yanamadula Date: Wed, 29 Jul 2026 17:05:14 -0400 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20Preserve=20stale=20Rudder=20?= =?UTF-8?q?update=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex Agent --- skills/rudder/scripts/update.mjs | 11 +++-------- test/skill-runtime.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/skills/rudder/scripts/update.mjs b/skills/rudder/scripts/update.mjs index f91062e..c2e4668 100644 --- a/skills/rudder/scripts/update.mjs +++ b/skills/rudder/scripts/update.mjs @@ -213,16 +213,11 @@ export async function checkForUpdate({ force = false } = {}) { tryWriteState(nextState); return result(currentVersion, latestVersion, 'registry'); } catch { - const nextState = { - schemaVersion: 1, - lastCheckedAt: new Date(now).toISOString(), - latestVersion: cachedVersion(state), - }; - tryWriteState(nextState); + const latestVersion = cachedVersion(state); return result( currentVersion, - cachedVersion(nextState), - cachedVersion(nextState) ? 'stale-cache' : 'unavailable' + latestVersion, + latestVersion ? 'stale-cache' : 'unavailable' ); } } diff --git a/test/skill-runtime.test.ts b/test/skill-runtime.test.ts index a23607a..ff3c317 100644 --- a/test/skill-runtime.test.ts +++ b/test/skill-runtime.test.ts @@ -267,6 +267,14 @@ test('uses stale cache or skips the prompt when registry data is unavailable', a assert.equal(staleCache.latestVersion, '0.1.4'); assert.equal(staleCache.shouldNotify, true); assert.equal(staleCache.source, 'stale-cache'); + const stateAfterFailure = JSON.parse( + readFileSync(updateStatePath, 'utf8') + ) as { + lastCheckedAt: string; + latestVersion: string; + }; + assert.equal(stateAfterFailure.lastCheckedAt, '2000-01-01T00:00:00.000Z'); + assert.equal(stateAfterFailure.latestVersion, '0.1.4'); rmSync(updateStatePath, { force: true }); globalThis.fetch = async () =>