tdd-enforcer 0.2.2 → 0.2.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.test.ts +218 -160
- package/adapters/pi/helpers.ts +50 -17
- package/adapters/pi/hooks.test.ts +472 -0
- package/adapters/pi/hooks.ts +215 -157
- package/adapters/pi/index.test.ts +302 -0
- package/adapters/pi/index.ts +192 -135
- package/adapters/pi/log.test.ts +63 -41
- package/adapters/pi/log.ts +13 -4
- package/adapters/pi/prompts.test.ts +43 -0
- package/adapters/pi/tools.test.ts +351 -0
- package/adapters/pi/tools.ts +321 -218
- package/behaviour.md +26 -11
- package/engine/git.test.ts +432 -173
- package/engine/git.ts +80 -49
- package/engine/index.ts +1 -1
- package/engine/transition.test.ts +62 -57
- package/engine/transition.ts +9 -2
- package/package.json +4 -1
- package/tsconfig.json +12 -0
package/adapters/pi/tools.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { exec } from "node:child_process";
|
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
|
|
6
6
|
const asyncExec = promisify(exec);
|
|
7
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import {
|
|
9
9
|
savePhaseState,
|
|
10
10
|
nextPhase,
|
|
@@ -21,6 +21,320 @@ import { getNudgePrompt } from "./prompts.js";
|
|
|
21
21
|
import { loadTddState } from "./helpers.js";
|
|
22
22
|
import { tddLog } from "./log.js";
|
|
23
23
|
|
|
24
|
+
// ── De dependency types ─────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export interface NextPhaseDeps {
|
|
27
|
+
loadTddState: typeof loadTddState;
|
|
28
|
+
nextPhase: typeof nextPhase;
|
|
29
|
+
getDisallowedChanges: typeof getDisallowedChanges;
|
|
30
|
+
checkGate: typeof checkGate;
|
|
31
|
+
snapshot: typeof snapshot;
|
|
32
|
+
savePhaseState: typeof savePhaseState;
|
|
33
|
+
getNudgePrompt: typeof getNudgePrompt;
|
|
34
|
+
asyncExec: (command: string, options?: { cwd?: string; timeout?: number }) => Promise<{ stdout: string; stderr: string }>;
|
|
35
|
+
tddLog: typeof tddLog;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PreviousPhaseDeps {
|
|
39
|
+
loadTddState: typeof loadTddState;
|
|
40
|
+
hasParent: typeof hasParent;
|
|
41
|
+
headMessage: typeof headMessage;
|
|
42
|
+
resetHard: typeof resetHard;
|
|
43
|
+
undoLastCommit: typeof undoLastCommit;
|
|
44
|
+
savePhaseState: typeof savePhaseState;
|
|
45
|
+
tddLog: typeof tddLog;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface TddStatusDeps {
|
|
49
|
+
loadTddState: typeof loadTddState;
|
|
50
|
+
tddLog: typeof tddLog;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Default deps ────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
const defaultNextPhaseDeps: NextPhaseDeps = {
|
|
56
|
+
loadTddState,
|
|
57
|
+
nextPhase,
|
|
58
|
+
getDisallowedChanges,
|
|
59
|
+
checkGate,
|
|
60
|
+
snapshot,
|
|
61
|
+
savePhaseState,
|
|
62
|
+
getNudgePrompt,
|
|
63
|
+
asyncExec,
|
|
64
|
+
tddLog,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const defaultPreviousPhaseDeps: PreviousPhaseDeps = {
|
|
68
|
+
loadTddState,
|
|
69
|
+
hasParent,
|
|
70
|
+
headMessage,
|
|
71
|
+
resetHard,
|
|
72
|
+
undoLastCommit,
|
|
73
|
+
savePhaseState,
|
|
74
|
+
tddLog,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const defaultTddStatusDeps: TddStatusDeps = {
|
|
78
|
+
loadTddState,
|
|
79
|
+
tddLog,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// ── executeNextPhase ────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export async function executeNextPhase(
|
|
85
|
+
ctx: ExtensionContext,
|
|
86
|
+
deps: NextPhaseDeps = defaultNextPhaseDeps,
|
|
87
|
+
): Promise<{ content: Array<{ type: string; text: string }>; details?: Record<string, unknown> }> {
|
|
88
|
+
const root = ctx.cwd;
|
|
89
|
+
const tddDir = join(root, ".pi", "tdd");
|
|
90
|
+
const tdd = deps.loadTddState(root);
|
|
91
|
+
if (!tdd.ok) {
|
|
92
|
+
deps.tddLog(tddDir, "WARN", "next_tdd_phase: TDD not active", { reason: tdd.reason });
|
|
93
|
+
return { content: [{ type: "text", text: `TDD: ${tdd.reason}` }], details: {} };
|
|
94
|
+
}
|
|
95
|
+
if (!tdd.state.enabled) {
|
|
96
|
+
deps.tddLog(tddDir, "WARN", "next_tdd_phase: TDD disabled");
|
|
97
|
+
return { content: [{ type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." }], details: {} };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const { state, config } = tdd;
|
|
101
|
+
const from = state.current;
|
|
102
|
+
const to = deps.nextPhase(from) as Phase;
|
|
103
|
+
|
|
104
|
+
deps.tddLog(tddDir, "INFO", "next_tdd_phase: starting", { from, to });
|
|
105
|
+
|
|
106
|
+
// 1. Allowlist check
|
|
107
|
+
const violations = deps.getDisallowedChanges(root, from, config);
|
|
108
|
+
if (violations.length > 0) {
|
|
109
|
+
deps.tddLog(tddDir, "WARN", "next_tdd_phase: blocked by allowlist", {
|
|
110
|
+
from,
|
|
111
|
+
violations,
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
content: [
|
|
115
|
+
{
|
|
116
|
+
type: "text",
|
|
117
|
+
text:
|
|
118
|
+
`BLOCKED: files not allowed in ${from.toUpperCase()} phase:\n` +
|
|
119
|
+
violations.map((f) => ` - ${f}`).join("\n") +
|
|
120
|
+
`\nRevert or remove them before proceeding.\n\nInspect with: cd .pi/tdd && git diff HEAD -- ${violations[0]}`,
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
details: {},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 2. Gate check
|
|
128
|
+
const testRunner: TestRunner = async (commands, timeout) => {
|
|
129
|
+
const results = await Promise.all(
|
|
130
|
+
commands.map(async (cmd) => {
|
|
131
|
+
try {
|
|
132
|
+
await deps.asyncExec(cmd, { cwd: root, timeout: timeout * 1000 });
|
|
133
|
+
return { command: cmd, passed: true };
|
|
134
|
+
} catch {
|
|
135
|
+
return { command: cmd, passed: false };
|
|
136
|
+
}
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const failed = results.filter((r) => !r.passed);
|
|
141
|
+
if (failed.length > 0) {
|
|
142
|
+
return {
|
|
143
|
+
passed: false,
|
|
144
|
+
message: "Tests failed:\n" + failed.map((f) => ` - ${f.command}`).join("\n"),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { passed: true, message: "All tests passed." };
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const gate = await deps.checkGate(from, to, testRunner, config);
|
|
151
|
+
deps.tddLog(tddDir, "DEBUG", "next_tdd_phase: gate result", {
|
|
152
|
+
from,
|
|
153
|
+
to,
|
|
154
|
+
passed: gate.passed,
|
|
155
|
+
message: gate.message,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!gate.passed) {
|
|
159
|
+
return { content: [{ type: "text", text: gate.message }], details: {} };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 3. Snapshot — label with the phase the work was done in
|
|
163
|
+
const hash = deps.snapshot(root, from);
|
|
164
|
+
deps.tddLog(tddDir, "INFO", "next_tdd_phase: snapshot created", {
|
|
165
|
+
from,
|
|
166
|
+
to,
|
|
167
|
+
hash,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// 4. Save state
|
|
171
|
+
state.current = to;
|
|
172
|
+
deps.savePhaseState(root, state);
|
|
173
|
+
deps.tddLog(tddDir, "INFO", "next_tdd_phase: complete", { from, to });
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
content: [{ type: "text", text: deps.getNudgePrompt(to, config) }],
|
|
177
|
+
details: {},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── executePreviousPhase ────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export async function executePreviousPhase(
|
|
184
|
+
ctx: ExtensionContext,
|
|
185
|
+
deps: PreviousPhaseDeps = defaultPreviousPhaseDeps,
|
|
186
|
+
): Promise<{ content: Array<{ type: string; text: string }>; details?: Record<string, unknown> }> {
|
|
187
|
+
const root = ctx.cwd;
|
|
188
|
+
const tddDir = join(root, ".pi", "tdd");
|
|
189
|
+
const tdd = deps.loadTddState(root);
|
|
190
|
+
if (!tdd.ok) {
|
|
191
|
+
deps.tddLog(tddDir, "WARN", "previous_tdd_phase: TDD not active", {
|
|
192
|
+
reason: tdd.reason,
|
|
193
|
+
});
|
|
194
|
+
return { content: [{ type: "text", text: `TDD: ${tdd.reason}` }], details: {} };
|
|
195
|
+
}
|
|
196
|
+
if (!tdd.state.enabled) {
|
|
197
|
+
deps.tddLog(tddDir, "WARN", "previous_tdd_phase: TDD disabled");
|
|
198
|
+
return { content: [{ type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." }], details: {} };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const { state } = tdd;
|
|
202
|
+
|
|
203
|
+
if (!deps.hasParent(root)) {
|
|
204
|
+
deps.tddLog(tddDir, "WARN", "previous_tdd_phase: no parent commit", {
|
|
205
|
+
phase: state.current,
|
|
206
|
+
});
|
|
207
|
+
return {
|
|
208
|
+
content: [{ type: "text", text: "No previous phase to revert to." }],
|
|
209
|
+
details: {},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Read phase from HEAD snapshot commit message (source of truth).
|
|
214
|
+
// Snapshot is labeled with the phase the work was done in, so we use
|
|
215
|
+
// it directly — no hardcoded phase map needed.
|
|
216
|
+
const headMsg = deps.headMessage(root);
|
|
217
|
+
const phaseMatch = headMsg.match(/^tdd: (red|green|refactor)/);
|
|
218
|
+
if (!phaseMatch) {
|
|
219
|
+
deps.tddLog(tddDir, "ERROR", "previous_tdd_phase: invalid HEAD message", {
|
|
220
|
+
headMsg,
|
|
221
|
+
});
|
|
222
|
+
return {
|
|
223
|
+
content: [
|
|
224
|
+
{
|
|
225
|
+
type: "text",
|
|
226
|
+
text: `HEAD commit "${headMsg}" is not a TDD snapshot. Cannot determine previous phase.\n` +
|
|
227
|
+
`The private git repo at .pi/tdd must not be manually modified. ` +
|
|
228
|
+
`Tampering with it will cause TDD state corruption.`,
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
details: {},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const label = phaseMatch[1];
|
|
235
|
+
if (label !== "red" && label !== "green" && label !== "refactor") {
|
|
236
|
+
deps.tddLog(tddDir, "ERROR", "previous_tdd_phase: invalid phase label", {
|
|
237
|
+
headMsg,
|
|
238
|
+
label,
|
|
239
|
+
});
|
|
240
|
+
return {
|
|
241
|
+
content: [
|
|
242
|
+
{
|
|
243
|
+
type: "text",
|
|
244
|
+
text: `HEAD commit "${headMsg}" has unexpected label. Cannot determine previous phase.`,
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
details: {},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const prevPhase: Phase = label;
|
|
251
|
+
deps.tddLog(tddDir, "INFO", "previous_tdd_phase: reverting", {
|
|
252
|
+
from: state.current,
|
|
253
|
+
to: prevPhase,
|
|
254
|
+
headMsg,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// 1. Nuke any uncommitted changes, WT matches HEAD
|
|
258
|
+
deps.resetHard(root);
|
|
259
|
+
deps.tddLog(tddDir, "DEBUG", "previous_tdd_phase: resetHard done");
|
|
260
|
+
|
|
261
|
+
// 2. Pop last snapshot commit, keep its content as unstaged
|
|
262
|
+
deps.undoLastCommit(root);
|
|
263
|
+
deps.tddLog(tddDir, "DEBUG", "previous_tdd_phase: undoLastCommit done");
|
|
264
|
+
|
|
265
|
+
// 3. Update phase label from the snapshot's own label
|
|
266
|
+
state.current = prevPhase;
|
|
267
|
+
deps.savePhaseState(root, state);
|
|
268
|
+
deps.tddLog(tddDir, "INFO", "previous_tdd_phase: complete", {
|
|
269
|
+
to: prevPhase,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
content: [
|
|
274
|
+
{
|
|
275
|
+
type: "text",
|
|
276
|
+
text: `Reverted to ${prevPhase.toUpperCase()}. Working tree has the previous snapshot content as unstaged changes.`,
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
details: {},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── executeTddStatus ────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
export async function executeTddStatus(
|
|
286
|
+
ctx: ExtensionContext,
|
|
287
|
+
deps: TddStatusDeps = defaultTddStatusDeps,
|
|
288
|
+
): Promise<{ content: Array<{ type: string; text: string }>; details?: Record<string, unknown> }> {
|
|
289
|
+
const root = ctx.cwd;
|
|
290
|
+
const tddDir = join(root, ".pi", "tdd");
|
|
291
|
+
const result = deps.loadTddState(root);
|
|
292
|
+
|
|
293
|
+
if (!result.ok) {
|
|
294
|
+
deps.tddLog(tddDir, "WARN", "tdd_status: TDD not active", {
|
|
295
|
+
reason: result.reason,
|
|
296
|
+
});
|
|
297
|
+
return { content: [{ type: "text", text: `TDD: ${result.reason}` }], details: {} };
|
|
298
|
+
}
|
|
299
|
+
if (!result.state.enabled) {
|
|
300
|
+
deps.tddLog(tddDir, "WARN", "tdd_status: TDD disabled");
|
|
301
|
+
return { content: [{ type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." }], details: {} };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const { state, config } = result;
|
|
305
|
+
const phaseStr = state.current.toUpperCase();
|
|
306
|
+
const redGlobs = config.allowedRedPhaseFiles.join(", ") || "(none)";
|
|
307
|
+
const greenGlobs = config.allowedGreenPhaseFiles.join(", ") || "(none)";
|
|
308
|
+
const commands = config.testCommands.join(", ") || "(none)";
|
|
309
|
+
|
|
310
|
+
deps.tddLog(tddDir, "INFO", "tdd_status: queried", {
|
|
311
|
+
phase: state.current,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
content: [
|
|
316
|
+
{
|
|
317
|
+
type: "text",
|
|
318
|
+
text:
|
|
319
|
+
`TDD enforcer enabled\n` +
|
|
320
|
+
`Current phase: ${phaseStr}\n` +
|
|
321
|
+
`Test files: ${redGlobs}\n` +
|
|
322
|
+
`Impl files: ${greenGlobs}\n` +
|
|
323
|
+
`Test commands: ${commands}`,
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
details: {
|
|
327
|
+
enabled: true,
|
|
328
|
+
phase: state.current,
|
|
329
|
+
allowedRedPhaseFiles: config.allowedRedPhaseFiles,
|
|
330
|
+
allowedGreenPhaseFiles: config.allowedGreenPhaseFiles,
|
|
331
|
+
testCommands: config.testCommands,
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── registerTools ───────────────────────────────────────────────────────────
|
|
337
|
+
|
|
24
338
|
export function registerTools(pi: ExtensionAPI): void {
|
|
25
339
|
pi.registerTool({
|
|
26
340
|
name: "next_tdd_phase",
|
|
@@ -29,98 +343,8 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
29
343
|
"Advance to the next TDD phase. Runs transition gates (test pass/fail checks) " +
|
|
30
344
|
"and allowlist validation (no forbidden files modified).",
|
|
31
345
|
parameters: Type.Object({}),
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const tddDir = join(root, ".pi", "tdd");
|
|
35
|
-
const tdd = loadTddState(root);
|
|
36
|
-
if (!tdd.ok) {
|
|
37
|
-
tddLog(tddDir, "WARN", "next_tdd_phase: TDD not active", { reason: tdd.reason });
|
|
38
|
-
return { content: [{ type: "text", text: `TDD: ${tdd.reason}` }], details: {} };
|
|
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
|
-
}
|
|
44
|
-
|
|
45
|
-
const { state, config } = tdd;
|
|
46
|
-
const from = state.current;
|
|
47
|
-
const to = nextPhase(from);
|
|
48
|
-
|
|
49
|
-
tddLog(tddDir, "INFO", "next_tdd_phase: starting", { from, to });
|
|
50
|
-
|
|
51
|
-
// 1. Allowlist check
|
|
52
|
-
const violations = getDisallowedChanges(root, from, config);
|
|
53
|
-
if (violations.length > 0) {
|
|
54
|
-
tddLog(tddDir, "WARN", "next_tdd_phase: blocked by allowlist", {
|
|
55
|
-
from,
|
|
56
|
-
violations,
|
|
57
|
-
});
|
|
58
|
-
return {
|
|
59
|
-
content: [
|
|
60
|
-
{
|
|
61
|
-
type: "text",
|
|
62
|
-
text:
|
|
63
|
-
`BLOCKED: files not allowed in ${from.toUpperCase()} phase:\n` +
|
|
64
|
-
violations.map((f) => ` - ${f}`).join("\n") +
|
|
65
|
-
`\nRevert or remove them before proceeding.\n\nInspect with: cd .pi/tdd && git diff HEAD -- ${violations[0]}`,
|
|
66
|
-
},
|
|
67
|
-
],
|
|
68
|
-
details: {},
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// 2. Gate check
|
|
73
|
-
const testRunner: TestRunner = async (commands, timeout) => {
|
|
74
|
-
const results = await Promise.all(
|
|
75
|
-
commands.map(async (cmd) => {
|
|
76
|
-
try {
|
|
77
|
-
await asyncExec(cmd, { cwd: root, timeout: timeout * 1000 });
|
|
78
|
-
return { command: cmd, passed: true };
|
|
79
|
-
} catch {
|
|
80
|
-
return { command: cmd, passed: false };
|
|
81
|
-
}
|
|
82
|
-
}),
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
const failed = results.filter((r) => !r.passed);
|
|
86
|
-
if (failed.length > 0) {
|
|
87
|
-
return {
|
|
88
|
-
passed: false,
|
|
89
|
-
message: "Tests failed:\n" + failed.map((f) => ` - ${f.command}`).join("\n"),
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
return { passed: true, message: "All tests passed." };
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const gate = await checkGate(from, to, testRunner, config);
|
|
96
|
-
tddLog(tddDir, "DEBUG", "next_tdd_phase: gate result", {
|
|
97
|
-
from,
|
|
98
|
-
to,
|
|
99
|
-
passed: gate.passed,
|
|
100
|
-
message: gate.message,
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
if (!gate.passed) {
|
|
104
|
-
return { content: [{ type: "text", text: gate.message }], details: {} };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// 3. Snapshot — label with the phase the work was done in
|
|
108
|
-
const hash = snapshot(root, from);
|
|
109
|
-
tddLog(tddDir, "INFO", "next_tdd_phase: snapshot created", {
|
|
110
|
-
from,
|
|
111
|
-
to,
|
|
112
|
-
hash,
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
// 4. Save state
|
|
116
|
-
state.current = to;
|
|
117
|
-
savePhaseState(root, state);
|
|
118
|
-
tddLog(tddDir, "INFO", "next_tdd_phase: complete", { from, to });
|
|
119
|
-
|
|
120
|
-
return {
|
|
121
|
-
content: [{ type: "text", text: getNudgePrompt(to, config) }],
|
|
122
|
-
details: {},
|
|
123
|
-
};
|
|
346
|
+
execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
347
|
+
return executeNextPhase(ctx, defaultNextPhaseDeps);
|
|
124
348
|
},
|
|
125
349
|
});
|
|
126
350
|
|
|
@@ -132,85 +356,8 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
132
356
|
"to what it was when the last phase ended. Use when the previous phase's work was wrong " +
|
|
133
357
|
"and this phase cannot proceed.",
|
|
134
358
|
parameters: Type.Object({}),
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const tddDir = join(root, ".pi", "tdd");
|
|
138
|
-
const tdd = loadTddState(root);
|
|
139
|
-
if (!tdd.ok) {
|
|
140
|
-
tddLog(tddDir, "WARN", "previous_tdd_phase: TDD not active", {
|
|
141
|
-
reason: tdd.reason,
|
|
142
|
-
});
|
|
143
|
-
return { content: [{ type: "text", text: `TDD: ${tdd.reason}` }], details: {} };
|
|
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
|
-
}
|
|
149
|
-
|
|
150
|
-
const { state } = tdd;
|
|
151
|
-
|
|
152
|
-
if (!hasParent(root)) {
|
|
153
|
-
tddLog(tddDir, "WARN", "previous_tdd_phase: no parent commit", {
|
|
154
|
-
phase: state.current,
|
|
155
|
-
});
|
|
156
|
-
return {
|
|
157
|
-
content: [{ type: "text", text: "No previous phase to revert to." }],
|
|
158
|
-
details: {},
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Read phase from HEAD snapshot commit message (source of truth).
|
|
163
|
-
// Snapshot is labeled with the phase the work was done in, so we use
|
|
164
|
-
// it directly — no hardcoded phase map needed.
|
|
165
|
-
const headMsg = headMessage(root);
|
|
166
|
-
const phaseMatch = headMsg.match(/^tdd: (red|green|refactor)/);
|
|
167
|
-
if (!phaseMatch) {
|
|
168
|
-
tddLog(tddDir, "ERROR", "previous_tdd_phase: invalid HEAD message", {
|
|
169
|
-
headMsg,
|
|
170
|
-
});
|
|
171
|
-
return {
|
|
172
|
-
content: [
|
|
173
|
-
{
|
|
174
|
-
type: "text",
|
|
175
|
-
text: `HEAD commit "${headMsg}" is not a TDD snapshot. Cannot determine previous phase.\n` +
|
|
176
|
-
`The private git repo at .pi/tdd must not be manually modified. ` +
|
|
177
|
-
`Tampering with it will cause TDD state corruption.`,
|
|
178
|
-
},
|
|
179
|
-
],
|
|
180
|
-
details: {},
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
const prevPhase = phaseMatch[1] as Phase;
|
|
184
|
-
tddLog(tddDir, "INFO", "previous_tdd_phase: reverting", {
|
|
185
|
-
from: state.current,
|
|
186
|
-
to: prevPhase,
|
|
187
|
-
headMsg,
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
// 1. Nuke any uncommitted changes, WT matches HEAD
|
|
191
|
-
resetHard(root);
|
|
192
|
-
tddLog(tddDir, "DEBUG", "previous_tdd_phase: resetHard done");
|
|
193
|
-
|
|
194
|
-
// 2. Pop last snapshot commit, keep its content as unstaged
|
|
195
|
-
undoLastCommit(root);
|
|
196
|
-
tddLog(tddDir, "DEBUG", "previous_tdd_phase: undoLastCommit done");
|
|
197
|
-
|
|
198
|
-
// 3. Update phase label from the snapshot's own label
|
|
199
|
-
state.current = prevPhase;
|
|
200
|
-
savePhaseState(root, state);
|
|
201
|
-
tddLog(tddDir, "INFO", "previous_tdd_phase: complete", {
|
|
202
|
-
to: prevPhase,
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
return {
|
|
206
|
-
content: [
|
|
207
|
-
{
|
|
208
|
-
type: "text",
|
|
209
|
-
text: `Reverted to ${prevPhase.toUpperCase()}. Working tree has the previous snapshot content as unstaged changes.`,
|
|
210
|
-
},
|
|
211
|
-
],
|
|
212
|
-
details: {},
|
|
213
|
-
};
|
|
359
|
+
execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
360
|
+
return executePreviousPhase(ctx, defaultPreviousPhaseDeps);
|
|
214
361
|
},
|
|
215
362
|
});
|
|
216
363
|
|
|
@@ -221,52 +368,8 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
221
368
|
"Show the current TDD enforcement status: enabled/disabled, current phase, " +
|
|
222
369
|
"allowed file globs, and test commands.",
|
|
223
370
|
parameters: Type.Object({}),
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const tddDir = join(root, ".pi", "tdd");
|
|
227
|
-
const result = loadTddState(root);
|
|
228
|
-
|
|
229
|
-
if (!result.ok) {
|
|
230
|
-
tddLog(tddDir, "WARN", "tdd_status: TDD not active", {
|
|
231
|
-
reason: result.reason,
|
|
232
|
-
});
|
|
233
|
-
return { content: [{ type: "text", text: `TDD: ${result.reason}` }], details: {} };
|
|
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
|
-
}
|
|
239
|
-
|
|
240
|
-
const { state, config } = result;
|
|
241
|
-
const phaseStr = state.current.toUpperCase();
|
|
242
|
-
const redGlobs = config.allowedRedPhaseFiles.join(", ") || "(none)";
|
|
243
|
-
const greenGlobs = config.allowedGreenPhaseFiles.join(", ") || "(none)";
|
|
244
|
-
const commands = config.testCommands.join(", ") || "(none)";
|
|
245
|
-
|
|
246
|
-
tddLog(tddDir, "INFO", "tdd_status: queried", {
|
|
247
|
-
phase: state.current,
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
return {
|
|
251
|
-
content: [
|
|
252
|
-
{
|
|
253
|
-
type: "text",
|
|
254
|
-
text:
|
|
255
|
-
`TDD enforcer enabled\n` +
|
|
256
|
-
`Current phase: ${phaseStr}\n` +
|
|
257
|
-
`Test files: ${redGlobs}\n` +
|
|
258
|
-
`Impl files: ${greenGlobs}\n` +
|
|
259
|
-
`Test commands: ${commands}`,
|
|
260
|
-
},
|
|
261
|
-
],
|
|
262
|
-
details: {
|
|
263
|
-
enabled: true,
|
|
264
|
-
phase: state.current,
|
|
265
|
-
allowedRedPhaseFiles: config.allowedRedPhaseFiles,
|
|
266
|
-
allowedGreenPhaseFiles: config.allowedGreenPhaseFiles,
|
|
267
|
-
testCommands: config.testCommands,
|
|
268
|
-
},
|
|
269
|
-
};
|
|
371
|
+
execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
372
|
+
return executeTddStatus(ctx, defaultTddStatusDeps);
|
|
270
373
|
},
|
|
271
374
|
});
|
|
272
375
|
}
|
package/behaviour.md
CHANGED
|
@@ -1,20 +1,35 @@
|
|
|
1
1
|
# TDD Enforcer — Behaviour Spec
|
|
2
2
|
|
|
3
|
-
## Entry Gate (all surfaces)
|
|
3
|
+
## Entry Gate — `loadTddState(root)` (all surfaces)
|
|
4
4
|
|
|
5
5
|
Every surface (commands, hooks, tools) hits this first:
|
|
6
6
|
|
|
7
7
|
```
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
NO
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 }
|
|
18
33
|
```
|
|
19
34
|
|
|
20
35
|
After this gate, every surface has `state` + `config`. Then branches on `state.enabled`.
|