tdd-enforcer 0.2.2 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine/git.ts CHANGED
@@ -4,6 +4,24 @@ import { join } from "node:path";
4
4
 
5
5
  const TDD_DIR = ".pi/tdd";
6
6
 
7
+ export type GitDeps = {
8
+ execSync: (command: string, options?: ExecSyncOptions) => Buffer;
9
+ existsSync: (path: string) => boolean;
10
+ mkdirSync: (path: string, options?: { recursive?: boolean }) => void;
11
+ writeFileSync: (path: string, data: string, encoding: BufferEncoding) => void;
12
+ unlinkSync: (path: string) => void;
13
+ rmSync: (path: string, options?: { recursive?: boolean; force?: boolean }) => void;
14
+ };
15
+
16
+ const defaultDeps: GitDeps = {
17
+ execSync: execSync as (command: string, options?: ExecSyncOptions) => Buffer,
18
+ existsSync,
19
+ mkdirSync,
20
+ writeFileSync: writeFileSync as (path: string, data: string, encoding: BufferEncoding) => void,
21
+ unlinkSync,
22
+ rmSync,
23
+ };
24
+
7
25
  function gitEnv(projectRoot: string): NodeJS.ProcessEnv {
8
26
  const gitDir = join(projectRoot, TDD_DIR, ".git");
9
27
  return {
@@ -12,116 +30,129 @@ function gitEnv(projectRoot: string): NodeJS.ProcessEnv {
12
30
  };
13
31
  }
14
32
 
15
- function gitExec(args: string, projectRoot: string, options?: ExecSyncOptions): string {
33
+ function gitExec(args: string, projectRoot: string, deps: GitDeps, options?: ExecSyncOptions): string {
16
34
  const env = { ...process.env, ...gitEnv(projectRoot) };
17
- return execSync(`git ${args}`, { ...options, env, encoding: "utf-8" } as ExecSyncOptions).toString();
35
+ return deps.execSync(`git ${args}`, { ...options, env, encoding: "utf-8" } as ExecSyncOptions).toString();
18
36
  }
19
37
 
20
- export function initGit(projectRoot: string): void {
38
+ export function initGit(projectRoot: string, deps: GitDeps = defaultDeps): void {
21
39
  const tddPath = join(projectRoot, TDD_DIR);
22
40
  const gitDir = join(tddPath, ".git");
23
- if (existsSync(gitDir)) return;
41
+ if (deps.existsSync(gitDir)) return;
24
42
 
25
- mkdirSync(tddPath, { recursive: true });
26
- gitExec(`init "${tddPath}"`, projectRoot, { stdio: "pipe" as const });
27
- gitExec(`config core.worktree "${projectRoot}"`, projectRoot, { stdio: "pipe" as const });
28
- gitExec(`config core.excludesFile "${join(tddPath, ".gitignore")}"`, projectRoot, { stdio: "pipe" as const });
43
+ deps.mkdirSync(tddPath, { recursive: true });
44
+ gitExec(`init "${tddPath}"`, projectRoot, deps, { stdio: "pipe" as const });
45
+ gitExec(`config core.worktree "${projectRoot}"`, projectRoot, deps, { stdio: "pipe" as const });
46
+ gitExec(`config core.excludesFile "${join(tddPath, ".gitignore")}"`, projectRoot, deps, { stdio: "pipe" as const });
29
47
 
30
48
  const gitignorePath = join(tddPath, ".gitignore");
31
- if (!existsSync(gitignorePath)) {
32
- writeFileSync(
49
+ if (!deps.existsSync(gitignorePath)) {
50
+ deps.writeFileSync(
33
51
  gitignorePath,
34
52
  ["node_modules/", ".pnpm-store/", ".next/", "dist/", "build/", ".cache/", "*.log", ".DS_Store", "Thumbs.db", ""].join("\n"),
35
53
  "utf-8",
36
54
  );
37
55
  }
38
56
 
39
- gitExec("add -A", projectRoot, { stdio: "pipe" as const });
40
- gitExec('commit --allow-empty -m "tdd: init"', projectRoot, { stdio: "pipe" as const });
57
+ gitExec("add -A", projectRoot, deps, { stdio: "pipe" as const });
58
+ stageFiles(projectRoot, [".pi/tdd/state.json", ".pi/tdd/rules.json", ".pi/tdd/.gitignore"], deps);
59
+ gitExec('commit --allow-empty -m "tdd: init"', projectRoot, deps, { stdio: "pipe" as const });
41
60
  }
42
61
 
43
62
  /** Destroy the private git repo and re-init from scratch. */
44
- export function resetGit(projectRoot: string): void {
63
+ export function resetGit(projectRoot: string, deps: GitDeps = defaultDeps): void {
45
64
  const tddPath = join(projectRoot, TDD_DIR);
46
65
  const gitDir = join(tddPath, ".git");
47
- if (existsSync(gitDir)) {
48
- rmSync(gitDir, { recursive: true, force: true });
66
+ if (deps.existsSync(gitDir)) {
67
+ deps.rmSync(gitDir, { recursive: true, force: true });
49
68
  }
50
- initGit(projectRoot);
69
+ initGit(projectRoot, deps);
51
70
  }
52
71
 
53
72
  /** Stage all + commit with --allow-empty so every phase transition has a labeled commit. */
54
- export function snapshot(projectRoot: string, phase: string): string {
55
- gitExec("add -A", projectRoot, { stdio: "pipe" as const });
56
- gitExec(`commit --allow-empty -m "tdd: ${phase}"`, projectRoot, { stdio: "pipe" as const });
57
- return gitExec("rev-parse HEAD", projectRoot).trim();
73
+ export function snapshot(projectRoot: string, phase: string, deps: GitDeps = defaultDeps): string {
74
+ gitExec("add -A", projectRoot, deps, { stdio: "pipe" as const });
75
+ stageFiles(projectRoot, [".pi/tdd/state.json", ".pi/tdd/rules.json", ".pi/tdd/.gitignore"], deps);
76
+ gitExec(`commit --allow-empty -m "tdd: ${phase}"`, projectRoot, deps, { stdio: "pipe" as const });
77
+ return gitExec("rev-parse HEAD", projectRoot, deps).trim();
58
78
  }
59
79
 
60
- export function modifiedFiles(projectRoot: string): string[] {
61
- const out = gitExec("diff --name-only HEAD", projectRoot).trim();
80
+ export function modifiedFiles(projectRoot: string, deps: GitDeps = defaultDeps): string[] {
81
+ const out = gitExec("diff --name-only HEAD", projectRoot, deps).trim();
62
82
  return out ? out.split("\n") : [];
63
83
  }
64
84
 
65
- export function untrackedFiles(projectRoot: string): string[] {
66
- const out = gitExec("ls-files --others --exclude-standard", projectRoot).trim();
85
+ export function untrackedFiles(projectRoot: string, deps: GitDeps = defaultDeps): string[] {
86
+ const out = gitExec("ls-files --others --exclude-standard", projectRoot, deps).trim();
67
87
  return out ? out.split("\n") : [];
68
88
  }
69
89
 
70
- export function changesSinceSnapshot(projectRoot: string): string[] {
71
- return [...new Set([...modifiedFiles(projectRoot), ...untrackedFiles(projectRoot)])];
90
+ export function changesSinceSnapshot(projectRoot: string, deps: GitDeps = defaultDeps): string[] {
91
+ return [...new Set([...modifiedFiles(projectRoot, deps), ...untrackedFiles(projectRoot, deps)])];
72
92
  }
73
93
 
74
- export function restoreFilesTo(projectRoot: string, files: string[], source?: string): void {
94
+ export function restoreFilesTo(projectRoot: string, files: string[], source?: string, deps: GitDeps = defaultDeps): void {
75
95
  if (files.length === 0) return;
76
96
 
77
97
  // Separate tracked (git restore) from untracked (delete)
78
- const tracked = gitExec("ls-files", projectRoot)
98
+ const tracked = gitExec("ls-files", projectRoot, deps)
79
99
  .trim()
80
100
  .split("\n")
81
101
  .filter(Boolean);
82
102
  const trackedSet = new Set(tracked);
83
103
 
84
104
  const trackedFiles = files.filter((f) => trackedSet.has(f));
85
- const untrackedFiles = files.filter((f) => !trackedSet.has(f));
105
+ const untrackedFilesList = files.filter((f) => !trackedSet.has(f));
86
106
 
87
107
  if (trackedFiles.length > 0) {
88
108
  const escaped = trackedFiles.map((f) => `"${f}"`).join(" ");
89
109
  const sourceFlag = source ? `--source=${source} ` : "";
90
- gitExec(`restore ${sourceFlag}--worktree -- ${escaped}`, projectRoot, { stdio: "pipe" as const });
110
+ gitExec(`restore ${sourceFlag}--worktree -- ${escaped}`, projectRoot, deps, { stdio: "pipe" as const });
91
111
  }
92
112
 
93
- for (const f of untrackedFiles) {
113
+ for (const f of untrackedFilesList) {
94
114
  try {
95
- unlinkSync(f);
115
+ deps.unlinkSync(f);
96
116
  } catch {
97
117
  // File may already be gone, ignore
98
118
  }
99
119
  }
100
120
  }
101
121
 
122
+ /**
123
+ * Force-stage files in the private git repo, bypassing worktree .gitignore.
124
+ * Does not commit — call snapshot() or commit separately to persist.
125
+ */
126
+ export function stageFiles(projectRoot: string, files: string[], deps: GitDeps = defaultDeps): void {
127
+ const existing = files.filter((f) => deps.existsSync(join(projectRoot, f)));
128
+ if (existing.length === 0) return;
129
+ const escaped = existing.map((f) => `"${f}"`).join(" ");
130
+ gitExec(`add -f ${escaped}`, projectRoot, deps, { stdio: "pipe" as const });
131
+ }
132
+
102
133
  /**
103
134
  * Create a lightweight commit of the current working tree without touching the stash ref.
104
135
  * Returns the commit hash. Used as a pre-bash baseline for per-command diff.
105
136
  */
106
- export function gitStashCreate(projectRoot: string): string {
107
- const hash = gitExec("stash create --include-untracked", projectRoot).trim();
108
- if (!hash) throw new Error("git stash create returned empty hash");
137
+ export function gitStashCreate(projectRoot: string, deps: GitDeps = defaultDeps): string {
138
+ const hash = gitExec("stash create --include-untracked", projectRoot, deps).trim();
139
+ if (!hash) return "HEAD";
109
140
  return hash;
110
141
  }
111
142
 
112
- export function headHash(projectRoot: string): string {
113
- return gitExec("rev-parse HEAD", projectRoot).trim();
143
+ export function headHash(projectRoot: string, deps: GitDeps = defaultDeps): string {
144
+ return gitExec("rev-parse HEAD", projectRoot, deps).trim();
114
145
  }
115
146
 
116
147
  /** Get the commit message of HEAD. */
117
- export function headMessage(projectRoot: string): string {
118
- return gitExec("log -1 --format=%s HEAD", projectRoot).trim();
148
+ export function headMessage(projectRoot: string, deps: GitDeps = defaultDeps): string {
149
+ return gitExec("log -1 --format=%s HEAD", projectRoot, deps).trim();
119
150
  }
120
151
 
121
152
  /** Check if HEAD has a parent commit (i.e. can go back one). */
122
- export function hasParent(projectRoot: string): boolean {
153
+ export function hasParent(projectRoot: string, deps: GitDeps = defaultDeps): boolean {
123
154
  try {
124
- gitExec("rev-parse HEAD~1", projectRoot);
155
+ gitExec("rev-parse HEAD~1", projectRoot, deps);
125
156
  return true;
126
157
  } catch {
127
158
  return false;
@@ -131,21 +162,21 @@ export function hasParent(projectRoot: string): boolean {
131
162
  /**
132
163
  * Get files changed since a specific commit (instead of HEAD).
133
164
  */
134
- export function changesSince(projectRoot: string, commitHash: string): string[] {
135
- const out = gitExec(`diff --name-only ${commitHash} -- .`, projectRoot).trim();
165
+ export function changesSince(projectRoot: string, commitHash: string, deps: GitDeps = defaultDeps): string[] {
166
+ const out = gitExec(`diff --name-only ${commitHash} -- .`, projectRoot, deps).trim();
136
167
  const files = out ? out.split("\n") : [];
137
168
  // Also include untracked files
138
- const untracked = untrackedFiles(projectRoot);
169
+ const untracked = untrackedFiles(projectRoot, deps);
139
170
  return [...new Set([...files, ...untracked])];
140
171
  }
141
172
 
142
173
  /** Hard reset — discard all uncommitted changes (tracked and untracked), keep HEAD. */
143
- export function resetHard(projectRoot: string): void {
144
- gitExec("reset --hard", projectRoot, { stdio: "pipe" as const });
145
- gitExec("clean -fd", projectRoot, { stdio: "pipe" as const });
174
+ export function resetHard(projectRoot: string, deps: GitDeps = defaultDeps): void {
175
+ gitExec("reset --hard", projectRoot, deps, { stdio: "pipe" as const });
176
+ gitExec("clean -fd", projectRoot, deps, { stdio: "pipe" as const });
146
177
  }
147
178
 
148
179
  /** Soft reset — remove last commit, keep working tree content as unstaged. */
149
- export function undoLastCommit(projectRoot: string): void {
150
- gitExec("reset --soft HEAD~1", projectRoot, { stdio: "pipe" as const });
180
+ export function undoLastCommit(projectRoot: string, deps: GitDeps = defaultDeps): void {
181
+ gitExec("reset --soft HEAD~1", projectRoot, deps, { stdio: "pipe" as const });
151
182
  }
package/engine/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { isAllowed, disallowedFiles } from "./enforce.js";
2
- export { initGit, resetGit, snapshot, changesSinceSnapshot, changesSince, modifiedFiles, untrackedFiles, restoreFilesTo, gitStashCreate, headHash, headMessage, hasParent, resetHard, undoLastCommit } from "./git.js";
2
+ export { initGit, resetGit, snapshot, changesSinceSnapshot, changesSince, modifiedFiles, untrackedFiles, restoreFilesTo, gitStashCreate, stageFiles, headHash, headMessage, hasParent, resetHard, undoLastCommit } from "./git.js";
3
3
  export { loadConfig } from "./config.js";
4
4
  export { loadPhaseState, savePhaseState } from "./state.js";
5
5
  export { nextPhase, checkGate, getDisallowedChanges } from "./transition.js";
@@ -1,10 +1,7 @@
1
- import { describe, it, expect } from "vitest";
2
- import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { tmpdir } from "node:os";
5
- import { nextPhase, checkGate, getDisallowedChanges } from "./transition.js";
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { getDisallowedChanges, nextPhase, checkGate } from "./transition.js";
6
3
  import type { Config, TestRunner } from "./types.js";
7
- import { initGit, snapshot } from "./git.js";
4
+ import picomatch from "picomatch";
8
5
 
9
6
  // ── Pure unit tests: nextPhase ──────────────────────────────────────────────
10
7
 
@@ -36,8 +33,8 @@ function makeRunner(passed: boolean): TestRunner {
36
33
  }
37
34
 
38
35
  const testConfig: Config = {
39
- allowedRedPhaseFiles: [],
40
- allowedGreenPhaseFiles: [],
36
+ blockedInRed: [],
37
+ blockedInGreen: [],
41
38
  testCommands: ["npm test"],
42
39
  timeoutSeconds: 30,
43
40
  };
@@ -124,81 +121,86 @@ describe("checkGate", () => {
124
121
  });
125
122
  });
126
123
 
127
- // ── Integration tests: getDisallowedChanges ──────────────────────────────────
128
-
129
- function withTempDir(fn: (dir: string) => void) {
130
- const dir = join(tmpdir(), `tdd-transition-test-${Date.now()}`);
131
- mkdirSync(dir, { recursive: true });
132
- try {
133
- fn(dir);
134
- } finally {
135
- rmSync(dir, { recursive: true, force: true });
136
- }
137
- }
124
+ // ── Pure unit tests: getDisallowedChanges ────────────────────────────────────
138
125
 
139
126
  const denyConfig: Config = {
140
- allowedRedPhaseFiles: ["tests/**/*.test.ts"],
141
- allowedGreenPhaseFiles: ["src/**/*.ts"],
127
+ blockedInRed: ["src/**/*.ts"],
128
+ blockedInGreen: ["tests/**/*.test.ts"],
142
129
  testCommands: [],
143
130
  timeoutSeconds: 30,
144
131
  };
145
132
 
146
133
  describe("getDisallowedChanges", () => {
134
+ let mockChangesSinceSnapshot: ReturnType<typeof vi.fn>;
135
+ let mockDisallowedFiles: ReturnType<typeof vi.fn>;
136
+
137
+ function makeDeps(overrides = {}) {
138
+ return {
139
+ changesSinceSnapshot: mockChangesSinceSnapshot,
140
+ disallowedFiles: mockDisallowedFiles,
141
+ ...overrides,
142
+ };
143
+ }
144
+
145
+ beforeEach(() => {
146
+ vi.clearAllMocks();
147
+ mockChangesSinceSnapshot = vi.fn().mockReturnValue([]);
148
+ mockDisallowedFiles = vi.fn().mockReturnValue([]);
149
+ });
150
+
147
151
  it("returns empty for refactor phase regardless of git state", () => {
148
- withTempDir((dir) => {
149
- // No git at all — safe because refactor returns early
150
- expect(getDisallowedChanges(dir, "refactor", denyConfig)).toEqual([]);
151
- });
152
+ const result = getDisallowedChanges("/any", "refactor", denyConfig, makeDeps());
153
+ expect(result).toEqual([]);
154
+ expect(mockChangesSinceSnapshot).not.toHaveBeenCalled();
152
155
  });
153
156
 
154
157
  it("returns empty when no files changed", () => {
155
- withTempDir((dir) => {
156
- initGit(dir);
157
- snapshot(dir, "red");
158
- expect(getDisallowedChanges(dir, "red", denyConfig)).toEqual([]);
159
- });
158
+ mockChangesSinceSnapshot.mockReturnValue([]);
159
+ const result = getDisallowedChanges("/test", "red", denyConfig, makeDeps());
160
+ expect(result).toEqual([]);
161
+ expect(mockChangesSinceSnapshot).toHaveBeenCalledWith("/test");
160
162
  });
161
163
 
162
164
  it("returns disallowed files in red phase", () => {
163
- withTempDir((dir) => {
164
- initGit(dir);
165
- snapshot(dir, "red");
166
- mkdirSync(join(dir, "src"), { recursive: true });
167
- mkdirSync(join(dir, "tests"), { recursive: true });
168
- writeFileSync(join(dir, "src", "main.ts"), "// impl", "utf-8");
169
- writeFileSync(join(dir, "tests", "foo.test.ts"), "// test", "utf-8");
170
- writeFileSync(join(dir, "README.md"), "// docs", "utf-8");
171
- const violations = getDisallowedChanges(dir, "red", denyConfig);
172
- expect(violations).toContain("src/main.ts");
173
- expect(violations).not.toContain("tests/foo.test.ts");
174
- expect(violations).not.toContain("README.md");
165
+ mockChangesSinceSnapshot.mockReturnValue(["src/main.ts", "tests/foo.test.ts", "README.md"]);
166
+ const matchBlockedInRed = picomatch(denyConfig.blockedInRed);
167
+ mockDisallowedFiles.mockImplementation((changed: string[], phase: string) => {
168
+ if (phase === "red") {
169
+ return changed.filter((f: string) => matchBlockedInRed(f));
170
+ }
171
+ return [];
175
172
  });
173
+ const violations = getDisallowedChanges("/test", "red", denyConfig, makeDeps());
174
+ expect(violations).toContain("src/main.ts");
175
+ expect(violations).not.toContain("tests/foo.test.ts");
176
+ expect(violations).not.toContain("README.md");
176
177
  });
177
178
 
178
179
  it("returns disallowed files in green phase", () => {
179
- withTempDir((dir) => {
180
- initGit(dir);
181
- snapshot(dir, "green");
182
- mkdirSync(join(dir, "tests"), { recursive: true });
183
- mkdirSync(join(dir, "src"), { recursive: true });
184
- writeFileSync(join(dir, "tests", "foo.test.ts"), "// test", "utf-8");
185
- writeFileSync(join(dir, "src", "main.ts"), "// impl", "utf-8");
186
- writeFileSync(join(dir, "package.json"), "{}", "utf-8");
187
- const violations = getDisallowedChanges(dir, "green", denyConfig);
188
- expect(violations).toContain("tests/foo.test.ts");
189
- expect(violations).not.toContain("src/main.ts");
190
- expect(violations).not.toContain("package.json");
180
+ mockChangesSinceSnapshot.mockReturnValue(["tests/foo.test.ts", "src/main.ts", "package.json"]);
181
+ const matchBlockedInGreen = picomatch(denyConfig.blockedInGreen);
182
+ mockDisallowedFiles.mockImplementation((changed: string[], phase: string) => {
183
+ if (phase === "green") {
184
+ return changed.filter((f: string) => matchBlockedInGreen(f));
185
+ }
186
+ return [];
191
187
  });
188
+ const violations = getDisallowedChanges("/test", "green", denyConfig, makeDeps());
189
+ expect(violations).toContain("tests/foo.test.ts");
190
+ expect(violations).not.toContain("src/main.ts");
191
+ expect(violations).not.toContain("package.json");
192
192
  });
193
193
 
194
194
  it("catches untracked files, not just modified", () => {
195
- withTempDir((dir) => {
196
- initGit(dir);
197
- snapshot(dir, "red");
198
- mkdirSync(join(dir, "src"), { recursive: true });
199
- writeFileSync(join(dir, "src", "new.ts"), "// brand new", "utf-8");
200
- const violations = getDisallowedChanges(dir, "red", denyConfig);
201
- expect(violations).toContain("src/new.ts");
195
+ mockChangesSinceSnapshot.mockReturnValue(["src/new.ts"]);
196
+ const matchBlockedInRed = picomatch(denyConfig.blockedInRed);
197
+ mockDisallowedFiles.mockImplementation((changed: string[], phase: string) => {
198
+ if (phase === "red") {
199
+ return changed.filter((f: string) => matchBlockedInRed(f));
200
+ }
201
+ return [];
202
202
  });
203
+ const violations = getDisallowedChanges("/test", "red", denyConfig, makeDeps());
204
+ expect(violations).toContain("src/new.ts");
203
205
  });
204
206
  });
@@ -70,9 +70,16 @@ export function getDisallowedChanges(
70
70
  projectRoot: string,
71
71
  phase: Phase,
72
72
  config: Config,
73
+ deps: {
74
+ changesSinceSnapshot: typeof changesSinceSnapshot;
75
+ disallowedFiles: typeof disallowedFiles;
76
+ } = {
77
+ changesSinceSnapshot,
78
+ disallowedFiles,
79
+ },
73
80
  ): string[] {
74
81
  if (phase === "refactor") return [];
75
82
 
76
- const changed = changesSinceSnapshot(projectRoot);
77
- return disallowedFiles(changed, phase, config);
83
+ const changed = deps.changesSinceSnapshot(projectRoot);
84
+ return deps.disallowedFiles(changed, phase, config);
78
85
  }
package/engine/types.ts CHANGED
@@ -6,8 +6,8 @@ export interface PhaseState {
6
6
  }
7
7
 
8
8
  export interface Config {
9
- allowedRedPhaseFiles: string[];
10
- allowedGreenPhaseFiles: string[];
9
+ blockedInRed: string[];
10
+ blockedInGreen: string[];
11
11
  testCommands: string[];
12
12
  timeoutSeconds: number;
13
13
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tdd-enforcer",
3
- "version": "0.2.2",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -9,6 +9,9 @@
9
9
  "picomatch": "^4.0.4"
10
10
  },
11
11
  "devDependencies": {
12
+ "@earendil-works/pi-coding-agent": "^0.79.6",
13
+ "@types/node": "^25.9.3",
14
+ "typebox": "^1.2.16",
12
15
  "vitest": "^3"
13
16
  },
14
17
  "pi": {
@@ -32,15 +32,16 @@ It locks files per phase — only test files in RED, only implementation files i
32
32
 
33
33
  ```json
34
34
  {
35
- "allowedRedPhaseFiles": ["tests/**/*.test.ts"],
36
- "allowedGreenPhaseFiles": ["src/**/*.ts"],
37
- "testCommands": ["npm test"],
35
+ "blockedInRed": ["src/**/*.ts", "lib/**/*.ts", "!src/**/*.test.ts"],
36
+ "blockedInGreen": ["**/*.test.ts", "**/*.spec.ts"],
37
+ "testCommands": ["npm test"],
38
38
  "timeoutSeconds": 30
39
39
  }
40
40
  ```
41
41
 
42
- - `allowedRedPhaseFiles` — globs the agent can create/modify in RED phase (tests)
43
- - `allowedGreenPhaseFiles` — globs the agent can create/modify in GREEN phase (implementation)
42
+ - `blockedInRed` — globs the agent **cannot** modify in RED phase (implementation files)
43
+ - `blockedInGreen` — globs the agent **cannot** modify in GREEN phase (test files)
44
+ - `!` exclusion prefix — optional, carves out subsets from a block list at init time. E.g. `!src/**/*.test.ts` excludes co-located test files from `blockedInRed` so the agent can write them in RED phase
44
45
  - `testCommands` — shell commands to run tests
45
46
  - `timeoutSeconds` — test timeout per command
46
47
 
@@ -51,31 +52,31 @@ It locks files per phase — only test files in RED, only implementation files i
51
52
  ## Phase Rules
52
53
 
53
54
  ### RED
54
- Write failing tests matching `allowedRedPhaseFiles` patterns. Files matching `allowedGreenPhaseFiles` are locked. Files matching neither set are always free. Call `next_tdd_phase` once tests fail.
55
+ Files matching `blockedInRed` are locked everything else is free.
55
56
 
56
- ### GREEN
57
- Implement features matching `allowedGreenPhaseFiles` patterns. Test files from `allowedRedPhaseFiles` are locked. Files matching neither set are always free. Call `next_tdd_phase` once tests pass.
57
+ Write failing tests for one feature at a time. Think about what could go wrong and test for it — don't just verify the happy path, cover unhappy paths and edge cases too. Keep cycles small so reverting is cheap and safe if assumptions turn out wrong.
58
58
 
59
- ### REFACTOR
60
- All files are free to modify. Refactor without changing behaviour. Call `next_tdd_phase` once tests pass to start a new RED cycle.
59
+ Call `next_tdd_phase` once tests fail.
61
60
 
62
- ---
61
+ ### GREEN
62
+ Files matching `blockedInGreen` are locked — everything else is free.
63
63
 
64
- ## Hard Rules
64
+ Write the simplest code that makes the failing tests pass — nothing more. The tests are your spec; if they pass, you're done.
65
65
 
66
- - **Never bypass TDD.** If TDD blocks a change, it's because you're in the wrong phase or the file isn't allowed in this phase. The solution is always to work within the rules, not around them.
66
+ If the RED phase tests were wrong, call `previous_tdd_phase` to go back and fix them before implementing. All current changes are lost, but that's better since the current changes was building on false assumptions. Don't be afraid to discard clean slate beats patched code.
67
67
 
68
- - Need to change a locked file? Either advance through the cycle (`next_tdd_phase`) or roll back to fix earlier work (`previous_tdd_phase`).
69
- - Wrong assumptions about the task? Roll back with `previous_tdd_phase`, the phase restarts clean.
70
- - Fundamentally blocked? Ask the user to run the appropriate `/tdd:` command (change phase, disable, or reset).
68
+ Call `next_tdd_phase` once all tests pass.
71
69
 
72
- - **Never write to `.pi/tdd/`.** The extension owns that directory — writes are blocked and bash changes are reverted. Any change you make there is ignored or overwritten.
70
+ ### REFACTOR
71
+ All files are free to modify. Refactor without changing behaviour.
72
+ Call `next_tdd_phase` once tests pass to start a new RED cycle.
73
73
 
74
- - **Never run `/tdd:` commands yourself.** They're registered as user-only commands. They won't work when you type them.
74
+ ---
75
75
 
76
- - **Don't be afraid to discard.** If the previous phase work was wrong, all current-phase changes are built on false assumptions. Prefer a clean slate — call `previous_tdd_phase` and redo it properly.
76
+ ## Hard Rules
77
77
 
78
- - **Keep cycles small but tests comprehensive.** Write tests for one feature at a time. Cover happy path, edge cases, and unhappy paths before moving to GREEN. Small cycles mean less to lose if assumptions turn out wrong. Reverting becomes cheap and safe.
78
+ - **Never write to `.pi/tdd/`.** The extension owns that directory writes are blocked and bash changes are reverted.
79
+ - **Never run `/tdd:` commands yourself.** They're registered as user-only commands and won't work when you type them.
79
80
 
80
81
  ---
81
82
 
@@ -93,7 +94,7 @@ Also validates no locked files were modified. On success, records the current st
93
94
  Use when the previous phase's work was wrong and the current phase cannot proceed because of it. Rolls back to the previous phase so that work can be redone correctly. All changes made in the current phase are lost.
94
95
 
95
96
  ### `tdd_status`
96
- Shows the current phase, allowed file globs, and test commands.
97
+ Shows the current phase, blocked file globs per phase, and test commands.
97
98
 
98
99
  ---
99
100
 
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "types": ["node"],
8
+ "skipLibCheck": true
9
+ },
10
+ "include": ["**/*.ts"],
11
+ "exclude": ["node_modules"]
12
+ }