tdd-enforcer 0.3.2 → 0.3.4

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.
@@ -1,8 +1,8 @@
1
1
  import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { describe, expect, it } from "vitest";
5
- import { loadPhaseState, savePhaseState } from "./state.js";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { loadPhaseState, loadTddState, savePhaseState } from "./state.js";
6
6
 
7
7
  function withTempDir(fn: (dir: string) => void) {
8
8
  const dir = join(tmpdir(), `tdd-state-test-${Date.now()}`);
@@ -82,3 +82,275 @@ describe("savePhaseState", () => {
82
82
  });
83
83
  });
84
84
  });
85
+
86
+ // ── loadTddState ────────────────────────────────────────────────────────────
87
+
88
+ const validRules = {
89
+ blockedInRed: ["tests/**/*.test.ts"],
90
+ blockedInGreen: ["src/**/*.ts"],
91
+ testCommands: ["npm test"],
92
+ timeoutSeconds: 30,
93
+ };
94
+
95
+ const validPhase = {
96
+ enabled: true,
97
+ current: "red",
98
+ };
99
+
100
+ describe("loadTddState", () => {
101
+ let mockExistsSync: ReturnType<typeof vi.fn>;
102
+ let mockLoadConfig: ReturnType<typeof vi.fn>;
103
+ let mockInitGit: ReturnType<typeof vi.fn>;
104
+ let mockLoadPhaseState: ReturnType<typeof vi.fn>;
105
+ let mockSavePhaseState: ReturnType<typeof vi.fn>;
106
+ let mockHeadMessage: ReturnType<typeof vi.fn>;
107
+ let mockNextPhase: ReturnType<typeof vi.fn>;
108
+ let mockStageFiles: ReturnType<typeof vi.fn>;
109
+
110
+ const realNextPhase = (p: string) =>
111
+ p === "red"
112
+ ? "green"
113
+ : p === "green"
114
+ ? "refactor"
115
+ : p === "refactor"
116
+ ? "red"
117
+ : null;
118
+
119
+ function makeDeps(overrides = {}) {
120
+ return {
121
+ existsSync: mockExistsSync,
122
+ loadConfig: mockLoadConfig,
123
+ initGit: mockInitGit,
124
+ loadPhaseState: mockLoadPhaseState,
125
+ savePhaseState: mockSavePhaseState,
126
+ headMessage: mockHeadMessage,
127
+ nextPhase: mockNextPhase,
128
+ stageFiles: mockStageFiles,
129
+ ...overrides,
130
+ };
131
+ }
132
+
133
+ beforeEach(() => {
134
+ vi.clearAllMocks();
135
+ mockExistsSync = vi.fn().mockImplementation((path: string) => {
136
+ if (path.includes(".git")) return false;
137
+ if (path.includes(".pi/tdd")) return true;
138
+ return true;
139
+ });
140
+ mockLoadConfig = vi.fn().mockReturnValue(validRules);
141
+ mockInitGit = vi.fn();
142
+ mockLoadPhaseState = vi.fn().mockReturnValue(validPhase);
143
+ mockSavePhaseState = vi.fn();
144
+ mockHeadMessage = vi.fn().mockReturnValue("tdd: red");
145
+ mockNextPhase = vi.fn().mockImplementation(realNextPhase);
146
+ mockStageFiles = vi.fn();
147
+ });
148
+
149
+ it("returns missing dir error when .pi/tdd does not exist", () => {
150
+ mockExistsSync.mockReturnValue(false);
151
+ const result = loadTddState("/test", makeDeps());
152
+ expect(result.ok).toBe(false);
153
+ expect(result.reason).toContain("Missing .pi/tdd/");
154
+ });
155
+
156
+ it("returns missing rules.json error when only dir exists", () => {
157
+ mockExistsSync.mockImplementation((path: string) => {
158
+ if (path.includes("rules.json")) return false;
159
+ if (path.includes(".pi/tdd")) return true;
160
+ return false;
161
+ });
162
+ const result = loadTddState("/test", makeDeps());
163
+ expect(result.ok).toBe(false);
164
+ expect(result.reason).toContain("rules.json");
165
+ });
166
+
167
+ it("auto-creates state.json when missing (default RED disabled)", () => {
168
+ mockExistsSync.mockImplementation((path: string) => {
169
+ if (path.includes("state.json")) return false;
170
+ if (path.includes(".git")) return false;
171
+ return true;
172
+ });
173
+ mockHeadMessage.mockReturnValue("tdd: init");
174
+ const result = loadTddState("/test", makeDeps());
175
+ expect(result.ok).toBe(true);
176
+ if (result.ok) {
177
+ expect(result.state.enabled).toBe(false);
178
+ expect(result.state.current).toBe("red");
179
+ }
180
+ expect(mockInitGit).toHaveBeenCalled();
181
+ expect(mockSavePhaseState).toHaveBeenCalledWith("/test", {
182
+ enabled: false,
183
+ current: "red",
184
+ });
185
+ });
186
+
187
+ it("auto-creates state.json when corrupted (recovers to default)", () => {
188
+ mockExistsSync.mockImplementation((path: string) => {
189
+ if (path.includes("state.json")) return true;
190
+ if (path.includes(".git")) return false;
191
+ return true;
192
+ });
193
+ mockLoadPhaseState.mockImplementation(() => {
194
+ throw new Error("corrupt");
195
+ });
196
+ mockHeadMessage.mockReturnValue("tdd: init");
197
+ const result = loadTddState("/test", makeDeps());
198
+ expect(result.ok).toBe(true);
199
+ if (result.ok) {
200
+ expect(result.state.enabled).toBe(false);
201
+ expect(result.state.current).toBe("red");
202
+ }
203
+ });
204
+
205
+ it("returns invalid rules.json error for malformed JSON", () => {
206
+ mockLoadConfig.mockImplementation(() => {
207
+ throw new Error("Unexpected token");
208
+ });
209
+ const result = loadTddState("/test", makeDeps());
210
+ expect(result.ok).toBe(false);
211
+ expect(result.reason).toContain("Invalid .pi/tdd/rules.json");
212
+ });
213
+
214
+ it("returns ok when state.json has enabled: false (callers check enabled)", () => {
215
+ mockLoadPhaseState.mockReturnValue({ enabled: false, current: "red" });
216
+ const result = loadTddState("/test", makeDeps());
217
+ expect(result.ok).toBe(true);
218
+ if (result.ok) {
219
+ expect(result.state.enabled).toBe(false);
220
+ expect(result.state.current).toBe("red");
221
+ }
222
+ });
223
+
224
+ it("returns ok with state and config when everything valid", () => {
225
+ mockLoadPhaseState.mockReturnValue(validPhase);
226
+ const result = loadTddState("/test", makeDeps());
227
+ expect(result.ok).toBe(true);
228
+ if (result.ok) {
229
+ expect(result.state.current).toBe("red");
230
+ expect(result.state.enabled).toBe(true);
231
+ expect(result.config.testCommands).toEqual(["npm test"]);
232
+ expect(result.config.blockedInRed).toEqual(["tests/**/*.test.ts"]);
233
+ }
234
+ });
235
+
236
+ it("auto-creates git repo when missing", () => {
237
+ let gitExists = false;
238
+ mockExistsSync.mockImplementation((path: string) => {
239
+ if (path.includes(".git")) return gitExists;
240
+ if (path.includes(".pi/tdd")) return true;
241
+ return true;
242
+ });
243
+ mockInitGit.mockImplementation(() => {
244
+ gitExists = true;
245
+ });
246
+ const result = loadTddState("/test", makeDeps());
247
+ expect(result.ok).toBe(true);
248
+ expect(mockInitGit).toHaveBeenCalled();
249
+ });
250
+
251
+ it("handles multiple calls without error", () => {
252
+ const r1 = loadTddState("/test", makeDeps());
253
+ expect(r1.ok).toBe(true);
254
+ const r2 = loadTddState("/test", makeDeps());
255
+ expect(r2.ok).toBe(true);
256
+ expect(mockLoadConfig).toHaveBeenCalledTimes(2);
257
+ });
258
+
259
+ it("recovers from invalid current phase in state.json (auto-creates default)", () => {
260
+ mockLoadPhaseState.mockImplementation(() => {
261
+ throw new Error("invalid phase");
262
+ });
263
+ mockHeadMessage.mockReturnValue("tdd: init");
264
+ const result = loadTddState("/test", makeDeps());
265
+ expect(result.ok).toBe(true);
266
+ if (result.ok) {
267
+ expect(result.state.enabled).toBe(false);
268
+ expect(result.state.current).toBe("red");
269
+ }
270
+ });
271
+
272
+ it("recoverState: HEAD tdd:red → enabled green", () => {
273
+ mockExistsSync.mockImplementation((path: string) => {
274
+ if (path.includes("state.json")) return false;
275
+ return true;
276
+ });
277
+ mockHeadMessage.mockReturnValue("tdd: red");
278
+ mockNextPhase.mockImplementation(realNextPhase);
279
+ const result = loadTddState("/test", makeDeps());
280
+ expect(result.ok).toBe(true);
281
+ if (result.ok) {
282
+ expect(result.state.enabled).toBe(true);
283
+ expect(result.state.current).toBe("green");
284
+ }
285
+ });
286
+
287
+ it("recoverState: HEAD tdd:green → enabled refactor", () => {
288
+ mockExistsSync.mockImplementation((path: string) => {
289
+ if (path.includes("state.json")) return false;
290
+ return true;
291
+ });
292
+ mockHeadMessage.mockReturnValue("tdd: green");
293
+ const result = loadTddState("/test", makeDeps());
294
+ expect(result.ok).toBe(true);
295
+ if (result.ok) {
296
+ expect(result.state.enabled).toBe(true);
297
+ expect(result.state.current).toBe("refactor");
298
+ }
299
+ });
300
+
301
+ it("recoverState: HEAD tdd:refactor → enabled red", () => {
302
+ mockExistsSync.mockImplementation((path: string) => {
303
+ if (path.includes("state.json")) return false;
304
+ return true;
305
+ });
306
+ mockHeadMessage.mockReturnValue("tdd: refactor");
307
+ const result = loadTddState("/test", makeDeps());
308
+ expect(result.ok).toBe(true);
309
+ if (result.ok) {
310
+ expect(result.state.enabled).toBe(true);
311
+ expect(result.state.current).toBe("red");
312
+ }
313
+ });
314
+
315
+ it("force-adds state.json after creating it from recovery", () => {
316
+ mockExistsSync.mockImplementation((path: string) => {
317
+ if (path.includes("state.json")) return false;
318
+ if (path.includes(".git")) return false;
319
+ return true;
320
+ });
321
+ mockHeadMessage.mockReturnValue("tdd: init");
322
+ const result = loadTddState("/test", makeDeps());
323
+ expect(result.ok).toBe(true);
324
+ expect(mockStageFiles).toHaveBeenCalledWith("/test", [
325
+ ".pi/tdd/state.json",
326
+ ]);
327
+ });
328
+
329
+ it("does not force-add state.json when it already exists and is valid", () => {
330
+ mockExistsSync.mockImplementation((path: string) => {
331
+ if (path.includes("state.json")) return true;
332
+ if (path.includes(".git")) return true;
333
+ return true;
334
+ });
335
+ const result = loadTddState("/test", makeDeps());
336
+ expect(result.ok).toBe(true);
337
+ expect(mockStageFiles).not.toHaveBeenCalled();
338
+ });
339
+
340
+ it("force-adds state.json after recovering from corrupted state", () => {
341
+ mockExistsSync.mockImplementation((path: string) => {
342
+ if (path.includes("state.json")) return true;
343
+ if (path.includes(".git")) return true;
344
+ return true;
345
+ });
346
+ mockLoadPhaseState.mockImplementation(() => {
347
+ throw new Error("corrupt");
348
+ });
349
+ mockHeadMessage.mockReturnValue("tdd: init");
350
+ const result = loadTddState("/test", makeDeps());
351
+ expect(result.ok).toBe(true);
352
+ expect(mockStageFiles).toHaveBeenCalledWith("/test", [
353
+ ".pi/tdd/state.json",
354
+ ]);
355
+ });
356
+ });
package/engine/state.ts CHANGED
@@ -1,10 +1,17 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import type { PhaseState } from "./types.js";
3
+ import { loadConfig } from "./config.js";
4
+ import { headMessage, initGit, stageFiles } from "./git.js";
5
+ import { nextPhase } from "./transition.js";
6
+ import type { Config, PhaseState } from "./types.js";
4
7
 
