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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/taskless/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down
5 changes: 5 additions & 0 deletions .changeset/qualitative-survey-feedback.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions .taskless/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
/sgconfig.yml
/.vale.ini
/.sgconfig.yml
/.tmp-*
2 changes: 1 addition & 1 deletion .taskless/taskless.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": 6,
"version": 7,
"install": {
"targets": {
".taskless": {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/filesystem/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
104 changes: 104 additions & 0 deletions packages/cli/test/migrate-ignore-scratch-files.test.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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");
});
});
Loading