tdd-enforcer 0.2.7 → 0.2.9

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.
@@ -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,7 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import {
3
+ handleBeforeAgentStart,
4
+ handleTddJump,
3
5
  handleTddOff,
4
6
  handleTddOn,
5
7
  handleTddReset,
@@ -310,3 +312,195 @@ describe("handleTddReset", () => {
310
312
  expect(ctx.notifications[0].type).toBe("error");
311
313
  });
312
314
  });
315
+
316
+ // ── handleTddJump ───────────────────────────────────────────────────────────
317
+
318
+ describe("handleTddJump", () => {
319
+ let mockLoadTddState: ReturnType<typeof vi.fn>;
320
+ let mockSnapshot: ReturnType<typeof vi.fn>;
321
+ let mockSavePhaseState: ReturnType<typeof vi.fn>;
322
+ let mockTddLog: ReturnType<typeof vi.fn>;
323
+
324
+ function makeDeps(overrides = {}) {
325
+ return {
326
+ loadTddState: mockLoadTddState,
327
+ snapshot: mockSnapshot,
328
+ savePhaseState: mockSavePhaseState,
329
+ tddLog: mockTddLog,
330
+ ...overrides,
331
+ };
332
+ }
333
+
334
+ beforeEach(() => {
335
+ vi.clearAllMocks();
336
+ mockLoadTddState = vi.fn();
337
+ mockSnapshot = vi.fn().mockReturnValue("snap123");
338
+ mockSavePhaseState = vi.fn();
339
+ mockTddLog = vi.fn();
340
+ });
341
+
342
+ function tddOk(overrides?: { current?: string; enabled?: boolean }) {
343
+ return {
344
+ ok: true as const,
345
+ state: {
346
+ enabled: overrides?.enabled ?? true,
347
+ current: overrides?.current ?? "red",
348
+ },
349
+ config,
350
+ };
351
+ }
352
+
353
+ it("shows error when TDD not setup", async () => {
354
+ mockLoadTddState.mockReturnValue({ ok: false, reason: "Missing .pi/tdd/" });
355
+ const ctx = makeCtx();
356
+ await handleTddJump("green", ctx, makeDeps());
357
+ expect(ctx.notifications[0].message).toContain("Missing");
358
+ expect(ctx.notifications[0].type).toBe("error");
359
+ expect(mockSavePhaseState).not.toHaveBeenCalled();
360
+ });
361
+
362
+ it("notifies no-op when already in target phase", async () => {
363
+ mockLoadTddState.mockReturnValue(tddOk({ current: "green" }));
364
+ const ctx = makeCtx();
365
+ await handleTddJump("green", ctx, makeDeps());
366
+ expect(ctx.notifications[0].message).toContain("already in GREEN");
367
+ expect(ctx.notifications[0].type).toBe("info");
368
+ expect(mockSnapshot).not.toHaveBeenCalled();
369
+ expect(mockSavePhaseState).not.toHaveBeenCalled();
370
+ });
371
+
372
+ it("snapshots, auto-enables, jumps phase, notifies", async () => {
373
+ mockLoadTddState.mockReturnValue(tddOk({ current: "red", enabled: true }));
374
+ const ctx = makeCtx();
375
+ await handleTddJump("green", ctx, makeDeps());
376
+
377
+ expect(mockSnapshot).toHaveBeenCalledWith("/test", "red");
378
+ expect(mockSavePhaseState).toHaveBeenCalledWith("/test", {
379
+ enabled: true,
380
+ current: "green",
381
+ });
382
+ expect(ctx.notifications[0].message).toContain("Skipped to GREEN phase");
383
+ expect(ctx.notifications[0].type).toBe("info");
384
+ });
385
+
386
+ it("auto-enables when TDD disabled", async () => {
387
+ mockLoadTddState.mockReturnValue(tddOk({ current: "red", enabled: false }));
388
+ const ctx = makeCtx();
389
+ await handleTddJump("green", ctx, makeDeps());
390
+
391
+ expect(mockSavePhaseState).toHaveBeenCalledWith("/test", {
392
+ enabled: true,
393
+ current: "green",
394
+ });
395
+ expect(ctx.notifications[0].message).toContain("Skipped to GREEN phase");
396
+ });
397
+
398
+ it("works for refactor from green", async () => {
399
+ mockLoadTddState.mockReturnValue(tddOk({ current: "green" }));
400
+ const ctx = makeCtx();
401
+ await handleTddJump("refactor", ctx, makeDeps());
402
+ expect(mockSavePhaseState).toHaveBeenCalledWith("/test", {
403
+ enabled: true,
404
+ current: "refactor",
405
+ });
406
+ expect(ctx.notifications[0].message).toContain("Skipped to REFACTOR phase");
407
+ });
408
+
409
+ it("works for red from green", async () => {
410
+ mockLoadTddState.mockReturnValue(tddOk({ current: "green" }));
411
+ const ctx = makeCtx();
412
+ await handleTddJump("red", ctx, makeDeps());
413
+ expect(mockSavePhaseState).toHaveBeenCalledWith("/test", {
414
+ enabled: true,
415
+ current: "red",
416
+ });
417
+ expect(ctx.notifications[0].message).toContain("Skipped to RED phase");
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(): {
434
+ systemPromptOptions: { promptGuidelines: string[] };
435
+ } {
436
+ return { systemPromptOptions: { promptGuidelines: [] } };
437
+ }
438
+
439
+ beforeEach(() => {
440
+ vi.clearAllMocks();
441
+ mockLoadTddState = vi.fn();
442
+ });
443
+
444
+ it("pushes guidelines when TDD enabled", async () => {
445
+ mockLoadTddState.mockReturnValue({
446
+ ok: true,
447
+ state: { enabled: true, current: "red" },
448
+ config: {
449
+ blockedInRed: [],
450
+ blockedInGreen: [],
451
+ testCommands: [],
452
+ timeoutSeconds: 30,
453
+ },
454
+ });
455
+ const event = makeEvent();
456
+ await handleBeforeAgentStart(
457
+ event as any,
458
+ { cwd: "/test" } as any,
459
+ makeDeps(),
460
+ );
461
+ expect(event.systemPromptOptions.promptGuidelines).toHaveLength(3);
462
+ expect(event.systemPromptOptions.promptGuidelines[0]).toContain(
463
+ "locked files will be blocked",
464
+ );
465
+ expect(event.systemPromptOptions.promptGuidelines[1]).toContain(
466
+ "next_tdd_phase",
467
+ );
468
+ expect(event.systemPromptOptions.promptGuidelines[2]).toContain(
469
+ "cycle so reverting is cheap",
470
+ );
471
+ });
472
+
473
+ it("does not push guidelines when TDD not setup", async () => {
474
+ mockLoadTddState.mockReturnValue({
475
+ ok: false,
476
+ reason: "Missing .pi/tdd/",
477
+ });
478
+ const event = makeEvent();
479
+ await handleBeforeAgentStart(
480
+ event as any,
481
+ { cwd: "/test" } as any,
482
+ makeDeps(),
483
+ );
484
+ expect(event.systemPromptOptions.promptGuidelines).toHaveLength(0);
485
+ });
486
+
487
+ it("does not push guidelines when TDD disabled", async () => {
488
+ mockLoadTddState.mockReturnValue({
489
+ ok: true,
490
+ state: { enabled: false, current: "red" },
491
+ config: {
492
+ blockedInRed: [],
493
+ blockedInGreen: [],
494
+ testCommands: [],
495
+ timeoutSeconds: 30,
496
+ },
497
+ });
498
+ const event = makeEvent();
499
+ await handleBeforeAgentStart(
500
+ event as any,
501
+ { cwd: "/test" } as any,
502
+ makeDeps(),
503
+ );
504
+ expect(event.systemPromptOptions.promptGuidelines).toHaveLength(0);
505
+ });
506
+ });
@@ -148,6 +148,77 @@ export async function handleTddStatus(
148
148
  );
149
149
  }
150
150
 
151
+ export async function handleBeforeAgentStart(
152
+ event: {
153
+ systemPromptOptions: { promptGuidelines: string[] };
154
+ },
155
+ ctx: { cwd: string },
156
+ deps: {
157
+ loadTddState: typeof loadTddState;
158
+ },
159
+ ): Promise<void> {
160
+ const tdd = deps.loadTddState(ctx.cwd);
161
+ if (!tdd.ok || !tdd.state.enabled) return;
162
+
163
+ event.systemPromptOptions.promptGuidelines.push(
164
+ "You are working under TDD enforcement. Each phase restricts which files you can modify — locked files will be blocked automatically.",
165
+ "Use `next_tdd_phase` to advance through the cycle, `previous_tdd_phase` to revert a phase, `tdd_status` to check current phase and blocked file rules.",
166
+ "Minimise the scope of each TDD cycle so reverting is cheap.",
167
+ );
168
+ }
169
+
170
+ export async function handleTddJump(
171
+ phase: "red" | "green" | "refactor",
172
+ ctx: ExtensionContext,
173
+ deps: {
174
+ loadTddState: typeof loadTddState;
175
+ snapshot: typeof snapshot;
176
+ savePhaseState: typeof savePhaseState;
177
+ tddLog: typeof tddLog;
178
+ } = {
179
+ loadTddState,
180
+ snapshot,
181
+ savePhaseState,
182
+ tddLog,
183
+ },
184
+ ): Promise<void> {
185
+ const root = ctx.cwd;
186
+ const tddDir = join(root, ".pi", "tdd");
187
+
188
+ const setup = deps.loadTddState(root);
189
+ if (!setup.ok) {
190
+ deps.tddLog(tddDir, "WARN", `tdd:${phase}: setup invalid`, {
191
+ reason: setup.reason,
192
+ });
193
+ ctx.ui.notify(setup.reason, "error");
194
+ return;
195
+ }
196
+
197
+ const { state } = setup;
198
+
199
+ if (state.current === phase) {
200
+ deps.tddLog(tddDir, "INFO", `tdd:${phase}: already in ${phase}`, {
201
+ phase,
202
+ });
203
+ ctx.ui.notify(`TDD: already in ${phase.toUpperCase()} phase.`, "info");
204
+ return;
205
+ }
206
+
207
+ // Snapshot the current phase's work before jumping
208
+ deps.snapshot(root, state.current);
209
+ deps.tddLog(tddDir, "INFO", `tdd:${phase}: snapshot taken`, {
210
+ from: state.current,
211
+ });
212
+
213
+ // Auto-enable if disabled, set phase
214
+ state.enabled = true;
215
+ state.current = phase;
216
+ deps.savePhaseState(root, state);
217
+
218
+ deps.tddLog(tddDir, "INFO", `tdd:${phase}: jumped`);
219
+ ctx.ui.notify(`Skipped to ${phase.toUpperCase()} phase.`, "info");
220
+ }
221
+
151
222
  export async function handleTddReset(
152
223
  ctx: ExtensionContext,
153
224
  deps: {
@@ -239,6 +310,20 @@ export default function (pi: ExtensionAPI) {
239
310
  handleTddReset(ctx, { ...defaultDeps, resetGit }),
240
311
  });
241
312
 
313
+ for (const phase of ["red", "green", "refactor"] as const) {
314
+ pi.registerCommand(`tdd:${phase}`, {
315
+ description:
316
+ `Skip to ${phase.toUpperCase()} phase. ` +
317
+ "Snapshot working tree, auto-enable TDD, set phase. No gate checks.",
318
+ handler: (_args: string, ctx: ExtensionContext) =>
319
+ handleTddJump(phase, ctx, defaultDeps),
320
+ });
321
+ }
322
+
242
323
  registerTools(pi);
243
324
  registerHooks(pi);
325
+
326
+ pi.on("before_agent_start", (event, ctx) =>
327
+ handleBeforeAgentStart(event, ctx, { loadTddState }),
328
+ );
244
329
  }
@@ -11,7 +11,8 @@ export function getNudgePrompt(phase: Phase, config: Config): string {
11
11
  `Blocked files: ${redBlock}\n` +
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
- "cover unhappy paths and edge cases too. Keep cycles small so reverting is cheap."
14
+ "cover unhappy paths and edge cases too.\n" +
15
+ "Minimise the scope of each TDD cycle so reverting is cheap."
15
16
  );
16
17
  case "green":
17
18
  return (
@@ -72,24 +72,26 @@ describe("executeNextPhase", () => {
72
72
  mockAsyncExec.mockResolvedValue({ stdout: "", stderr: "" });
73
73
  });
74
74
 
75
- it("returns config error when TDD not setup", async () => {
75
+ it("throws when TDD not setup", async () => {
76
76
  mockLoadTddState.mockReturnValue({
77
77
  ok: false,
78
78
  reason:
79
79
  "Missing .pi/tdd/ directory. See the tdd-enforcer skill to learn how to set up TDD configs.",
80
80
  });
81
- const result = await executeNextPhase({ cwd: "/test" } as any, makeDeps());
82
- expect(result.content[0].text).toContain("Missing .pi/tdd/");
81
+ await expect(
82
+ executeNextPhase({ cwd: "/test" } as any, makeDeps()),
83
+ ).rejects.toThrow("Missing .pi/tdd/");
83
84
  });
84
85
 
85
- it("returns disabled message when TDD disabled", async () => {
86
+ it("throws when TDD disabled", async () => {
86
87
  mockLoadTddState.mockReturnValue({
87
88
  ok: true,
88
89
  state: { enabled: false, current: "red" },
89
90
  config: CONFIG,
90
91
  });
91
- const result = await executeNextPhase({ cwd: "/test" } as any, makeDeps());
92
- expect(result.content[0].text).toContain("not enabled");
92
+ await expect(
93
+ executeNextPhase({ cwd: "/test" } as any, makeDeps()),
94
+ ).rejects.toThrow("not enabled");
93
95
  });
94
96
 
95
97
  it("blocks when allowlist violations exist", async () => {
@@ -99,9 +101,9 @@ describe("executeNextPhase", () => {
99
101
  config: CONFIG,
100
102
  });
101
103
  mockGetDisallowedChanges.mockReturnValue(["src/violation.ts"]);
102
- const result = await executeNextPhase({ cwd: "/test" } as any, makeDeps());
103
- expect(result.content[0].text).toContain("BLOCKED");
104
- expect(result.content[0].text).toContain("src/violation.ts");
104
+ await expect(
105
+ executeNextPhase({ cwd: "/test" } as any, makeDeps()),
106
+ ).rejects.toThrow("BLOCKED");
105
107
  });
106
108
 
107
109
  it("blocks red→green when tests pass (need failing test)", async () => {
@@ -115,9 +117,9 @@ describe("executeNextPhase", () => {
115
117
  message:
116
118
  "Tests passed. Add a failing test before transitioning to GREEN.",
117
119
  });
118
- const result = await executeNextPhase({ cwd: "/test" } as any, makeDeps());
119
- expect(result.content[0].text).toContain("Add a failing test");
120
- expect(result.content[0].text).toContain("GREEN");
120
+ await expect(
121
+ executeNextPhase({ cwd: "/test" } as any, makeDeps()),
122
+ ).rejects.toThrow("Add a failing test");
121
123
  });
122
124
 
123
125
  it("blocks green→refactor when tests fail", async () => {
@@ -130,9 +132,24 @@ describe("executeNextPhase", () => {
130
132
  passed: false,
131
133
  message: "Tests failed. Fix them before transitioning to REFACTOR.",
132
134
  });
135
+ await expect(
136
+ executeNextPhase({ cwd: "/test" } as any, makeDeps()),
137
+ ).rejects.toThrow("failed");
138
+ });
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");
133
151
  const result = await executeNextPhase({ cwd: "/test" } as any, makeDeps());
134
- expect(result.content[0].text).toContain("failed");
135
- expect(result.content[0].text).toContain("REFACTOR");
152
+ expect(result.content[0].text.startsWith("\n")).toBe(true);
136
153
  });
137
154
 
138
155
  it("advances red→green when tests fail", async () => {
@@ -185,9 +202,9 @@ describe("executeNextPhase", () => {
185
202
  passed: false,
186
203
  message: "Tests failed. Fix them before transitioning to RED.",
187
204
  });
188
- const result = await executeNextPhase({ cwd: "/test" } as any, makeDeps());
189
- expect(result.content[0].text).toContain("failed");
190
- expect(result.content[0].text).toContain("RED");
205
+ await expect(
206
+ executeNextPhase({ cwd: "/test" } as any, makeDeps()),
207
+ ).rejects.toThrow("failed");
191
208
  expect(mockSavePhaseState).not.toHaveBeenCalled();
192
209
  });
193
210
 
@@ -251,57 +268,49 @@ describe("executePreviousPhase", () => {
251
268
  mockHasParent.mockReturnValue(true);
252
269
  });
253
270
 
254
- it("returns config error when TDD not setup", async () => {
271
+ it("throws when TDD not setup", async () => {
255
272
  mockLoadTddState.mockReturnValue({
256
273
  ok: false,
257
274
  reason: "Missing .pi/tdd/ directory.",
258
275
  });
259
- const result = await executePreviousPhase(
260
- { cwd: "/test" } as any,
261
- makeDeps(),
262
- );
263
- expect(result.content[0].text).toContain("Missing .pi/tdd/");
276
+ await expect(
277
+ executePreviousPhase({ cwd: "/test" } as any, makeDeps()),
278
+ ).rejects.toThrow("Missing .pi/tdd/");
264
279
  });
265
280
 
266
- it("returns disabled message when TDD disabled", async () => {
281
+ it("throws when TDD disabled", async () => {
267
282
  mockLoadTddState.mockReturnValue({
268
283
  ok: true,
269
284
  state: { enabled: false, current: "red" },
270
285
  config: CONFIG,
271
286
  });
272
- const result = await executePreviousPhase(
273
- { cwd: "/test" } as any,
274
- makeDeps(),
275
- );
276
- expect(result.content[0].text).toContain("not enabled");
287
+ await expect(
288
+ executePreviousPhase({ cwd: "/test" } as any, makeDeps()),
289
+ ).rejects.toThrow("not enabled");
277
290
  });
278
291
 
279
- it("returns no-parent message when only init commit exists", async () => {
292
+ it("throws when no parent commit", async () => {
280
293
  mockLoadTddState.mockReturnValue({
281
294
  ok: true,
282
295
  state: { enabled: true, current: "red" },
283
296
  config: CONFIG,
284
297
  });
285
298
  mockHasParent.mockReturnValue(false);
286
- const result = await executePreviousPhase(
287
- { cwd: "/test" } as any,
288
- makeDeps(),
289
- );
290
- expect(result.content[0].text).toContain("No previous phase");
299
+ await expect(
300
+ executePreviousPhase({ cwd: "/test" } as any, makeDeps()),
301
+ ).rejects.toThrow("No previous phase");
291
302
  });
292
303
 
293
- it("returns error when HEAD message is not a TDD snapshot", async () => {
304
+ it("throws when HEAD message is not a TDD snapshot", async () => {
294
305
  mockLoadTddState.mockReturnValue({
295
306
  ok: true,
296
307
  state: { enabled: true, current: "red" },
297
308
  config: CONFIG,
298
309
  });
299
310
  mockHeadMessage.mockReturnValue("garbage");
300
- const result = await executePreviousPhase(
301
- { cwd: "/test" } as any,
302
- makeDeps(),
303
- );
304
- expect(result.content[0].text).toContain("not a TDD snapshot");
311
+ await expect(
312
+ executePreviousPhase({ cwd: "/test" } as any, makeDeps()),
313
+ ).rejects.toThrow("not a TDD snapshot");
305
314
  });
306
315
 
307
316
  it("reverts to previous phase on success", async () => {
@@ -363,23 +372,25 @@ describe("executeTddStatus", () => {
363
372
  mockTddLog = vi.fn();
364
373
  });
365
374
 
366
- it("returns config error when TDD not setup", async () => {
375
+ it("throws when TDD not setup", async () => {
367
376
  mockLoadTddState.mockReturnValue({
368
377
  ok: false,
369
378
  reason: "Missing .pi/tdd/ directory.",
370
379
  });
371
- const result = await executeTddStatus({ cwd: "/test" } as any, makeDeps());
372
- expect(result.content[0].text).toContain("Missing .pi/tdd/");
380
+ await expect(
381
+ executeTddStatus({ cwd: "/test" } as any, makeDeps()),
382
+ ).rejects.toThrow("Missing .pi/tdd/");
373
383
  });
374
384
 
375
- it("returns disabled message when TDD disabled", async () => {
385
+ it("throws when TDD disabled", async () => {
376
386
  mockLoadTddState.mockReturnValue({
377
387
  ok: true,
378
388
  state: { enabled: false, current: "red" },
379
389
  config: CONFIG,
380
390
  });
381
- const result = await executeTddStatus({ cwd: "/test" } as any, makeDeps());
382
- expect(result.content[0].text).toContain("not enabled");
391
+ await expect(
392
+ executeTddStatus({ cwd: "/test" } as any, makeDeps()),
393
+ ).rejects.toThrow("not enabled");
383
394
  });
384
395
 
385
396
  it("returns status details when TDD enabled", async () => {
@@ -102,19 +102,11 @@ export async function executeNextPhase(
102
102
  deps.tddLog(tddDir, "WARN", "next_tdd_phase: TDD not active", {
103
103
  reason: tdd.reason,
104
104
  });
105
- return {
106
- content: [{ type: "text", text: `TDD: ${tdd.reason}` }],
107
- details: {},
108
- };
105
+ throw new Error(`TDD: ${tdd.reason}`);
109
106
  }
110
107
  if (!tdd.state.enabled) {
111
108
  deps.tddLog(tddDir, "WARN", "next_tdd_phase: TDD disabled");
112
- return {
113
- content: [
114
- { type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." },
115
- ],
116
- details: {},
117
- };
109
+ throw new Error("TDD is not enabled. Run /tdd:on to enable it.");
118
110
  }
119
111
 
120
112
  const { state, config } = tdd;
@@ -130,18 +122,11 @@ export async function executeNextPhase(
130
122
  from,
131
123
  violations,
132
124
  });
133
- return {
134
- content: [
135
- {
136
- type: "text",
137
- text:
138
- `BLOCKED: files not allowed in ${from.toUpperCase()} phase:\n` +
139
- violations.map((f) => ` - ${f}`).join("\n") +
140
- `\nRevert or remove them before proceeding.\n\nInspect with: cd .pi/tdd && git diff HEAD -- ${violations[0]}`,
141
- },
142
- ],
143
- details: {},
144
- };
125
+ throw new Error(
126
+ `BLOCKED: files not allowed in ${from.toUpperCase()} phase:\n` +
127
+ violations.map((f) => ` - ${f}`).join("\n") +
128
+ `\nRevert or remove them before proceeding.\n\nInspect with: cd .pi/tdd && git diff HEAD -- ${violations[0]}`,
129
+ );
145
130
  }
146
131
 
147
132
  // 2. Gate check
@@ -176,7 +161,7 @@ export async function executeNextPhase(
176
161
  });
177
162
 
178
163
  if (!gate.passed) {
179
- return { content: [{ type: "text", text: gate.message }], details: {} };
164
+ throw new Error(gate.message);
180
165
  }
181
166
 
182
167
  // 3. Snapshot — label with the phase the work was done in
@@ -193,7 +178,7 @@ export async function executeNextPhase(
193
178
  deps.tddLog(tddDir, "INFO", "next_tdd_phase: complete", { from, to });
194
179
 
195
180
  return {
196
- content: [{ type: "text", text: deps.getNudgePrompt(to, config) }],
181
+ content: [{ type: "text", text: `\n${deps.getNudgePrompt(to, config)}` }],
197
182
  details: {},
198
183
  };
199
184
  }
@@ -214,19 +199,11 @@ export async function executePreviousPhase(
214
199
  deps.tddLog(tddDir, "WARN", "previous_tdd_phase: TDD not active", {
215
200
  reason: tdd.reason,
216
201
  });
217
- return {
218
- content: [{ type: "text", text: `TDD: ${tdd.reason}` }],
219
- details: {},
220
- };
202
+ throw new Error(`TDD: ${tdd.reason}`);
221
203
  }
222
204
  if (!tdd.state.enabled) {
223
205
  deps.tddLog(tddDir, "WARN", "previous_tdd_phase: TDD disabled");
224
- return {
225
- content: [
226
- { type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." },
227
- ],
228
- details: {},
229
- };
206
+ throw new Error("TDD is not enabled. Run /tdd:on to enable it.");
230
207
  }
231
208
 
232
209
  const { state } = tdd;
@@ -235,10 +212,7 @@ export async function executePreviousPhase(
235
212
  deps.tddLog(tddDir, "WARN", "previous_tdd_phase: no parent commit", {
236
213
  phase: state.current,
237
214
  });
238
- return {
239
- content: [{ type: "text", text: "No previous phase to revert to." }],
240
- details: {},
241
- };
215
+ throw new Error("No previous phase to revert to.");
242
216
  }
243
217
 
244
218
  // Read phase from HEAD snapshot commit message (source of truth).
@@ -250,18 +224,11 @@ export async function executePreviousPhase(
250
224
  deps.tddLog(tddDir, "ERROR", "previous_tdd_phase: invalid HEAD message", {
251
225
  headMsg,
252
226
  });
253
- return {
254
- content: [
255
- {
256
- type: "text",
257
- text:
258
- `HEAD commit "${headMsg}" is not a TDD snapshot. Cannot determine previous phase.\n` +
259
- `The private git repo at .pi/tdd must not be manually modified. ` +
260
- `Tampering with it will cause TDD state corruption.`,
261
- },
262
- ],
263
- details: {},
264
- };
227
+ throw new Error(
228
+ `HEAD commit "${headMsg}" is not a TDD snapshot. Cannot determine previous phase.\n` +
229
+ `The private git repo at .pi/tdd must not be manually modified. ` +
230
+ `Tampering with it will cause TDD state corruption.`,
231
+ );
265
232
  }
266
233
  const label = phaseMatch[1];
267
234
  if (label !== "red" && label !== "green" && label !== "refactor") {
@@ -269,15 +236,9 @@ export async function executePreviousPhase(
269
236
  headMsg,
270
237
  label,
271
238
  });
272
- return {
273
- content: [
274
- {
275
- type: "text",
276
- text: `HEAD commit "${headMsg}" has unexpected label. Cannot determine previous phase.`,
277
- },
278
- ],
279
- details: {},
280
- };
239
+ throw new Error(
240
+ `HEAD commit "${headMsg}" has unexpected label. Cannot determine previous phase.`,
241
+ );
281
242
  }
282
243
  const prevPhase: Phase = label;
283
244
  deps.tddLog(tddDir, "INFO", "previous_tdd_phase: reverting", {
@@ -305,7 +266,7 @@ export async function executePreviousPhase(
305
266
  content: [
306
267
  {
307
268
  type: "text",
308
- text: `Reverted to ${prevPhase.toUpperCase()}. Working tree has the previous snapshot content as unstaged changes.`,
269
+ text: `\nReverted to ${prevPhase.toUpperCase()}. Working tree has the previous snapshot content as unstaged changes.`,
309
270
  },
310
271
  ],
311
272
  details: {},
@@ -329,19 +290,11 @@ export async function executeTddStatus(
329
290
  deps.tddLog(tddDir, "WARN", "tdd_status: TDD not active", {
330
291
  reason: result.reason,
331
292
  });
332
- return {
333
- content: [{ type: "text", text: `TDD: ${result.reason}` }],
334
- details: {},
335
- };
293
+ throw new Error(`TDD: ${result.reason}`);
336
294
  }
337
295
  if (!result.state.enabled) {
338
296
  deps.tddLog(tddDir, "WARN", "tdd_status: TDD disabled");
339
- return {
340
- content: [
341
- { type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." },
342
- ],
343
- details: {},
344
- };
297
+ throw new Error("TDD is not enabled. Run /tdd:on to enable it.");
345
298
  }
346
299
 
347
300
  const { state, config } = result;
@@ -359,7 +312,7 @@ export async function executeTddStatus(
359
312
  {
360
313
  type: "text",
361
314
  text:
362
- `TDD enforcer enabled\n` +
315
+ `\nTDD enforcer enabled\n` +
363
316
  `Current phase: ${phaseStr}\n` +
364
317
  `Blocked in RED: ${redBlk}\n` +
365
318
  `Blocked in GREEN: ${greenBlk}\n` +
package/package.json CHANGED
@@ -1,32 +1,32 @@
1
1
  {
2
- "name": "tdd-enforcer",
3
- "version": "0.2.7",
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
- }
2
+ "name": "tdd-enforcer",
3
+ "version": "0.2.9",
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. Keep cycles small so reverting is cheap and safe if assumptions turn out wrong.
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