tdd-enforcer 0.3.4 → 0.3.6

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/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # tdd-enforcer
2
+
3
+ **Lock files per TDD phase. Gate transitions on test outcomes.**
4
+
5
+ [![CI](https://github.com/Cyclone1070/tdd-enforcer/actions/workflows/ci.yml/badge.svg)](https://github.com/Cyclone1070/tdd-enforcer/actions/workflows/ci.yml)
6
+ [![npm version](https://img.shields.io/npm/v/tdd-enforcer.svg)](https://www.npmjs.com/package/tdd-enforcer)
7
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
+
9
+ ---
10
+
11
+ ## Features
12
+
13
+ - **Phase-locked file access** — prevents the agent from modifying test files in GREEN and implementation files in RED. Everything is open in REFACTOR
14
+ - **Guards all file-modifying tools** — `write`, `edit`, and `bash` are all intercepted. All invalid modifications are blocked.
15
+ - **Automatic transition gates** — advancing to the next phase requires tests to fail (RED→GREEN) or pass (GREEN→REFACTOR, REFACTOR→RED). Misconfigured or broken transitions are rejected
16
+ - **Safe rollback** — if the previous phase's work was wrong, reverting discards all current changes and restores the exact working tree from before that phase
17
+ - **Stays out of your way** — TDD enforcement is opt-in (`/tdd:on`). Disable anytime with `/tdd:off` to unlock all files
18
+ - **Version-controlled config** — `.pi/tdd/rules.json` lives in your repo alongside the code, so the whole team shares the same rules
19
+
20
+ ---
21
+
22
+ ## Setup
23
+
24
+ ### 1. Install
25
+
26
+ ```bash
27
+ pi install npm:tdd-enforcer
28
+ ```
29
+
30
+ ### 2. Ask the agent to set up TDD
31
+
32
+ Tell the agent to configure TDD for your project, using the `tdd-enforcer` skill to create `.pi/tdd/rules.json` with the right file globs and test commands for your stack.
33
+
34
+ ### 3. Enable TDD
35
+
36
+ Once configured, run:
37
+
38
+ ```
39
+ /tdd:on
40
+ ```
41
+
42
+ ### Config reference
43
+
44
+ `.pi/tdd/rules.json` fields (created by the agent, not manually):
45
+
46
+ | Field | Description |
47
+ |-------|-------------|
48
+ | `blockedInRed` | Globs the agent **cannot** touch in RED phase (implementation files) |
49
+ | `blockedInGreen` | Globs the agent **cannot** touch in GREEN phase (test files) |
50
+ | `!` prefix | Exclusion: carves out subsets from a block (e.g. co-located test files) |
51
+ | `testCommands` | Commands run in parallel for gate checks. Exit 0 = pass, non-zero = block. Use `&&` inside a single entry to chain dependent steps |
52
+ | `timeoutSeconds` | Test timeout per command (default: 120) |
53
+
54
+ ---
55
+
56
+ ## Usage
57
+
58
+ ### User commands
59
+
60
+ | Command | Description |
61
+ |---------|-------------|
62
+ | `/tdd:on` | Enable TDD enforcement |
63
+ | `/tdd:off` | Disable TDD enforcement, all files become free |
64
+ | `/tdd:status` | Show phase, blocked globs, test commands |
65
+ | `/tdd:reset` | **Destructive**: nukes all snapshot history, resets to RED (disabled) |
66
+ | `/tdd:red`, `/tdd:green`, `/tdd:refactor` | Skip to a given phase (auto-enables, no gate checks) |
67
+
68
+ ### Agent tools
69
+
70
+ | Tool | When to use | Effect |
71
+ |------|-------------|--------|
72
+ | `next_tdd_phase` | Current phase work is complete | Runs allowlist check + gate test, snapshots state, advances phase|
73
+ | `previous_tdd_phase` | Previous phase work was wrong | **Discards all current-phase changes**, restores working tree to previous snapshot |
74
+ | `tdd_status` | Check current enforcement state | Returns phase, blocked globs, test commands |
75
+
76
+ ---
77
+
78
+ ## How it works
79
+
80
+ Uses a **private git repository** at `.pi/tdd/.git/` (separate from your project's real git history) to detect locked-file changes, revert invalid modifications, and track state across phase transitions.
81
+
82
+ ```
83
+ tests fail tests pass
84
+ ┌──────┐ (gate check) ┌────────┐ (gate check) ┌──────────┐
85
+ │ RED │ ──────────────────▶│ GREEN │ ───────────────▶│ REFACTOR │
86
+ │(test)│ │ (impl) │ │(cleanup) │
87
+ └──────┘ └────────┘ └──────────┘
88
+ ▲ │
89
+ └────────────────────────────────────────────────────────┘
90
+ tests pass
91
+ ```
92
+
93
+ Every phase transition runs two validations before advancing:
94
+
95
+ 1. **Allowlist check** — scans working tree changes against the phase's blocked globs. If any locked file has been modified, the transition is rejected with the violating paths listed
96
+ 2. **Gate check** — runs `testCommands` in parallel. The required outcome depends on the transition:
97
+ - RED→GREEN: all commands must fail (a passing test suite means there's no failing test to justify moving to GREEN)
98
+ - GREEN→REFACTOR: all commands must pass
99
+ - REFACTOR→RED: all commands must pass
100
+
101
+ If both checks pass, the working tree is snapshotted and the phase advances.
102
+
103
+ ### File-level enforcement
104
+
105
+ When TDD is active, every `write`, `edit`, and `bash` tool call is intercepted:
106
+
107
+ - **`write` / `edit`** — the target file path is checked against the current phase's blocked globs before the tool executes. Locked file writes are blocked with an error message
108
+ - **`bash`** — the working tree is stashed before the command runs. After it finishes, the diff is compared against the stash to find what changed. Any locked-file modifications are automatically reverted, and the command output is amended with a warning listing the violations
109
+
110
+ This means the agent can attempt any change — enforcement happens transparently at the tool layer.
111
+
112
+ ### Rollback mechanics
113
+
114
+ Each phase transition creates a labeled commit in a private git repository at `.pi/tdd/.git/`. Calling `previous_tdd_phase`:
115
+
116
+ 1. Confirms HEAD is a TDD snapshot (commit message starts with `tdd: {phase}`)
117
+ 2. Hard-resets the working tree to discard uncommitted changes
118
+ 3. Soft-resets HEAD~1 to pop the snapshot
119
+ 4. Sets the phase back
120
+
121
+ Since TDD owns its own git repo, rollback doesn't touch the project's real git history at all.
122
+
123
+ ### State recovery
124
+
125
+ If `state.json` is missing or corrupted, the extension recovers by reading the last TDD commit message from `.pi/tdd/.git/`. The label (`tdd: red`, `tdd: green`, etc.) determines the current phase. If no TDD commits exist, it defaults to disabled in RED.
126
+
127
+ ---
128
+
129
+ ## Development
130
+
131
+ ```bash
132
+ npm install
133
+ npm run check
134
+ ```
135
+
136
+ ### Project structure
137
+
138
+ ```
139
+ tdd-enforcer/
140
+ ├── engine/ # Framework-agnostic core
141
+ │ ├── types.ts # Phase, Config, Transition types
142
+ │ ├── config.ts # Load & validate rules.json
143
+ │ ├── state.ts # Load/save/recover phase state
144
+ │ ├── enforce.ts # Glob-based file allowlist checks
145
+ │ ├── transition.ts # Gate checks (test failure/pass per transition)
146
+ │ ├── orchestrate.ts # advancePhase / revertPhase orchestration
147
+ │ ├── git.ts # Private git repo for snapshots & diff
148
+ │ ├── log.ts # Append-only log with line cap
149
+ │ └── prompts.ts # Phase-specific agent nudges
150
+ ├── adapters/
151
+ │ └── pi/ # pi extension adapter
152
+ │ ├── index.ts # Extension entry: commands (tdd:on/off/status/reset/jump)
153
+ │ ├── hooks.ts # Intercept write/edit/bash tool calls & results
154
+ │ └── tools.ts # Agent tools: next_tdd_phase, previous_tdd_phase, tdd_status
155
+ ├── skills/
156
+ │ └── tdd-enforcer/
157
+ │ └── SKILL.md # Agent instructions for TDD workflows
158
+ └── package.json
159
+ ```
160
+
161
+ - `engine/` — pure logic, zero pi dependencies. Testable in isolation
162
+ - `adapters/pi/` — pi-specific wiring: commands, hooks, agent tools
163
+ - `skills/tdd-enforcer/` — agent instructions consumed at runtime
164
+
165
+ ---
166
+
167
+ ## License
168
+
169
+ [MIT](LICENSE)
@@ -1,6 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import {
3
- handleBeforeAgentStart,
4
3
  handleTddJump,
5
4
  handleTddOff,
6
5
  handleTddOn,
@@ -418,80 +417,4 @@ describe("handleTddJump", () => {
418
417
  });
419
418
  });
420
419
 
421
- // ── handleBeforeAgentStart ──────────────────────────────────────────────────
422
420
 
423
- describe("handleBeforeAgentStart", () => {
424
- let mockLoadTddState: ReturnType<typeof vi.fn>;
425
-
426
- function makeDeps(overrides = {}) {
427
- return {
428
- loadTddState: mockLoadTddState,
429
- ...overrides,
430
- };
431
- }
432
-
433
- function _makeEvent(): { systemPrompt: string } {
434
- return { systemPrompt: "" };
435
- }
436
-
437
- beforeEach(() => {
438
- vi.clearAllMocks();
439
- mockLoadTddState = vi.fn();
440
- });
441
-
442
- it("returns instructions when TDD enabled", async () => {
443
- mockLoadTddState.mockReturnValue({
444
- ok: true,
445
- state: { enabled: true, current: "red" },
446
- config: {
447
- blockedInRed: [],
448
- blockedInGreen: [],
449
- testCommands: [],
450
- timeoutSeconds: 30,
451
- },
452
- });
453
- const result = await handleBeforeAgentStart(
454
- { systemPrompt: "" } as any,
455
- { cwd: "/test" } as any,
456
- makeDeps(),
457
- );
458
- expect(result).toBeDefined();
459
- expect(result?.systemPrompt).toContain("TDD enforcement");
460
- expect(result?.systemPrompt).toContain("locked files will be blocked");
461
- expect(result?.systemPrompt).toContain("cycle so reverting is cheap");
462
- expect(result?.systemPrompt).not.toContain("next_tdd_phase");
463
- });
464
-
465
- it("returns undefined when TDD not setup", async () => {
466
- mockLoadTddState.mockReturnValue({
467
- ok: false,
468
- reason: "Missing .pi/tdd/",
469
- });
470
- const result = await handleBeforeAgentStart(
471
- { systemPrompt: "" } as any,
472
- { cwd: "/test" } as any,
473
- makeDeps(),
474
- );
475
- expect(result).toBeUndefined();
476
- });
477
-
478
- it("returns disabled message when TDD was disabled", async () => {
479
- mockLoadTddState.mockReturnValue({
480
- ok: true,
481
- state: { enabled: false, current: "red" },
482
- config: {
483
- blockedInRed: [],
484
- blockedInGreen: [],
485
- testCommands: [],
486
- timeoutSeconds: 30,
487
- },
488
- });
489
- const result = await handleBeforeAgentStart(
490
- { systemPrompt: "" } as any,
491
- { cwd: "/test" } as any,
492
- makeDeps(),
493
- );
494
- expect(result).toBeDefined();
495
- expect(result?.systemPrompt).toContain("was disabled");
496
- });
497
- });
@@ -152,30 +152,6 @@ export async function handleTddStatus(
152
152
  );
153
153
  }
154
154
 
155
- export async function handleBeforeAgentStart(
156
- event: { systemPrompt: string },
157
- ctx: { cwd: string },
158
- deps: {
159
- loadTddState: typeof loadTddState;
160
- },
161
- ): Promise<{ systemPrompt: string } | undefined> {
162
- const tdd = deps.loadTddState(ctx.cwd);
163
- if (!tdd.ok) return;
164
-
165
- if (tdd.state.enabled) {
166
- return {
167
- systemPrompt:
168
- event.systemPrompt +
169
- "\n\nYou are working under TDD enforcement. Each phase restricts which files you can modify — locked files will be blocked automatically.\n" +
170
- "Minimise the scope of each TDD cycle so reverting is cheap.",
171
- };
172
- }
173
- return {
174
- systemPrompt:
175
- event.systemPrompt +
176
- "\n\nTDD enforcement was disabled. File restrictions are no longer enforced.",
177
- };
178
- }
179
155
 
180
156
  export async function handleTddJump(
181
157
  phase: "red" | "green" | "refactor",
@@ -332,8 +308,4 @@ export default function (pi: ExtensionAPI) {
332
308
 
333
309
  registerTools(pi);
334
310
  registerHooks(pi);
335
-
336
- pi.on("before_agent_start", (event, ctx) =>
337
- handleBeforeAgentStart(event, ctx, { loadTddState }),
338
- );
339
311
  }
@@ -172,6 +172,25 @@ describe("executeNextPhase", () => {
172
172
  });
173
173
  });
174
174
 
175
+ it("blocks on timeout with timeout message from checkGate", async () => {
176
+ mockLoadTddState.mockReturnValue({
177
+ ok: true,
178
+ state: { enabled: true, current: "red" },
179
+ config: CONFIG,
180
+ });
181
+ mockCheckGate.mockResolvedValue({
182
+ passed: false,
183
+ timeout: true,
184
+ message:
185
+ "Tests timed out after 30s. The test command may have hung or an operation may be blocking.",
186
+ });
187
+ await expect(
188
+ executeNextPhase({ cwd: "/test" } as any, makeDeps()),
189
+ ).rejects.toThrow("timed out");
190
+ expect(mockSnapshot).not.toHaveBeenCalled();
191
+ expect(mockSavePhaseState).not.toHaveBeenCalled();
192
+ });
193
+
175
194
  it("advances green→refactor when tests pass", async () => {
176
195
  mockLoadTddState.mockReturnValue({
177
196
  ok: true,
@@ -126,14 +126,29 @@ export async function executeNextPhase(
126
126
  cwd: root,
127
127
  timeout: timeout * 1000,
128
128
  });
129
- return { command: cmd, passed: true };
130
- } catch {
131
- return { command: cmd, passed: false };
129
+ return { command: cmd, passed: true, timedOut: false } as const;
130
+ } catch (err) {
131
+ const timedOut = (err as any)?.killed === true;
132
+ return {
133
+ command: cmd,
134
+ passed: false,
135
+ timedOut,
136
+ } as const;
132
137
  }
133
138
  }),
134
139
  );
135
140
 
136
- const failed = results.filter((r) => !r.passed);
141
+ const timedOut = results.filter((r) => r.timedOut);
142
+ const failed = results.filter((r) => !r.passed && !r.timedOut);
143
+
144
+ if (timedOut.length > 0) {
145
+ return {
146
+ passed: false,
147
+ timeout: true,
148
+ message: `Tests timed out after ${timeout}s:\n${timedOut.map((f) => ` - ${f.command}`).join("\n")}`,
149
+ };
150
+ }
151
+
137
152
  if (failed.length > 0) {
138
153
  return {
139
154
  passed: false,
@@ -25,10 +25,15 @@ describe("nextPhase", () => {
25
25
 
26
26
  // ── Pure unit tests: checkGate ──────────────────────────────────────────────
27
27
 
28
- function makeRunner(passed: boolean): TestRunner {
28
+ function makeRunner(passed: boolean, timeout?: boolean): TestRunner {
29
29
  return async (_cmds, _timeout) => ({
30
30
  passed,
31
- message: passed ? "all ok" : "tests failed",
31
+ timeout,
32
+ message: timeout
33
+ ? "npm test: timed out"
34
+ : passed
35
+ ? "all ok"
36
+ : "tests failed",
32
37
  });
33
38
  }
34
39
 
@@ -52,6 +57,19 @@ describe("checkGate", () => {
52
57
  expect(r.passed).toBe(false);
53
58
  expect(r.message).toMatch(/transitioning to GREEN/i);
54
59
  });
60
+
61
+ it("blocks on timeout — does not treat as test failure", async () => {
62
+ const r = await checkGate(
63
+ "red",
64
+ "green",
65
+ makeRunner(false, true),
66
+ testConfig,
67
+ );
68
+ expect(r.passed).toBe(false);
69
+ expect(r.timeout).toBe(true);
70
+ expect(r.message).toMatch(/timed out/i);
71
+ expect(r.message).not.toMatch(/proceed|fail/i);
72
+ });
55
73
  });
56
74
 
57
75
  describe("green → refactor (tests must pass)", () => {
@@ -76,6 +94,19 @@ describe("checkGate", () => {
76
94
  expect(r.passed).toBe(false);
77
95
  expect(r.message).toMatch(/transitioning to REFACTOR/i);
78
96
  });
97
+
98
+ it("blocks on timeout with timeout message", async () => {
99
+ const r = await checkGate(
100
+ "green",
101
+ "refactor",
102
+ makeRunner(false, true),
103
+ testConfig,
104
+ );
105
+ expect(r.passed).toBe(false);
106
+ expect(r.timeout).toBe(true);
107
+ expect(r.message).toMatch(/timed out/i);
108
+ expect(r.message).not.toMatch(/fix them/i);
109
+ });
79
110
  });
80
111
 
81
112
  describe("refactor → red (tests must pass)", () => {
@@ -100,6 +131,19 @@ describe("checkGate", () => {
100
131
  expect(r.passed).toBe(false);
101
132
  expect(r.message).toMatch(/transitioning to RED/i);
102
133
  });
134
+
135
+ it("blocks on timeout with timeout message", async () => {
136
+ const r = await checkGate(
137
+ "refactor",
138
+ "red",
139
+ makeRunner(false, true),
140
+ testConfig,
141
+ );
142
+ expect(r.passed).toBe(false);
143
+ expect(r.timeout).toBe(true);
144
+ expect(r.message).toMatch(/timed out/i);
145
+ expect(r.message).not.toMatch(/fix them/i);
146
+ });
103
147
  });
104
148
 
105
149
  it("passes test commands to the runner", async () => {
@@ -13,6 +13,7 @@ export function nextPhase(current: Phase): Phase | null {
13
13
  export interface GateResult {
14
14
  passed: boolean;
15
15
  message: string;
16
+ timeout?: boolean;
16
17
  }
17
18
 
18
19
  export type TestRunner = (
@@ -34,6 +35,15 @@ export async function checkGate(
34
35
  ): Promise<GateResult> {
35
36
  const result = await testRunner(config.testCommands, config.timeoutSeconds);
36
37
 
38
+ // Timeout blocks all transitions — don't suggest "fix tests"
39
+ if (result.timeout) {
40
+ return {
41
+ passed: false,
42
+ timeout: true,
43
+ message: `Tests timed out after ${config.timeoutSeconds}s. The test command may have hung or an operation may be blocking.`,
44
+ };
45
+ }
46
+
37
47
  switch (`${from}→${to}` as Transition) {
38
48
  case "red→green":
39
49
  if (result.passed) {
package/package.json CHANGED
@@ -1,31 +1,43 @@
1
1
  {
2
- "name": "tdd-enforcer",
3
- "version": "0.3.4",
4
- "type": "module",
5
- "scripts": {
6
- "lint": "biome check .",
7
- "lint:fix": "biome check --write .",
8
- "test": "vitest run"
9
- },
10
- "keywords": [
11
- "pi-package"
12
- ],
13
- "dependencies": {
14
- "picomatch": "^4.0.4"
15
- },
16
- "devDependencies": {
17
- "@biomejs/biome": "^2.5.0",
18
- "@earendil-works/pi-coding-agent": "^0.79.6",
19
- "@types/node": "^25.9.3",
20
- "typebox": "^1.2.16",
21
- "vitest": "^3"
22
- },
23
- "pi": {
24
- "extensions": [
25
- "./adapters/pi/index.ts"
26
- ],
27
- "skills": [
28
- "./skills"
29
- ]
30
- }
2
+ "name": "tdd-enforcer",
3
+ "version": "0.3.6",
4
+ "type": "module",
5
+ "description": "TDD enforcer extension for the pi coding agent — enforces Red-Green-Refactor phases with file access restrictions and transition gates",
6
+ "license": "MIT",
7
+ "author": "Cyclone1070 <hoangmai1070@gmail.com>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Cyclone1070/tdd-enforcer.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/Cyclone1070/tdd-enforcer/issues"
14
+ },
15
+ "homepage": "https://github.com/Cyclone1070/tdd-enforcer#readme",
16
+ "scripts": {
17
+ "check": "biome check --write . && vitest run",
18
+ "lint": "biome check .",
19
+ "lint:fix": "biome check --write .",
20
+ "test": "vitest run"
21
+ },
22
+ "keywords": [
23
+ "pi-package"
24
+ ],
25
+ "dependencies": {
26
+ "picomatch": "^4.0.4"
27
+ },
28
+ "devDependencies": {
29
+ "@biomejs/biome": "^2.5.0",
30
+ "@earendil-works/pi-coding-agent": "^0.79.6",
31
+ "@types/node": "^25.9.3",
32
+ "typebox": "^1.2.16",
33
+ "vitest": "^3"
34
+ },
35
+ "pi": {
36
+ "extensions": [
37
+ "./adapters/pi/index.ts"
38
+ ],
39
+ "skills": [
40
+ "./skills"
41
+ ]
42
+ }
31
43
  }