diff --git a/.agents/skills/taskless/SKILL.md b/.agents/skills/taskless/SKILL.md index 88f3e201..e4d8b8a6 100644 --- a/.agents/skills/taskless/SKILL.md +++ b/.agents/skills/taskless/SKILL.md @@ -17,7 +17,7 @@ description: | Also trigger on any request to add/write/create a lint or code rule, including ones that name a specific tool (eslint, ruff, biome, stylelint, ast-grep). Naming a tool ENGAGES this skill's routing flow via - `npx @taskless/cli agent route`; it does NOT suppress the skill. + `agent route`; it does NOT suppress the skill. metadata: type: shim --- diff --git a/.changeset/qualitative-survey-feedback.md b/.changeset/qualitative-survey-feedback.md new file mode 100644 index 00000000..e1b398c0 --- /dev/null +++ b/.changeset/qualitative-survey-feedback.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +`.taskless/.gitignore` now ignores `/.tmp-*`, the scratch request files the agent recipes write (`.tmp-rule-request.json`, `.tmp-improve-request.json`), so a file an agent forgot to clean up is a stray rather than a commit. This is scaffold migration 7; the scaffold's own `version` field carries the compatibility signal, and a project at 6 gains one ignore line the next time it is bootstrapped. diff --git a/.taskless/.gitignore b/.taskless/.gitignore index f67703dc..0fe05ba6 100644 --- a/.taskless/.gitignore +++ b/.taskless/.gitignore @@ -2,3 +2,4 @@ /sgconfig.yml /.vale.ini /.sgconfig.yml +/.tmp-* diff --git a/.taskless/taskless.json b/.taskless/taskless.json index 6bf9eb0c..72bd9028 100644 --- a/.taskless/taskless.json +++ b/.taskless/taskless.json @@ -1,5 +1,5 @@ { - "version": 6, + "version": 7, "install": { "targets": { ".taskless": { diff --git a/packages/cli/src/filesystem/migrate.ts b/packages/cli/src/filesystem/migrate.ts index eb0cd663..57cd0097 100644 --- a/packages/cli/src/filesystem/migrate.ts +++ b/packages/cli/src/filesystem/migrate.ts @@ -12,6 +12,7 @@ import dropInstalledAt from "./migrations/0003-drop-installed-at"; import valeEngine from "./migrations/0004-vale-engine"; import ruleDirectories from "./migrations/0005-rule-directories"; import refreshReadme from "./migrations/0006-refresh-readme"; +import ignoreScratchFiles from "./migrations/0007-ignore-scratch-files"; export interface TasklessInstallTarget { skills?: string[]; @@ -75,6 +76,7 @@ const migrations: Migrations = { "4": valeEngine, "5": ruleDirectories, "6": refreshReadme, + "7": ignoreScratchFiles, }; /** Global flag that downgrades a too-new scaffold from an error to a skip. */ diff --git a/packages/cli/src/filesystem/migrations/0007-ignore-scratch-files.ts b/packages/cli/src/filesystem/migrations/0007-ignore-scratch-files.ts new file mode 100644 index 00000000..4056b92f --- /dev/null +++ b/packages/cli/src/filesystem/migrations/0007-ignore-scratch-files.ts @@ -0,0 +1,31 @@ +import { dirname } from "node:path"; + +import { addToGitignore } from "../gitignore"; +import type { Migration } from "../types"; + +/** + * Ignore the scratch request files agent recipes write under `.taskless/`. + * + * `create-remote-rule` writes `.tmp-rule-request.json`, `improve-rule` writes + * `.tmp-improve-request.json`, and the feedback recipe writes + * `.tmp-feedback.json`. Each recipe ends with a clean-up step, and an agent + * that skips it leaves a file that `git status` then offers for commit. The + * ignore makes a forgotten scratch file a stray rather than a commit. + * + * A migration rather than an edit to `0001`, which also writes this file. A + * shipped migration is frozen: `runMigrations` runs only the migrations above + * the recorded version, so a change to `0001` reaches new scaffolds and never + * the projects that already exist, which is most of them. A fresh scaffold + * runs `1` through `7` in order and ends with the same file an upgraded one + * has. + * + * Anchored with a leading `/` for the reason `0001` anchors `/sgconfig.yml`: + * an unanchored `.tmp-*` would match at any depth, and a rule directory is + * free to carry a file by that name. + */ +const migration: Migration = async (directory) => { + // `addToGitignore` takes the project root and appends `.taskless` itself. + await addToGitignore(dirname(directory), ["/.tmp-*"]); +}; + +export default migration; diff --git a/packages/cli/test/migrate-ignore-scratch-files.test.ts b/packages/cli/test/migrate-ignore-scratch-files.test.ts new file mode 100644 index 00000000..0d5d30e7 --- /dev/null +++ b/packages/cli/test/migrate-ignore-scratch-files.test.ts @@ -0,0 +1,104 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { ensureTasklessDirectory } from "../src/filesystem/directory"; +import { LATEST_SCHEMA_VERSION } from "../src/filesystem/migrate"; +import migration from "../src/filesystem/migrations/0007-ignore-scratch-files"; + +/** + * Migration 0007 adds `/.tmp-*` to `.taskless/.gitignore`. + * + * `migrate-install.test.ts` proves every prior version reaches the latest + * counter; it never reads `.gitignore`, so a 0007 that stopped writing the + * line would pass there. This file reads the line. + */ +describe("migration 0007 ignores scratch request files", () => { + let directory: string; + let taskless: string; + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "tskl-0007-")); + taskless = join(directory, ".taskless"); + }); + + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + async function gitignoreLines(): Promise { + const content = await readFile(join(taskless, ".gitignore"), "utf8"); + return content.split("\n").filter(Boolean); + } + + it("adds the line to a version-6 scaffold and records 7", async () => { + await mkdir(taskless, { recursive: true }); + await writeFile( + join(taskless, "taskless.json"), + JSON.stringify({ version: 6, install: {} }), + "utf8" + ); + // What 0001 and 0004 leave behind on a current project. + await writeFile( + join(taskless, ".gitignore"), + ".env.local.json\n/sgconfig.yml\n", + "utf8" + ); + + await ensureTasklessDirectory(directory, { onNotice: () => {} }); + + const lines = await gitignoreLines(); + expect(lines).toContain("/.tmp-*"); + // The existing entries survive: the helper appends, it does not rewrite. + expect(lines).toContain(".env.local.json"); + expect(lines).toContain("/sgconfig.yml"); + + const manifest = JSON.parse( + await readFile(join(taskless, "taskless.json"), "utf8") + ) as { version: number }; + expect(manifest.version).toBe(7); + expect(LATEST_SCHEMA_VERSION).toBeGreaterThanOrEqual(7); + }); + + it("leaves a fresh scaffold with the same file", async () => { + // No `.taskless/` at all: migrations 1 through 7 run in order. The point + // is that 0001 is unchanged and the fresh path still ends here. + await ensureTasklessDirectory(directory, { onNotice: () => {} }); + + const lines = await gitignoreLines(); + expect(lines).toEqual( + expect.arrayContaining([".env.local.json", "/sgconfig.yml", "/.tmp-*"]) + ); + }); + + it("is idempotent", async () => { + await mkdir(taskless, { recursive: true }); + await writeFile(join(taskless, ".gitignore"), "/.tmp-*\n", "utf8"); + + await migration(taskless); + await migration(taskless); + + const lines = await gitignoreLines(); + expect(lines.filter((line) => line === "/.tmp-*")).toHaveLength(1); + }); + + it("makes a forgotten scratch file invisible to git", async () => { + await ensureTasklessDirectory(directory, { onNotice: () => {} }); + await writeFile(join(taskless, ".tmp-feedback.json"), "{}", "utf8"); + + // `git check-ignore` reads the nested `.taskless/.gitignore` the same way + // `git status` does, so this asks git rather than re-deriving its rules. + const run = promisify(execFile); + await run("git", ["init", "-q"], { cwd: directory }); + const { stdout } = await run( + "git", + ["check-ignore", ".taskless/.tmp-feedback.json"], + { cwd: directory } + ); + expect(stdout.trim()).toBe(".taskless/.tmp-feedback.json"); + }); +});