tdd-enforcer 0.3.2 → 0.3.3
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/adapters/pi/helpers.ts +0 -3
- package/adapters/pi/tools.test.ts +9 -4
- package/adapters/pi/tools.ts +4 -7
- package/engine/config.test.ts +69 -0
- package/engine/config.ts +9 -0
- package/package.json +1 -2
- package/skills/tdd-enforcer/SKILL.md +4 -2
- package/DESIGN.md +0 -183
- package/behaviour.md +0 -175
package/adapters/pi/helpers.ts
CHANGED
|
@@ -38,9 +38,6 @@ function recoverState(
|
|
|
38
38
|
if (label === "init") {
|
|
39
39
|
return { enabled: false, current: "red" };
|
|
40
40
|
}
|
|
41
|
-
if (label !== "red" && label !== "green" && label !== "refactor") {
|
|
42
|
-
return { enabled: false, current: "red" };
|
|
43
|
-
}
|
|
44
41
|
const next = deps.nextPhase(label);
|
|
45
42
|
return { enabled: true, current: next ?? "red" };
|
|
46
43
|
}
|
|
@@ -382,15 +382,20 @@ describe("executeTddStatus", () => {
|
|
|
382
382
|
).rejects.toThrow("Missing .pi/tdd/");
|
|
383
383
|
});
|
|
384
384
|
|
|
385
|
-
it("
|
|
385
|
+
it("returns status details when TDD disabled", async () => {
|
|
386
386
|
mockLoadTddState.mockReturnValue({
|
|
387
387
|
ok: true,
|
|
388
388
|
state: { enabled: false, current: "red" },
|
|
389
389
|
config: CONFIG,
|
|
390
390
|
});
|
|
391
|
-
await
|
|
392
|
-
|
|
393
|
-
).
|
|
391
|
+
const result = await executeTddStatus({ cwd: "/test" } as any, makeDeps());
|
|
392
|
+
expect(result.content[0].text).toContain("disabled");
|
|
393
|
+
expect(result.content[0].text).toContain("RED");
|
|
394
|
+
expect(result.content[0].text).toContain("tests/**/*.test.ts");
|
|
395
|
+
expect(result.content[0].text).toContain("src/**/*.ts");
|
|
396
|
+
expect(result.details).toBeDefined();
|
|
397
|
+
expect(result.details?.enabled).toBe(false);
|
|
398
|
+
expect(result.details?.phase).toBe("red");
|
|
394
399
|
});
|
|
395
400
|
|
|
396
401
|
it("returns status details when TDD enabled", async () => {
|
package/adapters/pi/tools.ts
CHANGED
|
@@ -292,18 +292,15 @@ export async function executeTddStatus(
|
|
|
292
292
|
});
|
|
293
293
|
throw new Error(`TDD: ${result.reason}`);
|
|
294
294
|
}
|
|
295
|
-
if (!result.state.enabled) {
|
|
296
|
-
deps.tddLog(tddDir, "WARN", "tdd_status: TDD disabled");
|
|
297
|
-
throw new Error("TDD is not enabled. Run /tdd:on to enable it.");
|
|
298
|
-
}
|
|
299
|
-
|
|
300
295
|
const { state, config } = result;
|
|
296
|
+
const enabledStr = state.enabled ? "enabled" : "disabled";
|
|
301
297
|
const phaseStr = state.current.toUpperCase();
|
|
302
298
|
const redBlk = config.blockedInRed.join(", ") || "(none)";
|
|
303
299
|
const greenBlk = config.blockedInGreen.join(", ") || "(none)";
|
|
304
300
|
const commands = config.testCommands.join(", ") || "(none)";
|
|
305
301
|
|
|
306
302
|
deps.tddLog(tddDir, "INFO", "tdd_status: queried", {
|
|
303
|
+
enabled: state.enabled,
|
|
307
304
|
phase: state.current,
|
|
308
305
|
});
|
|
309
306
|
|
|
@@ -312,7 +309,7 @@ export async function executeTddStatus(
|
|
|
312
309
|
{
|
|
313
310
|
type: "text",
|
|
314
311
|
text:
|
|
315
|
-
`\nTDD enforcer
|
|
312
|
+
`\nTDD enforcer ${enabledStr}\n` +
|
|
316
313
|
`Current phase: ${phaseStr}\n` +
|
|
317
314
|
`Blocked in RED: ${redBlk}\n` +
|
|
318
315
|
`Blocked in GREEN: ${greenBlk}\n` +
|
|
@@ -320,7 +317,7 @@ export async function executeTddStatus(
|
|
|
320
317
|
},
|
|
321
318
|
],
|
|
322
319
|
details: {
|
|
323
|
-
enabled:
|
|
320
|
+
enabled: state.enabled,
|
|
324
321
|
phase: state.current,
|
|
325
322
|
blockedInRed: config.blockedInRed,
|
|
326
323
|
blockedInGreen: config.blockedInGreen,
|
package/engine/config.test.ts
CHANGED
|
@@ -61,6 +61,24 @@ describe("loadConfig", () => {
|
|
|
61
61
|
});
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
it("defaults timeoutSeconds to 120 when omitted", () => {
|
|
65
|
+
withTempDir((dir) => {
|
|
66
|
+
const tddDir = join(dir, ".pi", "tdd");
|
|
67
|
+
mkdirSync(tddDir, { recursive: true });
|
|
68
|
+
writeFileSync(
|
|
69
|
+
join(tddDir, "rules.json"),
|
|
70
|
+
JSON.stringify({
|
|
71
|
+
blockedInRed: ["tests/**/*.test.ts"],
|
|
72
|
+
blockedInGreen: ["src/**/*.ts"],
|
|
73
|
+
testCommands: ["npm test"],
|
|
74
|
+
}),
|
|
75
|
+
"utf-8",
|
|
76
|
+
);
|
|
77
|
+
const config = loadConfig(dir);
|
|
78
|
+
expect(config.timeoutSeconds).toBe(120);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
64
82
|
describe("validation — throws on invalid content", () => {
|
|
65
83
|
it("throws when blockedInRed is not an array", () => {
|
|
66
84
|
withTempDir((dir) => {
|
|
@@ -172,5 +190,56 @@ describe("loadConfig", () => {
|
|
|
172
190
|
expect(() => loadConfig(dir)).toThrow();
|
|
173
191
|
});
|
|
174
192
|
});
|
|
193
|
+
|
|
194
|
+
it("throws when blockedInRed contains non-strings", () => {
|
|
195
|
+
withTempDir((dir) => {
|
|
196
|
+
const tddDir = join(dir, ".pi", "tdd");
|
|
197
|
+
mkdirSync(tddDir, { recursive: true });
|
|
198
|
+
writeFileSync(
|
|
199
|
+
join(tddDir, "rules.json"),
|
|
200
|
+
JSON.stringify({
|
|
201
|
+
blockedInRed: [123],
|
|
202
|
+
blockedInGreen: ["src/**/*.ts"],
|
|
203
|
+
testCommands: ["npm test"],
|
|
204
|
+
}),
|
|
205
|
+
"utf-8",
|
|
206
|
+
);
|
|
207
|
+
expect(() => loadConfig(dir)).toThrow();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("throws when blockedInGreen contains non-strings", () => {
|
|
212
|
+
withTempDir((dir) => {
|
|
213
|
+
const tddDir = join(dir, ".pi", "tdd");
|
|
214
|
+
mkdirSync(tddDir, { recursive: true });
|
|
215
|
+
writeFileSync(
|
|
216
|
+
join(tddDir, "rules.json"),
|
|
217
|
+
JSON.stringify({
|
|
218
|
+
blockedInRed: ["tests/**/*.test.ts"],
|
|
219
|
+
blockedInGreen: [null],
|
|
220
|
+
testCommands: ["npm test"],
|
|
221
|
+
}),
|
|
222
|
+
"utf-8",
|
|
223
|
+
);
|
|
224
|
+
expect(() => loadConfig(dir)).toThrow();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("throws when testCommands contains non-strings", () => {
|
|
229
|
+
withTempDir((dir) => {
|
|
230
|
+
const tddDir = join(dir, ".pi", "tdd");
|
|
231
|
+
mkdirSync(tddDir, { recursive: true });
|
|
232
|
+
writeFileSync(
|
|
233
|
+
join(tddDir, "rules.json"),
|
|
234
|
+
JSON.stringify({
|
|
235
|
+
blockedInRed: ["tests/**/*.test.ts"],
|
|
236
|
+
blockedInGreen: ["src/**/*.ts"],
|
|
237
|
+
testCommands: ["npm test", 456],
|
|
238
|
+
}),
|
|
239
|
+
"utf-8",
|
|
240
|
+
);
|
|
241
|
+
expect(() => loadConfig(dir)).toThrow();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
175
244
|
});
|
|
176
245
|
});
|
package/engine/config.ts
CHANGED
|
@@ -16,15 +16,24 @@ export function loadConfig(projectRoot: string): Config {
|
|
|
16
16
|
if (!Array.isArray(parsed.blockedInRed) || parsed.blockedInRed.length === 0) {
|
|
17
17
|
throw new Error("rules.json: blockedInRed must be a non-empty array");
|
|
18
18
|
}
|
|
19
|
+
if (!parsed.blockedInRed.every((p: unknown) => typeof p === "string")) {
|
|
20
|
+
throw new Error("rules.json: blockedInRed must contain only strings");
|
|
21
|
+
}
|
|
19
22
|
if (
|
|
20
23
|
!Array.isArray(parsed.blockedInGreen) ||
|
|
21
24
|
parsed.blockedInGreen.length === 0
|
|
22
25
|
) {
|
|
23
26
|
throw new Error("rules.json: blockedInGreen must be a non-empty array");
|
|
24
27
|
}
|
|
28
|
+
if (!parsed.blockedInGreen.every((p: unknown) => typeof p === "string")) {
|
|
29
|
+
throw new Error("rules.json: blockedInGreen must contain only strings");
|
|
30
|
+
}
|
|
25
31
|
if (!Array.isArray(parsed.testCommands) || parsed.testCommands.length === 0) {
|
|
26
32
|
throw new Error("rules.json: testCommands must be a non-empty array");
|
|
27
33
|
}
|
|
34
|
+
if (!parsed.testCommands.every((p: unknown) => typeof p === "string")) {
|
|
35
|
+
throw new Error("rules.json: testCommands must contain only strings");
|
|
36
|
+
}
|
|
28
37
|
|
|
29
38
|
return {
|
|
30
39
|
blockedInRed: parsed.blockedInRed,
|
package/package.json
CHANGED
|
@@ -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` —
|
|
46
|
-
|
|
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?
|
package/behaviour.md
DELETED
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
# TDD Enforcer — Behaviour Spec
|
|
2
|
-
|
|
3
|
-
## Entry Gate — `loadTddState(root)` (all surfaces)
|
|
4
|
-
|
|
5
|
-
Every surface (commands, hooks, tools) hits this first:
|
|
6
|
-
|
|
7
|
-
```
|
|
8
|
-
loadTddState(root):
|
|
9
|
-
│
|
|
10
|
-
├── .pi/tdd/ exists? NO → "Missing .pi/tdd/. See the tdd-enforcer skill."
|
|
11
|
-
│
|
|
12
|
-
├── rules.json exists? NO → "Missing .pi/tdd/rules.json. See the tdd-enforcer skill."
|
|
13
|
-
│
|
|
14
|
-
├── rules.json valid? NO → "Invalid .pi/tdd/rules.json. See the tdd-enforcer skill."
|
|
15
|
-
│
|
|
16
|
-
├── .pi/tdd/.git/ exists? NO → initGit(root)
|
|
17
|
-
│ FAIL → "Failed to initialise private git repo."
|
|
18
|
-
│
|
|
19
|
-
├── state.json exists? NO → recoverState()
|
|
20
|
-
│ │
|
|
21
|
-
│ └── recoverState():
|
|
22
|
-
│ ├── .git exists? + headMessage readable?
|
|
23
|
-
│ │ ├── "tdd: init" → { enabled: false, current: "red" }
|
|
24
|
-
│ │ ├── "tdd: red" → { enabled: true, current: "green" }
|
|
25
|
-
│ │ ├── "tdd: green" → { enabled: true, current: "refactor" }
|
|
26
|
-
│ │ ├── "tdd: refactor" → { enabled: true, current: "red" }
|
|
27
|
-
│ │ └── no match / error → { enabled: false, current: "red" }
|
|
28
|
-
│ └── no .git / no commits → { enabled: false, current: "red" }
|
|
29
|
-
│
|
|
30
|
-
├── state.json valid? NO → recoverState() (same as above)
|
|
31
|
-
│
|
|
32
|
-
└── return { ok: true, state, config }
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
After this gate, every surface has `state` + `config`. Then branches on `state.enabled`.
|
|
36
|
-
|
|
37
|
-
---
|
|
38
|
-
|
|
39
|
-
## Commands (user only)
|
|
40
|
-
|
|
41
|
-
| Command | Gate check | After gate |
|
|
42
|
-
|---------|-----------|------------|
|
|
43
|
-
| `tdd:on` | Setup valid? | If `enabled` → error "already on". Init git (if missing), snapshot, set `enabled: true`. |
|
|
44
|
-
| `tdd:off` | Setup valid? | If `disabled` → error "already off". Set `enabled: false`. |
|
|
45
|
-
| `tdd:status` | Setup valid? | Show state + config regardless of `enabled`. |
|
|
46
|
-
| `tdd:reset` | Setup valid? | Nuke private git, re-init, snapshot, set `enabled: false`. |
|
|
47
|
-
| `tdd:red` | Setup valid? | If already in RED → no-op. Snapshot working tree, auto-enable if disabled, set `current: "red"`. Notify "Skipped to RED phase". |
|
|
48
|
-
| `tdd:green` | Setup valid? | If already in GREEN → no-op. Snapshot working tree, auto-enable if disabled, set `current: "green"`. Notify "Skipped to GREEN phase". |
|
|
49
|
-
| `tdd:refactor` | Setup valid? | If already in REFACTOR → no-op. Snapshot working tree, auto-enable if disabled, set `current: "refactor"`. Notify "Skipped to REFACTOR phase". |
|
|
50
|
-
|
|
51
|
-
All errors reference the tdd-enforcer skill.
|
|
52
|
-
|
|
53
|
-
### Phase Jump Commands — Usage
|
|
54
|
-
|
|
55
|
-
These let the user skip phases they don't need for small changes:
|
|
56
|
-
|
|
57
|
-
| Scenario | Command | Why |
|
|
58
|
-
|----------|---------|-----|
|
|
59
|
-
| "This is just an implementation change, no test needed" | `/tdd:green` | Skip RED, go straight to GREEN. Test files stay locked, implementation files unlocked. |
|
|
60
|
-
| "This is just cleanup, not new behaviour" | `/tdd:refactor` | Skip RED + GREEN. All files unlocked. |
|
|
61
|
-
| "Done refactoring, start next cycle" | `/tdd:red` | Same as `next_tdd_phase` from REFACTOR but direct — no gate check needed. |
|
|
62
|
-
|
|
63
|
-
Unlike `next_tdd_phase`, these commands do NOT run transition gate checks. The user is explicitly choosing to skip a phase — they own the consequences.
|
|
64
|
-
|
|
65
|
-
---
|
|
66
|
-
|
|
67
|
-
## Hooks (agent — automatic)
|
|
68
|
-
|
|
69
|
-
| Hook | Gate + enabled | Behaviour |
|
|
70
|
-
|------|---------------|-----------|
|
|
71
|
-
| `tool_call` (write/edit) | Setup broken → pass through. Disabled → pass through. **Enabled** → block if `relPath` starts with `.pi/tdd/` OR phase-locked. | |
|
|
72
|
-
| `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. | |
|
|
73
|
-
|
|
74
|
-
---
|
|
75
|
-
|
|
76
|
-
## Tools (agent — callable)
|
|
77
|
-
|
|
78
|
-
| Tool | Gate + enabled | Behaviour |
|
|
79
|
-
|------|---------------|-----------|
|
|
80
|
-
| `next_tdd_phase` | Disabled → error. Enabled → run gate + snapshot + transition. | |
|
|
81
|
-
| `previous_tdd_phase` | Disabled → error. Enabled → revert to previous snapshot. | |
|
|
82
|
-
| `tdd_status` | Disabled → error. Enabled → show state + config. | |
|
|
83
|
-
|
|
84
|
-
---
|
|
85
|
-
|
|
86
|
-
## Flow Diagrams
|
|
87
|
-
|
|
88
|
-
### Initial Setup Flow
|
|
89
|
-
|
|
90
|
-
```
|
|
91
|
-
User creates .pi/tdd/rules.json
|
|
92
|
-
│
|
|
93
|
-
▼
|
|
94
|
-
User runs /tdd:on
|
|
95
|
-
│
|
|
96
|
-
▼
|
|
97
|
-
Gate: .pi/tdd/ exists? ──NO──► error (agent creates it)
|
|
98
|
-
│
|
|
99
|
-
YES
|
|
100
|
-
│
|
|
101
|
-
▼
|
|
102
|
-
Gate: rules.json exists? ──NO──► error (agent creates it)
|
|
103
|
-
│
|
|
104
|
-
YES
|
|
105
|
-
│
|
|
106
|
-
▼
|
|
107
|
-
Gate: rules.json valid? ──NO──► error (agent fixes it)
|
|
108
|
-
│
|
|
109
|
-
YES
|
|
110
|
-
│
|
|
111
|
-
▼
|
|
112
|
-
Gate: state.json exists? ──NO──► auto-create
|
|
113
|
-
│
|
|
114
|
-
YES/auto-created
|
|
115
|
-
│
|
|
116
|
-
▼
|
|
117
|
-
Init git (if missing) ──► snapshot ──► enabled: true
|
|
118
|
-
│
|
|
119
|
-
▼
|
|
120
|
-
TDD active — RED phase, enforcement on
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
### Phase Cycle Flow
|
|
124
|
-
|
|
125
|
-
```
|
|
126
|
-
RED: write tests
|
|
127
|
-
│ tests fail? ──NO──► "Tests passed. Add a failing test before transitioning."
|
|
128
|
-
│ YES
|
|
129
|
-
├─► next_tdd_phase ──► GREEN
|
|
130
|
-
│
|
|
131
|
-
GREEN: implement features
|
|
132
|
-
│ tests pass? ──NO──► "Tests failed. Fix them before transitioning."
|
|
133
|
-
│ YES
|
|
134
|
-
├─► next_tdd_phase ──► REFACTOR
|
|
135
|
-
│
|
|
136
|
-
REFACTOR: refactor freely
|
|
137
|
-
│ tests pass? ──NO──► "Tests failed. Fix them before transitioning."
|
|
138
|
-
│ YES
|
|
139
|
-
├─► next_tdd_phase ──► RED (new cycle)
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
### Bash Enforcement Flow
|
|
143
|
-
|
|
144
|
-
```
|
|
145
|
-
Agent runs bash command
|
|
146
|
-
│
|
|
147
|
-
▼
|
|
148
|
-
tool_call hook: stash pre-command state
|
|
149
|
-
│
|
|
150
|
-
▼
|
|
151
|
-
Bash executes (modifies files)
|
|
152
|
-
│
|
|
153
|
-
▼
|
|
154
|
-
tool_result hook: diff against stash
|
|
155
|
-
│
|
|
156
|
-
▼
|
|
157
|
-
For each changed file:
|
|
158
|
-
├── path starts with ".pi/tdd/"? ──YES──► revert, flag as violation
|
|
159
|
-
├── locked in current phase? ──YES──► revert, flag as violation
|
|
160
|
-
└── no violations ──► keep changes
|
|
161
|
-
│
|
|
162
|
-
▼
|
|
163
|
-
Return warning listing reverted vs retained files
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
---
|
|
167
|
-
|
|
168
|
-
## Protection Summary
|
|
169
|
-
|
|
170
|
-
| Surface | `.pi/tdd/` locked? | Phase-locked files? |
|
|
171
|
-
|---------|-------------------|-------------------|
|
|
172
|
-
| write/edit (TDD enabled) | ✅ Block | ✅ Block |
|
|
173
|
-
| write/edit (TDD disabled) | ❌ Free | ❌ Free |
|
|
174
|
-
| bash (TDD enabled) | ✅ Reverted | ✅ Reverted |
|
|
175
|
-
| bash (TDD disabled) | ❌ Free | ❌ Free |
|