5
8
  const TDD_DIR = ".pi/tdd";
6
9
  const VALID_PHASES = new Set(["red", "green", "refactor"]);
7
10
 
11
+ export type TddLoadResult =
12
+ | { ok: true; state: PhaseState; config: Config }
13
+ | { ok: false; reason: string };
14
+
8
15
  export function phaseStatePath(projectRoot: string): string {
9
16
  return join(projectRoot, TDD_DIR, "state.json");
10
17
  }
@@ -38,3 +45,130 @@ export function savePhaseState(projectRoot: string, state: PhaseState): void {
38
45
  ensureDir(path);
39
46
  writeFileSync(path, JSON.stringify(state, null, 2), "utf-8");
40
47
  }
48
+
49
+ /**
50
+ * Recover state.json from private git HEAD, or create default.
51
+ * Returns the state (does not save to disk — caller does that).
52
+ */
53
+ function recoverState(
54
+ root: string,
55
+ tddDir: string,
56
+ deps: {
57
+ existsSync: typeof existsSync;
58
+ headMessage: typeof headMessage;
59
+ nextPhase: typeof nextPhase;
60
+ } = { existsSync, headMessage, nextPhase },
61
+ ): PhaseState {
62
+ const gitDir = join(tddDir, ".git");
63
+ if (deps.existsSync(gitDir)) {
64
+ try {
65
+ const msg = deps.headMessage(root);
66
+ const m = msg.match(/^tdd: (red|green|refactor|init)$/);
67
+ if (m) {
68
+ const label = m[1];
69
+ if (label === "init") {
70
+ return { enabled: false, current: "red" };
71
+ }
72
+ const next = deps.nextPhase(label);
73
+ return { enabled: true, current: next ?? "red" };
74
+ }
75
+ } catch {
76
+ // No commits or bad HEAD — fall through to default
77
+ }
78
+ }
79
+ return { enabled: false, current: "red" };
80
+ }
81
+
82
+ /**
83
+ * Load TDD state + config in one go.
84
+ * Auto-creates state.json from private git HEAD when missing or corrupted.
85
+ * Returns ok:true with state and config when rules.json is valid.
86
+ * Returns ok:false with a specific reason string otherwise.
87
+ *
88
+ * Callers must check state.enabled themselves if they need active enforcement.
89
+ */
90
+ export function loadTddState(
91
+ root: string,
92
+ deps: {
93
+ existsSync: typeof existsSync;
94
+ loadConfig: typeof loadConfig;
95
+ initGit: typeof initGit;
96
+ loadPhaseState: typeof loadPhaseState;
97
+ savePhaseState: typeof savePhaseState;
98
+ headMessage: typeof headMessage;
99
+ nextPhase: typeof nextPhase;
100
+ stageFiles: typeof stageFiles;
101
+ } = {
102
+ existsSync,
103
+ loadConfig,
104
+ initGit,
105
+ loadPhaseState,
106
+ savePhaseState,
107
+ headMessage,
108
+ nextPhase,
109
+ stageFiles,
110
+ },
111
+ ): TddLoadResult {
112
+ const tddDir = join(root, ".pi", "tdd");
113
+ if (!deps.existsSync(tddDir)) {
114
+ return {
115
+ ok: false,
116
+ reason:
117
+ "Missing .pi/tdd/ directory. See the tdd-enforcer skill to learn how to set up TDD configs.",
118
+ };
119
+ }
120
+
121
+ const rulesPath = join(tddDir, "rules.json");
122
+ if (!deps.existsSync(rulesPath)) {
123
+ return {
124
+ ok: false,
125
+ reason:
126
+ "Missing .pi/tdd/rules.json. See the tdd-enforcer skill to learn how to set up TDD configs.",
127
+ };
128
+ }
129
+
130
+ let config: Config;
131
+ try {
132
+ config = deps.loadConfig(root);
133
+ } catch (e) {
134
+ return {
135
+ ok: false,
136
+ reason: `Invalid .pi/tdd/rules.json: ${(e as Error).message}. See the tdd-enforcer skill.`,
137
+ };
138
+ }
139
+
140
+ // Init git if missing — required for state recovery and all consumers
141
+ const gitDir = join(tddDir, ".git");
142
+ if (!deps.existsSync(gitDir)) {
143
+ try {
144
+ deps.initGit(root);
145
+ } catch (e) {
146
+ return {
147
+ ok: false,
148
+ reason: `Failed to initialise private git repo: ${(e as Error).message}`,
149
+ };
150
+ }
151
+ }
152
+
153
+ // Auto-create state.json if missing or corrupted
154
+ const phasePath = join(tddDir, "state.json");
155
+ let state: PhaseState | undefined;
156
+ if (deps.existsSync(phasePath)) {
157
+ try {
158
+ state = deps.loadPhaseState(root);
159
+ } catch {
160
+ // Corrupted — recover below
161
+ }
162
+ }
163
+ if (!state) {
164
+ state = recoverState(root, tddDir, {
165
+ existsSync: deps.existsSync,
166
+ headMessage: deps.headMessage,
167
+ nextPhase: deps.nextPhase,
168
+ });
169
+ deps.savePhaseState(root, state);
170
+ deps.stageFiles(root, [".pi/tdd/state.json"]);
171
+ }
172
+
173
+ return { ok: true, state, config };
174
+ }
package/package.json CHANGED
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "name": "tdd-enforcer",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "scripts": {
6
- "fmt": "biome format --write .",
7
6
  "lint": "biome check .",
8
7
  "lint:fix": "biome check --write .",
9
8
  "test": "vitest run"
@@ -42,8 +42,10 @@ It locks files per phase — only test files in RED, only implementation files i
42
42
  - `blockedInRed` — globs the agent **cannot** modify in RED phase (implementation files)
43
43
  - `blockedInGreen` — globs the agent **cannot** modify in GREEN phase (test files)
44
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
45
- - `testCommands` — shell commands to run tests. **Runs sequentially** — each entry is started one after another. Use `&&` inside a single string entry to chain multiple commands in one step (e.g. `"npm run build && npm test"`). Do not rely on array ordering for dependency chains; put dependent commands in the same string entry with `&&`.
46
- - `timeoutSeconds` — test timeout per command
45
+ - `testCommands` — determines if a phase transition passes. Exit 0 passes, non-zero blocks. **Runs in parallel** — all entries are started concurrently. Use `&&` inside a single string entry to chain multiple commands in one step (e.g. `"npm run build && npm test"`). Do not rely on array ordering for dependency chains; put dependent commands in the same string entry with `&&`.
46
+
47
+ **Prefer auto-fix commands** that apply fixes (formatting, linting, etc.) before reporting remaining violations. Without auto-fix, formatting or lint issues in phase-locked files (e.g. test files in GREEN) will block the gate with no way to fix them — forcing `previous_tdd_phase` and losing all progress. Auto-fix commands avoid this deadlock by fixing locked files before the check runs.
48
+ - `timeoutSeconds` — test timeout per command (default: 120)
47
49
 
48
50
  2. **User** runs `/tdd:on` to enable enforcement.
49
51
 
package/DESIGN.md DELETED
@@ -1,183 +0,0 @@
1
- # TDD Enforcer — pi Extension Design
2
-
3
- ## Concept
4
-
5
- A pi extension that enforces the Red-Green-Refactor TDD cycle by:
6
- - Tracking current phase (RED / GREEN / REFACTOR)
7
- - Restricting which files the agent can modify per phase
8
- - Running tests on phase transitions to enforce red/green gate
9
- - Nudging the agent with phase-appropriate prompts
10
-
11
- ## Tools
12
-
13
- ### `next_tdd_phase`
14
-
15
- Advances the cycle:
16
-
17
- ```
18
- RED ──(tests fail)──► GREEN ──(tests pass)──► REFACTOR ──(tests pass)──► RED
19
- ```
20
-
21
- ### `previous_tdd_phase`
22
-
23
- Reverts to the previous snapshot by parsing the phase label from the last git snapshot commit. Restores working tree to exact prior state. No gate checks — just revert. Must have clear warning in the tool schema that this will revert all changes in the current state.
24
-
25
- ---
26
-
27
- ## Phase Rules
28
-
29
- | Phase | `allowedRedPhaseFiles` | `allowedGreenPhaseFiles` | Everything else |
30
- |-------|----------------------|------------------------|-----------------|
31
- | RED | ✅ Allowed | ❌ Locked | ✅ Free |
32
- | GREEN | ❌ Locked | ✅ Allowed | ✅ Free |
33
- | REFACTOR | ✅ Allowed | ✅ Allowed | ✅ Free |
34
-
35
- ### Transition Gates
36
-
37
- - **RED → GREEN**: Tests must **fail**. If tests pass, tool returns error — agent must break a test first.
38
- - **GREEN → REFACTOR**: Tests must **pass** (all exit codes zero).
39
- - **REFACTOR → RED**: Tests must **pass** (all exit codes zero).
40
-
41
- ### Nudging Prompts
42
-
43
- Each successful transition returns a message guiding the agent:
44
-
45
- - **→ RED**: *"You are now in **RED** phase. Write failing tests matching `allowedRedPhaseFiles` patterns. Only these files can be modified. Once tests fail, call `next_tdd_phase` to proceed to GREEN."* (list matched files)
46
- - **→ GREEN**: *"You are now in **GREEN** phase. Files matching `allowedRedPhaseFiles` are locked. Implement features in `allowedGreenPhaseFiles` to make tests pass. Call `next_tdd_phase` to proceed to REFACTOR."*
47
- - **→ REFACTOR**: *"You are now in **REFACTOR** phase. Both `allowedRedPhaseFiles` and `allowedGreenPhaseFiles` are free to modify. Refactor without changing behavior. Call `next_tdd_phase` to start a new RED cycle."*
48
-
49
- ---
50
-
51
- ## File Enforcement: Private Git + `tool_call` Fast-Feedback
52
-
53
- ### Source of truth: private git repo
54
-
55
- A separate git repository at `.pi/tdd/.git/` that tracks the project root as its working tree. The user's `.git/` is never touched.
56
-
57
- ```
58
- .pi/tdd/
59
- ├── .gitignore # private git — excludes file patterns from snapshots
60
- ├── state.json # {current: "red", enabled: true}
61
- ├── rules.json # user config
62
- └── .git/ # private git — init with --git-dir
63
- ```
64
-
65
- **Setup:** `git init` with `--git-dir=.pi/tdd/.git --work-tree=<project-root>`.
66
-
67
- **On phase entry (snapshot):**
68
- `git add -A && git commit -m "tdd: <phase> <ts>"` — captures entire working tree state.
69
-
70
- **On `next_tdd_phase` / `previous_tdd_phase` (allowlist check):**
71
- - `git diff --name-only HEAD` against previous snapshot commit
72
- - Cross-reference each changed file against phase allowlist
73
- - Violations → BLOCK with list of disallowed files
74
- - Also check for untracked files (`git ls-files --others --exclude-standard`)
75
-
76
- **On `previous_tdd_phase` (revert):**
77
- - `git restore --source=<prev-commit> --worktree -- .` — restores project to exact prior snapshot
78
- - Pop the last snapshot commit in the private git repo
79
-
80
- ### Benefits of private git
81
-
82
- - Catches ALL modifications — write, edit, bash, sed, python, C, anything — because it diffs the working tree, not tool calls
83
- - Cross-platform (git is everywhere)
84
- - No shell parsing, no fragile regexes, no edge cases
85
- - Zero interference with user's git — different `.git/`, no shared refs, no hooks, no global config
86
- - `.pi/tdd/` is disposable — user can nuke it anytime
87
- - Free diff, merge, partial restore, binary handling — no custom engine to write
88
-
89
- ### Fast feedback: per-tool enforcement
90
-
91
- The transition-time check catches everything, but it's wasteful to let the agent work on wrong files for a full phase. We enforce per-tool:
92
-
93
- #### `write` / `edit` — pre-execution block
94
-
95
- The file path is a direct parameter. In `tool_call`:
96
- - If path is disallowed in current phase → `{ block: true, reason: "..." }`
97
- - Otherwise → allow
98
-
99
- #### `bash` — post-execution detect-and-revert
100
-
101
- Bash can modify files indirectly (redirects, `sed -i`, scripts, compilers). Parsing command strings to predict targets is fragile. Instead:
102
-
103
- 1. **Let bash run** — no pre-check
104
- 2. **In `tool_result`:** `git diff --name-only HEAD` + `git ls-files --others --exclude-standard` to get all changes since phase snapshot
105
- 3. For each file: if it's disallowed in current phase → `git restore <filepath>` and append warning
106
-
107
- No in-memory tracking needed. The check is the same for every file regardless of how it was modified — write/edit changes that passed pre-check naturally match the allowed globs, violations get reverted.
108
-
109
- #### Why not regex bash parsing?
110
-
111
- Everyone else does it (pi-proof, pi-superteam, tdd-guard). It's fragile — misses `$(dynamic paths)`, glob expansion, scripts calling other scripts, piped commands, heredocs with variables. Our git-based post-check catches everything regex misses, with zero false negatives.
112
-
113
- Regex pre-check is optional (could catch obvious cases for better UX) but the git post-check is the reliable enforcer.
114
-
115
- ---
116
-
117
- ### `.gitignore`
118
-
119
- The private git's work-tree is the project root, so it respects the project's `.gitignore` automatically — no copy needed, no separate file needed.
120
-
121
- If the user wants to exclude additional files from TDD snapshots only, they can create `.pi/tdd/.gitignore` with those patterns. Git checks `.gitignore` starting from the work-tree root, so a file there is picked up naturally.
122
-
123
- ---
124
-
125
- ## Config: `.pi/tdd/rules.json`
126
-
127
- ```json
128
- {
129
- "allowedRedPhaseFiles": ["tests/**/*.test.ts", "specs/**/*.spec.ts"],
130
- "allowedGreenPhaseFiles": ["src/**/*.ts"],
131
- "testCommands": ["npm run test:unit", "npm run test:integration"],
132
- "timeoutSeconds": 120
133
- }
134
- ```
135
-
136
- - `allowedRedPhaseFiles`: Glob patterns for files allowed in RED phase (typically test files).
137
- - `allowedGreenPhaseFiles`: Glob patterns for files allowed in GREEN phase (typically implementation files).
138
- - Files matching neither set are free in all phases.
139
- - `testCommands`: `string` (shell-chained with `&&` for sequential) or `string[]` (run in parallel). Must be non-interactive.
140
- - `timeoutSeconds`: Per-command timeout. Extension passes it as pi's `bash` tool timeout param, so we don't rely on system `timeout` binary.
141
-
142
- ---
143
-
144
-
145
-
146
- ---
147
-
148
- ## Phase State Persistence
149
-
150
- Stored in `.pi/tdd/state.json`:
151
-
152
- ```json
153
- {
154
- "current": "green",
155
- "enabled": true
156
- }
157
- ```
158
-
159
- The snapshot history lives in the private git repo's commit log — no explicit stack array needed. `previous_tdd_phase` reads the phase label from the HEAD commit message and restores to the parent. Survives session restarts and extension reloads.
160
-
161
- ---
162
-
163
- ## Open Questions
164
-
165
- ### Initial state
166
-
167
- How does a project start?
168
-
169
- - **Auto RED**: Start in RED unconditionally. If tests already pass, agent sees a warning that there's no failing test yet — it needs to write one or break one.
170
- - **Auto-detect**: Run tests on startup. If failing → RED. If passing → GREEN (agent is already in the "make it pass" phase).
171
- - **Prompt**: Ask the user what phase to start in.
172
-
173
- ### Enabling/disabling
174
-
175
- Should we have `/tdd:on` and `/tdd:off` commands to toggle without unloading the extension?
176
-
177
- ### Config location
178
-
179
- Only `.pi/tdd/rules.json` in project root? Or support nested configs?
180
-
181
- ### Multiple projects / monorepos
182
-
183
- If the project root has sub-projects with different test commands, does rules.json support multiple entries keyed by directory?