initial commit

This commit is contained in:
Willem van den Ende 2026-06-20 17:02:23 +01:00
commit 414140b962
16 changed files with 13568 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# Dependencies
node_modules/
# Build output
dist/
# Environment variables
.env
.env.local
.env.*.local
# IDE
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.idea/
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Testing
coverage/
# OS
.DS_Store
Thumbs.db
# TypeScript
tsconfig.tsbuildinfo

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Living Software LTD
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

BIN
doc/spike-screenshot.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

172
doc/spike-smells.md Normal file
View File

@ -0,0 +1,172 @@
# Asked qwen3.6 35B about code smells
Analyze the code in src/ for code smells and improvements.
Qwen:
# Here's my analysis of the codebase:
────────────────────────────────────────────────────────────────────────────────
Code Smells & Improvements
### 1. Duplicated Constants (REFACTOR_FOCI)
Location: src/state.ts and src/ui.ts
Both files define their own copy of REFACTOR_FOCI:
```ts
// state.ts
export const REFACTOR_FOCI: RefactorFocus[] = [...];
// ui.ts (line 16)
const REFACTOR_FOCI: RefactorFocus[] = [...];
```
Fix: Export the constant from state.ts and import it in ui.ts. This is a direct DRY violation.
────────────────────────────────────────────────────────────────────────────────
### 2. canTransition is Dead Code
canTransition() in state.ts is never called by any tool or event handler. The phase enforcement in index.ts is done via imperative if checks in the go-to-refactor tool and the tool_call event
handler. The function sits there with full test coverage but zero usage.
Options:
- Delete it if it's not needed — the enforcement logic is already in the tools.
- Use it in go-to-refactor instead of the manual if/else checks, making the tool logic cleaner and the guard centralized.
────────────────────────────────────────────────────────────────────────────────
### 3. redToGreen is Never Called Directly
redToGreen() is only called internally inside recordTestResult() — it's never invoked by any tool. It's effectively a private helper, yet it's exported and has its own test suite.
Fix: Either make it private (or just not export it) and remove its standalone tests, or keep it exported if you want it as a reusable state-machine primitive.
────────────────────────────────────────────────────────────────────────────────
### 4. Magic String "npm test"
go-to-green hardcodes const testCommand = "npm test 2>&1"; while run-tests allows a custom command. The go-to-green tool should also accept an optional command parameter for consistency.
────────────────────────────────────────────────────────────────────────────────
### 5. persistState / restoreState — Fragile Serialization
The state persistence relies on pi.appendEntry / ctx.sessionManager.getEntries() with string-based filtering (e.type === "custom" && e.customType === "tdd-cycle"). This is fragile:
- No versioning — if the CycleState interface changes, old entries may corrupt state.
- No deduplication logic beyond .pop() — if multiple entries exist, only the last one is kept (which is correct, but the intent isn't explicit).
- restoreState is called at the start of every tool execution, meaning state is reloaded on each call — this is fine for a TDD cycle, but worth noting.
Suggestion: Add a version field to CycleState and handle version mismatches gracefully.
────────────────────────────────────────────────────────────────────────────────
### 6. buildSteeringMessage is a Long Switch
This function has 5 if branches that could be replaced with a Record<RefactorFocus, (decision: RefactorDecision) => string> mapping. This would make it data-driven and easier to extend:
```ts
const STEERING_MESSAGES: Record<RefactorFocus, (d: RefactorDecision) => string> = {
"next-test": () => "...",
audit: () => "...",
// etc.
};
```
────────────────────────────────────────────────────────────────────────────────
### 7. Repeated ctx.ui.setWidget Calls
Every tool and event handler calls ctx.ui.setWidget("tdd-status", ...) with similar patterns. This could be extracted into a helper like updateStatusWidget(ctx) to reduce repetition and ensure
consistency.
────────────────────────────────────────────────────────────────────────────────
### 8. renderResult Type Guards are Repetitive
All four tools have the same boilerplate in renderResult:
```ts
const details = result.details as { ... } | undefined;
if (!details) {
return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
}
```
Fix: Extract this into a shared renderResultFallback helper.
────────────────────────────────────────────────────────────────────────────────
### 9. go-to-refactor Non-TUI Fallback is Silent
When ctx.mode !== "tui", the tool silently defaults to { focus: "next-test" } without any user confirmation. This could be confusing — the developer has no way to choose a different refactoring
focus.
Suggestion: In non-TUI mode, return the REFACTOR phase status without auto-advancing, and let the developer call the tool again with an explicit parameter.
────────────────────────────────────────────────────────────────────────────────
### 10. invalidate in UI Component is a No-Op
```ts
return { render, invalidate: () => {}, handleInput };
```
The invalidate callback does nothing. If the TUI framework expects it to trigger a re-render, this is a bug. If it's intentionally unused, document why.
────────────────────────────────────────────────────────────────────────────────
### 11. recordTestResult Returns TransitionResult but Caller Ignores valid/error
In index.ts, recordTestResult is called and its state is used, but the valid and error fields are discarded:
```ts
const transition = recordTestResult(state, passed);
state = transition.state; // valid/error ignored
```
If recordTestResult returns invalid (e.g., called from REFACTOR phase), the state is still updated. The tool should check transition.valid and handle errors.
────────────────────────────────────────────────────────────────────────────────
### 12. go-to-green Hardcodes Timeout
Both go-to-green and run-tests use timeout: 60_000. This is fine, but consider making it configurable via a tool parameter for projects with slower test suites.
────────────────────────────────────────────────────────────────────────────────
### Summary Table
┌────┬───────────────────────────────────────────┬──────────┬────────┐
│ # │ Smell │ Severity │ Effort │
├────┼───────────────────────────────────────────┼──────────┼────────┤
│ 1 │ Duplicated REFACTOR_FOCI │ Low │ 5 min │
├────┼───────────────────────────────────────────┼──────────┼────────┤
│ 2 │ Dead canTransition │ Medium │ 10 min │
├────┼───────────────────────────────────────────┼──────────┼────────┤
│ 3 │ Unused export redToGreen │ Low │ 5 min │
├────┼───────────────────────────────────────────┼──────────┼────────┤
│ 4 │ Hardcoded "npm test" in go-to-green │ Low │ 5 min │
├────┼───────────────────────────────────────────┼──────────┼────────┤
│ 5 │ Fragile state persistence (no versioning) │ Medium │ 20 min │
├────┼───────────────────────────────────────────┼──────────┼────────┤
│ 6 │ Long switch → data-driven mapping │ Low │ 10 min │
├────┼───────────────────────────────────────────┼──────────┼────────┤
│ 7 │ Repeated setWidget calls │ Low │ 10 min │
├────┼───────────────────────────────────────────┼──────────┼────────┤
│ 8 │ Repetitive renderResult guards │ Low │ 10 min │
├────┼───────────────────────────────────────────┼──────────┼────────┤
│ 9 │ Silent non-TUI fallback │ Medium │ 15 min │
├────┼───────────────────────────────────────────┼──────────┼────────┤
│ 10 │ No-op invalidate │ Low │ 5 min │
├────┼───────────────────────────────────────────┼──────────┼────────┤
│ 11 │ Ignored valid/error from recordTestResult │ High │ 10 min │
├────┼───────────────────────────────────────────┼──────────┼────────┤
│ 12 │ Hardcoded timeout │ Low │ 5 min │
└────┴───────────────────────────────────────────┴──────────┴────────┘
The highest priority fix is #11 — the recordTestResult return value's valid field is silently ignored, which means invalid state transitions (e.g., recording test results while in REFACTOR phase)
would silently corrupt state.

2
mise.toml Normal file
View File

@ -0,0 +1,2 @@
[tools]
node = "24"

3350
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "pi-tdd-cycle",
"version": "0.1.0",
"description": "Pi extension that enforces the red-green-refactor TDD cycle",
"type": "module",
"private": true,
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"keywords": [
"pi-package",
"tdd",
"red-green-refactor",
"extension"
],
"pi": {
"extensions": [
"./src/index.ts"
]
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "latest",
"@earendil-works/pi-tui": "latest",
"@types/node": "latest",
"typebox": "latest",
"typescript": "latest",
"vitest": "latest"
}
}

