tdd-enforcer 0.1.9 → 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.
- package/adapters/pi/helpers.test.ts +27 -15
- package/adapters/pi/helpers.ts +50 -23
- package/adapters/pi/hooks.ts +30 -7
- package/adapters/pi/index.ts +20 -66
- package/adapters/pi/prompts.ts +6 -2
- package/adapters/pi/tools.ts +15 -2
- package/behaviour.md +146 -0
- package/package.json +4 -1
- package/skills/tdd-enforcer/SKILL.md +97 -0
|
@@ -44,7 +44,7 @@ describe("loadTddState", () => {
|
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
it("
|
|
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(
|
|
57
|
-
|
|
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("
|
|
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(
|
|
72
|
-
|
|
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
|
|
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(
|
|
110
|
-
|
|
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("
|
|
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
|
|
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("
|
|
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(
|
|
200
|
-
|
|
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
|
});
|
package/adapters/pi/helpers.ts
CHANGED
|
@@ -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
|
-
*
|
|
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-
|
|
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-
|
|
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
|
|
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
|
}
|
package/adapters/pi/hooks.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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:`;
|
package/adapters/pi/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
package/adapters/pi/prompts.ts
CHANGED
|
@@ -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 (
|
package/adapters/pi/tools.ts
CHANGED
|
@@ -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:
|
|
128
|
-
"
|
|
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.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
"pi": {
|
|
15
15
|
"extensions": [
|
|
16
16
|
"./adapters/pi/index.ts"
|
|
17
|
+
],
|
|
18
|
+
"skills": [
|
|
19
|
+
"./skills"
|
|
17
20
|
]
|
|
18
21
|
}
|
|
19
22
|
}
|
|
@@ -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`.
|