tdd-enforcer 0.2.0 → 0.2.1

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.
@@ -44,7 +44,7 @@ describe("loadTddState", () => {
44
44
  });
45
45
  });
46
46
 
47
- it("returns missing state.json when rules exists but state missing", () => {
47
+ it("auto-creates state.json when missing (default RED disabled)", () => {
48
48
  withTempDir((dir) => {
49
49
  mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
50
50
  writeFileSync(
@@ -53,12 +53,15 @@ describe("loadTddState", () => {
53
53
  "utf-8",
54
54
  );
55
55
  const result = loadTddState(dir);
56
- expect(result.ok).toBe(false);
57
- expect(result.reason).toContain("state.json");
56
+ expect(result.ok).toBe(true);
57
+ if (result.ok) {
58
+ expect(result.state.enabled).toBe(false);
59
+ expect(result.state.current).toBe("red");
60
+ }
58
61
  });
59
62
  });
60
63
 
61
- it("returns invalid state.json error for malformed JSON", () => {
64
+ it("auto-creates state.json when corrupted (recovers to default)", () => {
62
65
  withTempDir((dir) => {
63
66
  mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
64
67
  writeFileSync(
@@ -68,8 +71,11 @@ describe("loadTddState", () => {
68
71
  );
69
72
  writeFileSync(join(dir, ".pi", "tdd", "state.json"), "not json", "utf-8");
70
73
  const result = loadTddState(dir);
71
- expect(result.ok).toBe(false);
72
- expect(result.reason).toContain("Invalid .pi/tdd/state.json");
74
+ expect(result.ok).toBe(true);
75
+ if (result.ok) {
76
+ expect(result.state.enabled).toBe(false);
77
+ expect(result.state.current).toBe("red");
78
+ }
73
79
  });
74
80
  });
75
81
 
@@ -92,7 +98,7 @@ describe("loadTddState", () => {
92
98
  });
93
99
  });
94
100
 
95
- it("returns disabled error when state.json has enabled: false", () => {
101
+ it("returns ok when state.json has enabled: false (callers check enabled)", () => {
96
102
  withTempDir((dir) => {
97
103
  mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
98
104
  writeFileSync(
@@ -106,8 +112,11 @@ describe("loadTddState", () => {
106
112
  "utf-8",
107
113
  );
108
114
  const result = loadTddState(dir);
109
- expect(result.ok).toBe(false);
110
- expect(result.reason).toContain("TDD is not enabled");
115
+ expect(result.ok).toBe(true);
116
+ if (result.ok) {
117
+ expect(result.state.enabled).toBe(false);
118
+ expect(result.state.current).toBe("red");
119
+ }
111
120
  });
112
121
  });
113
122
 
@@ -135,7 +144,7 @@ describe("loadTddState", () => {
135
144
  });
136
145
  });
137
146
 
138
- it("heals missing git repo automatically", () => {
147
+ it("auto-creates git repo when missing", () => {
139
148
  withTempDir((dir) => {
140
149
  mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
141
150
  writeFileSync(
@@ -155,12 +164,12 @@ describe("loadTddState", () => {
155
164
  const result = loadTddState(dir);
156
165
  expect(result.ok).toBe(true);
157
166
 
158
- // Git should now exist
167
+ // Git should now exist — loadTddState heals it
159
168
  expect(existsSync(gitDir)).toBe(true);
160
169
  });
161
170
  });
162
171
 
163
- it("handles multiple calls without error (existing git is reused)", () => {
172
+ it("handles multiple calls without error", () => {
164
173
  withTempDir((dir) => {
165
174
  mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
166
175
  writeFileSync(
@@ -182,7 +191,7 @@ describe("loadTddState", () => {
182
191
  });
183
192
  });
184
193
 
185
- it("passes through the state.json validation error for invalid current phase", () => {
194
+ it("recovers from invalid current phase in state.json (auto-creates default)", () => {
186
195
  withTempDir((dir) => {
187
196
  mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
188
197
  writeFileSync(
@@ -196,8 +205,11 @@ describe("loadTddState", () => {
196
205
  "utf-8",
197
206
  );
198
207
  const result = loadTddState(dir);
199
- expect(result.ok).toBe(false);
200
- expect(result.reason).toContain("Invalid .pi/tdd/state.json");
208
+ expect(result.ok).toBe(true);
209
+ if (result.ok) {
210
+ expect(result.state.enabled).toBe(false);
211
+ expect(result.state.current).toBe("red");
212
+ }
201
213
  });
202
214
  });
203
215
  });
@@ -1,52 +1,64 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { loadPhaseState, loadConfig, initGit } from "../../engine/index.js";
4
- import type { PhaseState, Config } from "../../engine/types.js";
3
+ import { loadPhaseState, savePhaseState, loadConfig, headMessage, nextPhase, initGit } from "../../engine/index.js";
4
+ import type { PhaseState, Config, Phase } from "../../engine/types.js";
5
5
 
6
6
  export type TddLoadResult =
7
7
  | { ok: true; state: PhaseState; config: Config }
8
8
  | { ok: false; reason: string };
9
9
 
10
+ /**
11
+ * Recover state.json from private git HEAD, or create default.
12
+ * Returns the state (does not save to disk — caller does that).
13
+ */
14
+ function recoverState(root: string, tddDir: string): PhaseState {
15
+ const gitDir = join(tddDir, ".git");
16
+ if (existsSync(gitDir)) {
17
+ try {
18
+ const msg = headMessage(root);
19
+ const m = msg.match(/^tdd: (red|green|refactor|init)$/);
20
+ if (m) {
21
+ const headPhase = m[1] as Phase;
22
+ if (headPhase === "init") {
23
+ return { enabled: false, current: "red" };
24
+ }
25
+ const next = nextPhase(headPhase);
26
+ return { enabled: true, current: next ?? "red" };
27
+ }
28
+ } catch {
29
+ // No commits or bad HEAD — fall through to default
30
+ }
31
+ }
32
+ return { enabled: false, current: "red" };
33
+ }
34
+
10
35
  /**
11
36
  * Load TDD state + config in one go.
12
- * Returns ok:true with state and config when everything is valid and ready.
37
+ * Auto-creates state.json from private git HEAD when missing or corrupted.
38
+ * Returns ok:true with state and config when rules.json is valid.
13
39
  * Returns ok:false with a specific reason string otherwise.
40
+ *
41
+ * Callers must check state.enabled themselves if they need active enforcement.
14
42
  */
15
43
  export function loadTddState(root: string): TddLoadResult {
16
44
  const tddDir = join(root, ".pi", "tdd");
17
45
  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." };
46
+ return { ok: false, reason: "Missing .pi/tdd/ directory. See the tdd-enforcer skill to learn how to set up TDD configs." };
19
47
  }
20
48
 
21
49
  const rulesPath = join(tddDir, "rules.json");
22
50
  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, "state.json");
27
- if (!existsSync(phasePath)) {
28
- return { ok: false, reason: "Missing .pi/tdd/state.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/state.json: ${(e as Error).message}` };
51
+ return { ok: false, reason: "Missing .pi/tdd/rules.json. See the tdd-enforcer skill to learn how to set up TDD configs." };
36
52
  }
37
53
 
38
54
  let config: Config;
39
55
  try {
40
56
  config = loadConfig(root);
41
57
  } catch (e) {
42
- return { ok: false, reason: `Invalid .pi/tdd/rules.json: ${(e as Error).message}` };
58
+ return { ok: false, reason: `Invalid .pi/tdd/rules.json: ${(e as Error).message}. See the tdd-enforcer skill.` };
43
59
  }
44
60
 
45
- if (!state.enabled) {
46
- return { ok: false, reason: "TDD is not enabled. Run /tdd:on to enable it." };
47
- }
48
-
49
- // Heal git if missing
61
+ // Init git if missing — required for state recovery and all consumers
50
62
  const gitDir = join(tddDir, ".git");
51
63
  if (!existsSync(gitDir)) {
52
64
  try {
@@ -56,5 +68,20 @@ export function loadTddState(root: string): TddLoadResult {
56
68
  }
57
69
  }
58
70
 
71
+ // Auto-create state.json if missing or corrupted
72
+ const phasePath = join(tddDir, "state.json");
73
+ let state: PhaseState | undefined;
74
+ if (existsSync(phasePath)) {
75
+ try {
76
+ state = loadPhaseState(root);
77
+ } catch {
78
+ // Corrupted — recover below
79
+ }
80
+ }
81
+ if (!state) {
82
+ state = recoverState(root, tddDir);
83
+ savePhaseState(root, state);
84
+ }
85
+
59
86
  return { ok: true, state, config };
60
87
  }
@@ -67,6 +67,16 @@ export function registerHooks(pi: ExtensionAPI): void {
67
67
 
68
68
  // Patterns in rules.json are relative to repo root; convert absolute path
69
69
  const relPath = relative(root, filePath);
70
+
71
+ // Never allow writes to .pi/tdd/ when TDD is active
72
+ if (relPath.startsWith(".pi/tdd/")) {
73
+ tddLog(tddDir, "INFO", "tool_call: blocked .pi/tdd/ file", { toolName, relPath });
74
+ return {
75
+ block: true,
76
+ reason: "TDD: Config files are locked. No bypassing TDD allowed. If bypassing is justified, ask the user: turn TDD off (/tdd:off), reset (/tdd:reset), or change phase via /tdd commands.",
77
+ };
78
+ }
79
+
70
80
  const allowed = isAllowed(relPath, phase, config);
71
81
  tddLog(tddDir, "DEBUG", "tool_call: check", { toolName, relPath, phase, allowed });
72
82
 
@@ -111,10 +121,6 @@ export function registerHooks(pi: ExtensionAPI): void {
111
121
 
112
122
  const { state, config } = tdd;
113
123
  const phase = state.current;
114
- if (phase === "refactor") {
115
- tddLog(tddDir, "DEBUG", "tool_result: refactor phase, no check");
116
- return;
117
- }
118
124
 
119
125
  // Diff against pre-bash stash — only changes from THIS command
120
126
  const changed = changesSince(root, stashHash);
@@ -124,7 +130,22 @@ export function registerHooks(pi: ExtensionAPI): void {
124
130
  return;
125
131
  }
126
132
 
127
- const cmdViolations = changed.filter((f) => !isAllowed(f, phase, config));
133
+ // Config files (.pi/tdd/) are always violations when TDD is active
134
+ const tddViolations = changed.filter((f) => f.startsWith(".pi/tdd/"));
135
+
136
+ if (phase === "refactor") {
137
+ // In refactor, only .pi/tdd/ files are violations
138
+ if (tddViolations.length === 0) {
139
+ tddLog(tddDir, "DEBUG", "tool_result: refactor phase, no TDD dir violations");
140
+ return;
141
+ }
142
+ }
143
+
144
+ const phaseViolations =
145
+ phase === "refactor" ? [] : changed.filter((f) => !isAllowed(f, phase, config));
146
+
147
+ const cmdViolations = [...new Set([...tddViolations, ...phaseViolations])];
148
+
128
149
  if (cmdViolations.length === 0) {
129
150
  tddLog(tddDir, "DEBUG", "tool_result: no violations among changed files", {
130
151
  changed,
@@ -140,8 +161,10 @@ export function registerHooks(pi: ExtensionAPI): void {
140
161
  // Revert only this command's violations back to pre-bash state
141
162
  restoreFilesTo(root, cmdViolations, stashHash);
142
163
 
143
- // Find remaining allowed changes from this command
144
- const cmdAllowed = changed.filter((f) => isAllowed(f, phase, config));
164
+ // Find remaining allowed changes from this command (exclude .pi/tdd/)
165
+ const cmdAllowed = changed.filter(
166
+ (f) => isAllowed(f, phase, config) && !f.startsWith(".pi/tdd/"),
167
+ );
145
168
 
146
169
  const existingText = event.content.map((c) => ("text" in c ? c.text : "")).join("");
147
170
  let warning = `\n\n⛔ ${phase.toUpperCase()}: reverted locked files modified by bash:`;
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { loadPhaseState, loadConfig, savePhaseState, initGit, resetGit, snapshot } from "../../engine/index.js";
4
+ import { savePhaseState, resetGit, snapshot } from "../../engine/index.js";
5
5
  import { registerTools } from "./tools.js";
6
6
  import { registerHooks } from "./hooks.js";
7
7
  import { loadTddState } from "./helpers.js";
@@ -16,46 +16,14 @@ export default function (pi: ExtensionAPI) {
16
16
 
17
17
  tddLog(tddDir, "INFO", "tdd:on: starting");
18
18
 
19
- if (!existsSync(tddDir)) {
20
- tddLog(tddDir, "WARN", "tdd:on: missing .pi/tdd/ directory");
21
- ctx.ui.notify("Missing .pi/tdd/ directory. See the tdd-init skill to learn how to set up TDD configs.", "error");
19
+ const setup = loadTddState(root);
20
+ if (!setup.ok) {
21
+ tddLog(tddDir, "WARN", "tdd:on: setup invalid", { reason: setup.reason });
22
+ ctx.ui.notify(setup.reason, "error");
22
23
  return;
23
24
  }
24
25
 
25
- const rulesPath = join(tddDir, "rules.json");
26
- if (!existsSync(rulesPath)) {
27
- tddLog(tddDir, "WARN", "tdd:on: missing rules.json");
28
- ctx.ui.notify("Missing .pi/tdd/rules.json. See the tdd-init skill to learn how to set up TDD configs.", "error");
29
- return;
30
- }
31
-
32
- const phasePath = join(tddDir, "state.json");
33
- if (!existsSync(phasePath)) {
34
- tddLog(tddDir, "WARN", "tdd:on: missing state.json");
35
- ctx.ui.notify("Missing .pi/tdd/state.json. See the tdd-init skill to learn how to set up TDD configs.", "error");
36
- return;
37
- }
38
-
39
- let state;
40
- try {
41
- state = loadPhaseState(root);
42
- } catch (e) {
43
- tddLog(tddDir, "WARN", "tdd:on: invalid state.json", {
44
- error: (e as Error).message,
45
- });
46
- ctx.ui.notify("Invalid .pi/tdd/state.json. Fix or delete it, then run /tdd:on again.", "error");
47
- return;
48
- }
49
-
50
- try {
51
- loadConfig(root);
52
- } catch (e) {
53
- tddLog(tddDir, "WARN", "tdd:on: invalid rules.json", {
54
- error: (e as Error).message,
55
- });
56
- ctx.ui.notify("Invalid .pi/tdd/rules.json. Fix or delete it, then run /tdd:on again.", "error");
57
- return;
58
- }
26
+ const { state } = setup;
59
27
 
60
28
  if (state.enabled) {
61
29
  tddLog(tddDir, "INFO", "tdd:on: already enabled", {
@@ -65,21 +33,6 @@ export default function (pi: ExtensionAPI) {
65
33
  return;
66
34
  }
67
35
 
68
- if (!existsSync(join(tddDir, ".git", "HEAD"))) {
69
- try {
70
- initGit(root);
71
- tddLog(tddDir, "INFO", "tdd:on: git initialised");
72
- } catch (e) {
73
- tddLog(tddDir, "ERROR", "tdd:on: git init failed", {
74
- error: (e as Error).message,
75
- });
76
- ctx.ui.notify("Failed to initialise private git repo.", "error");
77
- return;
78
- }
79
- } else {
80
- tddLog(tddDir, "DEBUG", "tdd:on: git repo already exists");
81
- }
82
-
83
36
  // Snapshot working tree so stale baseline doesn't nuke user changes
84
37
  snapshot(root, state.current);
85
38
  tddLog(tddDir, "INFO", "tdd:on: snapshot taken", {
@@ -101,17 +54,15 @@ export default function (pi: ExtensionAPI) {
101
54
  const root = ctx.cwd;
102
55
  const tddDir = join(root, ".pi", "tdd");
103
56
 
104
- let state;
105
- try {
106
- state = loadPhaseState(root);
107
- } catch (e) {
108
- tddLog(tddDir, "WARN", "tdd:off: invalid state.json", {
109
- error: (e as Error).message,
110
- });
111
- ctx.ui.notify("Invalid .pi/tdd/state.json. Fix or delete it, then run /tdd:off again.", "error");
57
+ const setup = loadTddState(root);
58
+ if (!setup.ok) {
59
+ tddLog(tddDir, "WARN", "tdd:off: setup invalid", { reason: setup.reason });
60
+ ctx.ui.notify(setup.reason, "error");
112
61
  return;
113
62
  }
114
63
 
64
+ const { state } = setup;
65
+
115
66
  if (!state.enabled) {
116
67
  tddLog(tddDir, "INFO", "tdd:off: already disabled");
117
68
  ctx.ui.notify("TDD already disabled", "info");
@@ -135,7 +86,7 @@ export default function (pi: ExtensionAPI) {
135
86
  const result = loadTddState(root);
136
87
 
137
88
  if (!result.ok) {
138
- tddLog(tddDir, "WARN", "tdd:status: TDD not active", {
89
+ tddLog(tddDir, "WARN", "tdd:status: setup invalid", {
139
90
  reason: result.reason,
140
91
  });
141
92
  ctx.ui.notify(`TDD: ${result.reason}`, "error");
@@ -143,17 +94,19 @@ export default function (pi: ExtensionAPI) {
143
94
  }
144
95
 
145
96
  const { state, config } = result;
97
+ const enabledStr = state.enabled ? "enabled" : "disabled";
146
98
  const phaseStr = state.current.toUpperCase();
147
99
  const redGlobs = config.allowedRedPhaseFiles.join(", ") || "(none)";
148
100
  const greenGlobs = config.allowedGreenPhaseFiles.join(", ") || "(none)";
149
101
  const commands = config.testCommands.join(", ") || "(none)";
150
102
 
151
103
  tddLog(tddDir, "INFO", "tdd:status: queried", {
104
+ enabled: state.enabled,
152
105
  phase: state.current,
153
106
  });
154
107
 
155
108
  ctx.ui.notify(
156
- `TDD enforcer enabled\n` +
109
+ `TDD enforcer ${enabledStr}\n` +
157
110
  `Current phase: ${phaseStr}\n` +
158
111
  `Test files: ${redGlobs}\n` +
159
112
  `Impl files: ${greenGlobs}\n` +
@@ -173,9 +126,10 @@ export default function (pi: ExtensionAPI) {
173
126
 
174
127
  tddLog(tddDir, "INFO", "tdd:reset: starting");
175
128
 
176
- if (!existsSync(tddDir)) {
177
- tddLog(tddDir, "WARN", "tdd:reset: missing .pi/tdd/ directory");
178
- ctx.ui.notify("No .pi/tdd/ directory found nothing to reset.", "error");
129
+ const setup = loadTddState(root);
130
+ if (!setup.ok) {
131
+ tddLog(tddDir, "WARN", "tdd:reset: setup invalid", { reason: setup.reason });
132
+ ctx.ui.notify(setup.reason, "error");
179
133
  return;
180
134
  }
181
135
 
@@ -8,13 +8,17 @@ export function getNudgePrompt(phase: Phase, config: Config, matchedFiles?: stri
8
8
  case "red":
9
9
  return (
10
10
  `You are now in **RED** phase. Write failing tests matching: ${redPatterns}\n` +
11
- "Only these files can be modified. Once tests fail, call `next_tdd_phase` to proceed to GREEN."
11
+ "Only these files can be modified. Once tests fail, call `next_tdd_phase` to proceed to GREEN.\n" +
12
+ "Keep this cycle small. Write tests for one feature at a time. " +
13
+ "Cover happy path, edge cases, and unhappy paths before advancing to GREEN."
12
14
  );
13
15
  case "green":
14
16
  return (
15
17
  `You are now in **GREEN** phase. Test files (${redPatterns}) are locked.\n` +
16
18
  `Implement features matching: ${greenPatterns}\n` +
17
- "Call `next_tdd_phase` to proceed to REFACTOR."
19
+ "Call `next_tdd_phase` to proceed to REFACTOR.\n" +
20
+ "If the RED phase tests were wrong, call `previous_tdd_phase` to go back and fix them. " +
21
+ "Don't be afraid to discard — clean slate beats patched code."
18
22
  );
19
23
  case "refactor":
20
24
  return (
@@ -37,6 +37,10 @@ export function registerTools(pi: ExtensionAPI): void {
37
37
  tddLog(tddDir, "WARN", "next_tdd_phase: TDD not active", { reason: tdd.reason });
38
38
  return { content: [{ type: "text", text: `TDD: ${tdd.reason}` }], details: {} };
39
39
  }
40
+ if (!tdd.state.enabled) {
41
+ tddLog(tddDir, "WARN", "next_tdd_phase: TDD disabled");
42
+ return { content: [{ type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." }], details: {} };
43
+ }
40
44
 
41
45
  const { state, config } = tdd;
42
46
  const from = state.current;
@@ -124,8 +128,9 @@ export function registerTools(pi: ExtensionAPI): void {
124
128
  name: "previous_tdd_phase",
125
129
  label: "Previous TDD Phase",
126
130
  description:
127
- "WARNING: Destroys ALL uncommitted changes and pops the last snapshot commit. " +
128
- "Working tree keeps the popped commit's content as unstaged changes.",
131
+ "WARNING: Discards ALL changes made in the current phase and reverts the working tree " +
132
+ "to what it was when the last phase ended. Use when the previous phase's work was wrong " +
133
+ "and this phase cannot proceed.",
129
134
  parameters: Type.Object({}),
130
135
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
131
136
  const root = ctx.cwd;
@@ -137,6 +142,10 @@ export function registerTools(pi: ExtensionAPI): void {
137
142
  });
138
143
  return { content: [{ type: "text", text: `TDD: ${tdd.reason}` }], details: {} };
139
144
  }
145
+ if (!tdd.state.enabled) {
146
+ tddLog(tddDir, "WARN", "previous_tdd_phase: TDD disabled");
147
+ return { content: [{ type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." }], details: {} };
148
+ }
140
149
 
141
150
  const { state } = tdd;
142
151
 
@@ -223,6 +232,10 @@ export function registerTools(pi: ExtensionAPI): void {
223
232
  });
224
233
  return { content: [{ type: "text", text: `TDD: ${result.reason}` }], details: {} };
225
234
  }
235
+ if (!result.state.enabled) {
236
+ tddLog(tddDir, "WARN", "tdd_status: TDD disabled");
237
+ return { content: [{ type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." }], details: {} };
238
+ }
226
239
 
227
240
  const { state, config } = result;
228
241
  const phaseStr = state.current.toUpperCase();
package/behaviour.md ADDED
@@ -0,0 +1,146 @@
1
+ # TDD Enforcer — Behaviour Spec
2
+
3
+ ## Entry Gate (all surfaces)
4
+
5
+ Every surface (commands, hooks, tools) hits this first:
6
+
7
+ ```
8
+ 1. .pi/tdd/ exists?
9
+ NO ──► error: "Missing .pi/tdd/. See the tdd-enforcer skill."
10
+
11
+ 2. rules.json exists and parses?
12
+ NO ──► error: "Missing/invalid rules.json. See the tdd-enforcer skill."
13
+
14
+ 3. state.json exists and parses?
15
+ YES ──► use it
16
+ NO ──► auto-create from git HEAD commit message.
17
+ If no git repo: create { enabled: false, current: "red" }.
18
+ ```
19
+
20
+ After this gate, every surface has `state` + `config`. Then branches on `state.enabled`.
21
+
22
+ ---
23
+
24
+ ## Commands (user only)
25
+
26
+ | Command | Gate check | After gate |
27
+ |---------|-----------|------------|
28
+ | `tdd:on` | Setup valid? | If `enabled` → error "already on". Init git (if missing), snapshot, set `enabled: true`. |
29
+ | `tdd:off` | Setup valid? | If `disabled` → error "already off". Set `enabled: false`. |
30
+ | `tdd:status` | Setup valid? | Show state + config regardless of `enabled`. |
31
+ | `tdd:reset` | Setup valid? | Nuke private git, re-init, snapshot, set `enabled: false`. |
32
+ | `tdd:red` / `tdd:green` / `tdd:refactor` | Setup valid? | If `disabled` → error "not enabled". Set `current` to target phase. |
33
+
34
+ All errors reference the tdd-enforcer skill.
35
+
36
+ ---
37
+
38
+ ## Hooks (agent — automatic)
39
+
40
+ | Hook | Gate + enabled | Behaviour |
41
+ |------|---------------|-----------|
42
+ | `tool_call` (write/edit) | Setup broken → pass through. Disabled → pass through. **Enabled** → block if `relPath` starts with `.pi/tdd/` OR phase-locked. | |
43
+ | `tool_result` (bash) | Setup broken → pass through. Disabled → pass through. **Enabled** → revert if path starts with `.pi/tdd/` OR phase-locked. RETOOL revert paths to `.pi/tdd/` prefix check. | |
44
+
45
+ ---
46
+
47
+ ## Tools (agent — callable)
48
+
49
+ | Tool | Gate + enabled | Behaviour |
50
+ |------|---------------|-----------|
51
+ | `next_tdd_phase` | Disabled → error. Enabled → run gate + snapshot + transition. | |
52
+ | `previous_tdd_phase` | Disabled → error. Enabled → revert to previous snapshot. | |
53
+ | `tdd_status` | Disabled → error. Enabled → show state + config. | |
54
+
55
+ ---
56
+
57
+ ## Flow Diagrams
58
+
59
+ ### Initial Setup Flow
60
+
61
+ ```
62
+ User creates .pi/tdd/rules.json
63
+
64
+
65
+ User runs /tdd:on
66
+
67
+
68
+ Gate: .pi/tdd/ exists? ──NO──► error (agent creates it)
69
+
70
+ YES
71
+
72
+
73
+ Gate: rules.json exists? ──NO──► error (agent creates it)
74
+
75
+ YES
76
+
77
+
78
+ Gate: rules.json valid? ──NO──► error (agent fixes it)
79
+
80
+ YES
81
+
82
+
83
+ Gate: state.json exists? ──NO──► auto-create
84
+
85
+ YES/auto-created
86
+
87
+
88
+ Init git (if missing) ──► snapshot ──► enabled: true
89
+
90
+
91
+ TDD active — RED phase, enforcement on
92
+ ```
93
+
94
+ ### Phase Cycle Flow
95
+
96
+ ```
97
+ RED: write tests
98
+ │ tests fail? ──NO──► "Tests passed. Add a failing test before transitioning."
99
+ │ YES
100
+ ├─► next_tdd_phase ──► GREEN
101
+
102
+ GREEN: implement features
103
+ │ tests pass? ──NO──► "Tests failed. Fix them before transitioning."
104
+ │ YES
105
+ ├─► next_tdd_phase ──► REFACTOR
106
+
107
+ REFACTOR: refactor freely
108
+ │ tests pass? ──NO──► "Tests failed. Fix them before transitioning."
109
+ │ YES
110
+ ├─► next_tdd_phase ──► RED (new cycle)
111
+ ```
112
+
113
+ ### Bash Enforcement Flow
114
+
115
+ ```
116
+ Agent runs bash command
117
+
118
+
119
+ tool_call hook: stash pre-command state
120
+
121
+
122
+ Bash executes (modifies files)
123
+
124
+
125
+ tool_result hook: diff against stash
126
+
127
+
128
+ For each changed file:
129
+ ├── path starts with ".pi/tdd/"? ──YES──► revert, flag as violation
130
+ ├── locked in current phase? ──YES──► revert, flag as violation
131
+ └── no violations ──► keep changes
132
+
133
+
134
+ Return warning listing reverted vs retained files
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Protection Summary
140
+
141
+ | Surface | `.pi/tdd/` locked? | Phase-locked files? |
142
+ |---------|-------------------|-------------------|
143
+ | write/edit (TDD enabled) | ✅ Block | ✅ Block |
144
+ | write/edit (TDD disabled) | ❌ Free | ❌ Free |
145
+ | bash (TDD enabled) | ✅ Reverted | ✅ Reverted |
146
+ | bash (TDD disabled) | ❌ Free | ❌ Free |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tdd-enforcer",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -0,0 +1,97 @@
1
+ # TDD Enforcer Skill
2
+
3
+ This extension enforces the **Red-Green-Refactor** cycle of TDD.
4
+
5
+ It locks files per phase — only test files in RED, only implementation files in GREEN, everything allowed in REFACTOR. The entire `.pi/tdd/` directory is locked when TDD is active.
6
+
7
+ ---
8
+
9
+ ## What the Agent Controls vs What the User Controls
10
+
11
+ | Action | Who |
12
+ |--------|-----|
13
+ | Create `.pi/tdd/rules.json` with file patterns and test commands | **Agent** |
14
+ | Run `/tdd:on` to enable enforcement | **User** |
15
+ | Run `/tdd:off` to disable enforcement | **User** |
16
+ | Run `/tdd:reset` to nuke all recorded state and start fresh | **User** |
17
+ | Run `/tdd:status` to check state and config | **User** |
18
+ | Call `next_tdd_phase` to advance through the cycle | **Agent** |
19
+ | Call `previous_tdd_phase` to roll back when previous phase work was wrong | **Agent** |
20
+ | Call `tdd_status` to check enforcement status | **Agent** |
21
+
22
+ ---
23
+
24
+ ## Setup
25
+
26
+ 1. **Agent** creates `.pi/tdd/rules.json` with these fields:
27
+
28
+ ```json
29
+ {
30
+ "allowedRedPhaseFiles": ["tests/**/*.test.ts"],
31
+ "allowedGreenPhaseFiles": ["src/**/*.ts"],
32
+ "testCommands": ["npm test"],
33
+ "timeoutSeconds": 30
34
+ }
35
+ ```
36
+
37
+ - `allowedRedPhaseFiles` — globs the agent can create/modify in RED phase (tests)
38
+ - `allowedGreenPhaseFiles` — globs the agent can create/modify in GREEN phase (implementation)
39
+ - `testCommands` — shell commands to run tests
40
+ - `timeoutSeconds` — test timeout per command
41
+
42
+ 2. **User** runs `/tdd:on` to enable enforcement.
43
+
44
+ ---
45
+
46
+ ## Phase Rules
47
+
48
+ ### RED
49
+ 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.
50
+
51
+ ### GREEN
52
+ 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.
53
+
54
+ ### REFACTOR
55
+ All files are free to modify. Refactor without changing behaviour. Call `next_tdd_phase` once tests pass to start a new RED cycle.
56
+
57
+ ---
58
+
59
+ ## Hard Rules
60
+
61
+ - **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.
62
+
63
+ - Need to change a locked file? Either advance through the cycle (`next_tdd_phase`) or roll back to fix earlier work (`previous_tdd_phase`).
64
+ - Wrong assumptions about the task? Roll back with `previous_tdd_phase`, the phase restarts clean.
65
+ - Fundamentally blocked? Ask the user to run the appropriate `/tdd:` command (change phase, disable, or reset).
66
+
67
+ - **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.
68
+
69
+ - **Never run `/tdd:` commands yourself.** They're registered as user-only commands. They won't work when you type them.
70
+
71
+ - **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.
72
+
73
+ - **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.
74
+
75
+ ---
76
+
77
+ ## Agent Tools
78
+
79
+ ### `next_tdd_phase`
80
+ Runs transition gate checks. Fails if:
81
+ - RED→GREEN: tests don't fail (must have a failing test)
82
+ - GREEN→REFACTOR: tests fail (must pass)
83
+ - REFACTOR→RED: tests fail (must pass)
84
+
85
+ Also validates no locked files were modified. On success, records the current state and advances the phase.
86
+
87
+ ### `previous_tdd_phase`
88
+ 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.
89
+
90
+ ### `tdd_status`
91
+ Shows the current phase, allowed file globs, and test commands.
92
+
93
+ ---
94
+
95
+ ## TDD is OFF — Enforcement is Suspended
96
+
97
+ When TDD is disabled (`/tdd:off`), all files are free to modify. The hooks pass through and no blocks or reverts occur. To re-enable, ask the user to run `/tdd:on`.
@@ -1,90 +0,0 @@
1
- ---
2
- name: tdd-init
3
- description: Use when the TDD enforcer extension reports missing configuration — .pi/tdd/ directory, rules.json, or state.json not found. Also use when starting TDD on a new project.
4
- ---
5
-
6
- # TDD Init
7
-
8
- ## Overview
9
-
10
- Set up the TDD enforcer. The extension enforces the Red-Green-Refactor cycle by restricting which files the agent can modify per phase.
11
-
12
- ## What to Create
13
-
14
- ```
15
- project-root/
16
- .pi/
17
- tdd/
18
- rules.json # File patterns and test commands
19
- state.json # Phase state (create, then /tdd:on sets enabled)
20
- ```
21
-
22
- ## rules.json
23
-
24
- ```json
25
- {
26
- "allowedRedPhaseFiles": ["tests/**/*.test.ts"],
27
- "allowedGreenPhaseFiles": ["src/**/*.ts"],
28
- "testCommands": ["npm run test"],
29
- "timeoutSeconds": 120
30
- }
31
- ```
32
-
33
- | Field | What | Why |
34
- |-------|------|-----|
35
- | `allowedRedPhaseFiles` | Glob patterns for files writable in RED phase | Typically test files |
36
- | `allowedGreenPhaseFiles` | Glob patterns for files writable in GREEN phase | Typically implementation files |
37
- | `testCommands` | Non-interactive commands (string or array) | Run on phase transitions to check gate |
38
- | `timeoutSeconds` | Per-command timeout (default 120) | Prevents hung suites |
39
-
40
- - Globs are relative to project root
41
- - Files matching neither set are free in all phases
42
- - All 3 array fields **must** be non-empty
43
-
44
- ## state.json
45
-
46
- ```json
47
- {
48
- "enabled": false,
49
- "current": "red"
50
- }
51
- ```
52
-
53
- Start with `enabled: false`. Run `/tdd:on` — it validates config, initialises the private git repo, snapshots the working tree, and sets `enabled: true`.
54
-
55
- ## Setup Steps
56
-
57
- 1. Create `.pi/tdd/` directory
58
- 2. Create `.pi/tdd/rules.json` with file patterns and test commands
59
- 3. Create `.pi/tdd/state.json` with `enabled: false, current: "red"`
60
- 4. Run `/tdd:on` to enable enforcement
61
-
62
- ## TDD Cycle
63
-
64
- | Phase | Allowed | Gate to advance |
65
- |-------|---------|-----------------|
66
- | RED | Test files only | Tests must fail |
67
- | GREEN | Implementation files only | Tests must pass |
68
- | REFACTOR | All files | Tests must pass |
69
-
70
- Use `next_tdd_phase` to advance, `previous_tdd_phase` to revert.
71
-
72
- ## Common Patterns
73
-
74
- **Standard split:**
75
- ```json
76
- "allowedRedPhaseFiles": ["tests/**/*.test.ts"],
77
- "allowedGreenPhaseFiles": ["src/**/*.ts"]
78
- ```
79
-
80
- **Monorepo:**
81
- ```json
82
- "testCommands": ["npm run test:unit", "npm run test:e2e"]
83
- ```
84
-
85
- ## Recovery
86
-
87
- ```bash
88
- /tdd:reset # Destroys snapshot history, resets to RED (disabled)
89
- /tdd:on # Re-enable with fresh snapshot
90
- ```