495
src/index.ts Normal file
View File

@ -0,0 +1,495 @@
/**
* TDD Cycle Extension
*
* Enforces the red-green-refactor TDD cycle:
* 🔴 RED Write a failing test, then implement to make it pass
* 🟢 GREEN Tests pass, ready for refactoring
* 🔀 REFACTOR Developer chooses what to refactor; agent executes;
* loop back to developer until "next test" chosen
*
* Tools:
* go-to-green - Run tests and transition based on result
* go-to-refactor - Enter the developer decision phase (or report back)
* run-tests - Run tests without phase transition
* tdd-status - Show current phase and state
*/
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { Text } from "@earendil-works/pi-tui";
import {
createInitialState,
recordTestResult,
greenToRefactor,
applyRefactorDecision,
phaseEmoji,
phaseDescription,
phaseGuidelines,
type CycleState,
type RefactorDecision,
} from "./state.js";
import { createRefactorDecisionComponent } from "./ui.js";
export default function tddCycle(pi: ExtensionAPI): void {
let state: CycleState = createInitialState();
function persistState(): void {
pi.appendEntry("tdd-cycle", {
phase: state.phase,
testDescription: state.testDescription,
testsRan: state.testsRan,
testsPassed: state.testsPassed,
testsWritten: state.testsWritten,
lastDecision: state.lastDecision,
customRefactoringInstruction: state.customRefactoringInstruction,
});
}
function restoreState(ctx: ExtensionContext): void {
const entries = ctx.sessionManager.getEntries();
const tddEntry = entries
.filter(
(e: { type: string; customType?: string }) =>
e.type === "custom" && e.customType === "tdd-cycle",
)
.pop() as { data?: Partial<CycleState> } | undefined;
if (tddEntry?.data) {
state = {
...createInitialState(),
...tddEntry.data,
};
}
}
function statusMessage(): string {
return phaseDescription(state);
}
function guidelinesMessage(): string {
return phaseGuidelines(state);
}
function buildSteeringMessage(decision: RefactorDecision): string {
if (decision.focus === "next-test") {
return (
"🔀 **Next test.** Phase changed: REFACTOR → **RED**.\n\n" +
"Write the next failing test."
);
}
if (decision.focus === "audit") {
return (
"🔍 **Code audit requested.**\n\n" +
"Review the implementation:\n" +
"1. Identify any code smells\n" +
"2. Is all the code actually required?\n" +
"3. Is the implementation minimal — does it only implement what the tests require?\n" +
"4. Report your findings back to the developer\n\n" +
"After your audit, call **go-to-refactor** again to report back."
);
}
if (decision.focus === "tests") {
return (
"🛠 **Refactor the tests.**\n\n" +
"Improve test structure and coverage while keeping tests passing.\n" +
"After refactoring, run tests to verify they still pass, then call **go-to-refactor**."
);
}
if (decision.focus === "implementation") {
return (
"🛠 **Refactor the implementation.**\n\n" +
"Improve production code structure.\n" +
"After refactoring, run tests to verify they still pass, then call **go-to-refactor**."
);
}
if (decision.focus === "custom" && decision.customInstruction) {
return (
"🔀 **Custom refactoring instruction:**\n\n" +
decision.customInstruction +
"\n\nAfter refactoring, run tests to verify they still pass, then call **go-to-refactor**."
);
}
return "Refactoring decision applied. Call go-to-refactor when done.";
}
// ── Tool: go-to-green ────────────────────────────────────────────────────
pi.registerTool({
name: "go-to-green",
label: "Go to Green",
description:
"Run tests to verify implementation. If tests pass, transitions to GREEN phase. " +
"If tests fail, stays in RED with feedback. Use this after writing a failing test and its implementation.",
parameters: Type.Object({
testDescription: Type.Optional(
Type.String({
description:
"Brief description of what test(s) to run (e.g., 'user login test'). " +
"Used for display purposes.",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
restoreState(ctx);
const testDesc = params.testDescription ?? state.testDescription;
if (testDesc) {
state.testDescription = testDesc;
}
ctx.ui.setWidget("tdd-status", [
ctx.ui.theme.fg("accent", statusMessage()),
]);
const testCommand = "npm test 2>&1";
const result = await pi.exec("bash", ["-c", testCommand], {
signal: ctx.signal,
timeout: 60_000,
});
const passed = result.code === 0;
const transition = recordTestResult(state, passed);
state = transition.state;
ctx.ui.setWidget("tdd-status", [
ctx.ui.theme.fg("accent", statusMessage()),
ctx.ui.theme.fg(passed ? "success" : "error", passed ? "✓ Tests passed" : "✗ Tests failed"),
]);
if (transition.state.phase === "GREEN") {
pi.sendMessage(
{
customType: "tdd-cycle",
content:
"🟢 **GREEN PHASE ACHIEVED!**\n\n" +
(transition.message ?? "") + "\n\n" +
"The developer must now decide what to refactor.\n" +
"Call **go-to-refactor** to enter the refactoring decision phase.",
display: true,
},
{ triggerTurn: false },
);
}
persistState();
return {
content: [
{
type: "text",
text: transition.message ?? "Tests executed.",
},
],
details: {
passed,
phase: transition.state.phase,
exitCode: result.code,
output: result.stdout.slice(0, 50_000),
},
};
},
renderCall(args, theme, _context) {
const text = theme.fg("toolTitle", theme.bold("go-to-green ")) + (args.testDescription ? theme.fg("muted", args.testDescription) : "");
return new Text(text, 0, 0);
},
renderResult(result, _options, theme, _context) {
const details = result.details as { passed?: boolean; phase?: string } | undefined;
if (!details) {
return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
}
const phase = details.phase ? phaseEmoji(details.phase as any) : "";
const status = details.passed
? theme.fg("success", phase + " Tests passed")
: theme.fg("error", phase + " Tests failed");
return new Text(status, 0, 0);
},
});
// ── Tool: go-to-refactor ─────────────────────────────────────────────────
pi.registerTool({
name: "go-to-refactor",
label: "Go to Refactor",
description:
"Enter the REFACTOR phase for developer decisions, or report back after refactoring work is done. " +
"When called from GREEN: enters REFACTOR and prompts developer. " +
"When called from REFACTOR: re-prompts the developer for a new decision. " +
"Call this after refactoring work is complete and tests pass again.",
parameters: Type.Object({}),
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
restoreState(ctx);
if (state.phase === "GREEN") {
// First entry: transition from GREEN to REFACTOR
const transition = greenToRefactor(state);
state = transition.state;
persistState();
} else if (state.phase !== "REFACTOR") {
return {
content: [
{
type: "text",
text: "Cannot enter REFACTOR from phase " + state.phase + ". Must be in GREEN or already in REFACTOR.",
},
],
details: { phase: state.phase },
};
}
ctx.ui.setWidget("tdd-status", [
ctx.ui.theme.fg("accent", statusMessage()),
]);
if (ctx.mode === "tui" && ctx.hasUI) {
const decision = await ctx.ui.custom<RefactorDecision | null>(
createRefactorDecisionComponent(state.testDescription),
);
if (decision) {
// Get custom instruction text if needed
let effectiveDecision = decision;
if (decision.focus === "custom") {
const customText = await ctx.ui.input(
"Custom instruction:",
"e.g. 'Extract the service class'",
);
if (customText?.trim()) {
effectiveDecision = { focus: "custom", customInstruction: customText.trim() };
} else {
// Cancelled or empty — default to implementation
effectiveDecision = { focus: "implementation" };
}
}
const applyResult = applyRefactorDecision(state, effectiveDecision);
state = applyResult.state;
persistState();
const steeringMessage = buildSteeringMessage(effectiveDecision);
pi.sendMessage(
{
customType: "tdd-cycle",
content: steeringMessage,
display: true,
},
{ triggerTurn: true },
);
return {
content: [
{
type: "text",
text: applyResult.message ?? "Refactoring decision applied.",
},
],
details: {
decision: effectiveDecision,
phase: state.phase,
},
};
} else {
return {
content: [
{
type: "text",
text: "Developer cancelled. Staying in REFACTOR phase.",
},
],
details: { phase: "REFACTOR" },
};
}
}
// Non-TUI mode: fallback to next-test
if (state.phase === "REFACTOR") {
const fallbackDecision: RefactorDecision = { focus: "next-test" };
const applyResult = applyRefactorDecision(state, fallbackDecision);
state = applyResult.state;
persistState();
return {
content: [
{ type: "text", text: applyResult.message ?? "Non-TUI: advancing to next test." },
],
details: { phase: "RED" },
};
}
return {
content: [{ type: "text", text: "Entered REFACTOR phase." }],
details: { phase: "REFACTOR" },
};
},
renderCall(_args, theme, _context) {
return new Text(theme.fg("toolTitle", theme.bold("go-to-refactor")), 0, 0);
},
renderResult(result, _options, theme, _context) {
const details = result.details as { phase?: string; decision?: RefactorDecision } | undefined;
if (!details) {
return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
}
const phase = details.phase ? phaseEmoji(details.phase as any) : "";
const text = details.decision
? phase + " Refactored: " + details.decision.focus
: phase + " Entered refactor phase";
return new Text(text, 0, 0);
},
});
// ── Tool: run-tests ──────────────────────────────────────────────────────
pi.registerTool({
name: "run-tests",
label: "Run Tests",
description:
"Run tests without phase transition. Useful for checking test status " +
"during development. Returns exit code and output.",
parameters: Type.Object({
command: Type.Optional(
Type.String({
description: "Custom test command to run (default: 'npm test').",
}),
),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
restoreState(ctx);
const command = params.command ?? "npm test";
const result = await pi.exec("bash", ["-c", command], {
signal,
timeout: 60_000,
});
const passed = result.code === 0;
ctx.ui.setWidget("tdd-status", [
ctx.ui.theme.fg("accent", statusMessage()),
ctx.ui.theme.fg(passed ? "success" : "error", passed ? "✓ Tests passed" : "✗ Tests failed"),
]);
return {
content: [
{
type: "text",
text: (passed ? "✓" : "✗") + " Tests " + (passed ? "passed" : "failed") + " (exit code: " + result.code + ")",
},
],
details: {
passed,
exitCode: result.code,
output: result.stdout.slice(0, 50_000),
stderr: result.stderr.slice(0, 50_000),
},
};
},
renderCall(args, theme, _context) {
const text = theme.fg("toolTitle", theme.bold("run-tests ")) + (args.command ? theme.fg("muted", args.command) : theme.fg("muted", "npm test"));
return new Text(text, 0, 0);
},
renderResult(result, _options, theme, _context) {
const details = result.details as { passed?: boolean } | undefined;
if (!details) {
return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
}
const status = details.passed
? theme.fg("success", "✓ Tests passed")
: theme.fg("error", "✗ Tests failed");
return new Text(status, 0, 0);
},
});
// ── Tool: tdd-status ─────────────────────────────────────────────────────
pi.registerTool({
name: "tdd-status",
label: "TDD Status",
description:
"Show current TDD cycle phase and state. Returns the current phase, " +
"test count, and guidelines for what to do next.",
parameters: Type.Object({}),
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
restoreState(ctx);
const statusText = phaseDescription(state);
const guidelines = phaseGuidelines(state);
const testsWritten = state.testsWritten ?? 0;
ctx.ui.setWidget("tdd-status", [
ctx.ui.theme.fg("accent", statusText),
ctx.ui.theme.fg("dim", "Tests written: " + testsWritten),
]);
return {
content: [
{
type: "text",
text: statusText + "\n\nTests written: " + testsWritten + "\n\n" + guidelines,
},
],
details: {
phase: state.phase,
testsRan: state.testsRan,
testsPassed: state.testsPassed,
testsWritten,
},
};
},
renderCall(_args, theme, _context) {
return new Text(theme.fg("toolTitle", theme.bold("tdd-status")), 0, 0);
},
renderResult(result, _options, theme, _context) {
const details = result.details as { phase?: string } | undefined;
if (!details) {
return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
}
const phase = details.phase ? phaseEmoji(details.phase as any) : "";
return new Text(phase + " " + details.phase, 0, 0);
},
});
// ── Events ───────────────────────────────────────────────────────────────
pi.on("session_start", async (_event, ctx) => {
restoreState(ctx);
ctx.ui.setWidget("tdd-status", [
ctx.ui.theme.fg("accent", statusMessage()),
]);
if (ctx.mode === "tui") {
ctx.ui.notify("TDD Cycle started in " + state.phase + " phase", "info");
}
});
// Allow go-to-green during REFACTOR (to verify tests after refactoring)
// Only block: RED → go-to-refactor (must pass tests first)
pi.on("tool_call", async (event, ctx) => {
if (state.phase === "RED" && event.toolName === "go-to-refactor") {
return {
block: true,
reason: "Cannot go to REFACTOR from RED phase. You must first make tests pass (GREEN phase). Call go-to-green after your implementation passes the test.",
};
}
});
pi.on("before_agent_start", async () => {
return {
message: {
customType: "tdd-cycle-guidelines",
content: "\n\n[TDD CYCLE - " + state.phase + " PHASE]\n" + guidelinesMessage(),
display: false,
},
};
});
pi.on("turn_end", async (_event, ctx) => {
ctx.ui.setWidget("tdd-status", [
ctx.ui.theme.fg("accent", statusMessage()),
]);
});
pi.on("session_shutdown", async () => {
persistState();
});
}

337
src/state.ts Normal file
View File

@ -0,0 +1,337 @@
/**
* TDD Cycle State Machine
*
* Enforces the red-green-refactor cycle:
* RED Write a failing test, then implement to make it pass
* GREEN Tests pass, ready for refactoring
* REFACTOR Developer chooses what to refactor
* After refactor: back to RED for next test (or GREEN if no new test)
*/
// ── Phase ────────────────────────────────────────────────────────────────────
export type Phase = "RED" | "GREEN" | "REFACTOR";
export const PHASES: Phase[] = ["RED", "GREEN", "REFACTOR"];
// ── Refactor Focus ───────────────────────────────────────────────────────────
export type RefactorFocus = "audit" | "tests" | "implementation" | "next-test" | "custom";
export const REFACTOR_FOCI: RefactorFocus[] = [
"audit",
"tests",
"implementation",
"next-test",
"custom",
];
// ── Decision Result ──────────────────────────────────────────────────────────
export interface RefactorDecision {
focus: RefactorFocus;
customInstruction?: string;
}
// ── Cycle State ──────────────────────────────────────────────────────────────
export interface CycleState {
/** Current phase */
phase: Phase;
/** Test description (what we're trying to make pass) */
testDescription?: string;
/** Whether tests have been run in the current cycle */
testsRan?: boolean;
/** Whether tests passed in the current cycle */
testsPassed?: boolean;
/** Number of tests written so far in this session */
testsWritten?: number;
/** Last refactoring decision (if any) */
lastDecision?: RefactorDecision;
/** Custom refactoring instruction from developer */
customRefactoringInstruction?: string;
}
export function createInitialState(): CycleState {
return {
phase: "RED",
testsRan: false,
testsPassed: false,
testsWritten: 0,
};
}
// ── Transitions ──────────────────────────────────────────────────────────────
export interface TransitionResult {
/** New state */
state: CycleState;
/** Message to show to the LLM (optional) */
message?: string;
/** Whether the transition was valid */
valid: boolean;
/** Error message if transition was invalid */
error?: string;
}
/**
* Transition from RED to GREEN.
* Valid only when tests have been run and passed.
*/
export function redToGreen(state: CycleState): TransitionResult {
if (!state.testsRan) {
return {
state,
valid: false,
error: "Cannot transition to GREEN: tests have not been run yet. Run tests first.",
};
}
if (!state.testsPassed) {
return {
state,
valid: false,
error: "Cannot transition to GREEN: tests did not pass. Stay in RED and make tests pass.",
};
}
return {
state: { ...state, phase: "GREEN", testsRan: false, testsPassed: false },
message:
"✅ Tests pass! Phase changed: GREEN → REFACTOR. " +
"You should now refactor. Call go-to-refactor when ready.",
valid: true,
};
}
/**
* Transition from GREEN to REFACTOR.
* Always valid.
*/
export function greenToRefactor(state: CycleState): TransitionResult {
return {
state: { ...state, phase: "REFACTOR" },
message:
"🔄 Phase changed: GREEN → REFACTOR. " +
"Developer will now decide what to refactor.",
valid: true,
};
}
/**
* Apply a refactoring decision.
* Only transitions to RED for "next-test"; other options stay in REFACTOR
* so the agent can do the actual refactoring work.
*/
export function applyRefactorDecision(
state: CycleState,
decision: RefactorDecision,
): TransitionResult {
const newState: CycleState = {
...state,
phase: "RED",
lastDecision: decision,
customRefactoringInstruction:
decision.focus === "custom" ? decision.customInstruction : undefined,
testsRan: false,
testsPassed: false,
};
if (decision.focus === "next-test") {
newState.testsWritten = (state.testsWritten ?? 0) + 1;
newState.phase = "RED";
let message = `🔀 Next test. Phase changed: REFACTOR → RED.`;
return { state: newState, message, valid: true };
}
// For audit, tests, implementation, custom — stay in REFACTOR for agent to work
newState.phase = "REFACTOR";
let message = `🔀 Refactoring decision: ${decision.focus}.`;
if (decision.focus === "custom" && decision.customInstruction) {
message += ` Custom instruction: "${decision.customInstruction}"`;
}
message += " Phase: REFACTOR (working).";
return {
state: newState,
message,
valid: true,
};
}
/**
* Record that tests have been run and their result.
* This is called by the go-to-green tool after test execution.
*/
export function recordTestResult(
state: CycleState,
passed: boolean,
): TransitionResult {
if (state.phase !== "RED" && state.phase !== "GREEN") {
return {
state,
valid: false,
error: `Cannot record test results in phase ${state.phase}. Must be in RED or GREEN.`,
};
}
const newState: CycleState = {
...state,
testsRan: true,
testsPassed: passed,
};
if (passed && state.phase === "RED") {
// Tests passed while in RED → auto-transition to GREEN
// Keep testsRan/testsPassed from newState (they reflect the just-run tests)
const greenResult = redToGreen(newState);
return {
...greenResult,
state: { ...greenResult.state, testsRan: true, testsPassed: true },
};
}
if (!passed) {
return {
state: newState,
message:
"❌ Tests failed. Stay in RED. Fix the implementation to make tests pass.",
valid: true,
};
}
// Tests passed while already in GREEN → stay in GREEN
return {
state: newState,
message: "✅ Tests still pass. Stay in GREEN.",
valid: true,
};
}
/**
* Check if a transition is valid before executing it.
*/
export function canTransition(
state: CycleState,
targetPhase: Phase,
): { valid: boolean; reason: string } {
if (state.phase === targetPhase) {
return { valid: true, reason: `Already in ${targetPhase}` };
}
if (state.phase === "RED") {
if (targetPhase === "GREEN") {
const can = !!state.testsRan && !!state.testsPassed;
return {
valid: can,
reason: can
? "Tests passed, can go to GREEN"
: "Must run passing tests first",
};
}
if (targetPhase === "REFACTOR") {
return {
valid: false,
reason:
"Cannot go directly from RED to REFACTOR. Must go through GREEN first.",
};
}
}
if (state.phase === "GREEN") {
if (targetPhase === "RED") {
return {
valid: false,
reason:
"Cannot go from GREEN back to RED. Complete refactoring first.",
};
}
if (targetPhase === "REFACTOR") {
return { valid: true, reason: "Can go to REFACTOR from GREEN" };
}
}
if (state.phase === "REFACTOR") {
if (targetPhase === "RED") {
return { valid: true, reason: "Can go to RED after refactoring decision" };
}
if (targetPhase === "GREEN") {
return {
valid: false,
reason:
"Cannot go from REFACTOR to GREEN. Must go to RED first (write next test).",
};
}
}
return { valid: false, reason: `Invalid transition from ${state.phase} to ${targetPhase}` };
}
// ── Formatting ───────────────────────────────────────────────────────────────
export function phaseEmoji(phase: Phase): string {
switch (phase) {
case "RED":
return "🔴";
case "GREEN":
return "🟢";
case "REFACTOR":
return "🔀";
}
}
export function phaseDescription(state: CycleState): string {
const emoji = phaseEmoji(state.phase);
const phase = state.phase;
switch (phase) {
case "RED":
if (!state.testsRan) {
return `${emoji} RED — Write a failing test, then implement to make it pass.`;
}
if (state.testsPassed) {
return `${emoji} RED→GREEN — Tests passed! Ready for refactoring.`;
}
return `${emoji} RED — Tests failed. Fix implementation.`;
case "GREEN":
return `${emoji} GREEN — Tests pass. Ready for refactoring.`;
case "REFACTOR":
return `${emoji} REFACTOR — Developer decides what to refactor.`;
}
}
export function phaseGuidelines(state: CycleState): string {
switch (state.phase) {
case "RED":
if (!state.testsRan) {
return `You are in RED phase.
- Write a failing test first (the test should fail before your implementation)
- Then write minimal implementation to make the test pass
- Run tests with bash to verify
- When tests pass, the system will move you to GREEN`;
}
if (state.testsPassed) {
return `You are in RED→GREEN transition. Tests passed!
- You should now call go-to-refactor to enter the REFACTOR phase
- Do NOT write a new test yet refactor first`;
}
return `You are in RED phase. Tests failed.
- Improve your implementation to make tests pass
- Do NOT change the test the test defines the requirement
- Run tests with bash to verify`;
case "GREEN":
return `You are in GREEN phase. Tests pass.
- Call go-to-refactor to enter the REFACTOR phase
- The developer will then decide what to refactor`;
case "REFACTOR":
if (state.lastDecision) {
return `You are in REFACTOR phase. The developer chose: ${state.lastDecision.focus}.
- Execute the refactoring work now
- After refactoring, run tests to verify they still pass
- Then call go-to-refactor again to report back to the developer`;
}
return `You are in REFACTOR phase. The developer is deciding what to refactor.
- Wait for the developer's decision
- Options: audit (code smells), refactor tests, refactor implementation, next test, or custom
- After refactoring, run tests to verify they still pass
- Then call go-to-refactor to report back to the developer`;
}
}

297
src/test/state.test.ts Normal file
View File

@ -0,0 +1,297 @@
import { describe, it, expect } from "vitest";
import {
createInitialState,
redToGreen,
greenToRefactor,
applyRefactorDecision,
recordTestResult,
canTransition,
phaseEmoji,
phaseDescription,
phaseGuidelines,
type CycleState,
type RefactorDecision,
} from "../state.js";
describe("createInitialState", () => {
it("returns RED phase with defaults", () => {
const state = createInitialState();
expect(state.phase).toBe("RED");
expect(state.testsRan).toBe(false);
expect(state.testsPassed).toBe(false);
expect(state.testsWritten).toBe(0);
});
});
describe("redToGreen", () => {
it("transitions when tests ran and passed", () => {
const state: CycleState = {
phase: "RED",
testsRan: true,
testsPassed: true,
testsWritten: 1,
};
const result = redToGreen(state);
expect(result.valid).toBe(true);
expect(result.state.phase).toBe("GREEN");
expect(result.state.testsRan).toBe(false);
expect(result.state.testsPassed).toBe(false);
expect(result.message).toContain("GREEN");
});
it("rejects when tests have not been run", () => {
const state: CycleState = {
phase: "RED",
testsRan: false,
testsPassed: false,
};
const result = redToGreen(state);
expect(result.valid).toBe(false);
expect(result.error).toContain("have not been run");
});
it("rejects when tests failed", () => {
const state: CycleState = {
phase: "RED",
testsRan: true,
testsPassed: false,
};
const result = redToGreen(state);
expect(result.valid).toBe(false);
expect(result.error).toContain("did not pass");
});
});
describe("greenToRefactor", () => {
it("always transitions from GREEN to REFACTOR", () => {
const state: CycleState = { phase: "GREEN" };
const result = greenToRefactor(state);
expect(result.valid).toBe(true);
expect(result.state.phase).toBe("REFACTOR");
expect(result.message).toContain("REFACTOR");
});
});
describe("applyRefactorDecision", () => {
it("stays in REFACTOR with tests focus", () => {
const state: CycleState = { phase: "REFACTOR", testsWritten: 1 };
const decision: RefactorDecision = { focus: "tests" };
const result = applyRefactorDecision(state, decision);
expect(result.valid).toBe(true);
expect(result.state.phase).toBe("REFACTOR");
expect(result.state.lastDecision).toEqual(decision);
});
it("stays in REFACTOR with implementation focus", () => {
const state: CycleState = { phase: "REFACTOR" };
const decision: RefactorDecision = { focus: "implementation" };
const result = applyRefactorDecision(state, decision);
expect(result.state.phase).toBe("REFACTOR");
expect(result.state.lastDecision).toEqual(decision);
});
it("stays in REFACTOR with audit focus", () => {
const state: CycleState = { phase: "REFACTOR" };
const decision: RefactorDecision = { focus: "audit" };
const result = applyRefactorDecision(state, decision);
expect(result.state.phase).toBe("REFACTOR");
expect(result.state.lastDecision).toEqual(decision);
});
it("transitions to RED for next-test focus", () => {
const state: CycleState = { phase: "REFACTOR", testsWritten: 3 };
const decision: RefactorDecision = { focus: "next-test" };
const result = applyRefactorDecision(state, decision);
expect(result.state.testsWritten).toBe(4);
expect(result.state.phase).toBe("RED");
});
it("stores custom instruction for custom focus", () => {
const state: CycleState = { phase: "REFACTOR" };
const decision: RefactorDecision = {
focus: "custom",
customInstruction: "Extract the service class",
};
const result = applyRefactorDecision(state, decision);
expect(result.state.customRefactoringInstruction).toBe(
"Extract the service class",
);
});
it("resets testsRan and testsPassed", () => {
const state: CycleState = {
phase: "REFACTOR",
testsRan: true,
testsPassed: true,
};
const decision: RefactorDecision = { focus: "implementation" };
const result = applyRefactorDecision(state, decision);
expect(result.state.testsRan).toBe(false);
expect(result.state.testsPassed).toBe(false);
});
});
describe("recordTestResult", () => {
it("records passing tests in RED → auto-transitions to GREEN", () => {
const state: CycleState = { phase: "RED", testsRan: false, testsPassed: false };
const result = recordTestResult(state, true);
expect(result.valid).toBe(true);
expect(result.state.testsRan).toBe(true);
expect(result.state.testsPassed).toBe(true);
expect(result.state.phase).toBe("GREEN");
});
it("records failing tests in RED → stays in RED", () => {
const state: CycleState = { phase: "RED", testsRan: false, testsPassed: false };
const result = recordTestResult(state, false);
expect(result.valid).toBe(true);
expect(result.state.testsRan).toBe(true);
expect(result.state.testsPassed).toBe(false);
expect(result.state.phase).toBe("RED");
expect(result.message).toContain("failed");
});
it("records passing tests in GREEN → stays in GREEN", () => {
const state: CycleState = { phase: "GREEN", testsRan: true, testsPassed: true };
const result = recordTestResult(state, true);
expect(result.valid).toBe(true);
expect(result.state.phase).toBe("GREEN");
});
it("rejects recording in REFACTOR phase", () => {
const state: CycleState = { phase: "REFACTOR" };
const result = recordTestResult(state, true);
expect(result.valid).toBe(false);
expect(result.error).toContain("REFACTOR");
});
});
describe("canTransition", () => {
it("allows RED → GREEN when tests passed", () => {
const state: CycleState = {
phase: "RED",
testsRan: true,
testsPassed: true,
};
const result = canTransition(state, "GREEN");
expect(result.valid).toBe(true);
});
it("blocks RED → GREEN when tests failed", () => {
const state: CycleState = {
phase: "RED",
testsRan: true,
testsPassed: false,
};
const result = canTransition(state, "GREEN");
expect(result.valid).toBe(false);
});
it("blocks RED → REFACTOR (must go through GREEN)", () => {
const state: CycleState = {
phase: "RED",
testsRan: true,
testsPassed: true,
};
const result = canTransition(state, "REFACTOR");
expect(result.valid).toBe(false);
});
it("allows GREEN → REFACTOR", () => {
const state: CycleState = { phase: "GREEN" };
const result = canTransition(state, "REFACTOR");
expect(result.valid).toBe(true);
});
it("blocks GREEN → RED", () => {
const state: CycleState = { phase: "GREEN" };
const result = canTransition(state, "RED");
expect(result.valid).toBe(false);
});
it("allows REFACTOR → RED", () => {
const state: CycleState = { phase: "REFACTOR" };
const result = canTransition(state, "RED");
expect(result.valid).toBe(true);
});
it("blocks REFACTOR → GREEN", () => {
const state: CycleState = { phase: "REFACTOR" };
const result = canTransition(state, "GREEN");
expect(result.valid).toBe(false);
});
});
describe("phaseEmoji", () => {
it("returns correct emoji for each phase", () => {
expect(phaseEmoji("RED")).toBe("🔴");
expect(phaseEmoji("GREEN")).toBe("🟢");
expect(phaseEmoji("REFACTOR")).toBe("🔀");
});
});
describe("phaseDescription", () => {
it("describes RED when no tests ran", () => {
const state = createInitialState();
expect(phaseDescription(state)).toContain("RED");
expect(phaseDescription(state)).toContain("Write a failing test");
});
it("describes RED→GREEN when tests passed in RED", () => {
const state: CycleState = {
phase: "RED",
testsRan: true,
testsPassed: true,
};
expect(phaseDescription(state)).toContain("RED→GREEN");
});
it("describes RED when tests failed", () => {
const state: CycleState = {
phase: "RED",
testsRan: true,
testsPassed: false,
};
expect(phaseDescription(state)).toContain("Tests failed");
});
});
describe("phaseGuidelines", () => {
it("gives RED guidelines when no tests ran", () => {
const state = createInitialState();
const guidelines = phaseGuidelines(state);
expect(guidelines).toContain("Write a failing test");
});
it("gives RED guidelines when tests failed", () => {
const state: CycleState = {
phase: "RED",
testsRan: true,
testsPassed: false,
};
const guidelines = phaseGuidelines(state);
expect(guidelines).toContain("Tests failed");
expect(guidelines).toContain("Do NOT change the test");
});
it("gives GREEN guidelines", () => {
const state: CycleState = { phase: "GREEN" };
const guidelines = phaseGuidelines(state);
expect(guidelines).toContain("GREEN");
expect(guidelines).toContain("go-to-refactor");
});
it("gives REFACTOR guidelines when deciding", () => {
const state: CycleState = { phase: "REFACTOR" };
const guidelines = phaseGuidelines(state);
expect(guidelines).toContain("REFACTOR");
expect(guidelines).toContain("Wait for the developer");
});
it("gives REFACTOR guidelines when working on a decision", () => {
const state: CycleState = { phase: "REFACTOR", lastDecision: { focus: "implementation" } };
const guidelines = phaseGuidelines(state);
expect(guidelines).toContain("REFACTOR");
expect(guidelines).toContain("Execute the refactoring");
});
});

143
src/ui.ts Normal file
View File

@ -0,0 +1,143 @@
/**
* Custom UI for the TDD cycle developer decision prompt.
*
* Renders an interactive menu during the REFACTOR phase where the developer
* can choose what to do next.
*
* Usage: pass the returned component to ctx.ui.custom().
* For custom text input, use ctx.ui.input() in the tool's execute function.
*/
import {
matchesKey,
Key,
type TUI,
} from "@earendil-works/pi-tui";
import type { Theme, KeybindingsManager } from "@earendil-works/pi-coding-agent";
import type { RefactorFocus, RefactorDecision } from "./state.js";
const REFACTOR_FOCI: RefactorFocus[] = [
"audit",
"tests",
"implementation",
"next-test",
"custom",
];
const FOCUS_LABELS: Record<RefactorFocus, string> = {
audit: "Identify code smells & verify minimality",
tests: "Refactor the tests",
implementation: "Refactor the implementation",
"next-test": "Continue to next test",
custom: "Something else (custom instruction)",
};
const FOCUS_DESCRIPTIONS: Record<RefactorFocus, string> = {
audit:
"Audit implementation: is all code needed? Is it minimal? Are there code smells?",
tests: "Clean up and improve test structure",
implementation: "Improve production code structure",
"next-test": "Write the next failing test",
custom: "Provide a custom instruction",
};
/**
* Creates a custom UI component for the refactoring decision prompt.
* Call this from ctx.ui.custom() callback.
* For "custom" selection, the execute function should use ctx.ui.input() for the text.
*/
export function createRefactorDecisionComponent(
testDescription?: string,
): (
tui: TUI,
theme: Theme,
_kb: KeybindingsManager,
done: (decision: RefactorDecision | null) => void,
) => {
render: (width: number) => string[];
invalidate: () => void;
handleInput: (data: string) => boolean;
} {
let selectedIndex = 0;
return (tui, theme, _kb, done) => {
function render(width: number): string[] {
const lines: string[] = [];
const w = Math.max(1, width - 2);
// Header
lines.push(theme.fg("accent", "─".repeat(w)));
lines.push(
theme.fg("accent", " 🔀 REFACTOR PHASE — What should we do next?"),
);
if (testDescription) {
lines.push(theme.fg("muted", ` Current test: ${testDescription}`));
}
lines.push("");
// Options
for (let i = 0; i < REFACTOR_FOCI.length; i++) {
const focus = REFACTOR_FOCI[i];
const selected = i === selectedIndex;
const prefix = selected
? theme.fg("accent", "▸ ")
: " ";
const label = theme.fg(
selected ? "accent" : "text",
FOCUS_LABELS[focus],
);
lines.push(`${prefix}${label}`);
if (selected) {
lines.push(
` ${theme.fg("dim", FOCUS_DESCRIPTIONS[focus])}`,
);
}
}
lines.push("");
lines.push(
theme.fg(
"dim",
` ${theme.bold("↑↓")} navigate ${theme.bold("Enter")} select ${theme.bold("Esc")} cancel`,
),
);
lines.push(theme.fg("accent", "─".repeat(w)));
return lines;
}
function handleInput(data: string): boolean {
if (matchesKey(data, Key.up)) {
selectedIndex = Math.max(0, selectedIndex - 1);
tui.requestRender();
return true;
}
if (matchesKey(data, Key.down)) {
selectedIndex = Math.min(
REFACTOR_FOCI.length - 1,
selectedIndex + 1,
);
tui.requestRender();
return true;
}
if (matchesKey(data, Key.enter)) {
const focus = REFACTOR_FOCI[selectedIndex];
// For "custom", return focus without instruction text;
// the execute function will use ctx.ui.input() to get the text.
done({ focus });
return true;
}
if (matchesKey(data, Key.escape)) {
done(null);
return true;
}
return false;
}
return { render, invalidate: () => {}, handleInput };
};
}

4328
transcripts/code-smells.html Normal file

File diff suppressed because one or more lines are too long

4328
transcripts/spike.html Normal file

File diff suppressed because one or more lines are too long

19
tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"noEmit": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

9
vitest.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/test/**/*.test.ts"],
},
});