tdd-enforcer 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters/pi/hooks.ts +4 -1
- package/adapters/pi/index.test.ts +80 -0
- package/adapters/pi/index.ts +299 -275
- package/adapters/pi/prompts.ts +1 -1
- package/adapters/pi/tools.test.ts +15 -0
- package/adapters/pi/tools.ts +3 -3
- package/package.json +30 -30
- package/skills/tdd-enforcer/SKILL.md +1 -1
package/adapters/pi/hooks.ts
CHANGED
|
@@ -120,7 +120,7 @@ export async function handleToolCall(
|
|
|
120
120
|
return {
|
|
121
121
|
block: true,
|
|
122
122
|
reason:
|
|
123
|
-
"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.",
|
|
123
|
+
"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.\n\nIf TDD reverts too much of your progress, reduce the scope of each TDD cycle to minimise lost progress.",
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
|
|
@@ -267,6 +267,9 @@ function formatWarning(
|
|
|
267
267
|
.join("");
|
|
268
268
|
let warning = `\n\n⛔ ${phase.toUpperCase()}: reverted locked files modified by bash:`;
|
|
269
269
|
for (const f of cmdViolations) warning += `\n - ${f}`;
|
|
270
|
+
if (cmdViolations.some((f) => f.startsWith(".pi/tdd/"))) {
|
|
271
|
+
warning += `\n\nIf TDD reverts too much of your progress, reduce the scope of each TDD cycle to minimise lost progress.`;
|
|
272
|
+
}
|
|
270
273
|
if (cmdAllowed.length > 0) {
|
|
271
274
|
warning += `\n\nAllowed changes retained:`;
|
|
272
275
|
for (const f of cmdAllowed) warning += `\n - ${f}`;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
handleBeforeAgentStart,
|
|
3
4
|
handleTddJump,
|
|
4
5
|
handleTddOff,
|
|
5
6
|
handleTddOn,
|
|
@@ -416,3 +417,82 @@ describe("handleTddJump", () => {
|
|
|
416
417
|
expect(ctx.notifications[0].message).toContain("Skipped to RED phase");
|
|
417
418
|
});
|
|
418
419
|
});
|
|
420
|
+
|
|
421
|
+
// ── handleBeforeAgentStart ──────────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
describe("handleBeforeAgentStart", () => {
|
|
424
|
+
let mockLoadTddState: ReturnType<typeof vi.fn>;
|
|
425
|
+
|
|
426
|
+
function makeDeps(overrides = {}) {
|
|
427
|
+
return {
|
|
428
|
+
loadTddState: mockLoadTddState,
|
|
429
|
+
...overrides,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function makeEvent(): { systemPrompt: string } {
|
|
434
|
+
return { systemPrompt: "" };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
beforeEach(() => {
|
|
438
|
+
vi.clearAllMocks();
|
|
439
|
+
mockLoadTddState = vi.fn();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("appends TDD instructions when TDD enabled", async () => {
|
|
443
|
+
mockLoadTddState.mockReturnValue({
|
|
444
|
+
ok: true,
|
|
445
|
+
state: { enabled: true, current: "red" },
|
|
446
|
+
config: {
|
|
447
|
+
blockedInRed: [],
|
|
448
|
+
blockedInGreen: [],
|
|
449
|
+
testCommands: [],
|
|
450
|
+
timeoutSeconds: 30,
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
const event = makeEvent();
|
|
454
|
+
await handleBeforeAgentStart(
|
|
455
|
+
event as any,
|
|
456
|
+
{ cwd: "/test" } as any,
|
|
457
|
+
makeDeps(),
|
|
458
|
+
);
|
|
459
|
+
expect(event.systemPrompt).toContain("TDD enforcement");
|
|
460
|
+
expect(event.systemPrompt).toContain("locked files will be blocked");
|
|
461
|
+
expect(event.systemPrompt).toContain("cycle so reverting is cheap");
|
|
462
|
+
expect(event.systemPrompt).not.toContain("next_tdd_phase");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("does not modify systemPrompt when TDD not setup", async () => {
|
|
466
|
+
mockLoadTddState.mockReturnValue({
|
|
467
|
+
ok: false,
|
|
468
|
+
reason: "Missing .pi/tdd/",
|
|
469
|
+
});
|
|
470
|
+
const event = makeEvent();
|
|
471
|
+
await handleBeforeAgentStart(
|
|
472
|
+
event as any,
|
|
473
|
+
{ cwd: "/test" } as any,
|
|
474
|
+
makeDeps(),
|
|
475
|
+
);
|
|
476
|
+
expect(event.systemPrompt).toBe("");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("appends disabled message when TDD was disabled", async () => {
|
|
480
|
+
mockLoadTddState.mockReturnValue({
|
|
481
|
+
ok: true,
|
|
482
|
+
state: { enabled: false, current: "red" },
|
|
483
|
+
config: {
|
|
484
|
+
blockedInRed: [],
|
|
485
|
+
blockedInGreen: [],
|
|
486
|
+
testCommands: [],
|
|
487
|
+
timeoutSeconds: 30,
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
const event = makeEvent();
|
|
491
|
+
await handleBeforeAgentStart(
|
|
492
|
+
event as any,
|
|
493
|
+
{ cwd: "/test" } as any,
|
|
494
|
+
makeDeps(),
|
|
495
|
+
);
|
|
496
|
+
expect(event.systemPrompt).toContain("was disabled");
|
|
497
|
+
});
|
|
498
|
+
});
|
package/adapters/pi/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type {
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { resetGit, savePhaseState, snapshot } from "../../engine/index.js";
|
|
7
7
|
import { loadTddState } from "./helpers.js";
|
|
@@ -10,297 +10,321 @@ import { tddLog } from "./log.js";
|
|
|
10
10
|
import { registerTools } from "./tools.js";
|
|
11
11
|
|
|
12
12
|
export async function handleTddOn(
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
13
|
+
ctx: ExtensionContext,
|
|
14
|
+
deps: {
|
|
15
|
+
loadTddState: typeof loadTddState;
|
|
16
|
+
snapshot: typeof snapshot;
|
|
17
|
+
savePhaseState: typeof savePhaseState;
|
|
18
|
+
tddLog: typeof tddLog;
|
|
19
|
+
} = {
|
|
20
|
+
loadTddState,
|
|
21
|
+
snapshot,
|
|
22
|
+
savePhaseState,
|
|
23
|
+
tddLog,
|
|
24
|
+
},
|
|
25
25
|
): Promise<void> {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
26
|
+
const root = ctx.cwd;
|
|
27
|
+
const tddDir = join(root, ".pi", "tdd");
|
|
28
|
+
|
|
29
|
+
deps.tddLog(tddDir, "INFO", "tdd:on: starting");
|
|
30
|
+
|
|
31
|
+
const setup = deps.loadTddState(root);
|
|
32
|
+
if (!setup.ok) {
|
|
33
|
+
deps.tddLog(tddDir, "WARN", "tdd:on: setup invalid", {
|
|
34
|
+
reason: setup.reason,
|
|
35
|
+
});
|
|
36
|
+
ctx.ui.notify(setup.reason, "error");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { state } = setup;
|
|
41
|
+
|
|
42
|
+
if (state.enabled) {
|
|
43
|
+
deps.tddLog(tddDir, "INFO", "tdd:on: already enabled", {
|
|
44
|
+
phase: state.current,
|
|
45
|
+
});
|
|
46
|
+
ctx.ui.notify(
|
|
47
|
+
`TDD already enabled — ${state.current.toUpperCase()} phase`,
|
|
48
|
+
"info",
|
|
49
|
+
);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Snapshot working tree so stale baseline doesn't nuke user changes
|
|
54
|
+
deps.snapshot(root, state.current);
|
|
55
|
+
deps.tddLog(tddDir, "INFO", "tdd:on: snapshot taken", {
|
|
56
|
+
phase: state.current,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
state.enabled = true;
|
|
60
|
+
deps.savePhaseState(root, state);
|
|
61
|
+
deps.tddLog(tddDir, "INFO", "tdd:on: enabled", {
|
|
62
|
+
phase: state.current,
|
|
63
|
+
});
|
|
64
|
+
ctx.ui.notify(`TDD enabled — ${state.current.toUpperCase()} phase`, "info");
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
export async function handleTddOff(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
68
|
+
ctx: ExtensionContext,
|
|
69
|
+
deps: {
|
|
70
|
+
loadTddState: typeof loadTddState;
|
|
71
|
+
savePhaseState: typeof savePhaseState;
|
|
72
|
+
tddLog: typeof tddLog;
|
|
73
|
+
} = {
|
|
74
|
+
loadTddState,
|
|
75
|
+
savePhaseState,
|
|
76
|
+
tddLog,
|
|
77
|
+
},
|
|
78
78
|
): Promise<void> {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
79
|
+
const root = ctx.cwd;
|
|
80
|
+
const tddDir = join(root, ".pi", "tdd");
|
|
81
|
+
|
|
82
|
+
const setup = deps.loadTddState(root);
|
|
83
|
+
if (!setup.ok) {
|
|
84
|
+
deps.tddLog(tddDir, "WARN", "tdd:off: setup invalid", {
|
|
85
|
+
reason: setup.reason,
|
|
86
|
+
});
|
|
87
|
+
ctx.ui.notify(setup.reason, "error");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { state } = setup;
|
|
92
|
+
|
|
93
|
+
if (!state.enabled) {
|
|
94
|
+
deps.tddLog(tddDir, "INFO", "tdd:off: already disabled");
|
|
95
|
+
ctx.ui.notify("TDD already disabled", "info");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
state.enabled = false;
|
|
100
|
+
deps.savePhaseState(root, state);
|
|
101
|
+
deps.tddLog(tddDir, "INFO", "tdd:off: disabled", {
|
|
102
|
+
was: state.current,
|
|
103
|
+
});
|
|
104
|
+
ctx.ui.notify("TDD disabled", "info");
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export async function handleTddStatus(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
108
|
+
ctx: ExtensionContext,
|
|
109
|
+
deps: {
|
|
110
|
+
loadTddState: typeof loadTddState;
|
|
111
|
+
tddLog: typeof tddLog;
|
|
112
|
+
} = {
|
|
113
|
+
loadTddState,
|
|
114
|
+
tddLog,
|
|
115
|
+
},
|
|
116
116
|
): Promise<void> {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
117
|
+
const root = ctx.cwd;
|
|
118
|
+
const tddDir = join(root, ".pi", "tdd");
|
|
119
|
+
const result = deps.loadTddState(root);
|
|
120
|
+
|
|
121
|
+
if (!result.ok) {
|
|
122
|
+
deps.tddLog(tddDir, "WARN", "tdd:status: setup invalid", {
|
|
123
|
+
reason: result.reason,
|
|
124
|
+
});
|
|
125
|
+
ctx.ui.notify(`TDD: ${result.reason}`, "error");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const { state, config } = result;
|
|
130
|
+
const enabledStr = state.enabled ? "enabled" : "disabled";
|
|
131
|
+
const phaseStr = state.current.toUpperCase();
|
|
132
|
+
const redBlk = config.blockedInRed.join(", ") || "(none)";
|
|
133
|
+
const greenBlk = config.blockedInGreen.join(", ") || "(none)";
|
|
134
|
+
const commands = config.testCommands.join(", ") || "(none)";
|
|
135
|
+
|
|
136
|
+
deps.tddLog(tddDir, "INFO", "tdd:status: queried", {
|
|
137
|
+
enabled: state.enabled,
|
|
138
|
+
phase: state.current,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
ctx.ui.notify(
|
|
142
|
+
`TDD enforcer ${enabledStr}\n` +
|
|
143
|
+
`Current phase: ${phaseStr}\n` +
|
|
144
|
+
`Blocked in RED: ${redBlk}\n` +
|
|
145
|
+
`Blocked in GREEN: ${greenBlk}\n` +
|
|
146
|
+
`Test commands: ${commands}`,
|
|
147
|
+
"info",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function handleBeforeAgentStart(
|
|
152
|
+
event: { systemPrompt: string },
|
|
153
|
+
ctx: { cwd: string },
|
|
154
|
+
deps: {
|
|
155
|
+
loadTddState: typeof loadTddState;
|
|
156
|
+
},
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
const tdd = deps.loadTddState(ctx.cwd);
|
|
159
|
+
if (!tdd.ok) return;
|
|
160
|
+
|
|
161
|
+
if (tdd.state.enabled) {
|
|
162
|
+
event.systemPrompt +=
|
|
163
|
+
"\n\nYou are working under TDD enforcement. Each phase restricts which files you can modify — locked files will be blocked automatically.\n" +
|
|
164
|
+
"Minimise the scope of each TDD cycle so reverting is cheap.";
|
|
165
|
+
} else {
|
|
166
|
+
event.systemPrompt +=
|
|
167
|
+
"\n\nTDD enforcement was disabled. File restrictions are no longer enforced.";
|
|
168
|
+
}
|
|
149
169
|
}
|
|
150
170
|
|
|
151
171
|
export async function handleTddJump(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
172
|
+
phase: "red" | "green" | "refactor",
|
|
173
|
+
ctx: ExtensionContext,
|
|
174
|
+
deps: {
|
|
175
|
+
loadTddState: typeof loadTddState;
|
|
176
|
+
snapshot: typeof snapshot;
|
|
177
|
+
savePhaseState: typeof savePhaseState;
|
|
178
|
+
tddLog: typeof tddLog;
|
|
179
|
+
} = {
|
|
180
|
+
loadTddState,
|
|
181
|
+
snapshot,
|
|
182
|
+
savePhaseState,
|
|
183
|
+
tddLog,
|
|
184
|
+
},
|
|
165
185
|
): Promise<void> {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
186
|
+
const root = ctx.cwd;
|
|
187
|
+
const tddDir = join(root, ".pi", "tdd");
|
|
188
|
+
|
|
189
|
+
const setup = deps.loadTddState(root);
|
|
190
|
+
if (!setup.ok) {
|
|
191
|
+
deps.tddLog(tddDir, "WARN", `tdd:${phase}: setup invalid`, {
|
|
192
|
+
reason: setup.reason,
|
|
193
|
+
});
|
|
194
|
+
ctx.ui.notify(setup.reason, "error");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const { state } = setup;
|
|
199
|
+
|
|
200
|
+
if (state.current === phase) {
|
|
201
|
+
deps.tddLog(tddDir, "INFO", `tdd:${phase}: already in ${phase}`, {
|
|
202
|
+
phase,
|
|
203
|
+
});
|
|
204
|
+
ctx.ui.notify(`TDD: already in ${phase.toUpperCase()} phase.`, "info");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Snapshot the current phase's work before jumping
|
|
209
|
+
deps.snapshot(root, state.current);
|
|
210
|
+
deps.tddLog(tddDir, "INFO", `tdd:${phase}: snapshot taken`, {
|
|
211
|
+
from: state.current,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Auto-enable if disabled, set phase
|
|
215
|
+
state.enabled = true;
|
|
216
|
+
state.current = phase;
|
|
217
|
+
deps.savePhaseState(root, state);
|
|
218
|
+
|
|
219
|
+
deps.tddLog(tddDir, "INFO", `tdd:${phase}: jumped`);
|
|
220
|
+
ctx.ui.notify(`Skipped to ${phase.toUpperCase()} phase.`, "info");
|
|
201
221
|
}
|
|
202
222
|
|
|
203
223
|
export async function handleTddReset(
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
224
|
+
ctx: ExtensionContext,
|
|
225
|
+
deps: {
|
|
226
|
+
loadTddState: typeof loadTddState;
|
|
227
|
+
resetGit: typeof resetGit;
|
|
228
|
+
snapshot: typeof snapshot;
|
|
229
|
+
savePhaseState: typeof savePhaseState;
|
|
230
|
+
tddLog: typeof tddLog;
|
|
231
|
+
} = {
|
|
232
|
+
loadTddState,
|
|
233
|
+
resetGit,
|
|
234
|
+
snapshot,
|
|
235
|
+
savePhaseState,
|
|
236
|
+
tddLog,
|
|
237
|
+
},
|
|
218
238
|
): Promise<void> {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
239
|
+
const root = ctx.cwd;
|
|
240
|
+
const tddDir = join(root, ".pi", "tdd");
|
|
241
|
+
|
|
242
|
+
deps.tddLog(tddDir, "INFO", "tdd:reset: starting");
|
|
243
|
+
|
|
244
|
+
const setup = deps.loadTddState(root);
|
|
245
|
+
if (!setup.ok) {
|
|
246
|
+
deps.tddLog(tddDir, "WARN", "tdd:reset: setup invalid", {
|
|
247
|
+
reason: setup.reason,
|
|
248
|
+
});
|
|
249
|
+
ctx.ui.notify(setup.reason, "error");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Nuke git history and re-init
|
|
254
|
+
try {
|
|
255
|
+
deps.resetGit(root);
|
|
256
|
+
deps.tddLog(tddDir, "INFO", "tdd:reset: git reset and re-initialised");
|
|
257
|
+
} catch (e) {
|
|
258
|
+
deps.tddLog(tddDir, "ERROR", "tdd:reset: git reset failed", {
|
|
259
|
+
error: (e as Error).message,
|
|
260
|
+
});
|
|
261
|
+
ctx.ui.notify("Failed to reset private git repo.", "error");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Snapshot current working tree
|
|
266
|
+
deps.snapshot(root, "red");
|
|
267
|
+
deps.tddLog(tddDir, "INFO", "tdd:reset: snapshot taken");
|
|
268
|
+
|
|
269
|
+
// Reset state to RED (disabled, user must run /tdd:on)
|
|
270
|
+
deps.savePhaseState(root, { enabled: false, current: "red" });
|
|
271
|
+
deps.tddLog(tddDir, "INFO", "tdd:reset: complete");
|
|
272
|
+
|
|
273
|
+
ctx.ui.notify(
|
|
274
|
+
"TDD snapshot history reset. Run /tdd:on to re-enable enforcement.",
|
|
275
|
+
"warning",
|
|
276
|
+
);
|
|
257
277
|
}
|
|
258
278
|
|
|
259
279
|
export default function (pi: ExtensionAPI) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
280
|
+
const defaultDeps = {
|
|
281
|
+
loadTddState,
|
|
282
|
+
snapshot,
|
|
283
|
+
savePhaseState,
|
|
284
|
+
tddLog,
|
|
285
|
+
resetGit,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
pi.registerCommand("tdd:on", {
|
|
289
|
+
description: "Enable TDD enforcement",
|
|
290
|
+
handler: (_args: string, ctx: ExtensionContext) =>
|
|
291
|
+
handleTddOn(ctx, defaultDeps),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
pi.registerCommand("tdd:off", {
|
|
295
|
+
description: "Disable TDD enforcement",
|
|
296
|
+
handler: (_args: string, ctx: ExtensionContext) =>
|
|
297
|
+
handleTddOff(ctx, defaultDeps),
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
pi.registerCommand("tdd:status", {
|
|
301
|
+
description: "Show TDD enforcement status",
|
|
302
|
+
handler: (_args: string, ctx: ExtensionContext) =>
|
|
303
|
+
handleTddStatus(ctx, defaultDeps),
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
pi.registerCommand("tdd:reset", {
|
|
307
|
+
description:
|
|
308
|
+
"WARNING: Destroys ALL TDD snapshot history and resets to RED phase. " +
|
|
309
|
+
"Working tree is preserved. Run /tdd:on to re-enable after reset.",
|
|
310
|
+
handler: (_args: string, ctx: ExtensionContext) =>
|
|
311
|
+
handleTddReset(ctx, { ...defaultDeps, resetGit }),
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
for (const phase of ["red", "green", "refactor"] as const) {
|
|
315
|
+
pi.registerCommand(`tdd:${phase}`, {
|
|
316
|
+
description:
|
|
317
|
+
`Skip to ${phase.toUpperCase()} phase. ` +
|
|
318
|
+
"Snapshot working tree, auto-enable TDD, set phase. No gate checks.",
|
|
319
|
+
handler: (_args: string, ctx: ExtensionContext) =>
|
|
320
|
+
handleTddJump(phase, ctx, defaultDeps),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
registerTools(pi);
|
|
325
|
+
registerHooks(pi);
|
|
326
|
+
|
|
327
|
+
pi.on("before_agent_start", (event, ctx) =>
|
|
328
|
+
handleBeforeAgentStart(event, ctx, { loadTddState }),
|
|
329
|
+
);
|
|
306
330
|
}
|
package/adapters/pi/prompts.ts
CHANGED
|
@@ -12,7 +12,7 @@ export function getNudgePrompt(phase: Phase, config: Config): string {
|
|
|
12
12
|
"All other files are free to modify. Call `next_tdd_phase` to proceed to GREEN.\n" +
|
|
13
13
|
"Think about what could go wrong and test for it — don't just verify the happy path, " +
|
|
14
14
|
"cover unhappy paths and edge cases too.\n" +
|
|
15
|
-
"
|
|
15
|
+
"Minimise the scope of each TDD cycle so reverting is cheap."
|
|
16
16
|
);
|
|
17
17
|
case "green":
|
|
18
18
|
return (
|
|
@@ -137,6 +137,21 @@ describe("executeNextPhase", () => {
|
|
|
137
137
|
).rejects.toThrow("failed");
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
+
it("result text has leading newline for spacing", async () => {
|
|
141
|
+
mockLoadTddState.mockReturnValue({
|
|
142
|
+
ok: true,
|
|
143
|
+
state: { enabled: true, current: "red" },
|
|
144
|
+
config: CONFIG,
|
|
145
|
+
});
|
|
146
|
+
mockCheckGate.mockResolvedValue({
|
|
147
|
+
passed: true,
|
|
148
|
+
message: "Tests fail — proceed to GREEN.",
|
|
149
|
+
});
|
|
150
|
+
mockGetNudgePrompt.mockReturnValue("content");
|
|
151
|
+
const result = await executeNextPhase({ cwd: "/test" } as any, makeDeps());
|
|
152
|
+
expect(result.content[0].text.startsWith("\n")).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
140
155
|
it("advances red→green when tests fail", async () => {
|
|
141
156
|
mockLoadTddState.mockReturnValue({
|
|
142
157
|
ok: true,
|
package/adapters/pi/tools.ts
CHANGED
|
@@ -178,7 +178,7 @@ export async function executeNextPhase(
|
|
|
178
178
|
deps.tddLog(tddDir, "INFO", "next_tdd_phase: complete", { from, to });
|
|
179
179
|
|
|
180
180
|
return {
|
|
181
|
-
content: [{ type: "text", text: deps.getNudgePrompt(to, config) }],
|
|
181
|
+
content: [{ type: "text", text: `\n${deps.getNudgePrompt(to, config)}` }],
|
|
182
182
|
details: {},
|
|
183
183
|
};
|
|
184
184
|
}
|
|
@@ -266,7 +266,7 @@ export async function executePreviousPhase(
|
|
|
266
266
|
content: [
|
|
267
267
|
{
|
|
268
268
|
type: "text",
|
|
269
|
-
text:
|
|
269
|
+
text: `\nReverted to ${prevPhase.toUpperCase()}. Working tree has the previous snapshot content as unstaged changes.`,
|
|
270
270
|
},
|
|
271
271
|
],
|
|
272
272
|
details: {},
|
|
@@ -312,7 +312,7 @@ export async function executeTddStatus(
|
|
|
312
312
|
{
|
|
313
313
|
type: "text",
|
|
314
314
|
text:
|
|
315
|
-
|
|
315
|
+
`\nTDD enforcer enabled\n` +
|
|
316
316
|
`Current phase: ${phaseStr}\n` +
|
|
317
317
|
`Blocked in RED: ${redBlk}\n` +
|
|
318
318
|
`Blocked in GREEN: ${greenBlk}\n` +
|
package/package.json
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
2
|
+
"name": "tdd-enforcer",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"fmt": "biome format --write .",
|
|
7
|
+
"lint": "biome check .",
|
|
8
|
+
"lint:fix": "biome check --write .",
|
|
9
|
+
"test": "vitest run"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pi-package"
|
|
13
|
+
],
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"picomatch": "^4.0.4"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@biomejs/biome": "^2.5.0",
|
|
19
|
+
"@earendil-works/pi-coding-agent": "^0.79.6",
|
|
20
|
+
"@types/node": "^25.9.3",
|
|
21
|
+
"typebox": "^1.2.16",
|
|
22
|
+
"vitest": "^3"
|
|
23
|
+
},
|
|
24
|
+
"pi": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./adapters/pi/index.ts"
|
|
27
|
+
],
|
|
28
|
+
"skills": [
|
|
29
|
+
"./skills"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
32
|
}
|
|
@@ -54,7 +54,7 @@ It locks files per phase — only test files in RED, only implementation files i
|
|
|
54
54
|
### RED
|
|
55
55
|
Files matching `blockedInRed` are locked — everything else is free.
|
|
56
56
|
|
|
57
|
-
Write failing tests for one feature at a time. Think about what could go wrong and test for it — don't just verify the happy path, cover unhappy paths and edge cases too.
|
|
57
|
+
Write failing tests for one feature at a time. Think about what could go wrong and test for it — don't just verify the happy path, cover unhappy paths and edge cases too. Minimise the scope of each TDD cycle so reverting is cheap and safe if assumptions turn out wrong.
|
|
58
58
|
|
|
59
59
|
Call `next_tdd_phase` once tests fail.
|
|
60
60
|
|