tdd-enforcer 0.1.0

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/DESIGN.md ADDED
@@ -0,0 +1,184 @@
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. Pops the phase stack, 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
+ ├── phase.json # {current: "red", stack: ["s1","s2","s3"]}
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 stack in `phase.json`
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/phase.json`:
151
+
152
+ ```json
153
+ {
154
+ "current": "green",
155
+ "enabled": true,
156
+ "stack": ["commit-hash-1", "commit-hash-2", "commit-hash-3"]
157
+ }
158
+ ```
159
+
160
+ The git commit hashes anchor each snapshot. `previous_tdd_phase` restores to the parent of the latest commit and pops the stack. Survives session restarts and extension reloads.
161
+
162
+ ---
163
+
164
+ ## Open Questions
165
+
166
+ ### Initial state
167
+
168
+ How does a project start?
169
+
170
+ - **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.
171
+ - **Auto-detect**: Run tests on startup. If failing → RED. If passing → GREEN (agent is already in the "make it pass" phase).
172
+ - **Prompt**: Ask the user what phase to start in.
173
+
174
+ ### Enabling/disabling
175
+
176
+ Should we have `/tdd:on` and `/tdd:off` commands to toggle without unloading the extension?
177
+
178
+ ### Config location
179
+
180
+ Only `.pi/tdd/rules.json` in project root? Or support nested configs?
181
+
182
+ ### Multiple projects / monorepos
183
+
184
+ If the project root has sub-projects with different test commands, does rules.json support multiple entries keyed by directory?
@@ -0,0 +1,203 @@
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 { loadTddState } from "./helpers.js";
6
+
7
+ function withTempDir(fn: (dir: string) => void) {
8
+ const dir = join(tmpdir(), `tdd-helpers-test-${Date.now()}`);
9
+ mkdirSync(dir, { recursive: true });
10
+ try {
11
+ fn(dir);
12
+ } finally {
13
+ rmSync(dir, { recursive: true, force: true });
14
+ }
15
+ }
16
+
17
+ const validRules = {
18
+ allowedRedPhaseFiles: ["tests/**/*.test.ts"],
19
+ allowedGreenPhaseFiles: ["src/**/*.ts"],
20
+ testCommands: ["npm test"],
21
+ timeoutSeconds: 30,
22
+ };
23
+
24
+ const validPhase = {
25
+ enabled: true,
26
+ current: "red",
27
+ };
28
+
29
+ describe("loadTddState", () => {
30
+ it("returns missing dir error when .pi/tdd does not exist", () => {
31
+ withTempDir((dir) => {
32
+ const result = loadTddState(dir);
33
+ expect(result.ok).toBe(false);
34
+ expect(result.reason).toContain("Missing .pi/tdd/");
35
+ });
36
+ });
37
+
38
+ it("returns missing rules.json error when only dir exists", () => {
39
+ withTempDir((dir) => {
40
+ mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
41
+ const result = loadTddState(dir);
42
+ expect(result.ok).toBe(false);
43
+ expect(result.reason).toContain("rules.json");
44
+ });
45
+ });
46
+
47
+ it("returns missing phase.json when rules exists but phase missing", () => {
48
+ withTempDir((dir) => {
49
+ mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
50
+ writeFileSync(
51
+ join(dir, ".pi", "tdd", "rules.json"),
52
+ JSON.stringify(validRules),
53
+ "utf-8",
54
+ );
55
+ const result = loadTddState(dir);
56
+ expect(result.ok).toBe(false);
57
+ expect(result.reason).toContain("phase.json");
58
+ });
59
+ });
60
+
61
+ it("returns invalid phase.json error for malformed JSON", () => {
62
+ withTempDir((dir) => {
63
+ mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
64
+ writeFileSync(
65
+ join(dir, ".pi", "tdd", "rules.json"),
66
+ JSON.stringify(validRules),
67
+ "utf-8",
68
+ );
69
+ writeFileSync(join(dir, ".pi", "tdd", "phase.json"), "not json", "utf-8");
70
+ const result = loadTddState(dir);
71
+ expect(result.ok).toBe(false);
72
+ expect(result.reason).toContain("Invalid .pi/tdd/phase.json");
73
+ });
74
+ });
75
+
76
+ it("returns invalid rules.json error for malformed JSON", () => {
77
+ withTempDir((dir) => {
78
+ mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
79
+ writeFileSync(
80
+ join(dir, ".pi", "tdd", "rules.json"),
81
+ "not json",
82
+ "utf-8",
83
+ );
84
+ writeFileSync(
85
+ join(dir, ".pi", "tdd", "phase.json"),
86
+ JSON.stringify(validPhase),
87
+ "utf-8",
88
+ );
89
+ const result = loadTddState(dir);
90
+ expect(result.ok).toBe(false);
91
+ expect(result.reason).toContain("Invalid .pi/tdd/rules.json");
92
+ });
93
+ });
94
+
95
+ it("returns disabled error when phase.json has enabled: false", () => {
96
+ withTempDir((dir) => {
97
+ mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
98
+ writeFileSync(
99
+ join(dir, ".pi", "tdd", "rules.json"),
100
+ JSON.stringify(validRules),
101
+ "utf-8",
102
+ );
103
+ writeFileSync(
104
+ join(dir, ".pi", "tdd", "phase.json"),
105
+ JSON.stringify({ enabled: false, current: "red" }),
106
+ "utf-8",
107
+ );
108
+ const result = loadTddState(dir);
109
+ expect(result.ok).toBe(false);
110
+ expect(result.reason).toContain("TDD is not enabled");
111
+ });
112
+ });
113
+
114
+ it("returns ok with state and config when everything valid", () => {
115
+ withTempDir((dir) => {
116
+ mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
117
+ writeFileSync(
118
+ join(dir, ".pi", "tdd", "rules.json"),
119
+ JSON.stringify(validRules),
120
+ "utf-8",
121
+ );
122
+ writeFileSync(
123
+ join(dir, ".pi", "tdd", "phase.json"),
124
+ JSON.stringify(validPhase),
125
+ "utf-8",
126
+ );
127
+ const result = loadTddState(dir);
128
+ expect(result.ok).toBe(true);
129
+ if (result.ok) {
130
+ expect(result.state.current).toBe("red");
131
+ expect(result.state.enabled).toBe(true);
132
+ expect(result.config.testCommands).toEqual(["npm test"]);
133
+ expect(result.config.allowedRedPhaseFiles).toEqual(["tests/**/*.test.ts"]);
134
+ }
135
+ });
136
+ });
137
+
138
+ it("heals missing git repo automatically", () => {
139
+ withTempDir((dir) => {
140
+ mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
141
+ writeFileSync(
142
+ join(dir, ".pi", "tdd", "rules.json"),
143
+ JSON.stringify(validRules),
144
+ "utf-8",
145
+ );
146
+ writeFileSync(
147
+ join(dir, ".pi", "tdd", "phase.json"),
148
+ JSON.stringify(validPhase),
149
+ "utf-8",
150
+ );
151
+
152
+ const gitDir = join(dir, ".pi", "tdd", ".git");
153
+ expect(existsSync(gitDir)).toBe(false);
154
+
155
+ const result = loadTddState(dir);
156
+ expect(result.ok).toBe(true);
157
+
158
+ // Git should now exist
159
+ expect(existsSync(gitDir)).toBe(true);
160
+ });
161
+ });
162
+
163
+ it("handles multiple calls without error (existing git is reused)", () => {
164
+ withTempDir((dir) => {
165
+ mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
166
+ writeFileSync(
167
+ join(dir, ".pi", "tdd", "rules.json"),
168
+ JSON.stringify(validRules),
169
+ "utf-8",
170
+ );
171
+ writeFileSync(
172
+ join(dir, ".pi", "tdd", "phase.json"),
173
+ JSON.stringify(validPhase),
174
+ "utf-8",
175
+ );
176
+
177
+ const r1 = loadTddState(dir);
178
+ expect(r1.ok).toBe(true);
179
+
180
+ const r2 = loadTddState(dir);
181
+ expect(r2.ok).toBe(true);
182
+ });
183
+ });
184
+
185
+ it("passes through the phase.json validation error for invalid current phase", () => {
186
+ withTempDir((dir) => {
187
+ mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
188
+ writeFileSync(
189
+ join(dir, ".pi", "tdd", "rules.json"),
190
+ JSON.stringify(validRules),
191
+ "utf-8",
192
+ );
193
+ writeFileSync(
194
+ join(dir, ".pi", "tdd", "phase.json"),
195
+ JSON.stringify({ enabled: true, current: "blurple" }),
196
+ "utf-8",
197
+ );
198
+ const result = loadTddState(dir);
199
+ expect(result.ok).toBe(false);
200
+ expect(result.reason).toContain("Invalid .pi/tdd/phase.json");
201
+ });
202
+ });
203
+ });
@@ -0,0 +1,60 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { loadPhaseState, loadConfig, initGit } from "../../engine/index.js";
4
+ import type { PhaseState, Config } from "../../engine/types.js";
5
+
6
+ export type TddLoadResult =
7
+ | { ok: true; state: PhaseState; config: Config }
8
+ | { ok: false; reason: string };
9
+
10
+ /**
11
+ * Load TDD state + config in one go.
12
+ * Returns ok:true with state and config when everything is valid and ready.
13
+ * Returns ok:false with a specific reason string otherwise.
14
+ */
15
+ export function loadTddState(root: string): TddLoadResult {
16
+ const tddDir = join(root, ".pi", "tdd");
17
+ if (!existsSync(tddDir)) {
18
+ return { ok: false, reason: "Missing .pi/tdd/ directory. See the tdd-init skill to learn how to set up TDD configs." };
19
+ }
20
+
21
+ const rulesPath = join(tddDir, "rules.json");
22
+ if (!existsSync(rulesPath)) {
23
+ return { ok: false, reason: "Missing .pi/tdd/rules.json. See the tdd-init skill to learn how to set up TDD configs." };
24
+ }
25
+
26
+ const phasePath = join(tddDir, "phase.json");
27
+ if (!existsSync(phasePath)) {
28
+ return { ok: false, reason: "Missing .pi/tdd/phase.json. See the tdd-init skill to learn how to set up TDD configs." };
29
+ }
30
+
31
+ let state: PhaseState;
32
+ try {
33
+ state = loadPhaseState(root);
34
+ } catch (e) {
35
+ return { ok: false, reason: `Invalid .pi/tdd/phase.json: ${(e as Error).message}` };
36
+ }
37
+
38
+ let config: Config;
39
+ try {
40
+ config = loadConfig(root);
41
+ } catch (e) {
42
+ return { ok: false, reason: `Invalid .pi/tdd/rules.json: ${(e as Error).message}` };
43
+ }
44
+
45
+ if (!state.enabled) {
46
+ return { ok: false, reason: "TDD is not enabled. Run /tdd to enable it." };
47
+ }
48
+
49
+ // Heal git if missing
50
+ const gitDir = join(tddDir, ".git");
51
+ if (!existsSync(gitDir)) {
52
+ try {
53
+ initGit(root);
54
+ } catch (e) {
55
+ return { ok: false, reason: `Failed to initialise private git repo: ${(e as Error).message}` };
56
+ }
57
+ }
58
+
59
+ return { ok: true, state, config };
60
+ }
@@ -0,0 +1,59 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { isToolCallEventType, isBashToolResult } from "@earendil-works/pi-coding-agent";
3
+ import { isAllowed } from "../../engine/enforce.js";
4
+ import { changesSinceSnapshot, restoreFiles } from "../../engine/git.js";
5
+ import { loadTddState } from "./helpers.js";
6
+
7
+ export function registerHooks(pi: ExtensionAPI): void {
8
+ pi.on("tool_call", async (event, ctx: ExtensionContext) => {
9
+ const root = ctx.cwd;
10
+ const tdd = loadTddState(root);
11
+ if (!tdd.ok) return;
12
+
13
+ const { state, config } = tdd;
14
+ const phase = state.current;
15
+
16
+ // write/edit pre-block
17
+ let filePath: string | undefined;
18
+ if (isToolCallEventType("write", event)) filePath = event.input.path;
19
+ else if (isToolCallEventType("edit", event)) filePath = event.input.path;
20
+ else return;
21
+
22
+ if (!filePath) return;
23
+
24
+ if (!isAllowed(filePath, phase, config)) {
25
+ return {
26
+ block: true,
27
+ reason: `TDD ${phase.toUpperCase()}: "${filePath}" is locked in this phase.`,
28
+ };
29
+ }
30
+ });
31
+
32
+ pi.on("tool_result", async (event, ctx: ExtensionContext) => {
33
+ if (!isBashToolResult(event)) return;
34
+
35
+ const root = ctx.cwd;
36
+ const tdd = loadTddState(root);
37
+ if (!tdd.ok) return;
38
+
39
+ const { state, config } = tdd;
40
+ const phase = state.current;
41
+ if (phase === "refactor") return;
42
+
43
+ const changed = changesSinceSnapshot(root);
44
+ if (changed.length === 0) return;
45
+
46
+ const violations = changed.filter((f) => !isAllowed(f, phase, config));
47
+ if (violations.length === 0) return;
48
+
49
+ restoreFiles(root, violations);
50
+
51
+ const existingText = event.content.map((c) => ("text" in c ? c.text : "")).join("");
52
+ return {
53
+ content: [
54
+ { type: "text", text: existingText + `\n\n⚠️ TDD: Bash modified files locked in ${phase.toUpperCase()} phase. Reverted: ${violations.join(", ")}` },
55
+ ],
56
+ };
57
+ });
58
+
59
+ }
@@ -0,0 +1,122 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { loadPhaseState, loadConfig, savePhaseState, initGit } from "../../engine/index.js";
5
+ import { registerTools } from "./tools.js";
6
+ import { registerHooks } from "./hooks.js";
7
+ import { loadTddState } from "./helpers.js";
8
+
9
+ export default function (pi: ExtensionAPI) {
10
+ pi.registerCommand("tdd:on", {
11
+ description: "Enable TDD enforcement",
12
+ handler: async (_args: string, ctx: ExtensionContext) => {
13
+ const root = ctx.cwd;
14
+ const tddDir = join(root, ".pi", "tdd");
15
+
16
+ if (!existsSync(tddDir)) {
17
+ ctx.ui.notify("Missing .pi/tdd/ directory. See the tdd-init skill to learn how to set up TDD configs.", "error");
18
+ return;
19
+ }
20
+
21
+ const rulesPath = join(tddDir, "rules.json");
22
+ if (!existsSync(rulesPath)) {
23
+ ctx.ui.notify("Missing .pi/tdd/rules.json. See the tdd-init skill to learn how to set up TDD configs.", "error");
24
+ return;
25
+ }
26
+
27
+ const phasePath = join(tddDir, "phase.json");
28
+ if (!existsSync(phasePath)) {
29
+ ctx.ui.notify("Missing .pi/tdd/phase.json. See the tdd-init skill to learn how to set up TDD configs.", "error");
30
+ return;
31
+ }
32
+
33
+ let state;
34
+ try {
35
+ state = loadPhaseState(root);
36
+ } catch {
37
+ ctx.ui.notify("Invalid .pi/tdd/phase.json. Fix or delete it, then run /tdd:on again.", "error");
38
+ return;
39
+ }
40
+
41
+ try {
42
+ loadConfig(root);
43
+ } catch {
44
+ ctx.ui.notify("Invalid .pi/tdd/rules.json. Fix or delete it, then run /tdd:on again.", "error");
45
+ return;
46
+ }
47
+
48
+ if (state.enabled) {
49
+ ctx.ui.notify(`TDD already enabled — ${state.current.toUpperCase()} phase`, "info");
50
+ return;
51
+ }
52
+
53
+ if (!existsSync(join(tddDir, ".git", "HEAD"))) {
54
+ try {
55
+ initGit(root);
56
+ } catch {
57
+ ctx.ui.notify("Failed to initialise private git repo.", "error");
58
+ return;
59
+ }
60
+ }
61
+
62
+ state.enabled = true;
63
+ savePhaseState(root, state);
64
+ ctx.ui.notify(`TDD enabled — ${state.current.toUpperCase()} phase`, "info");
65
+ },
66
+ });
67
+
68
+ pi.registerCommand("tdd:off", {
69
+ description: "Disable TDD enforcement",
70
+ handler: async (_args: string, ctx: ExtensionContext) => {
71
+ const root = ctx.cwd;
72
+
73
+ let state;
74
+ try {
75
+ state = loadPhaseState(root);
76
+ } catch {
77
+ ctx.ui.notify("Invalid .pi/tdd/phase.json. Fix or delete it, then run /tdd:off again.", "error");
78
+ return;
79
+ }
80
+
81
+ if (!state.enabled) {
82
+ ctx.ui.notify("TDD already disabled", "info");
83
+ return;
84
+ }
85
+
86
+ state.enabled = false;
87
+ savePhaseState(root, state);
88
+ ctx.ui.notify("TDD disabled", "info");
89
+ },
90
+ });
91
+
92
+ pi.registerCommand("tdd:status", {
93
+ description: "Show TDD enforcement status",
94
+ handler: async (_args: string, ctx: ExtensionContext) => {
95
+ const root = ctx.cwd;
96
+ const result = loadTddState(root);
97
+
98
+ if (!result.ok) {
99
+ ctx.ui.notify(`TDD: ${result.reason}`, "error");
100
+ return;
101
+ }
102
+
103
+ const { state, config } = result;
104
+ const phaseStr = state.current.toUpperCase();
105
+ const redGlobs = config.allowedRedPhaseFiles.join(", ") || "(none)";
106
+ const greenGlobs = config.allowedGreenPhaseFiles.join(", ") || "(none)";
107
+ const commands = config.testCommands.join(", ") || "(none)";
108
+
109
+ ctx.ui.notify(
110
+ `TDD enforcer enabled\n` +
111
+ `Current phase: ${phaseStr}\n` +
112
+ `Test files: ${redGlobs}\n` +
113
+ `Impl files: ${greenGlobs}\n` +
114
+ `Test commands: ${commands}`,
115
+ "info"
116
+ );
117
+ },
118
+ });
119
+
120
+ registerTools(pi);
121
+ registerHooks(pi);
122
+ }
@@ -0,0 +1,64 @@
1
+ import type { Phase, Config } from "../../engine/types.js";
2
+
3
+ function listMatched(files: string[]): string {
4
+ if (files.length === 0) return "";
5
+ return "\nMatched files: " + files.join(", ");
6
+ }
7
+
8
+ export function getNudgePrompt(phase: Phase, config: Config, matchedFiles?: string[]): string {
9
+ switch (phase) {
10
+ case "red":
11
+ return (
12
+ "You are now in **RED** phase. Write failing tests matching `allowedRedPhaseFiles` patterns. " +
13
+ "Only these files can be modified. Once tests fail, call `next_tdd_phase` to proceed to GREEN." +
14
+ listMatched(matchedFiles ?? config.allowedRedPhaseFiles)
15
+ );
16
+ case "green":
17
+ return (
18
+ "You are now in **GREEN** phase. Files matching `allowedRedPhaseFiles` are locked. " +
19
+ "Implement features in `allowedGreenPhaseFiles` to make tests pass. " +
20
+ "Call `next_tdd_phase` to proceed to REFACTOR."
21
+ );
22
+ case "refactor":
23
+ return (
24
+ "You are now in **REFACTOR** phase. Both test and implementation files are free to modify. " +
25
+ "Refactor without changing behavior. Call `next_tdd_phase` to start a new RED cycle."
26
+ );
27
+ default:
28
+ return "";
29
+ }
30
+ }
31
+
32
+ export function buildPhasePrompt(phase: Phase, config: Config): string {
33
+ const redPatterns = config.allowedRedPhaseFiles.join(", ") || "(none)";
34
+ const greenPatterns = config.allowedGreenPhaseFiles.join(", ") || "(none)";
35
+
36
+ let allowed: string;
37
+ let locked: string;
38
+
39
+ switch (phase) {
40
+ case "red":
41
+ allowed = `Test files (${redPatterns})`;
42
+ locked = `Implementation files (${greenPatterns})`;
43
+ break;
44
+ case "green":
45
+ allowed = `Implementation files (${greenPatterns})`;
46
+ locked = `Test files (${redPatterns})`;
47
+ break;
48
+ case "refactor":
49
+ return (
50
+ "**TDD phase: REFACTOR** — All files are free to modify. " +
51
+ "Do not change behavior. When done, call `next_tdd_phase`."
52
+ );
53
+ default:
54
+ return "";
55
+ }
56
+
57
+ return (
58
+ `**TDD phase: ${phase.toUpperCase()}**\n` +
59
+ `- Allowed: ${allowed}\n` +
60
+ `- Locked: ${locked}\n` +
61
+ "Files not matching either pattern are free to modify.\n" +
62
+ "Call `next_tdd_phase` to advance the cycle."
63
+ );
64
+ }